From 719150a34d4bcbd98b69b24a80bb1a5f3545346f Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:07:30 -0300 Subject: [PATCH] fix(providers): harden Felo retirement tombstones --- open-sse/executors/index.ts | 9 +- open-sse/handlers/chatCore/executorProxy.ts | 9 +- open-sse/utils/error.ts | 23 +- src/app/api/v1/chat/completions/route.ts | 23 +- src/app/api/v1/messages/count_tokens/route.ts | 16 + src/app/api/v1/models/catalog.ts | 8 +- src/app/api/v1/session-leases/route.ts | 7 +- src/lib/db/migrations/163_retire_felo_web.sql | 137 +++++++-- src/lib/db/providers.ts | 25 +- src/lib/providerNodePrefixes.ts | 19 +- src/shared/constants/providerRetirement.ts | 31 ++ .../constants/reservedProviderPrefixes.ts | 22 +- src/shared/validation/schemas/provider.ts | 22 +- src/sse/handlers/chat.ts | 9 +- src/sse/handlers/chatHelpers.ts | 16 +- src/sse/services/model.ts | 6 + tests/unit/chatcore-executor-proxy.test.ts | 32 +- tests/unit/felo-web-runtime-block.test.ts | 281 +++++++++++++++++- .../unit/messages-count-tokens-route.test.ts | 26 ++ .../migration-163-retire-felo-web.test.ts | 183 +++++++++++- .../provider-node-reserved-prefix.test.ts | 48 ++- tests/unit/session-leases-route.test.ts | 27 ++ 22 files changed, 875 insertions(+), 104 deletions(-) diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index ee45b9b626..fbc6db5b0a 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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 { - 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)) { diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts index 3d7e8917d4..ccc0e0b564 100644 --- a/open-sse/handlers/chatCore/executorProxy.ts +++ b/open-sse/handlers/chatCore/executorProxy.ts @@ -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 | 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) { diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 8034c5a536..4fafd05b1a 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -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", + }, + } + ); } /** diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index a7e02842c6..2dc0dfcbce 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -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) { diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 80bbc5d77f..0be43d5d0a 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -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)}` diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 214232e7bf..7b55a283fd 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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; } diff --git a/src/app/api/v1/session-leases/route.ts b/src/app/api/v1/session-leases/route.ts index 53671f5ad3..d2151a8098 100644 --- a/src/app/api/v1/session-leases/route.ts +++ b/src/app/api/v1/session-leases/route.ts @@ -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 => handleCorsOptions(); export async function POST(request: Request): Promise { 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 { 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"); } diff --git a/src/lib/db/migrations/163_retire_felo_web.sql b/src/lib/db/migrations/163_retire_felo_web.sql index 1feffc9669..40d8732e2e 100644 --- a/src/lib/db/migrations/163_retire_felo_web.sql +++ b/src/lib/db/migrations/163_retire_felo_web.sql @@ -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; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index ae79516db2..4ac145d88e 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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 { diff --git a/src/lib/providerNodePrefixes.ts b/src/lib/providerNodePrefixes.ts index 83a4fbb623..2ce26a543b 100644 --- a/src/lib/providerNodePrefixes.ts +++ b/src/lib/providerNodePrefixes.ts @@ -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 { - const reserved = new Set(); - 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 { - 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 { const eligibleNodeIds = new Set(); 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; diff --git a/src/shared/constants/providerRetirement.ts b/src/shared/constants/providerRetirement.ts index ad3048cc15..975d7a9b88 100644 --- a/src/shared/constants/providerRetirement.ts +++ b/src/shared/constants/providerRetirement.ts @@ -4,6 +4,13 @@ * together so neither executor dispatch nor credential selection can fall back. */ export const RUNTIME_RETIRED_PROVIDER_IDS: ReadonlySet = 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; +} diff --git a/src/shared/constants/reservedProviderPrefixes.ts b/src/shared/constants/reservedProviderPrefixes.ts index fb6471b760..5af2867b9f 100644 --- a/src/shared/constants/reservedProviderPrefixes.ts +++ b/src/shared/constants/reservedProviderPrefixes.ts @@ -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 | null = null; function buildReservedProviderPrefixes(): Set { @@ -29,13 +34,15 @@ function buildReservedProviderPrefixes(): Set { 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 { return buildReservedProviderPrefixes(); @@ -58,7 +65,10 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet = 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)) + ); } /** diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index a99ba7c947..696db7de9c 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -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({ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index aadf7d29d3..b78924023b 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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. diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index bcf9a51c62..94361eb81b 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -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 | 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 ( diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index aa317dcb96..0aa8743594 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -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) => { diff --git a/tests/unit/chatcore-executor-proxy.test.ts b/tests/unit/chatcore-executor-proxy.test.ts index dd4cc6e2fa..d2311c5fa7 100644 --- a/tests/unit/chatcore-executor-proxy.test.ts +++ b/tests/unit/chatcore-executor-proxy.test.ts @@ -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")); +}); diff --git a/tests/unit/felo-web-runtime-block.test.ts b/tests/unit/felo-web-runtime-block.test.ts index c16741b96c..82eea583f2 100644 --- a/tests/unit/felo-web-runtime-block.test.ts +++ b/tests/unit/felo-web-runtime-block.test.ts @@ -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(); + 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()) { diff --git a/tests/unit/messages-count-tokens-route.test.ts b/tests/unit/messages-count-tokens-route.test.ts index f3a345a433..f056fa339a 100644 --- a/tests/unit/messages-count-tokens-route.test.ts +++ b/tests/unit/messages-count-tokens-route.test.ts @@ -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", diff --git a/tests/unit/migration-163-retire-felo-web.test.ts b/tests/unit/migration-163-retire-felo-web.test.ts index 4bb659ab97..be9c25eb02 100644 --- a/tests/unit/migration-163-retire-felo-web.test.ts +++ b/tests/unit/migration-163-retire-felo-web.test.ts @@ -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(); + 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"); diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index d7514541b7..f8d7826a69 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -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", diff --git a/tests/unit/session-leases-route.test.ts b/tests/unit/session-leases-route.test.ts index f2c7492f3b..b2f4f23c94 100644 --- a/tests/unit/session-leases-route.test.ts +++ b/tests/unit/session-leases-route.test.ts @@ -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]);