mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
fix(providers): harden Felo retirement tombstones
This commit is contained in:
committed by
Markus Hartung
parent
6f62c97faf
commit
719150a34d
@@ -1,5 +1,5 @@
|
||||
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
|
||||
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
|
||||
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
|
||||
import {
|
||||
registerLazyExecutor,
|
||||
loadRegisteredExecutor,
|
||||
@@ -241,11 +241,8 @@ const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]);
|
||||
const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS));
|
||||
|
||||
export async function getExecutor(provider: string): Promise<BaseExecutor> {
|
||||
if (isRuntimeRetiredProviderId(provider)) {
|
||||
const err = new Error("Provider is retired and unavailable.");
|
||||
(err as Error & { status?: number }).status = 410;
|
||||
throw err;
|
||||
}
|
||||
assertRuntimeProviderAvailable(provider);
|
||||
|
||||
const loaded = await loadRegisteredExecutor(provider);
|
||||
if (loaded) return loaded;
|
||||
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* account pool (not a configured bearer key) and has its own server-side model-alias mechanism.
|
||||
*/
|
||||
|
||||
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
|
||||
|
||||
import { getExecutor } from "../../executors/index.ts";
|
||||
import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts";
|
||||
import { isDarioDeepModeEnabled } from "../../executors/dario.ts";
|
||||
@@ -94,6 +96,8 @@ export async function resolveExecutorWithProxy(
|
||||
log?: LoggerLike,
|
||||
providerSpecificData?: Record<string, unknown> | null
|
||||
) {
|
||||
assertRuntimeProviderAvailable(prov);
|
||||
|
||||
// Per-connection routing override (#6339): the resolved connection can opt itself
|
||||
// into the CLIProxyAPI passthrough executor via providerSpecificData.cliproxyapiMode
|
||||
// === "claude-native" (UI toggle). This takes precedence over the provider-level
|
||||
@@ -173,7 +177,10 @@ export async function resolveExecutorWithProxy(
|
||||
result = await nativeExec.execute(input);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via ${backendLabel}`);
|
||||
log?.info?.(
|
||||
"UPSTREAM_PROXY",
|
||||
`${prov} native error (${errMsg}), retrying via ${backendLabel}`
|
||||
);
|
||||
try {
|
||||
return await proxyExec.execute(input);
|
||||
} catch (proxyErr) {
|
||||
|
||||
@@ -343,13 +343,22 @@ export function errorResponseWithComboDiagnostics(
|
||||
* @param {string} message - Error message
|
||||
* @returns {Response} HTTP Response object
|
||||
*/
|
||||
export function errorResponse(statusCode: number, message: string): Response {
|
||||
return new Response(JSON.stringify(buildErrorBody(statusCode, sanitizeErrorMessage(message))), {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
export function errorResponse(
|
||||
statusCode: number,
|
||||
message: string,
|
||||
classification?: ErrorBodyClassification
|
||||
): Response {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(statusCode, sanitizeErrorMessage(message), undefined, classification)
|
||||
),
|
||||
{
|
||||
status: statusCode,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
withCompressionHeaderEcho,
|
||||
} from "@/shared/utils/compressionHeaderEcho";
|
||||
import { resolveModelAliasWithSeedFallbackOnBody } from "@/lib/modelAliasResolver";
|
||||
import {
|
||||
assertRuntimeModelProviderAvailable,
|
||||
isRuntimeProviderRetirementError,
|
||||
} from "@/shared/constants/providerRetirement";
|
||||
|
||||
let initPromise = null;
|
||||
|
||||
@@ -158,6 +162,24 @@ export async function POST(request) {
|
||||
admission.lease?.release();
|
||||
return finishAdmission(structuralAdmission.response);
|
||||
}
|
||||
admission.lease = structuralAdmission.lease;
|
||||
|
||||
// Preserve the caller-supplied provider identity long enough to enforce
|
||||
// retirement. A persisted alias can otherwise rewrite felo-web/... to a
|
||||
// healthy provider before getModelInfo or the executor tombstones see it.
|
||||
try {
|
||||
assertRuntimeModelProviderAvailable(parsedBody.model);
|
||||
} catch (error) {
|
||||
if (isRuntimeProviderRetirementError(error)) {
|
||||
return finishAdmission(
|
||||
errorResponse(error.status, error.message, {
|
||||
type: "provider_error",
|
||||
code: error.code,
|
||||
})
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Resolve model alias before forwarding to handleChat
|
||||
if (parsedBody && typeof parsedBody === "object") {
|
||||
@@ -165,7 +187,6 @@ export async function POST(request) {
|
||||
/* swallow — fall through with original model */
|
||||
});
|
||||
}
|
||||
admission.lease = structuralAdmission.lease;
|
||||
|
||||
const { blocked, result } = injectionGuard(parsedBody);
|
||||
if (blocked) {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { v1CountTokensSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { countTextTokens, type TokenizerContext } from "@/shared/utils/tiktokenCounter";
|
||||
import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { getModelInfo } from "@/sse/services/model";
|
||||
import { extractApiKey, getProviderCredentials, isValidApiKey } from "@/sse/services/auth";
|
||||
@@ -103,6 +105,20 @@ export async function POST(request) {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (isRuntimeProviderRetirementError(error)) {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(error.status, error.message, null, {
|
||||
type: "provider_error",
|
||||
code: error.code,
|
||||
})
|
||||
),
|
||||
{
|
||||
status: error.status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
log.debug(
|
||||
"COUNT_TOKENS",
|
||||
`Falling back to estimate for ${requestedModel}: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
||||
@@ -71,7 +71,10 @@ import { getModelsDevPricing, getSyncedCapability } from "@/lib/modelsDevSync";
|
||||
import { getModelSpec } from "@/shared/constants/modelSpecs";
|
||||
import { classifyModelSupportedEndpoints } from "@/shared/constants/modelSupportedEndpoints";
|
||||
import { getModelsCatalogPrefixMode } from "@/shared/utils/featureFlags";
|
||||
import { buildReservedPrefixes, selectCompatibleNodeForPrefix } from "@/lib/providerNodePrefixes";
|
||||
import {
|
||||
isProviderNodePrefixReserved,
|
||||
selectCompatibleNodeForPrefix,
|
||||
} from "@/lib/providerNodePrefixes";
|
||||
import { applyCatalogPostFilters, finalizeCatalogResponse } from "./catalogResponse";
|
||||
import {
|
||||
isNoAuthProviderBlocked,
|
||||
@@ -373,9 +376,8 @@ async function buildUnifiedModelsResponseCore(
|
||||
nodeIdToProviderType[node.id] = node.type;
|
||||
}
|
||||
}
|
||||
const reservedProviderPrefixes = buildReservedPrefixes();
|
||||
for (const prefix of new Set(Object.values(providerIdToPrefix))) {
|
||||
if (reservedProviderPrefixes.has(prefix)) continue;
|
||||
if (isProviderNodePrefixReserved(prefix)) continue;
|
||||
const winner = selectCompatibleNodeForPrefix(providerNodes, prefix);
|
||||
if (winner?.id) providerNodeIdByPrefix[prefix] = winner.id;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import {
|
||||
@@ -55,8 +56,7 @@ export const OPTIONS = async (): Promise<Response> => handleCorsOptions();
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) return error(401, "LEASE_AUTHENTICATION_REQUIRED", "Authentication required");
|
||||
if (!(await isValidApiKey(apiKey)))
|
||||
return error(401, "LEASE_API_KEY_INVALID", "Invalid API key");
|
||||
if (!(await isValidApiKey(apiKey))) return error(401, "LEASE_API_KEY_INVALID", "Invalid API key");
|
||||
const contentType = request.headers.get("content-type")?.toLowerCase().split(";", 1)[0].trim();
|
||||
if (contentType !== "application/json") {
|
||||
return error(415, "LEASE_CONTENT_TYPE_REQUIRED", "Content-Type must be application/json");
|
||||
@@ -133,6 +133,9 @@ export async function POST(request: Request): Promise<Response> {
|
||||
const result = selection as ExclusiveLeaseSelectionResult;
|
||||
return json(200, lifecycle(result.exclusiveLease));
|
||||
} catch (cause) {
|
||||
if (isRuntimeProviderRetirementError(cause)) {
|
||||
return error(cause.status, cause.code, cause.message);
|
||||
}
|
||||
if (cause instanceof LeaseContextError) return error(cause.status, cause.code, cause.message);
|
||||
return error(503, "LEASE_SERVICE_UNAVAILABLE", "Lease service unavailable");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
-- Retire the Felo Web integration while its GPL-derived provenance remains on hold.
|
||||
--
|
||||
-- Match the complete ECMAScript trim whitespace set so database tombstones and
|
||||
-- the TypeScript runtime agree even for restored provider ids wrapped in Unicode
|
||||
-- spaces (NBSP, OGHAM, U+2000..U+200A, line/paragraph separators and BOM).
|
||||
--
|
||||
-- Keep connection rows and historical records for auditability. Disabling the
|
||||
-- connections is deliberately fail-closed: API-key allowed_connections entries
|
||||
-- continue to reference the same connection ids instead of becoming an empty
|
||||
@@ -7,16 +11,16 @@
|
||||
|
||||
UPDATE exclusive_connection_leases
|
||||
SET state = 'INVALIDATED',
|
||||
ended_at = COALESCE(ended_at, datetime('now')),
|
||||
ended_at = datetime('now'),
|
||||
end_reason = 'CONNECTION_INELIGIBLE'
|
||||
WHERE state = 'ACTIVE'
|
||||
AND (
|
||||
lower(trim(provider, ' ' || char(9) || char(10) || char(11) || char(12) || char(13)))
|
||||
lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
OR connection_id IN (
|
||||
SELECT id
|
||||
FROM provider_connections
|
||||
WHERE lower(trim(provider, ' ' || char(9) || char(10) || char(11) || char(12) || char(13)))
|
||||
WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
)
|
||||
);
|
||||
@@ -27,10 +31,10 @@ SET is_active = 0,
|
||||
error_code = 'PROVIDER_REMOVED',
|
||||
last_error = 'Provider integration retired from OmniRoute v3.8.50',
|
||||
last_error_type = 'provider_removed',
|
||||
last_error_source = 'migration:163',
|
||||
last_error_source = 'migration:retire-felo-web',
|
||||
last_error_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE lower(trim(provider, ' ' || char(9) || char(10) || char(11) || char(12) || char(13)))
|
||||
WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
AND (
|
||||
is_active IS NOT 0
|
||||
@@ -38,7 +42,7 @@ WHERE lower(trim(provider, ' ' || char(9) || char(10) || char(11) || char(12) ||
|
||||
OR error_code IS NOT 'PROVIDER_REMOVED'
|
||||
OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
|
||||
OR last_error_type IS NOT 'provider_removed'
|
||||
OR last_error_source IS NOT 'migration:163'
|
||||
OR last_error_source IS NOT 'migration:retire-felo-web'
|
||||
OR last_error_at IS NULL
|
||||
);
|
||||
|
||||
@@ -48,17 +52,8 @@ WHERE lower(trim(provider, ' ' || char(9) || char(10) || char(11) || char(12) ||
|
||||
-- when an already-normalized row is written again.
|
||||
CREATE TRIGGER IF NOT EXISTS provider_connections_retire_felo_web_insert
|
||||
AFTER INSERT ON provider_connections
|
||||
WHEN lower(trim(NEW.provider, ' ' || char(9) || char(10) || char(11) || char(12) || char(13)))
|
||||
WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
AND (
|
||||
NEW.is_active IS NOT 0
|
||||
OR NEW.test_status IS NOT 'unavailable'
|
||||
OR NEW.error_code IS NOT 'PROVIDER_REMOVED'
|
||||
OR NEW.last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
|
||||
OR NEW.last_error_type IS NOT 'provider_removed'
|
||||
OR NEW.last_error_source IS NOT 'migration:163'
|
||||
OR NEW.last_error_at IS NULL
|
||||
)
|
||||
BEGIN
|
||||
UPDATE provider_connections
|
||||
SET is_active = 0,
|
||||
@@ -66,7 +61,7 @@ BEGIN
|
||||
error_code = 'PROVIDER_REMOVED',
|
||||
last_error = 'Provider integration retired from OmniRoute v3.8.50',
|
||||
last_error_type = 'provider_removed',
|
||||
last_error_source = 'migration:163',
|
||||
last_error_source = 'migration:retire-felo-web',
|
||||
last_error_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = NEW.id
|
||||
@@ -76,25 +71,23 @@ BEGIN
|
||||
OR error_code IS NOT 'PROVIDER_REMOVED'
|
||||
OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
|
||||
OR last_error_type IS NOT 'provider_removed'
|
||||
OR last_error_source IS NOT 'migration:163'
|
||||
OR last_error_source IS NOT 'migration:retire-felo-web'
|
||||
OR last_error_at IS NULL
|
||||
);
|
||||
|
||||
UPDATE exclusive_connection_leases
|
||||
SET state = 'INVALIDATED',
|
||||
ended_at = datetime('now'),
|
||||
end_reason = 'CONNECTION_INELIGIBLE'
|
||||
WHERE state = 'ACTIVE'
|
||||
AND connection_id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS provider_connections_retire_felo_web_update
|
||||
AFTER UPDATE OF provider, is_active, test_status, error_code, last_error,
|
||||
last_error_type, last_error_source, last_error_at ON provider_connections
|
||||
WHEN lower(trim(NEW.provider, ' ' || char(9) || char(10) || char(11) || char(12) || char(13)))
|
||||
WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
AND (
|
||||
NEW.is_active IS NOT 0
|
||||
OR NEW.test_status IS NOT 'unavailable'
|
||||
OR NEW.error_code IS NOT 'PROVIDER_REMOVED'
|
||||
OR NEW.last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
|
||||
OR NEW.last_error_type IS NOT 'provider_removed'
|
||||
OR NEW.last_error_source IS NOT 'migration:163'
|
||||
OR NEW.last_error_at IS NULL
|
||||
)
|
||||
BEGIN
|
||||
UPDATE provider_connections
|
||||
SET is_active = 0,
|
||||
@@ -102,7 +95,7 @@ BEGIN
|
||||
error_code = 'PROVIDER_REMOVED',
|
||||
last_error = 'Provider integration retired from OmniRoute v3.8.50',
|
||||
last_error_type = 'provider_removed',
|
||||
last_error_source = 'migration:163',
|
||||
last_error_source = 'migration:retire-felo-web',
|
||||
last_error_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = NEW.id
|
||||
@@ -112,7 +105,91 @@ BEGIN
|
||||
OR error_code IS NOT 'PROVIDER_REMOVED'
|
||||
OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
|
||||
OR last_error_type IS NOT 'provider_removed'
|
||||
OR last_error_source IS NOT 'migration:163'
|
||||
OR last_error_source IS NOT 'migration:retire-felo-web'
|
||||
OR last_error_at IS NULL
|
||||
);
|
||||
|
||||
UPDATE exclusive_connection_leases
|
||||
SET state = 'INVALIDATED',
|
||||
ended_at = datetime('now'),
|
||||
end_reason = 'CONNECTION_INELIGIBLE'
|
||||
WHERE state = 'ACTIVE'
|
||||
AND connection_id = NEW.id;
|
||||
END;
|
||||
|
||||
-- Once a connection id belongs to a retired provider, imports and internal
|
||||
-- writers must not repurpose that same audited identity as another provider.
|
||||
-- Retired-to-retired normalization remains allowed and is re-tombstoned by the
|
||||
-- AFTER UPDATE trigger above.
|
||||
CREATE TRIGGER IF NOT EXISTS provider_connections_preserve_felo_web_identity_insert
|
||||
BEFORE INSERT ON provider_connections
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE id = NEW.id
|
||||
AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
)
|
||||
AND lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
NOT IN ('felo-web', 'felo')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Retired provider connection identity cannot be changed');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS provider_connections_preserve_felo_web_identity_update
|
||||
BEFORE UPDATE OF provider ON provider_connections
|
||||
WHEN lower(trim(OLD.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
AND lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
NOT IN ('felo-web', 'felo')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Retired provider connection identity cannot be changed');
|
||||
END;
|
||||
|
||||
-- A restore can also insert lease rows after migrations have completed. Keep
|
||||
-- lease state fail-closed independently of request-time auth selection.
|
||||
CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_felo_web_insert
|
||||
AFTER INSERT ON exclusive_connection_leases
|
||||
WHEN NEW.state = 'ACTIVE'
|
||||
AND (
|
||||
lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE id = NEW.connection_id
|
||||
AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
UPDATE exclusive_connection_leases
|
||||
SET state = 'INVALIDATED',
|
||||
ended_at = datetime('now'),
|
||||
end_reason = 'CONNECTION_INELIGIBLE'
|
||||
WHERE id = NEW.id
|
||||
AND state = 'ACTIVE';
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_felo_web_update
|
||||
AFTER UPDATE OF provider, connection_id, state ON exclusive_connection_leases
|
||||
WHEN NEW.state = 'ACTIVE'
|
||||
AND (
|
||||
lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE id = NEW.connection_id
|
||||
AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
|
||||
IN ('felo-web', 'felo')
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
UPDATE exclusive_connection_leases
|
||||
SET state = 'INVALIDATED',
|
||||
ended_at = datetime('now'),
|
||||
end_reason = 'CONNECTION_INELIGIBLE'
|
||||
WHERE id = NEW.id
|
||||
AND state = 'ACTIVE';
|
||||
END;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
|
||||
import { getDbInstance, rowToCamel, cleanNulls } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import {
|
||||
@@ -595,13 +596,19 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
_updateConnectionRow(db, existingId, encryptConnectionFields(persistence));
|
||||
})();
|
||||
backupDbFile("pre-write");
|
||||
return withNullableRateLimitOverrides(
|
||||
const returnedConnection = withNullableRateLimitOverrides(
|
||||
withNullableQuotaWindowThresholds(
|
||||
withNullableMaxConcurrent(cleanNulls(merged), merged),
|
||||
merged
|
||||
),
|
||||
merged
|
||||
);
|
||||
|
||||
if (isRuntimeRetiredProviderId(merged.provider)) {
|
||||
return (await getProviderConnectionById(existingId)) ?? returnedConnection;
|
||||
}
|
||||
|
||||
return returnedConnection;
|
||||
}
|
||||
|
||||
// Generate name: prefer explicit name, then email, then a stable short-ID label.
|
||||
@@ -722,13 +729,19 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections"); // Bust connections read cache
|
||||
|
||||
return withNullableRateLimitOverrides(
|
||||
const returnedConnection = withNullableRateLimitOverrides(
|
||||
withNullableQuotaWindowThresholds(
|
||||
withNullableMaxConcurrent(cleanNulls(connection), connection),
|
||||
connection
|
||||
),
|
||||
connection
|
||||
);
|
||||
|
||||
if (isRuntimeRetiredProviderId(providerId)) {
|
||||
return (await getProviderConnectionById(String(connection.id))) ?? returnedConnection;
|
||||
}
|
||||
|
||||
return returnedConnection;
|
||||
}
|
||||
|
||||
function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
@@ -967,13 +980,19 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
|
||||
reorderConnections(db, providerId);
|
||||
}
|
||||
|
||||
return withNullableRateLimitOverrides(
|
||||
const returnedConnection = withNullableRateLimitOverrides(
|
||||
withNullableQuotaWindowThresholds(
|
||||
withNullableMaxConcurrent(cleanNulls(merged), merged),
|
||||
merged
|
||||
),
|
||||
merged
|
||||
);
|
||||
|
||||
if (isRuntimeRetiredProviderId(merged.provider)) {
|
||||
return (await getProviderConnectionById(id)) ?? returnedConnection;
|
||||
}
|
||||
|
||||
return returnedConnection;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -35,8 +35,11 @@
|
||||
* Built-in/no-compatible catalog entries are always eligible.
|
||||
*/
|
||||
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getProviderNodes } from "@/lib/db/providers/nodes";
|
||||
import {
|
||||
getReservedProviderPrefixes,
|
||||
isReservedProviderPrefix,
|
||||
} from "@/shared/constants/reservedProviderPrefixes";
|
||||
|
||||
export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved";
|
||||
|
||||
@@ -66,12 +69,11 @@ export interface ProviderPrefixIndex {
|
||||
* prefixes can never shadow a built-in provider.
|
||||
*/
|
||||
export function buildReservedPrefixes(): Set<string> {
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
return reserved;
|
||||
return new Set(getReservedProviderPrefixes());
|
||||
}
|
||||
|
||||
export function isProviderNodePrefixReserved(value: unknown): boolean {
|
||||
return isReservedProviderPrefix(value);
|
||||
}
|
||||
|
||||
export interface CompatibleNodeLike {
|
||||
@@ -97,7 +99,6 @@ export function selectCompatibleNodeForPrefix(
|
||||
}
|
||||
|
||||
export async function getProviderPrefixIndex(): Promise<ProviderPrefixIndex> {
|
||||
const reserved = buildReservedPrefixes();
|
||||
const nodes = (await getProviderNodes()) as CompatibleNodeLike[];
|
||||
const compatible = nodes.filter(
|
||||
(n) => n.type === "openai-compatible" || n.type === "anthropic-compatible"
|
||||
@@ -123,7 +124,7 @@ export async function getProviderPrefixIndex(): Promise<ProviderPrefixIndex> {
|
||||
const eligibleNodeIds = new Set<string>();
|
||||
|
||||
for (const [prefix, prefixNodes] of byPrefix) {
|
||||
if (reserved.has(prefix)) {
|
||||
if (isProviderNodePrefixReserved(prefix)) {
|
||||
// Built-in registry id/alias — never a compatible public target.
|
||||
entries.set(prefix, { prefix, status: "reserved" });
|
||||
continue;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* together so neither executor dispatch nor credential selection can fall back.
|
||||
*/
|
||||
export const RUNTIME_RETIRED_PROVIDER_IDS: ReadonlySet<string> = new Set(["felo-web", "felo"]);
|
||||
export const RUNTIME_PROVIDER_RETIRED_ERROR_CODE = "PROVIDER_RETIRED";
|
||||
export const RUNTIME_PROVIDER_RETIRED_MESSAGE = "Provider is retired and unavailable.";
|
||||
|
||||
type RuntimeProviderRetirementError = Error & {
|
||||
code: typeof RUNTIME_PROVIDER_RETIRED_ERROR_CODE;
|
||||
status: 410;
|
||||
};
|
||||
|
||||
export function isRuntimeRetiredProviderId(providerId: unknown): providerId is string {
|
||||
return (
|
||||
@@ -11,3 +18,27 @@ export function isRuntimeRetiredProviderId(providerId: unknown): providerId is s
|
||||
RUNTIME_RETIRED_PROVIDER_IDS.has(providerId.trim().toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function assertRuntimeProviderAvailable(providerId: unknown): void {
|
||||
if (!isRuntimeRetiredProviderId(providerId)) return;
|
||||
|
||||
const error = new Error(RUNTIME_PROVIDER_RETIRED_MESSAGE) as RuntimeProviderRetirementError;
|
||||
error.code = RUNTIME_PROVIDER_RETIRED_ERROR_CODE;
|
||||
error.status = 410;
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function assertRuntimeModelProviderAvailable(modelId: unknown): void {
|
||||
if (typeof modelId !== "string") return;
|
||||
const slashIndex = modelId.indexOf("/");
|
||||
if (slashIndex <= 0) return;
|
||||
assertRuntimeProviderAvailable(modelId.slice(0, slashIndex));
|
||||
}
|
||||
|
||||
export function isRuntimeProviderRetirementError(
|
||||
error: unknown
|
||||
): error is RuntimeProviderRetirementError {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const typed = error as Error & { code?: unknown; status?: unknown };
|
||||
return typed.code === RUNTIME_PROVIDER_RETIRED_ERROR_CODE && typed.status === 410;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,19 @@
|
||||
// tokenrouter" despite a fully configured compatible node).
|
||||
//
|
||||
// Semantics (mirror the original inline runtime guard exactly):
|
||||
// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY
|
||||
// - REGISTRY entry ids + aliases, plus permanent tombstones for retired
|
||||
// provider ids. Manual alias ids outside REGISTRY
|
||||
// (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore
|
||||
// deliberately NOT reserved — including them would cause false-positive
|
||||
// rejections.
|
||||
// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with
|
||||
// the runtime lookup (`Set.has` is exact-match), so it stays allowed.
|
||||
// - Active registry ids remain case-sensitive: mixed-case input like
|
||||
// "TokenRouter" does not collide with the runtime lookup (`Set.has` is
|
||||
// exact-match), so it stays allowed. Retired ids use the tombstone's
|
||||
// trim/lowercase normalization so casing cannot bypass retirement.
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
|
||||
import { isRuntimeRetiredProviderId, RUNTIME_RETIRED_PROVIDER_IDS } from "./providerRetirement";
|
||||
|
||||
let _reserved: Set<string> | null = null;
|
||||
|
||||
function buildReservedProviderPrefixes(): Set<string> {
|
||||
@@ -29,13 +34,15 @@ function buildReservedProviderPrefixes(): Set<string> {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
for (const providerId of RUNTIME_RETIRED_PROVIDER_IDS) reserved.add(providerId);
|
||||
_reserved = reserved;
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/**
|
||||
* All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the
|
||||
* registry is only walked once per process.
|
||||
* All exact reserved provider prefixes (REGISTRY ids + aliases and retired
|
||||
* provider tombstones). Built lazily so the registry is only walked once per
|
||||
* process.
|
||||
*/
|
||||
export function getReservedProviderPrefixes(): ReadonlySet<string> {
|
||||
return buildReservedProviderPrefixes();
|
||||
@@ -58,7 +65,10 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet<string> = getReservedProvid
|
||||
* reserved (mirrors the runtime guard's typeof check).
|
||||
*/
|
||||
export function isReservedProviderPrefix(value: unknown): boolean {
|
||||
return typeof value === "string" && buildReservedProviderPrefixes().has(value);
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(buildReservedProviderPrefixes().has(value) || isRuntimeRetiredProviderId(value))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -339,6 +339,17 @@ export const createProviderNodeSchema = z
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const nodeType = value.type || "openai-compatible";
|
||||
const normalizedPrefix = value.prefix?.trim();
|
||||
if (normalizedPrefix && isReservedProviderPrefix(normalizedPrefix)) {
|
||||
// Validate caller-supplied prefixes before preset handling. Presets may
|
||||
// provide a default, but the route preserves an explicit prefix; an early
|
||||
// return here used to let retired identities create unreachable nodes.
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: reservedProviderPrefixMessage(normalizedPrefix),
|
||||
path: ["prefix"],
|
||||
});
|
||||
}
|
||||
if (value.preset === "vibeproxy-openai") {
|
||||
// Preset supplies name/prefix/apiType — but baseUrl is still mandatory
|
||||
// (a local proxy's host/port is operator-specific, unlike the generic
|
||||
@@ -365,17 +376,6 @@ export const createProviderNodeSchema = z
|
||||
message: "Prefix is required",
|
||||
path: ["prefix"],
|
||||
});
|
||||
} else if (isReservedProviderPrefix(value.prefix.trim())) {
|
||||
// Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
|
||||
// compatible-node lookup for built-in registry ids/aliases, so a node
|
||||
// created with such a prefix could never be reached by it and silently
|
||||
// routed requests to the built-in provider instead. Reject at the write
|
||||
// path. Case-sensitive to match the runtime guard exactly.
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: reservedProviderPrefixMessage(value.prefix.trim()),
|
||||
path: ["prefix"],
|
||||
});
|
||||
}
|
||||
if (nodeType === "openai-compatible" && !value.apiType) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -63,6 +63,7 @@ import * as log from "../utils/logger";
|
||||
import { checkAndRefreshToken } from "../services/tokenRefresh";
|
||||
import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry";
|
||||
import { rejectPeerRequest } from "@/shared/resilience/peerRouting";
|
||||
import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
|
||||
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
|
||||
import { getComboByName, updateCombo } from "@/lib/db/combos";
|
||||
import { isModelAllowedForKey } from "@/lib/db/apiKeys";
|
||||
@@ -948,7 +949,13 @@ async function handleChatImplementation(
|
||||
// prefix may differ from the credential provider ID (e.g. model
|
||||
// "xiaomi/mimo-v2-flash" resolves to provider "xiaomi" but the combo
|
||||
// target specifies providerId: "opengate" for credential lookup).
|
||||
const modelInfo = await getModelInfo(modelString);
|
||||
let modelInfo;
|
||||
try {
|
||||
modelInfo = await getModelInfo(modelString);
|
||||
} catch (error) {
|
||||
if (isRuntimeProviderRetirementError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
// Apply the same prefix-override guard as handleSingleModelChat:
|
||||
// if providerId is just the prefix already in the model string, use
|
||||
// the fully-resolved modelInfo.provider for a precise credential check.
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { resolveProxyForConnection } from "@/lib/localDb";
|
||||
import { hasBlockingProxyAssignment } from "@/lib/db/proxies";
|
||||
import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
|
||||
import {
|
||||
CircuitBreakerOpenError,
|
||||
getCircuitBreaker,
|
||||
@@ -120,7 +121,20 @@ export async function resolveModelOrError(
|
||||
endpointPath: string = "",
|
||||
requestHeaders: Record<string, unknown> | null | undefined = null
|
||||
) {
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
let modelInfo;
|
||||
try {
|
||||
modelInfo = await getModelInfo(modelStr);
|
||||
} catch (error) {
|
||||
if (isRuntimeProviderRetirementError(error)) {
|
||||
return {
|
||||
error: errorResponse(error.status, error.message, {
|
||||
type: "provider_error",
|
||||
code: error.code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
|
||||
|
||||
if (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
|
||||
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
|
||||
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
|
||||
|
||||
export { parseModel, stripContextWindowSuffix };
|
||||
|
||||
@@ -423,6 +424,11 @@ function stripRedundantNodeRoutingSegments(model: string, routingIds: unknown[])
|
||||
*/
|
||||
export async function getModelInfo(modelStr) {
|
||||
const parsed = parseModel(modelStr);
|
||||
// Fail before compatible-node lookup and stripModelPrefix can erase or remap
|
||||
// a retired provider identity. Executor/auth tombstones are later defenses;
|
||||
// they cannot see the original prefix after either remapping path.
|
||||
assertRuntimeProviderAvailable(parsed.providerAlias);
|
||||
assertRuntimeProviderAvailable(parsed.provider);
|
||||
const { extendedContext } = parsed;
|
||||
|
||||
const attachRuntimeModelMeta = async (info: any) => {
|
||||
|
||||
@@ -17,13 +17,11 @@ process.env.DATA_DIR = testDataDir;
|
||||
// Dynamic imports AFTER DATA_DIR is set so core.ts picks up the temp path.
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
|
||||
const { resolveExecutorWithProxy } = await import(
|
||||
"../../open-sse/handlers/chatCore/executorProxy.ts"
|
||||
);
|
||||
const { resolveExecutorWithProxy } =
|
||||
await import("../../open-sse/handlers/chatCore/executorProxy.ts");
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const { clearUpstreamProxyConfigCache } = await import(
|
||||
"../../open-sse/handlers/chatCore/comboContextCache.ts"
|
||||
);
|
||||
const { clearUpstreamProxyConfigCache } =
|
||||
await import("../../open-sse/handlers/chatCore/comboContextCache.ts");
|
||||
|
||||
before(async () => {
|
||||
await coreDb.ensureDbInitialized();
|
||||
@@ -137,3 +135,25 @@ test("connection override wins over provider mode 'fallback'", async () => {
|
||||
// Connection override short-circuits to the passthrough executor, not the fallback wrapper.
|
||||
assert.equal(exec, await getExecutor("cliproxyapi"));
|
||||
});
|
||||
|
||||
test("retired Felo ids cannot bypass the tombstone through a connection proxy", async () => {
|
||||
for (const providerId of ["felo-web", "felo", " FeLo-Web ", "\tFELO\n"]) {
|
||||
await assert.rejects(
|
||||
resolveExecutorWithProxy(providerId, undefined, {
|
||||
cliproxyapiMode: "claude-native",
|
||||
}),
|
||||
(error: unknown) => {
|
||||
const typed = error as Error & { code?: string; status?: number };
|
||||
assert.equal(typed.code, "PROVIDER_RETIRED");
|
||||
assert.equal(typed.status, 410);
|
||||
assert.match(typed.message, /retired/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const openAi = await resolveExecutorWithProxy("openai", undefined, {
|
||||
cliproxyapiMode: "claude-native",
|
||||
});
|
||||
assert.equal(openAi, getExecutor("cliproxyapi"));
|
||||
});
|
||||
|
||||
@@ -8,19 +8,288 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-felo-runt
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts");
|
||||
const modelAliasesDb = await import("../../src/lib/db/models/aliases.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const modelAliasResolver = await import("../../src/lib/modelAliasResolver.ts");
|
||||
const providerPrefixIndex = await import("../../src/lib/providerNodePrefixes.ts");
|
||||
const { hashLeaseOwnerId } = await import("../../src/lib/db/exclusiveConnectionLeases.ts");
|
||||
const { getProviderCredentials } = await import("../../src/sse/services/auth.ts");
|
||||
const { getModelInfo } = await import("../../src/sse/services/model.ts");
|
||||
const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts");
|
||||
const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
|
||||
|
||||
const RETIRED_PROVIDER_VARIANTS = ["felo-web", "felo", " FeLo-Web ", "\tFELO\n"] as const;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const RETIRED_PROVIDER_VARIANTS = [
|
||||
"felo-web",
|
||||
"felo",
|
||||
" FeLo-Web ",
|
||||
"\tFELO\n",
|
||||
"\u00a0FELO-WEB\uFEFF",
|
||||
"\u2003felo\u2029",
|
||||
"\u3000FELO-WEB\u3000",
|
||||
] as const;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
core.getDbInstance();
|
||||
modelAliasResolver.invalidateAliasCache();
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function isRetiredError(error: unknown): boolean {
|
||||
const typed = error as Error & { code?: string; status?: number };
|
||||
assert.equal(typed.code, "PROVIDER_RETIRED");
|
||||
assert.equal(typed.status, 410);
|
||||
assert.match(typed.message, /retired/i);
|
||||
return true;
|
||||
}
|
||||
|
||||
test("retired Felo model prefixes cannot shadow custom compatible nodes", async () => {
|
||||
const nodeIdsByPrefix = new Map<string, string>();
|
||||
for (const [index, providerId] of ["felo-web", "felo", "FeLo-WeB", "FELO"].entries()) {
|
||||
const nodeId = `openai-compatible-chat-retired-felo-prefix-${index}`;
|
||||
await providerNodesDb.createProviderNode({
|
||||
id: nodeId,
|
||||
type: "openai-compatible",
|
||||
name: `Retired prefix ${providerId}`,
|
||||
prefix: providerId,
|
||||
apiType: "chat",
|
||||
baseUrl: "https://retired.example.invalid/v1",
|
||||
});
|
||||
nodeIdsByPrefix.set(providerId, nodeId);
|
||||
|
||||
await assert.rejects(() => getModelInfo(`${providerId}/gpt-4o`), isRetiredError);
|
||||
}
|
||||
|
||||
const prefixIndex = await providerPrefixIndex.getProviderPrefixIndex();
|
||||
for (const [providerId, nodeId] of nodeIdsByPrefix) {
|
||||
assert.equal(
|
||||
prefixIndex.entries.get(providerId)?.status,
|
||||
"reserved",
|
||||
`${providerId} must remain reserved in pricing and override indexes`
|
||||
);
|
||||
assert.equal(prefixIndex.eligibleNodeIds.has(nodeId), false);
|
||||
assert.equal(prefixIndex.prefixToNode.has(providerId), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("stripModelPrefix cannot erase retired Felo identities before dispatch", async () => {
|
||||
await settingsDb.updateSettings({ stripModelPrefix: true });
|
||||
try {
|
||||
for (const providerId of ["felo-web", "felo", "FeLo-WeB", "FELO"]) {
|
||||
await assert.rejects(() => getModelInfo(`${providerId}/gpt-4o`), isRetiredError);
|
||||
}
|
||||
} finally {
|
||||
await settingsDb.updateSettings({ stripModelPrefix: false });
|
||||
}
|
||||
});
|
||||
|
||||
test("direct chat resolution converts retired Felo failures into sanitized HTTP 410", async () => {
|
||||
for (const providerId of ["felo-web", "felo"]) {
|
||||
const result = await resolveModelOrError(
|
||||
`${providerId}/gpt-4o`,
|
||||
{ model: `${providerId}/gpt-4o`, messages: [{ role: "user", content: "hello" }] },
|
||||
"/v1/chat/completions"
|
||||
);
|
||||
assert.ok(result.error instanceof Response);
|
||||
assert.equal(result.error.status, 410);
|
||||
const body = (await result.error.json()) as {
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
assert.equal(body.error?.code, "PROVIDER_RETIRED");
|
||||
assert.equal(body.error?.message, "Provider is retired and unavailable.");
|
||||
assert.equal(JSON.stringify(body).includes(providerId), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("persisted aliases cannot rewrite retired Felo models before the route tombstone", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Retired Felo alias bypass control",
|
||||
apiKey: "sk-retired-felo-alias-bypass",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await modelAliasesDb.setModelAlias("felo-web/gpt-4o", "openai/gpt-4o");
|
||||
modelAliasResolver.invalidateAliasCache();
|
||||
|
||||
const fetchCalls: string[] = [];
|
||||
globalThis.fetch = async (input: string | URL | Request) => {
|
||||
fetchCalls.push(String(input));
|
||||
return Response.json({
|
||||
id: "chatcmpl-retired-felo-alias-bypass",
|
||||
choices: [{ message: { role: "assistant", content: "alias bypassed retirement" } }],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await chatRoute.POST(
|
||||
new Request("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "felo-web/gpt-4o",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 410);
|
||||
assert.equal(fetchCalls.length, 0, "a retired alias must be rejected before upstream fetch");
|
||||
const body = (await response.json()) as {
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
assert.equal(body.error?.code, "PROVIDER_RETIRED");
|
||||
assert.equal(body.error?.message, "Provider is retired and unavailable.");
|
||||
|
||||
// Bare model names are aliases/combos, not provider prefixes. An operator is
|
||||
// still allowed to own an unrelated alias named "felo"; only the slashful
|
||||
// retired provider identity must be rejected before alias resolution.
|
||||
await modelAliasesDb.setModelAlias("felo", "openai/gpt-4o");
|
||||
modelAliasResolver.invalidateAliasCache();
|
||||
const bareAliasResponse = await chatRoute.POST(
|
||||
new Request("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "felo",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(bareAliasResponse.status, 200);
|
||||
assert.equal(fetchCalls.length, 1, "a bare alias named felo must remain routable");
|
||||
});
|
||||
|
||||
test("priority combo skips retired Felo target and falls back to a healthy target", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Healthy Felo combo fallback",
|
||||
apiKey: "sk-healthy-felo-combo-fallback",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "retired-felo-fallback",
|
||||
strategy: "priority",
|
||||
models: [
|
||||
{ provider: "felo-web", model: "gpt-4o" },
|
||||
{ provider: "openai", model: "gpt-4o" },
|
||||
],
|
||||
});
|
||||
|
||||
const fetchCalls: string[] = [];
|
||||
globalThis.fetch = async (input: string | URL | Request) => {
|
||||
fetchCalls.push(String(input));
|
||||
return Response.json({
|
||||
id: "chatcmpl-retired-felo-fallback",
|
||||
choices: [{ message: { role: "assistant", content: "healthy fallback" } }],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await chatRoute.POST(
|
||||
new Request("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-OmniRoute-No-Cache": "true",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "retired-felo-fallback",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
const body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||
assert.equal(body.choices?.[0]?.message?.content, "healthy fallback");
|
||||
});
|
||||
|
||||
test("retired Felo ids stay ineligible after imports, even if DB triggers are bypassed", async () => {
|
||||
const db = core.getDbInstance();
|
||||
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: "felo-web",
|
||||
authType: "apikey",
|
||||
name: "Retired Felo create response",
|
||||
apiKey: "retired-felo-create-key",
|
||||
isActive: true,
|
||||
});
|
||||
assert.equal(created.isActive, false, "create must report the tombstoned persisted state");
|
||||
|
||||
const updated = await providersDb.updateProviderConnection(created.id, {
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
errorCode: null,
|
||||
lastError: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
lastErrorAt: null,
|
||||
});
|
||||
assert.equal(updated?.isActive, false, "update must report the tombstoned persisted state");
|
||||
assert.equal(updated?.errorCode, "PROVIDER_REMOVED");
|
||||
|
||||
const deduplicated = await providersDb.createProviderConnection({
|
||||
provider: "felo-web",
|
||||
authType: "apikey",
|
||||
name: "Retired Felo create response",
|
||||
apiKey: "retired-felo-create-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
assert.equal(deduplicated.id, created.id, "the second create must use the dedup path");
|
||||
assert.equal(
|
||||
deduplicated.isActive,
|
||||
false,
|
||||
"a deduplicated create must report the tombstoned persisted state"
|
||||
);
|
||||
assert.equal(deduplicated.errorCode, "PROVIDER_REMOVED");
|
||||
|
||||
await assert.rejects(
|
||||
providersDb.updateProviderConnection(created.id, {
|
||||
provider: "openai",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
errorCode: null,
|
||||
lastError: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
lastErrorAt: null,
|
||||
}),
|
||||
/retired provider connection identity cannot be changed/i
|
||||
);
|
||||
const identityPreserved = await providersDb.getProviderConnectionById(created.id);
|
||||
assert.equal(identityPreserved?.provider, "felo-web");
|
||||
assert.equal(identityPreserved?.isActive, false);
|
||||
assert.equal(identityPreserved?.errorCode, "PROVIDER_REMOVED");
|
||||
|
||||
for (const [index, providerId] of RETIRED_PROVIDER_VARIANTS.entries()) {
|
||||
const connectionId = `trigger-normalized-${index}`;
|
||||
db.prepare(
|
||||
@@ -46,7 +315,7 @@ test("retired Felo ids stay ineligible after imports, even if DB triggers are by
|
||||
test_status: "unavailable",
|
||||
error_code: "PROVIDER_REMOVED",
|
||||
last_error_type: "provider_removed",
|
||||
last_error_source: "migration:163",
|
||||
last_error_source: "migration:retire-felo-web",
|
||||
});
|
||||
|
||||
const credentials = await getProviderCredentials(
|
||||
@@ -64,8 +333,12 @@ test("retired Felo ids stay ineligible after imports, even if DB triggers are by
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
DROP TRIGGER provider_connections_retire_felo_web_insert;
|
||||
DROP TRIGGER provider_connections_retire_felo_web_update;
|
||||
DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_insert;
|
||||
DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_update;
|
||||
DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_insert;
|
||||
DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_update;
|
||||
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_insert;
|
||||
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_update;
|
||||
`);
|
||||
|
||||
for (const [index, providerId] of RETIRED_PROVIDER_VARIANTS.entries()) {
|
||||
|
||||
@@ -18,6 +18,13 @@ type CountTokensResponse = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type ErrorResponse = {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
@@ -108,6 +115,25 @@ test("messages/count_tokens falls back to estimate when model is missing", async
|
||||
assert.equal(body.source, "local");
|
||||
});
|
||||
|
||||
test("messages/count_tokens rejects retired Felo models instead of estimating locally", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/v1/messages/count_tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "felo-web/gpt-4o",
|
||||
messages: [{ role: "user", content: "Count these tokens" }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 410);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
assert.equal(body.error?.code, "PROVIDER_RETIRED");
|
||||
assert.equal(body.error?.message, "Provider is retired and unavailable.");
|
||||
assert.equal(JSON.stringify(body).includes("felo-web"), false);
|
||||
});
|
||||
|
||||
test("count_tokens fallback uses exact tiktoken count with source=local", async () => {
|
||||
const req = new Request("http://localhost/v1/messages/count_tokens", {
|
||||
method: "POST",
|
||||
|
||||
@@ -51,6 +51,10 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
db.exec(`
|
||||
DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_insert;
|
||||
DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_update;
|
||||
DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_insert;
|
||||
DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_update;
|
||||
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_insert;
|
||||
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_update;
|
||||
`);
|
||||
|
||||
// The domain module reconciles API-key policy columns on a fresh database.
|
||||
@@ -77,6 +81,9 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
const normalizedProviderVariants = [
|
||||
{ id: "mixed-case-felo-web-connection", provider: " FeLo-Web " },
|
||||
{ id: "mixed-case-felo-alias-connection", provider: "\tFELO\n" },
|
||||
{ id: "nbsp-felo-web-connection", provider: "\u00a0FELO-WEB\uFEFF" },
|
||||
{ id: "em-space-felo-alias-connection", provider: "\u2003felo\u2029" },
|
||||
{ id: "ideographic-felo-web-connection", provider: "\u3000FELO-WEB\u3000" },
|
||||
] as const;
|
||||
for (const { id, provider } of normalizedProviderVariants) {
|
||||
db.prepare(
|
||||
@@ -108,6 +115,7 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
).run(mixedAllowedConnectionsRaw);
|
||||
|
||||
const leaseIds = new Map<string, number>();
|
||||
const staleLeaseEndedAt = "2000-01-01T00:00:00.000Z";
|
||||
for (const provider of RETIRED_PROVIDER_IDS) {
|
||||
const connectionId = `${provider}-connection`;
|
||||
const leaseProvider = provider === "felo-web" ? "legacy-imported-provider" : provider;
|
||||
@@ -120,6 +128,12 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
)
|
||||
.run(provider.padEnd(64, "0"), leaseProvider, connectionId);
|
||||
leaseIds.set(provider, Number(insertedLease.lastInsertRowid));
|
||||
if (provider === "felo-web") {
|
||||
db.prepare("UPDATE exclusive_connection_leases SET ended_at = ? WHERE id = ?").run(
|
||||
staleLeaseEndedAt,
|
||||
Number(insertedLease.lastInsertRowid)
|
||||
);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO usage_history (provider, model, timestamp) " +
|
||||
@@ -211,7 +225,7 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
assert.equal(connection.error_code, "PROVIDER_REMOVED");
|
||||
assert.equal(connection.last_error, "Provider integration retired from OmniRoute v3.8.50");
|
||||
assert.equal(connection.last_error_type, "provider_removed");
|
||||
assert.equal(connection.last_error_source, "migration:163");
|
||||
assert.equal(connection.last_error_source, "migration:retire-felo-web");
|
||||
assert.notEqual(connection.last_error_at, "2000-01-01T00:00:00.000Z");
|
||||
assert.notEqual(connection.updated_at, "2000-01-01T00:00:00.000Z");
|
||||
|
||||
@@ -219,6 +233,13 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
assert.equal(lease.generation, 7);
|
||||
assert.equal(lease.state, "INVALIDATED");
|
||||
assert.ok(lease.ended_at);
|
||||
if (provider === "felo-web") {
|
||||
assert.notEqual(
|
||||
lease.ended_at,
|
||||
staleLeaseEndedAt,
|
||||
"the retirement event must replace a stale restored end timestamp"
|
||||
);
|
||||
}
|
||||
assert.equal(lease.end_reason, "CONNECTION_INELIGIBLE");
|
||||
|
||||
assert.ok(db.prepare("SELECT id FROM usage_history WHERE provider = ?").get(provider));
|
||||
@@ -244,7 +265,7 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
test_status: "unavailable",
|
||||
error_code: "PROVIDER_REMOVED",
|
||||
last_error_type: "provider_removed",
|
||||
last_error_source: "migration:163",
|
||||
last_error_source: "migration:retire-felo-web",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,7 +328,19 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
assert.equal(postMigrationConnection.test_status, "unavailable");
|
||||
assert.equal(postMigrationConnection.error_code, "PROVIDER_REMOVED");
|
||||
assert.equal(postMigrationConnection.last_error_type, "provider_removed");
|
||||
assert.equal(postMigrationConnection.last_error_source, "migration:163");
|
||||
assert.equal(postMigrationConnection.last_error_source, "migration:retire-felo-web");
|
||||
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO provider_connections " +
|
||||
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
|
||||
"VALUES ('post-migration-replace-felo', '\fFELO\r', 'apikey', 'replace import', " +
|
||||
"1, 'active', datetime('now'), datetime('now'))"
|
||||
).run();
|
||||
const postMigrationReplace = readConnectionById("post-migration-replace-felo");
|
||||
assert.equal(postMigrationReplace.is_active, 0);
|
||||
assert.equal(postMigrationReplace.test_status, "unavailable");
|
||||
assert.equal(postMigrationReplace.error_code, "PROVIDER_REMOVED");
|
||||
assert.equal(postMigrationReplace.last_error_source, "migration:retire-felo-web");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections " +
|
||||
@@ -330,7 +363,99 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
is_active: 0,
|
||||
test_status: "unavailable",
|
||||
error_code: "PROVIDER_REMOVED",
|
||||
last_error_source: "migration:163",
|
||||
last_error_source: "migration:retire-felo-web",
|
||||
});
|
||||
|
||||
const insertActiveLease = (owner: string, provider: string, connectionId: string) =>
|
||||
Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO exclusive_connection_leases " +
|
||||
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
|
||||
"acquired_at, renewed_at, expires_at) VALUES (?, ?, ?, ?, 1, 'ACTIVE', " +
|
||||
"datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
|
||||
)
|
||||
.run(owner.padEnd(64, "0"), `${owner}-key`, provider, connectionId).lastInsertRowid
|
||||
);
|
||||
|
||||
const alreadyTombstonedInsertLeaseId = insertActiveLease(
|
||||
"already-tombstoned-felo-insert",
|
||||
"legacy-imported-provider",
|
||||
"already-tombstoned-felo-insert-connection"
|
||||
);
|
||||
assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "ACTIVE");
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections " +
|
||||
"(id, provider, auth_type, name, is_active, test_status, error_code, last_error, " +
|
||||
"last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
|
||||
"VALUES ('already-tombstoned-felo-insert-connection', '\u00a0felo-web\uFEFF', " +
|
||||
"'apikey', 'already tombstoned restore', 0, 'unavailable', 'PROVIDER_REMOVED', " +
|
||||
"'Provider integration retired from OmniRoute v3.8.50', 'provider_removed', " +
|
||||
"'migration:retire-felo-web', '2001-01-01T00:00:00.000Z', datetime('now'), datetime('now'))"
|
||||
).run();
|
||||
assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "INVALIDATED");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections " +
|
||||
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
|
||||
"VALUES ('already-tombstoned-felo-update-connection', 'legacy-provider', 'apikey', " +
|
||||
"'update to retired', 1, datetime('now'), datetime('now'))"
|
||||
).run();
|
||||
const alreadyTombstonedUpdateLeaseId = insertActiveLease(
|
||||
"already-tombstoned-felo-update",
|
||||
"legacy-imported-provider",
|
||||
"already-tombstoned-felo-update-connection"
|
||||
);
|
||||
assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "ACTIVE");
|
||||
db.prepare(
|
||||
"UPDATE provider_connections SET provider = '\u2003FELO\u2029', is_active = 0, " +
|
||||
"test_status = 'unavailable', error_code = 'PROVIDER_REMOVED', " +
|
||||
"last_error = 'Provider integration retired from OmniRoute v3.8.50', " +
|
||||
"last_error_type = 'provider_removed', last_error_source = 'migration:retire-felo-web', " +
|
||||
"last_error_at = '2001-01-01T00:00:00.000Z' " +
|
||||
"WHERE id = 'already-tombstoned-felo-update-connection'"
|
||||
).run();
|
||||
assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "INVALIDATED");
|
||||
|
||||
const directRetiredLeaseId = insertActiveLease(
|
||||
"post-direct-felo",
|
||||
" FeLo-Web ",
|
||||
"direct-retired-felo-provider-connection"
|
||||
);
|
||||
assert.equal(readLease(directRetiredLeaseId).state, "INVALIDATED");
|
||||
|
||||
const retiredConnectionLeaseId = insertActiveLease(
|
||||
"post-retired-felo-connection",
|
||||
"legacy-imported-provider",
|
||||
"post-migration-felo"
|
||||
);
|
||||
assert.equal(readLease(retiredConnectionLeaseId).state, "INVALIDATED");
|
||||
|
||||
const restoredBeforeConnectionLeaseId = insertActiveLease(
|
||||
"restored-before-felo-connection",
|
||||
"legacy-imported-provider",
|
||||
"restored-felo-web-connection"
|
||||
);
|
||||
assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "ACTIVE");
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections " +
|
||||
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
|
||||
"VALUES ('restored-felo-web-connection', 'felo-web', 'apikey', " +
|
||||
"'restored after lease', 1, datetime('now'), datetime('now'))"
|
||||
).run();
|
||||
assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "INVALIDATED");
|
||||
|
||||
const openCodeLeaseId = insertActiveLease(
|
||||
"post-opencode-control",
|
||||
"opencode",
|
||||
"post-opencode-control-connection"
|
||||
);
|
||||
assert.deepEqual(readLease(openCodeLeaseId), {
|
||||
id: openCodeLeaseId,
|
||||
generation: 1,
|
||||
state: "ACTIVE",
|
||||
ended_at: null,
|
||||
end_reason: null,
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
@@ -343,7 +468,55 @@ test("migration 163 retires every Felo id fail-closed and preserves audit histor
|
||||
assert.equal(updateProtectedConnection.test_status, "unavailable");
|
||||
assert.equal(updateProtectedConnection.error_code, "PROVIDER_REMOVED");
|
||||
assert.equal(updateProtectedConnection.last_error_type, "provider_removed");
|
||||
assert.equal(updateProtectedConnection.last_error_source, "migration:163");
|
||||
assert.equal(updateProtectedConnection.last_error_source, "migration:retire-felo-web");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE provider_connections SET provider = 'openai', is_active = 1, " +
|
||||
"test_status = 'active', error_code = NULL WHERE id = 'felo-web-connection'"
|
||||
)
|
||||
.run(),
|
||||
/retired provider connection identity cannot be changed/i
|
||||
);
|
||||
const updateIdentityControl = db
|
||||
.prepare("SELECT provider, is_active, error_code FROM provider_connections WHERE id = ?")
|
||||
.get("felo-web-connection") as {
|
||||
provider: string;
|
||||
is_active: number;
|
||||
error_code: string;
|
||||
};
|
||||
assert.deepEqual(updateIdentityControl, {
|
||||
provider: "felo-web",
|
||||
is_active: 0,
|
||||
error_code: "PROVIDER_REMOVED",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO provider_connections " +
|
||||
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
|
||||
"VALUES ('felo-connection', 'openai', 'apikey', 'identity replacement', 1, " +
|
||||
"'active', datetime('now'), datetime('now'))"
|
||||
)
|
||||
.run(),
|
||||
/retired provider connection identity cannot be changed/i
|
||||
);
|
||||
const replaceIdentityControl = db
|
||||
.prepare("SELECT provider, is_active, error_code FROM provider_connections WHERE id = ?")
|
||||
.get("felo-connection") as {
|
||||
provider: string;
|
||||
is_active: number;
|
||||
error_code: string;
|
||||
};
|
||||
assert.deepEqual(replaceIdentityControl, {
|
||||
provider: " FeLo ",
|
||||
is_active: 0,
|
||||
error_code: "PROVIDER_REMOVED",
|
||||
});
|
||||
|
||||
db.prepare("UPDATE provider_connections SET name = 'renamed' WHERE id = 'felo-connection'").run();
|
||||
const unrelatedUpdate = readConnectionById("felo-connection");
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
//
|
||||
// The reserved set is shared between the runtime guard and the validation
|
||||
// schemas via src/shared/constants/reservedProviderPrefixes.ts (single source of
|
||||
// truth). Set semantics mirror the old inline guard exactly:
|
||||
// - REGISTRY entry ids + aliases only;
|
||||
// - case-sensitive (mixed-case "TokenRouter" does NOT collide at runtime);
|
||||
// truth). Set semantics mirror runtime behavior:
|
||||
// - active REGISTRY entry ids + aliases, plus permanent retirement tombstones;
|
||||
// - active ids stay case-sensitive, while retired ids use trim/lowercase;
|
||||
// - manual alias ids that live outside REGISTRY (xiaomi/llamacpp/aq) are NOT
|
||||
// included — verified they do not intercept nodes at runtime.
|
||||
import test from "node:test";
|
||||
@@ -90,6 +90,13 @@ test("shared set contains REGISTRY ids and aliases (tokenrouter + trk)", () => {
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("trk"), true);
|
||||
});
|
||||
|
||||
test("shared guard keeps retired Felo ids reserved after registry removal", () => {
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("felo-web"), true);
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("felo"), true);
|
||||
assert.equal(isReservedProviderPrefix(" FeLo-Web "), true);
|
||||
assert.equal(isReservedProviderPrefix("\u00a0FELO\uFEFF"), true);
|
||||
});
|
||||
|
||||
test("shared set is case-sensitive like the runtime guard", () => {
|
||||
assert.equal(isReservedProviderPrefix("TokenRouter"), false);
|
||||
assert.equal(isReservedProviderPrefix("TOKENROUTER"), false);
|
||||
@@ -106,12 +113,12 @@ test("shared set excludes manual aliases that never intercept nodes at runtime",
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("aq"), false);
|
||||
});
|
||||
|
||||
test("shared set size matches full REGISTRY scan (398 unique prefixes)", () => {
|
||||
test("shared set size matches registry plus retired tombstones (398 unique prefixes)", () => {
|
||||
// Count measured against release/v3.8.51 tip after #11629 (opper) and
|
||||
// #11631 (1min.ai) boarded — 398 unique ids/aliases walked from the
|
||||
// provider REGISTRY on top of the 395 pinned post-#11333.
|
||||
// the assertion pins that the set is a full REGISTRY walk, not a
|
||||
// hand-maintained list.
|
||||
// #11631 (1min.ai) boarded (398 pinned post-#11333) — Felo's live REGISTRY
|
||||
// entry (id + alias) is removed but the same two strings are re-added as
|
||||
// permanent tombstones, so the set size is unchanged. The assertion pins
|
||||
// the actual computed set size, not a hand-derived sum.
|
||||
assert.equal(RESERVED_PREFIX_COUNT, 398);
|
||||
});
|
||||
|
||||
@@ -148,6 +155,31 @@ test("createProviderNodeSchema rejects reserved alias 'trk'", () => {
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("provider node schemas reject retired Felo prefixes and normalized variants", () => {
|
||||
for (const prefix of ["felo-web", "felo", " FeLo-Web ", "\u00a0FELO\uFEFF"]) {
|
||||
const created = createProviderNodeSchema.safeParse({
|
||||
name: "Retired prefix",
|
||||
prefix,
|
||||
apiType: "chat",
|
||||
});
|
||||
assert.equal(created.success, false, `create must reject ${JSON.stringify(prefix)}`);
|
||||
|
||||
const updated = updateProviderNodeSchema.safeParse({
|
||||
name: "Retired prefix",
|
||||
prefix,
|
||||
baseUrl: "https://retired.example.invalid/v1",
|
||||
});
|
||||
assert.equal(updated.success, false, `update must reject ${JSON.stringify(prefix)}`);
|
||||
|
||||
const preset = createProviderNodeSchema.safeParse({
|
||||
preset: "vibeproxy-openai",
|
||||
prefix,
|
||||
baseUrl: "http://localhost:8317",
|
||||
});
|
||||
assert.equal(preset.success, false, `preset create must reject ${JSON.stringify(prefix)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("createProviderNodeSchema accepts mixed-case 'TokenRouter' (no runtime collision)", () => {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "Case Test",
|
||||
|
||||
@@ -138,6 +138,33 @@ test("requires JSON mutation input after authenticating and exposes generic CORS
|
||||
assert.equal(attemptedExternalCalls, 0);
|
||||
});
|
||||
|
||||
test("acquire rejects retired Felo models with the sanitized retirement response", async () => {
|
||||
const retiredConnection = (await providersDb.createProviderConnection({
|
||||
provider: "felo-web",
|
||||
authType: "apikey",
|
||||
name: "retired-felo-lease-route",
|
||||
apiKey: "sk-retired-felo-lease-route",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
})) as { id: string };
|
||||
const managed = await seedKey([retiredConnection.id]);
|
||||
|
||||
const response = await route.POST(
|
||||
request(managed.key, { action: "acquire", model: "felo-web/gpt-4o" }, OWNER_A)
|
||||
);
|
||||
|
||||
assert.equal(response.status, 410);
|
||||
const body = await json(response);
|
||||
assert.equal((body.error as { code?: string }).code, "PROVIDER_RETIRED");
|
||||
assert.equal(
|
||||
(body.error as { message?: string }).message,
|
||||
"Provider is retired and unavailable."
|
||||
);
|
||||
assert.equal(JSON.stringify(body).includes("felo-web"), false);
|
||||
assert.equal(attemptedExternalCalls, 0);
|
||||
});
|
||||
|
||||
test("acquires, reuses, renews, releases, and fences a stale lifecycle", async () => {
|
||||
const connection = await seedConnection(1);
|
||||
const managed = await seedKey([connection.id]);
|
||||
|
||||
Reference in New Issue
Block a user