fix(providers): harden Felo retirement tombstones

This commit is contained in:
diegosouzapw
2026-08-26 21:07:30 -03:00
committed by Markus Hartung
parent 6f62c97faf
commit 719150a34d
22 changed files with 875 additions and 104 deletions

View File

@@ -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)) {

View File

@@ -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) {

View File

@@ -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",
},
}
);
}
/**

View File

@@ -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) {

View File

@@ -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)}`

View File

@@ -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;
}

View File

@@ -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");
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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))
);
}
/**

View File

@@ -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({

View File

@@ -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.

View File

@@ -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 (

View File

@@ -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) => {

View File

@@ -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"));
});

View File

@@ -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()) {

View File

@@ -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",

View File

@@ -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");

View File

@@ -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",

View File

@@ -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]);