From 6ab41cc520482d45fb79b27f81f8dd9043fa72ea Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 26 Jul 2026 11:07:49 +0900 Subject: [PATCH] fix: enforce OpenAI model lifecycle without silent reroutes --- config/quality/eslint-suppressions.json | 2 +- open-sse/config/constants.ts | 1 + open-sse/config/errorConfig.ts | 2 + open-sse/handlers/chatCore.ts | 34 +-- .../handlers/chatCore/modelLifecyclePolicy.ts | 60 +++++ open-sse/services/modelDeprecation.ts | 11 +- open-sse/services/modelEndpointPolicy.ts | 114 +++++++++ open-sse/services/modelFamilyFallback.ts | 144 +++++++----- open-sse/services/modelLifecycle.ts | 217 ++++++++++++++++++ .../[id]/hooks/useModelImportHandlers.ts | 2 +- .../[id]/models/modelRouteProjection.ts | 112 +++++++++ src/app/api/providers/[id]/models/route.ts | 112 ++------- .../api/providers/[id]/sync-models/route.ts | 1 + src/app/api/v1/models/catalog.ts | 11 +- src/app/api/v1/models/catalogModelPolicy.ts | 18 ++ src/lib/providerModels/managedModelImport.ts | 16 +- src/lib/providerModels/modelDiscovery.ts | 7 +- src/shared/components/ModelSelectModal.tsx | 7 +- tests/unit/chatcore-translation-paths.test.ts | 41 ++-- tests/unit/managed-model-import.test.ts | 49 ++++ tests/unit/model-deprecation.test.ts | 6 +- tests/unit/model-endpoint-policy.test.ts | 77 +++++++ .../unit/model-lifecycle-integration.test.ts | 143 ++++++++++++ tests/unit/model-lifecycle.test.ts | 81 +++++++ .../t30-kiro-400-model-unavailable.test.ts | 35 ++- 25 files changed, 1088 insertions(+), 215 deletions(-) create mode 100644 open-sse/handlers/chatCore/modelLifecyclePolicy.ts create mode 100644 open-sse/services/modelEndpointPolicy.ts create mode 100644 open-sse/services/modelLifecycle.ts create mode 100644 src/app/api/providers/[id]/models/modelRouteProjection.ts create mode 100644 src/app/api/v1/models/catalogModelPolicy.ts create mode 100644 tests/unit/model-endpoint-policy.test.ts create mode 100644 tests/unit/model-lifecycle-integration.test.ts create mode 100644 tests/unit/model-lifecycle.test.ts diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 6e2c8113902b..a2d6ecea6d70 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1788,7 +1788,7 @@ }, "tests/unit/chatcore-translation-paths.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 34 + "count": 31 } }, "tests/unit/chatgpt-web-tools-5240.test.ts": { diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index ef4b9e818d39..0ee89452aa30 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -172,6 +172,7 @@ export const HTTP_STATUS = { NOT_FOUND: 404, NOT_ACCEPTABLE: 406, REQUEST_TIMEOUT: 408, + GONE: 410, RATE_LIMITED: 429, SERVER_ERROR: 500, BAD_GATEWAY: 502, diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index dcdf3bae9c8d..3081f53fb911 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -28,6 +28,7 @@ export const ERROR_TYPES: Record = { 403: { type: "permission_error", code: "insufficient_quota" }, 404: { type: "invalid_request_error", code: "model_not_found" }, 406: { type: "invalid_request_error", code: "model_not_supported" }, + 410: { type: "invalid_request_error", code: "model_shutdown" }, 429: { type: "rate_limit_error", code: "rate_limit_exceeded" }, 499: { type: "client_disconnected", code: "client_disconnected" }, 500: { type: "server_error", code: "internal_server_error" }, @@ -44,6 +45,7 @@ export const DEFAULT_ERROR_MESSAGES: Record = { 403: "You exceeded your current quota", 404: "Model not found", 406: "Model not supported", + 410: "Model has been shut down", 429: "Rate limit exceeded", 499: "Client disconnected", 500: "Internal server error", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4130bed73817..ced96013390b 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -7,6 +7,7 @@ import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; +import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts"; import { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage, @@ -116,7 +117,6 @@ import { getStripTypesForProviderModel, stripIncompatibleMessageContent, } from "../services/modelStrip.ts"; -import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { isOpencodeGoProvider, @@ -643,6 +643,9 @@ export async function handleChatCore({ responsesInputItems ); + const requestedLifecycleError = checkLifecycle(provider, model, log); + if (requestedLifecycleError) return requestedLifecycleError; + // Check for bypass patterns (warmup, skip) - return fake response const bypassResponse = handleBypassRequest(body, model, userAgent); if (bypassResponse) { @@ -707,16 +710,13 @@ export async function handleChatCore({ }); } - // Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472) - // Custom aliases take priority over built-in and must be resolved here so the - // downstream getModelTargetFormat() lookup AND the actual provider request use - // the correct, aliased model ID. Without this, aliases only affect format detection. - const resolvedModel = resolveModelAlias(model); - // Use resolvedModel for all downstream operations (routing, provider requests, logging) - let effectiveModel = resolvedModel === model ? model : resolvedModel; - if (resolvedModel !== model) { - log?.info?.("ALIAS", `Model alias applied: ${model} → ${resolvedModel}`); - } + // Custom aliases remain explicit; lifecycle replacements are advisory and never silently routed. + let [resolvedModel, effectiveModel, routedLifecycleError] = resolveLifecycle( + provider, + model, + log + ); + if (routedLifecycleError) return routedLifecycleError; // Effort-variant model ids: the Claude / Claude-Code model picker (e.g. VS Code's // "Effort" slider) advertises claude-...-{low,medium,high,xhigh,max}. Anthropic has @@ -3870,8 +3870,8 @@ export async function handleChatCore({ // Before returning a model-unavailable error upstream, try sibling models // from the same family. This keeps the request alive on the same account // instead of failing the entire combo. - if (isModelUnavailableError(statusCode, message)) { - const nextModel = getNextFamilyFallback(currentModel, triedModels); + if (isModelUnavailableError(statusCode, message, provider)) { + const nextModel = getNextFamilyFallback(currentModel, triedModels, provider); if (nextModel) { triedModels.add(nextModel); currentModel = nextModel; @@ -3957,12 +3957,12 @@ export async function handleChatCore({ ); } } else if (isContextOverflowError(statusCode, message)) { - const familyCandidates = getModelFamily(currentModel).filter( + const familyCandidates = getModelFamily(currentModel, provider).filter( (m) => m !== currentModel && !triedModels.has(m) ); const nextModel = - findLargerContextModel(currentModel, familyCandidates) ?? - getNextFamilyFallback(currentModel, triedModels); + findLargerContextModel(currentModel, familyCandidates, provider) ?? + getNextFamilyFallback(currentModel, triedModels, provider); if (nextModel) { triedModels.add(nextModel); currentModel = nextModel; @@ -4213,7 +4213,7 @@ export async function handleChatCore({ persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "empty_content"); // Trigger non-recursive fallback for empty content - const nextModel = getNextFamilyFallback(currentModel, triedModels); + const nextModel = getNextFamilyFallback(currentModel, triedModels, provider); if (nextModel) { triedModels.add(nextModel); currentModel = nextModel; diff --git a/open-sse/handlers/chatCore/modelLifecyclePolicy.ts b/open-sse/handlers/chatCore/modelLifecyclePolicy.ts new file mode 100644 index 000000000000..698e3c5c8e19 --- /dev/null +++ b/open-sse/handlers/chatCore/modelLifecyclePolicy.ts @@ -0,0 +1,60 @@ +import { HTTP_STATUS } from "../../config/constants.ts"; +import { + formatModelLifecycleMessage, + getModelLifecycleDecision, +} from "../../services/modelLifecycle.ts"; +import { resolveModelAlias } from "../../services/modelDeprecation.ts"; +import { createErrorResult } from "../../utils/error.ts"; + +type LifecycleLogger = { + info?: (tag: string, message: string) => unknown; + warn?: (tag: string, message: string) => unknown; +} | null; + +function getModelLifecycleError({ + provider, + model, + log, + warnOnDeprecation = false, +}: { + provider: string; + model: string; + log?: LifecycleLogger; + warnOnDeprecation?: boolean; +}): ReturnType | null { + const decision = getModelLifecycleDecision(provider, model); + const message = formatModelLifecycleMessage(decision); + if (message && (decision.action === "reject" || warnOnDeprecation)) { + log?.warn?.("MODEL_LIFECYCLE", message); + } + if (decision.action !== "reject" || !message) return null; + return createErrorResult( + HTTP_STATUS.GONE, + message, + null, + "model_shutdown", + "invalid_request_error" + ); +} + +export function checkLifecycle(provider: string, model: string, log?: LifecycleLogger) { + return getModelLifecycleError({ + provider, + model: resolveModelAlias(model), + log, + }); +} + +export function resolveLifecycle(provider: string, model: string, log?: LifecycleLogger) { + const resolvedModel = resolveModelAlias(model); + if (resolvedModel !== model) { + log?.info?.("ALIAS", `Model alias applied: ${model} → ${resolvedModel}`); + } + const lifecycleError = getModelLifecycleError({ + provider, + model: resolvedModel, + log, + warnOnDeprecation: true, + }); + return [resolvedModel, resolvedModel === model ? model : resolvedModel, lifecycleError] as const; +} diff --git a/open-sse/services/modelDeprecation.ts b/open-sse/services/modelDeprecation.ts index cf965abf345a..4f38d68854dc 100644 --- a/open-sse/services/modelDeprecation.ts +++ b/open-sse/services/modelDeprecation.ts @@ -32,12 +32,6 @@ const BUILT_IN_ALIASES: Record = { "claude-3-5-sonnet-latest": "claude-sonnet-4-20250514", "claude-3-5-haiku-latest": "claude-3-5-sonnet-20241022", - // OpenAI legacy → current - "gpt-4-turbo-preview": "gpt-4-turbo", - "gpt-4-0125-preview": "gpt-4-turbo", - "gpt-4-1106-preview": "gpt-4-turbo", - "gpt-3.5-turbo-0125": "gpt-3.5-turbo", - // Kimi/Moonshot — Fireworks long-path aliases (#265) "accounts/fireworks/models/kimi-k2p5": "moonshotai/Kimi-K2.5", "fireworks/accounts/fireworks/models/kimi-k2p5": "moonshotai/Kimi-K2.5", @@ -70,10 +64,7 @@ const BUILT_IN_ALIASES: Record = { // root cause (both instances read/write one store), mirroring the #5312 pattern already // applied to thinkingBudget.ts and backgroundTaskDetector.ts (and systemPrompt.ts #2470). const CUSTOM_ALIASES_GLOBAL_KEY = "__omniroute_customAliases__"; -const _aliasStore = globalThis as unknown as Record< - string, - Record | undefined ->; +const _aliasStore = globalThis as unknown as Record | undefined>; function customAliases(): Record { if (!_aliasStore[CUSTOM_ALIASES_GLOBAL_KEY]) { diff --git a/open-sse/services/modelEndpointPolicy.ts b/open-sse/services/modelEndpointPolicy.ts new file mode 100644 index 000000000000..665149f12453 --- /dev/null +++ b/open-sse/services/modelEndpointPolicy.ts @@ -0,0 +1,114 @@ +/** + * Provider model endpoint policy. + * + * Upstream `/models` responses often omit endpoint/modality metadata. In that + * case, specialty models can otherwise be imported as chat models simply + * because "chat" is OmniRoute's historical default. Keep the exceptional + * provider knowledge here so discovery, import, and catalog projection agree. + */ + +export type ModelEndpointKind = "chat" | "image" | "video" | "non-chat" | "unknown"; + +export type ModelEndpointDecision = { + kind: ModelEndpointKind; + chatSelectable: boolean; + reason: "explicit-endpoints" | "provider-policy" | "unclassified"; +}; + +type EndpointAwareModel = { + id: string; + supportedEndpoints?: readonly string[]; +}; + +const CHAT_ENDPOINTS = new Set([ + "chat", + "chat-completions", + "chat/completions", + "messages", + "responses", +]); +const IMAGE_ENDPOINTS = new Set(["image", "images", "images/generations"]); +const VIDEO_ENDPOINTS = new Set(["video", "videos", "videos/generations"]); + +function normalizeEndpoint(endpoint: string): string { + return endpoint.trim().toLowerCase().replace(/^\/+/, "").replace(/^v1\//, ""); +} + +function classifyExplicitEndpoints( + supportedEndpoints: readonly string[] | undefined +): ModelEndpointDecision | null { + if (!supportedEndpoints?.length) return null; + + const endpoints = supportedEndpoints.map(normalizeEndpoint).filter(Boolean); + if (endpoints.some((endpoint) => CHAT_ENDPOINTS.has(endpoint))) { + return { kind: "chat", chatSelectable: true, reason: "explicit-endpoints" }; + } + if (endpoints.some((endpoint) => IMAGE_ENDPOINTS.has(endpoint))) { + return { kind: "image", chatSelectable: false, reason: "explicit-endpoints" }; + } + if (endpoints.some((endpoint) => VIDEO_ENDPOINTS.has(endpoint))) { + return { kind: "video", chatSelectable: false, reason: "explicit-endpoints" }; + } + return { kind: "non-chat", chatSelectable: false, reason: "explicit-endpoints" }; +} + +function normalizeOpenAiModelId(modelId: string): string { + return modelId.startsWith("openai/") ? modelId.slice("openai/".length) : modelId; +} + +function classifyOpenAiModel(modelId: string): ModelEndpointDecision | null { + const normalized = normalizeOpenAiModelId(modelId).toLowerCase(); + if ( + normalized.startsWith("gpt-image-") || + normalized.startsWith("dall-e-") || + normalized === "chatgpt-image-latest" + ) { + return { kind: "image", chatSelectable: false, reason: "provider-policy" }; + } + if (normalized.startsWith("sora-")) { + return { kind: "video", chatSelectable: false, reason: "provider-policy" }; + } + return null; +} + +export function getModelEndpointDecision( + provider: string | null | undefined, + modelId: string, + supportedEndpoints?: readonly string[] +): ModelEndpointDecision { + const explicit = classifyExplicitEndpoints(supportedEndpoints); + if (provider?.trim().toLowerCase() === "openai") { + const openAiDecision = classifyOpenAiModel(modelId); + if (openAiDecision) { + // Old imported rows were persisted with `["chat"]` as a synthetic default + // even when upstream `/models` supplied no endpoint metadata. Do not let + // that default reclassify a known specialty model. A genuinely + // multi-endpoint model can opt in by explicitly naming both its specialty + // endpoint and a chat/Responses endpoint. + const normalizedEndpoints = supportedEndpoints?.map(normalizeEndpoint) ?? []; + const hasSpecialtyEndpoint = + openAiDecision.kind === "image" + ? normalizedEndpoints.some((endpoint) => IMAGE_ENDPOINTS.has(endpoint)) + : normalizedEndpoints.some((endpoint) => VIDEO_ENDPOINTS.has(endpoint)); + if (explicit?.chatSelectable && hasSpecialtyEndpoint) return explicit; + return openAiDecision; + } + } + + if (explicit) return explicit; + return { kind: "unknown", chatSelectable: true, reason: "unclassified" }; +} + +export function isChatSelectableModel( + provider: string | null | undefined, + model: EndpointAwareModel +): boolean { + return getModelEndpointDecision(provider, model.id, model.supportedEndpoints).chatSelectable; +} + +export function filterChatSelectableModels( + provider: string | null | undefined, + models: readonly T[] +): T[] { + return models.filter((model) => isChatSelectableModel(provider, model)); +} diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 9411e0a2dc47..5e80fe0d4050 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -19,6 +19,7 @@ import { isResourceNotFoundResponse, } from "./errorClassifier.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { isModelSelectable } from "./modelLifecycle.ts"; // ── Model Family Definitions ───────────────────────────────────────────────── @@ -26,7 +27,7 @@ import { getRegistryEntry } from "../config/providerRegistry.ts"; * Ordered candidate lists per model family. * First entry is the most preferred; fallback proceeds in order. */ -const MODEL_FAMILIES: Record = { +const FAMILY_FALLBACK_TEMPLATES: Record = { // Gemini 3 / 3.1 Pro family — ordered by preference "gemini-3-pro": [ "gemini-3.1-pro-preview", @@ -96,10 +97,6 @@ const MODEL_FAMILIES: Record = { ], "claude-sonnet-4-6": ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"], "claude-sonnet-4-5-20250929": ["claude-sonnet-4-6", "claude-sonnet-4-20250514"], - - // GPT-5 family - "gpt-5": ["gpt-5-mini", "gpt-4o"], - "gpt-5.1": ["gpt-5.1-mini", "gpt-5", "gpt-4o"], }; // ── Error Detection ────────────────────────────────────────────────────────── @@ -119,21 +116,24 @@ const MODEL_UNAVAILABLE_FRAGMENTS = [ "this model does not exist", "invalid model", "model not supported", - "does not support", "not enabled for", "access to model", - "improperly formed request", // Kiro 400 (model unavailable) ]; /** * Returns true if the HTTP status + error message indicates the model * itself is not available, not a transient server error. */ -export function isModelUnavailableError(status: number, errorMessage: string): boolean { +export function isModelUnavailableError( + status: number, + errorMessage: string, + provider?: string | null +): boolean { if (status === 404) return !isResourceNotFoundResponse(errorMessage); if (status !== 400 && status !== 403) return false; const msg = errorMessage.toLowerCase(); + if (provider === "kiro" && msg.includes("improperly formed request")) return true; if (MODEL_UNAVAILABLE_FRAGMENTS.some((fragment) => msg.includes(fragment))) return true; return containsModelUnavailableMessage(errorMessage); } @@ -177,46 +177,88 @@ function resolveCandidateNotation(candidate: string, supportedIds: Set): return candidateNotationVariants(candidate).find((variant) => supportedIds.has(variant)) ?? null; } +function resolveFamilyContext(currentModel: string, providerHint?: string | null) { + const parsed = parseModel(currentModel); + const bareModel = parsed.model || currentModel; + const explicitProvider = parsed.provider || parsed.providerAlias || null; + const registryEntry = getRegistryEntry(explicitProvider || providerHint || ""); + if (!registryEntry) return null; + + const lookupKey = bareModel.replace(/\./g, "-"); + const family = + FAMILY_FALLBACK_TEMPLATES[lookupKey] ?? FAMILY_FALLBACK_TEMPLATES[bareModel] ?? null; + if (!family) return null; + + return { + bareModel, + family, + provider: registryEntry.id, + outputPrefix: explicitProvider ? `${registryEntry.id}/` : "", + supportedIds: new Set(registryEntry.models.map((model) => model.id)), + }; +} + +function wasCandidateTried( + candidateModel: string, + provider: string, + triedModels: Set +): boolean { + for (const attempted of triedModels) { + const parsed = parseModel(attempted); + const attemptedModel = parsed.model || attempted; + const attemptedProvider = parsed.provider || parsed.providerAlias || provider; + const registryEntry = getRegistryEntry(attemptedProvider); + if ( + (registryEntry?.id || attemptedProvider) === provider && + attemptedModel === candidateModel + ) { + return true; + } + } + return false; +} + +function resolveProviderFamilyCandidates( + currentModel: string, + providerHint?: string | null +): { provider: string; outputPrefix: string; candidates: string[] } | null { + const context = resolveFamilyContext(currentModel, providerHint); + if (!context) return null; + + const candidates: string[] = []; + for (const candidate of context.family) { + const resolvedCandidate = resolveCandidateNotation(candidate, context.supportedIds); + if (!resolvedCandidate) continue; + if (!isModelSelectable(context.provider, resolvedCandidate)) continue; + if (!candidates.includes(resolvedCandidate)) candidates.push(resolvedCandidate); + } + + return { + provider: context.provider, + outputPrefix: context.outputPrefix, + candidates, + }; +} + /** * Get the next fallback model from the same family. * * @param currentModel The model that just failed * @param triedModels Set of model IDs already tried (to avoid cycles) + * @param providerHint Current provider when currentModel is an unprefixed wire ID * @returns Next model to try, or null if family exhausted */ export function getNextFamilyFallback( currentModel: string, - triedModels: Set + triedModels: Set, + providerHint?: string | null ): string | null { - const parsed = parseModel(currentModel); - const bareModel = parsed.model || currentModel; - const provider = parsed.provider || parsed.providerAlias || ""; - const prefix = provider ? `${provider}/` : ""; - - // Normalize dots to hyphens so kiro/claude-opus-4.8 finds the right entry. - // Fall back to the bare model name to support keys like "gemini-3.1-pro-high" - // whose dots are part of the literal name, not a version separator. - const lookupKey = bareModel.replace(/\./g, "-"); - const family = MODEL_FAMILIES[lookupKey] ?? MODEL_FAMILIES[bareModel]; - if (!family) return null; + const resolved = resolveProviderFamilyCandidates(currentModel, providerHint); + if (!resolved) return null; - // Resolve the provider's supported model IDs so we can match notation (dot vs hyphen) - const registryEntry = provider ? getRegistryEntry(provider) : null; - const supportedIds = registryEntry ? new Set(registryEntry.models.map((m) => m.id)) : null; - - for (const candidate of family) { - let resolvedCandidate = candidate; - if (supportedIds && !supportedIds.has(candidate)) { - const match = resolveCandidateNotation(candidate, supportedIds); - // Provider catalog is known but this candidate has no match under any - // notation — it is provably unsupported, so skip it instead of - // returning an id the provider will just 400 on again. - if (!match) continue; - resolvedCandidate = match; - } - const fullCandidate = `${prefix}${resolvedCandidate}`; - if (!triedModels.has(fullCandidate)) { - return fullCandidate; + for (const candidate of resolved.candidates) { + if (!wasCandidateTried(candidate, resolved.provider, triedModels)) { + return `${resolved.outputPrefix}${candidate}`; } } @@ -226,24 +268,18 @@ export function getNextFamilyFallback( /** * Check if a model belongs to any registered family. */ -export function isInModelFamily(model: string): boolean { - const parsed = parseModel(model); - const bareModel = parsed.model || model; - return bareModel in MODEL_FAMILIES; +export function isInModelFamily(model: string, providerHint?: string | null): boolean { + const resolved = resolveProviderFamilyCandidates(model, providerHint); + return Boolean(resolved?.candidates.length); } /** * Get all members of a model's family (including itself). */ -export function getModelFamily(model: string): string[] { - const parsed = parseModel(model); - const bareModel = parsed.model || model; - const prefix = - parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : ""; - - const family = MODEL_FAMILIES[bareModel]; - if (!family) return [model]; - return [model, ...family.map((c) => `${prefix}${c}`)]; +export function getModelFamily(model: string, providerHint?: string | null): string[] { + const resolved = resolveProviderFamilyCandidates(model, providerHint); + if (!resolved) return [model]; + return [model, ...resolved.candidates.map((candidate) => `${resolved.outputPrefix}${candidate}`)]; } /** @@ -252,10 +288,12 @@ export function getModelFamily(model: string): string[] { */ export function findLargerContextModel( currentModel: string, - availableModels: string[] + availableModels: string[], + providerHint?: string | null ): string | null { const currentParsed = parseModel(currentModel); - const currentProvider = currentParsed.provider || currentParsed.providerAlias || "unknown"; + const currentProvider = + currentParsed.provider || currentParsed.providerAlias || providerHint || "unknown"; const currentModelId = currentParsed.model || currentModel; const currentLimit = getModelContextLimit(currentProvider, currentModelId) ?? 0; @@ -265,7 +303,7 @@ export function findLargerContextModel( for (const candidate of availableModels) { if (candidate === currentModel) continue; const parsed = parseModel(candidate); - const provider = parsed.provider || parsed.providerAlias || "unknown"; + const provider = parsed.provider || parsed.providerAlias || providerHint || "unknown"; const modelId = parsed.model || candidate; const limit = getModelContextLimit(provider, modelId) ?? 0; diff --git a/open-sse/services/modelLifecycle.ts b/open-sse/services/modelLifecycle.ts new file mode 100644 index 000000000000..8efd93db7f96 --- /dev/null +++ b/open-sse/services/modelLifecycle.ts @@ -0,0 +1,217 @@ +/** + * Provider-scoped model lifecycle policy. + * + * Replacement model IDs are migration guidance only. This module never rewrites a + * request: shutdown models are rejected, deprecated models remain callable until + * their shutdown date, and untracked models pass through unchanged. + */ + +export const OPENAI_MODEL_DEPRECATIONS_URL = "https://developers.openai.com/api/docs/deprecations"; + +export type ModelLifecycleStatus = "untracked" | "deprecated" | "shutdown"; +export type ModelLifecycleAction = "allow" | "warn" | "reject"; +export type ModelLifecycleKind = + "audio" | "computer-use" | "deep-research" | "realtime" | "search" | "speech" | "text"; + +export type ModelLifecycleReplacement = { + provider: string; + model: string; + notes?: string; +}; + +export type ModelLifecycleRecord = { + provider: string; + model: string; + shutdownAt: string; + replacement: ModelLifecycleReplacement | null; + kind: ModelLifecycleKind; + source: string; +}; + +export type ModelLifecycleDecision = { + provider: string; + model: string; + status: ModelLifecycleStatus; + action: ModelLifecycleAction; + shutdownAt: string | null; + replacement: ModelLifecycleReplacement | null; + source: string | null; +}; + +const OPENAI_SOURCE = OPENAI_MODEL_DEPRECATIONS_URL; + +function openAiRecord( + model: string, + shutdownAt: string, + replacement: string | null, + kind: ModelLifecycleKind, + notes?: string +): ModelLifecycleRecord { + return { + provider: "openai", + model, + shutdownAt, + replacement: replacement + ? { + provider: "openai", + model: replacement, + ...(notes ? { notes } : {}), + } + : null, + kind, + source: OPENAI_SOURCE, + }; +} + +/** + * Unambiguous shutdowns from the official OpenAI deprecations page, verified + * 2026-07-26. The page lists gpt-4-1106-preview with conflicting shutdown dates, + * so that model is intentionally omitted until the upstream conflict is resolved. + */ +export const MODEL_LIFECYCLE_RECORDS: readonly ModelLifecycleRecord[] = Object.freeze([ + openAiRecord("computer-use-preview-2025-03-11", "2026-07-23", "gpt-5.6-terra", "computer-use"), + openAiRecord("computer-use-preview", "2026-07-23", "gpt-5.6-terra", "computer-use"), + openAiRecord("gpt-4o-mini-search-preview-2025-03-11", "2026-07-23", "gpt-5.6-terra", "search"), + openAiRecord("gpt-4o-search-preview-2025-03-11", "2026-07-23", "gpt-5.6-terra", "search"), + openAiRecord("gpt-4o-mini-tts-2025-03-20", "2026-07-23", "gpt-4o-mini-tts-2025-12-15", "speech"), + openAiRecord("gpt-5-chat-latest", "2026-07-23", "gpt-5.6-sol", "text"), + openAiRecord("gpt-5-codex", "2026-07-23", "gpt-5.6-sol", "text"), + openAiRecord("gpt-5.1-chat-latest", "2026-07-23", "gpt-5.6-sol", "text"), + openAiRecord("gpt-5.1-codex", "2026-07-23", "gpt-5.6-sol", "text"), + openAiRecord("gpt-5.1-codex-max", "2026-07-23", "gpt-5.6-sol", "text"), + openAiRecord("gpt-5.1-codex-mini", "2026-07-23", "gpt-5.6-terra", "text"), + openAiRecord("gpt-5.2-codex", "2026-07-23", "gpt-5.6-sol", "text"), + openAiRecord("o3-deep-research-2025-06-26", "2026-07-23", "gpt-5.6-sol", "deep-research"), + openAiRecord("o3-deep-research", "2026-07-23", "gpt-5.6-sol", "deep-research"), + openAiRecord("o4-mini-deep-research-2025-06-26", "2026-07-23", "gpt-5.6-sol", "deep-research"), + openAiRecord("o4-mini-deep-research", "2026-07-23", "gpt-5.6-sol", "deep-research"), + openAiRecord("gpt-audio-mini-2025-10-06", "2026-07-23", "gpt-audio-1.5", "audio"), + openAiRecord("gpt-realtime-mini-2025-10-06", "2026-07-23", "gpt-realtime-2.1-mini", "realtime"), + openAiRecord("gpt-5.2-chat-latest", "2026-08-10", "gpt-5.6-sol", "text"), + openAiRecord("gpt-5.3-chat-latest", "2026-08-10", "gpt-5.6-sol", "text"), + openAiRecord("gpt-3.5-turbo-0125", "2026-10-23", "gpt-5.6-terra", "text"), + openAiRecord("gpt-4-0314", "2026-03-26", null, "text"), + openAiRecord("gpt-4-0125-preview", "2026-03-26", null, "text"), + openAiRecord("gpt-4-turbo-preview", "2026-03-26", null, "text"), +]); + +const RECORDS_BY_KEY = new Map(); + +function lifecycleKey(provider: string, model: string): string { + return `${provider.trim().toLowerCase()}\0${model.trim()}`; +} + +for (const record of MODEL_LIFECYCLE_RECORDS) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(record.shutdownAt)) { + throw new Error( + `Invalid model lifecycle shutdown date for ${record.provider}/${record.model}: ${record.shutdownAt}` + ); + } + const key = lifecycleKey(record.provider, record.model); + if (RECORDS_BY_KEY.has(key)) { + throw new Error(`Duplicate model lifecycle record: ${record.provider}/${record.model}`); + } + if (record.replacement) Object.freeze(record.replacement); + RECORDS_BY_KEY.set(key, Object.freeze(record)); +} + +function toTimestamp(asOf: Date | number | string): number { + const value = + asOf instanceof Date ? asOf.getTime() : typeof asOf === "number" ? asOf : Date.parse(asOf); + if (!Number.isFinite(value)) { + throw new TypeError(`Invalid model lifecycle date: ${String(asOf)}`); + } + return value; +} + +function shutdownTimestamp(shutdownAt: string): number { + return Date.parse(`${shutdownAt}T00:00:00.000Z`); +} + +export function getModelLifecycleDecision( + provider: string | null | undefined, + model: string | null | undefined, + asOf: Date | number | string = Date.now() +): ModelLifecycleDecision { + const normalizedProvider = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + const normalizedModel = typeof model === "string" ? model.trim() : ""; + const record = RECORDS_BY_KEY.get(lifecycleKey(normalizedProvider, normalizedModel)); + + if (!record) { + return { + provider: normalizedProvider, + model: normalizedModel, + status: "untracked", + action: "allow", + shutdownAt: null, + replacement: null, + source: null, + }; + } + + const status = + toTimestamp(asOf) >= shutdownTimestamp(record.shutdownAt) ? "shutdown" : "deprecated"; + return { + provider: record.provider, + model: record.model, + status, + action: status === "shutdown" ? "reject" : "warn", + shutdownAt: record.shutdownAt, + replacement: record.replacement, + source: record.source, + }; +} + +export function formatModelLifecycleMessage(decision: ModelLifecycleDecision): string | null { + if (decision.status === "untracked") return null; + + const modelRef = `${decision.provider}/${decision.model}`; + const replacement = decision.replacement + ? ` Use "${decision.replacement.provider}/${decision.replacement.model}" instead.` + : ""; + if (decision.status === "shutdown") { + return `Model "${modelRef}" was shut down on ${decision.shutdownAt} and cannot be routed automatically.${replacement}`; + } + return `Model "${modelRef}" is deprecated and is scheduled to shut down on ${decision.shutdownAt}.${replacement}`; +} + +export function filterSelectableModels( + provider: string, + models: readonly T[], + { + asOf = Date.now(), + includeDeprecated = false, + includeShutdown = false, + }: { + asOf?: Date | number | string; + includeDeprecated?: boolean; + includeShutdown?: boolean; + } = {} +): T[] { + return models.filter((model) => + isModelSelectable(provider, model.id, { + asOf, + includeDeprecated, + includeShutdown, + }) + ); +} + +export function isModelSelectable( + provider: string, + model: string, + { + asOf = Date.now(), + includeDeprecated = false, + includeShutdown = false, + }: { + asOf?: Date | number | string; + includeDeprecated?: boolean; + includeShutdown?: boolean; + } = {} +): boolean { + const decision = getModelLifecycleDecision(provider, model, asOf); + if (decision.status === "deprecated") return includeDeprecated; + if (decision.status === "shutdown") return includeShutdown; + return true; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts index 17fe29fcd2d5..ca411e7fd032 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts @@ -129,7 +129,7 @@ export function useModelImportHandlers({ }); try { - const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`); + const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true&chatOnly=true`); const data = await res.json(); if (!res.ok) { setImportProgress((prev) => ({ diff --git a/src/app/api/providers/[id]/models/modelRouteProjection.ts b/src/app/api/providers/[id]/models/modelRouteProjection.ts new file mode 100644 index 000000000000..cdfd71dac671 --- /dev/null +++ b/src/app/api/providers/[id]/models/modelRouteProjection.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts"; +import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts"; +import { getModelIsHidden } from "@/lib/db/models"; +import { getSettings } from "@/lib/db/settings"; +import { getStaticModelsForProvider } from "@/lib/providers/staticModels"; +import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders"; +import { mergeLocalCatalogModels } from "./discovery/helpers"; + +export function filterModelsForRoute< + T extends { id: string; supportedEndpoints?: readonly string[] }, +>(provider: string, models: readonly T[], chatOnly: boolean): T[] { + const selectable = filterSelectableModels(provider, models); + return chatOnly ? filterChatSelectableModels(provider, selectable) : selectable; +} + +function toLiveModel(item: Record): { id: string; name: string } | null { + const itemId = typeof item.id === "string" ? item.id.trim() : ""; + if (!itemId) return null; + const itemName = + typeof item.display_name === "string" + ? item.display_name + : typeof item.name === "string" + ? item.name + : itemId; + return { id: itemId, name: itemName }; +} + +async function fetchLiveNoAuthModels( + modelsUrl: string, + providerId: string, + connectionId: string, + excludeHidden: boolean, + chatOnly: boolean +): Promise { + try { + const liveResponse = await safeOutboundFetch(modelsUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + if (!liveResponse.ok) return null; + + const data = await liveResponse.json(); + const liveModels: Array<{ id: string; name: string }> = ( + (data.data || data.models || []) as Array> + ) + .map(toLiveModel) + .filter((model): model is { id: string; name: string } => model !== null); + if (liveModels.length === 0) return null; + + const selectable = filterModelsForRoute(providerId, liveModels, chatOnly); + const visible = excludeHidden + ? selectable.filter((model) => !getModelIsHidden(providerId, model.id)) + : selectable; + return NextResponse.json({ + provider: providerId, + connectionId, + models: visible, + source: "upstream", + }); + } catch { + return null; + } +} + +export async function buildNoAuthModelsResponse( + providerId: string, + connectionId: string, + excludeHidden: boolean, + chatOnly: boolean +) { + if (isProviderBlockedByIdOrAlias(providerId, (await getSettings()).blockedProviders)) { + return NextResponse.json({ error: "Provider is disabled" }, { status: 403 }); + } + + const registryEntry = getRegistryEntry(providerId); + const modelsUrl = + typeof registryEntry?.modelsUrl === "string" && registryEntry.modelsUrl.length > 0 + ? registryEntry.modelsUrl + : null; + if (modelsUrl) { + const live = await fetchLiveNoAuthModels( + modelsUrl, + providerId, + connectionId, + excludeHidden, + chatOnly + ); + if (live) return live; + } + + const catalog = mergeLocalCatalogModels( + getModelsByProviderId(providerId) || [], + getStaticModelsForProvider(providerId) || [] + ).map((model) => ({ id: model.id, name: model.name || model.id })); + const selectable = filterModelsForRoute(providerId, catalog, chatOnly); + const visible = excludeHidden + ? selectable.filter((model) => !getModelIsHidden(providerId, model.id)) + : selectable; + return NextResponse.json({ + provider: providerId, + connectionId, + models: visible, + source: "local_catalog", + }); +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 8c2ebdd101ef..7ad24bb1336b 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -10,10 +10,8 @@ import { getModelsByProviderId } from "@/shared/constants/models"; import { resolveAlibabaProviderModelsUrl } from "@/shared/constants/alibabaProviderRegions"; import { getStaticModelsForProvider } from "@/lib/providers/staticModels"; import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; -import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders"; import { getCachedProviderConnectionById, - getSettings, getModelIsHidden, resolveProxyForProvider, } from "@/lib/localDb"; @@ -128,91 +126,7 @@ import { fetchCodexGithubCatalogModels, } from "./discovery/codex"; import { maybeHandleConolModelDiscovery } from "./conolDiscovery"; - -function toLiveModel(item: Record): { id: string; name: string } | null { - const itemId = typeof item.id === "string" ? item.id.trim() : ""; - if (!itemId) return null; - const itemName = - typeof item.display_name === "string" - ? item.display_name - : typeof item.name === "string" - ? item.name - : itemId; - return { id: itemId, name: itemName }; -} - -async function fetchLiveNoAuthModels( - modelsUrl: string, - providerId: string, - connectionId: string, - excludeHidden: boolean -): Promise { - try { - const liveResponse = await safeOutboundFetch(modelsUrl, { - ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, - guard: getProviderOutboundGuard(), - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - if (!liveResponse.ok) return null; - - const data = await liveResponse.json(); - const liveModels: Array<{ id: string; name: string }> = ( - (data.data || data.models || []) as Array> - ) - .map(toLiveModel) - .filter((model): model is { id: string; name: string } => model !== null); - if (liveModels.length === 0) return null; - - const visible = excludeHidden - ? liveModels.filter((model) => !getModelIsHidden(providerId, model.id)) - : liveModels; - return NextResponse.json({ - provider: providerId, - connectionId, - models: visible, - source: "upstream", - }); - } catch { - // Live fetch failed — fall back to the bundled catalog. - return null; - } -} - -async function buildNoAuthModelsResponse( - providerId: string, - connectionId: string, - excludeHidden: boolean -) { - if (isProviderBlockedByIdOrAlias(providerId, (await getSettings()).blockedProviders)) { - return NextResponse.json({ error: "Provider is disabled" }, { status: 403 }); - } - - const registryEntry = getRegistryEntry(providerId); - const modelsUrl = - typeof registryEntry?.modelsUrl === "string" && registryEntry.modelsUrl.length > 0 - ? registryEntry.modelsUrl - : null; - - if (modelsUrl) { - const live = await fetchLiveNoAuthModels(modelsUrl, providerId, connectionId, excludeHidden); - if (live) return live; - } - - const catalog = mergeLocalCatalogModels( - getModelsByProviderId(providerId) || [], - getStaticModelsForProvider(providerId) || [] - ).map((model) => ({ id: model.id, name: model.name || model.id })); - const visible = excludeHidden - ? catalog.filter((model) => !getModelIsHidden(providerId, model.id)) - : catalog; - return NextResponse.json({ - provider: providerId, - connectionId, - models: visible, - source: "local_catalog", - }); -} +import { buildNoAuthModelsResponse, filterModelsForRoute } from "./modelRouteProjection"; /** * GET /api/providers/[id]/models - Get models list from provider @@ -230,6 +144,9 @@ export async function GET( const excludeHidden = searchParams.get("excludeHidden") === "true"; const excludeCustom = searchParams.get("excludeCustom") === "true"; const refresh = searchParams.get("refresh") === "true"; + const chatOnly = + searchParams.get("chatOnly") === "true" || + request.headers.get("x-omniroute-model-surface")?.toLowerCase() === "chat"; const connection = await getCachedProviderConnectionById(id); const connectionProvider = @@ -252,7 +169,8 @@ export async function GET( return buildNoAuthModelsResponse( noAuthProviderId, typeof connection?.id === "string" ? connection.id : id, - excludeHidden + excludeHidden, + chatOnly ); } @@ -311,6 +229,7 @@ export async function GET( const buildResponse = (payload: any, statusConfig?: ResponseInit) => { if (payload.models && Array.isArray(payload.models)) { payload.models = mergeCustomModels(payload.models); + payload.models = filterModelsForRoute(provider, payload.models, chatOnly); } if (excludeHidden && payload.models && Array.isArray(payload.models)) { payload.models = payload.models.filter((m: any) => !getModelIsHidden(provider, m.id)); @@ -324,7 +243,11 @@ export async function GET( const autoFetchModels = isAutoFetchModelsEnabled(connection.providerSpecificData); const cachedDiscoveryModels = usesCuratedModelsOnly ? [] - : await getCachedDiscoveredModels(provider, connectionId); + : filterModelsForRoute( + provider, + await getCachedDiscoveredModels(provider, connectionId), + chatOnly + ); // Check for synced models from ANY connection of this provider. // When sync has been performed (even on a different connection), @@ -337,8 +260,9 @@ export async function GET( }> | null = null; try { const allSynced = usesCuratedModelsOnly ? [] : await getSyncedAvailableModels(provider); - if (Array.isArray(allSynced) && allSynced.length > 0) { - providerSyncedModels = allSynced.map((m) => ({ + const selectableSynced = filterModelsForRoute(provider, allSynced, chatOnly); + if (selectableSynced.length > 0) { + providerSyncedModels = selectableSynced.map((m) => ({ id: m.id, name: m.name || m.id, ...(m.apiFormat ? { apiFormat: m.apiFormat } : {}), @@ -490,7 +414,11 @@ export async function GET( // its other connections) or the static catalog when none remain. let freshSynced: Awaited> = []; try { - freshSynced = await getSyncedAvailableModels(provider); + freshSynced = filterModelsForRoute( + provider, + await getSyncedAvailableModels(provider), + chatOnly + ); } catch { /* DB unavailable — fall through to static catalog */ } diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 2dda7638b42c..95986e15a92c 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -334,6 +334,7 @@ async function fetchProviderModelsForSync(request: Request, connectionId: string "?refresh=true&excludeCustom=true"; const headers = { cookie: request.headers.get("cookie") || "", + "x-omniroute-model-surface": "chat", ...buildModelSyncInternalHeaders(), }; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 02cafab7b498..adc23e3aa453 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -29,6 +29,7 @@ import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model"; +import { isModelSelectable } from "@omniroute/open-sse/services/modelLifecycle"; import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo"; import { AUTO_TEMPLATE_VARIANTS, @@ -101,6 +102,7 @@ import { isCcDiscoveryModelCatalogClient, } from "./catalogRequest"; import { incrementCcDiscoveryHitCount } from "@/lib/db/ccDiscoveryMetrics"; +import { isUnifiedChatSourceModelSelectable } from "./catalogModelPolicy"; import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; @@ -719,7 +721,10 @@ async function buildUnifiedModelsResponseCore( Object.keys(syncedModelsByProvider).filter((pid) => { if (providerUsesCuratedModelsOnly(pid)) return false; const models = syncedModelsByProvider[pid]; - return Array.isArray(models) && models.length > 0; + return ( + Array.isArray(models) && + models.some((model) => isUnifiedChatSourceModelSelectable(pid, model)) + ); }) ); const isRegisteredEffortVariant = ( @@ -792,6 +797,7 @@ async function buildUnifiedModelsResponseCore( (!isRegisteredEffortVariant(providerModels, model.id) && !hasDeclaredEffortTiers)) ) continue; + if (!isModelSelectable(canonicalProviderId, model.id)) continue; if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; if (getModelIsHidden(canonicalProviderId, model.id)) continue; @@ -909,6 +915,7 @@ async function buildUnifiedModelsResponseCore( })) ) : syncedModels) { + if (!isUnifiedChatSourceModelSelectable(canonicalProviderId, sm)) continue; if (!providerSupportsModel(canonicalProviderId, sm.id)) continue; if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) { continue; @@ -1304,6 +1311,8 @@ async function buildUnifiedModelsResponseCore( for (const model of providerCustomModels) { const modelId = typeof model.id === "string" ? model.id : null; if (!modelId) continue; + if (!isUnifiedChatSourceModelSelectable(canonicalProviderId, { ...model, id: modelId })) + continue; if (model.isHidden === true) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to user-defined custom rows too. diff --git a/src/app/api/v1/models/catalogModelPolicy.ts b/src/app/api/v1/models/catalogModelPolicy.ts new file mode 100644 index 000000000000..f2d5dcb34fd1 --- /dev/null +++ b/src/app/api/v1/models/catalogModelPolicy.ts @@ -0,0 +1,18 @@ +import { getModelEndpointDecision } from "@omniroute/open-sse/services/modelEndpointPolicy"; +import { isModelSelectable } from "@omniroute/open-sse/services/modelLifecycle"; + +type CatalogModelPolicyInput = { + id: string; + supportedEndpoints?: readonly string[]; +}; + +export function isUnifiedChatSourceModelSelectable( + provider: string, + model: CatalogModelPolicyInput +): boolean { + return ( + isModelSelectable(provider, model.id) && + getModelEndpointDecision(provider, model.id, model.supportedEndpoints).reason !== + "provider-policy" + ); +} diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index 0d605a901bd5..238628df8112 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -21,6 +21,8 @@ import { ANTIGRAVITY_MODEL_ALIASES, ANTIGRAVITY_REVERSE_MODEL_ALIASES, } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; +import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts"; +import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts"; type JsonRecord = Record; @@ -95,9 +97,10 @@ function normalizeImportedModel(model: JsonRecord): ManagedImportedModel { return normalized; } -function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] { - const discovered = normalizeDiscoveredModels(fetchedModels); - return discovered.map((model) => normalizeImportedModel(model as JsonRecord)); +function normalizeImportedModels( + discoveredModels: readonly SyncedAvailableModel[] +): ManagedImportedModel[] { + return discoveredModels.map((model) => normalizeImportedModel(model as JsonRecord)); } function isImportedSource(source: unknown): boolean { @@ -250,8 +253,11 @@ export async function importManagedModels({ const previousSyncedAvailableModels = previousSyncedAvailableModelsInput ?? (await getSyncedAvailableModelsForConnection(providerId, connectionId)); - const discoveredModels = normalizeDiscoveredModels(fetchedModels); - const candidateImportedModels = normalizeImportedModels(fetchedModels); + const discoveredModels = filterChatSelectableModels( + providerId, + filterSelectableModels(providerId, normalizeDiscoveredModels(fetchedModels)) + ); + const candidateImportedModels = normalizeImportedModels(discoveredModels); const importedIds = new Set(candidateImportedModels.map((model) => model.id)); const discoveredIds = new Set(discoveredModels.map((model) => model.id)); diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index b11cd6678578..7f6e28827b44 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -6,6 +6,8 @@ import { } from "@/lib/db/models"; import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts"; +import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts"; +import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts"; type JsonRecord = Record; @@ -293,7 +295,10 @@ export async function persistDiscoveredModels( connectionId: string, models: unknown ): Promise { - const normalized = normalizeDiscoveredModels(models); + const normalized = filterChatSelectableModels( + providerId, + filterSelectableModels(providerId, normalizeDiscoveredModels(models)) + ); await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized); return normalized; } diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 91254ffeab5d..13d241d3cdad 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -217,8 +217,11 @@ export default function ModelSelectModal({ if (!connection?.id) return null; // #9203: ask the live route to drop hidden models server-side too, so the - // operator's visibility settings apply before the rows reach the picker. - const res = await fetch(`/api/providers/${connection.id}/models?excludeHidden=true`); + // operator's visibility settings apply before the rows reach the picker, + // while chatOnly excludes media and retired models from this chat surface. + const res = await fetch( + `/api/providers/${connection.id}/models?excludeHidden=true&chatOnly=true` + ); if (!res.ok) { console.warn(`Failed to fetch models for ${providerId}: ${res.status}`); return null; diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index b45ff0de6b0f..8c60e1a789a3 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -2031,7 +2031,7 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow assert.equal((afterFallback as any).testStatus, "unavailable"); assert.ok(cooldownRemaining > 0 && cooldownRemaining <= 2_000); }); -test("chatCore falls back to the next family model when the requested model is unavailable", async () => { +test("chatCore does not substitute an OpenAI model after model-unavailable", async () => { const { calls, result } = await invokeChatCore({ provider: "openai", model: "gpt-5.1", @@ -2047,17 +2047,15 @@ test("chatCore falls back to the next family model when the requested model is u headers: { "Content-Type": "application/json" }, }); } - return buildOpenAIResponse(false, "family fallback ok"); + return buildOpenAIResponse(false, "unexpected fallback"); }, }); - const payload = (await result.response.json()) as any; - assert.equal(result.success, true); - assert.equal(calls.length, 2); - assert.equal(calls[1].body.model, "gpt-5.1-mini"); - assert.equal(payload.choices[0].message.content, "family fallback ok"); + assert.equal(result.success, false); + assert.equal(result.status, 404); + assert.equal(calls.length, 1); }); -test("chatCore falls back to a larger-context sibling when the request overflows context", async () => { +test("chatCore does not substitute an OpenAI model after context overflow", async () => { saveModelsDevCapabilities({ unknown: { "gpt-5": capabilityEntry(128_000), @@ -2081,15 +2079,13 @@ test("chatCore falls back to a larger-context sibling when the request overflows headers: { "Content-Type": "application/json" }, }); } - return buildOpenAIResponse(false, "larger context fallback"); + return buildOpenAIResponse(false, "unexpected fallback"); }, }); - const payload = (await result.response.json()) as any; - assert.equal(result.success, true); - assert.equal(calls.length, 2); - assert.equal(calls[1].body.model, "gpt-4o"); - assert.equal(payload.choices[0].message.content, "larger context fallback"); + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(calls.length, 1); }); test("chatCore parses upstream SSE payloads for non-streaming requests", async () => { const { result } = await invokeChatCore({ @@ -2151,7 +2147,7 @@ test("chatCore rejects malformed non-streaming JSON payloads", async () => { assert.equal(result.status, 502); assert.equal(result.error, "Invalid JSON response from provider"); }); -test("chatCore falls back after an empty-content success response", async () => { +test("chatCore does not substitute an OpenAI model after empty content", async () => { const { calls, result } = await invokeChatCore({ provider: "openai", model: "gpt-5.1", @@ -2181,17 +2177,15 @@ test("chatCore falls back after an empty-content success response", async () => } ); } - return buildOpenAIResponse(false, "empty-content fallback ok"); + return buildOpenAIResponse(false, "unexpected fallback"); }, }); - const payload = (await result.response.json()) as any; - assert.equal(result.success, true); - assert.equal(calls.length, 2); - assert.equal(calls[1].body.model, "gpt-5.1-mini"); - assert.equal(payload.choices[0].message.content, "empty-content fallback ok"); + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal(calls.length, 1); }); -test("chatCore returns a gateway error when the empty-content fallback responds with invalid JSON", async () => { +test("chatCore returns a gateway error without probing another OpenAI model", async () => { const { result, calls } = await invokeChatCore({ provider: "openai", model: "gpt-5.1", @@ -2232,8 +2226,7 @@ test("chatCore returns a gateway error when the empty-content fallback responds assert.equal(result.success, false); assert.equal(result.status, 502); assert.equal(result.error, "Provider returned empty content"); - assert.equal(calls.length, 2); - assert.equal(calls[1].body.model, "gpt-5.1-mini"); + assert.equal(calls.length, 1); }); test("chatCore records Claude prompt cache and cache usage metadata in call logs", async () => { await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" }); diff --git a/tests/unit/managed-model-import.test.ts b/tests/unit/managed-model-import.test.ts index 5df2d935a3c6..24afe9f3506f 100644 --- a/tests/unit/managed-model-import.test.ts +++ b/tests/unit/managed-model-import.test.ts @@ -85,6 +85,55 @@ test("provider-level synced model deletion removes only that provider", async () ]); }); +test("OpenAI import excludes deprecated and shutdown models from new selections", async () => { + const result = await importManagedModels({ + providerId: "openai", + connectionId: "openai-conn", + mode: "sync", + fetchedModels: [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { id: "gpt-5.3-chat-latest", name: "GPT-5.3 Chat" }, + ], + }); + + assert.deepEqual( + result.discoveredModels.map((model) => model.id), + ["gpt-5.6-sol"] + ); + assert.deepEqual( + (await modelsDb.getSyncedAvailableModels("openai")).map((model) => model.id), + ["gpt-5.6-sol"] + ); +}); + +test("OpenAI import excludes image and video generation models from chat selections", async () => { + const result = await importManagedModels({ + providerId: "openai", + connectionId: "openai-media-conn", + mode: "sync", + fetchedModels: [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { id: "gpt-image-2", name: "GPT Image 2" }, + { id: "sora-2-pro", name: "Sora 2 Pro" }, + { + id: "vendor-image-model", + name: "Vendor Image Model", + supportedEndpoints: ["/v1/images/generations"], + }, + ], + }); + + assert.deepEqual( + result.discoveredModels.map((model) => model.id), + ["gpt-5.6-sol"] + ); + assert.deepEqual( + (await modelsDb.getSyncedAvailableModels("openai")).map((model) => model.id), + ["gpt-5.6-sol"] + ); +}); + test("pruning stale connection available models during import", async () => { const db = core.getDbInstance(); // Insert connections diff --git a/tests/unit/model-deprecation.test.ts b/tests/unit/model-deprecation.test.ts index d498cacc7278..7560a6afc211 100644 --- a/tests/unit/model-deprecation.test.ts +++ b/tests/unit/model-deprecation.test.ts @@ -37,9 +37,9 @@ test("resolveModelAlias: resolves deprecated Claude model", () => { assert.equal(resolveModelAlias("claude-3-5-sonnet-latest"), "claude-sonnet-4-20250514"); }); -test("resolveModelAlias: resolves deprecated OpenAI model", () => { - assert.equal(resolveModelAlias("gpt-4-turbo-preview"), "gpt-4-turbo"); - assert.equal(resolveModelAlias("gpt-3.5-turbo-0125"), "gpt-3.5-turbo"); +test("resolveModelAlias: does not silently reroute retired OpenAI models", () => { + assert.equal(resolveModelAlias("gpt-4-turbo-preview"), "gpt-4-turbo-preview"); + assert.equal(resolveModelAlias("gpt-3.5-turbo-0125"), "gpt-3.5-turbo-0125"); }); test("resolveModelAlias: handles null/empty", () => { diff --git a/tests/unit/model-endpoint-policy.test.ts b/tests/unit/model-endpoint-policy.test.ts new file mode 100644 index 000000000000..4f851c9fd8f6 --- /dev/null +++ b/tests/unit/model-endpoint-policy.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { filterChatSelectableModels, getModelEndpointDecision, isChatSelectableModel } = + await import("../../open-sse/services/modelEndpointPolicy.ts"); + +test("OpenAI image models are not chat-selectable without upstream endpoint metadata", () => { + for (const modelId of [ + "gpt-image-2", + "gpt-image-1.5", + "gpt-image-1-mini", + "dall-e-3", + "chatgpt-image-latest", + ]) { + assert.deepEqual(getModelEndpointDecision("openai", modelId), { + kind: "image", + chatSelectable: false, + reason: "provider-policy", + }); + } +}); + +test("OpenAI video models are not chat-selectable without upstream endpoint metadata", () => { + assert.deepEqual(getModelEndpointDecision("openai", "sora-2-pro"), { + kind: "video", + chatSelectable: false, + reason: "provider-policy", + }); +}); + +test("provider policy is scoped and does not classify another provider by model name", () => { + assert.equal( + isChatSelectableModel("custom-provider", { id: "gpt-image-shaped-chat-model" }), + true + ); +}); + +test("explicit chat capability wins for a multi-endpoint model", () => { + assert.equal( + isChatSelectableModel("openai", { + id: "gpt-image-shaped-multimodal-model", + supportedEndpoints: ["/v1/images/generations", "/v1/responses"], + }), + true + ); +}); + +test("a synthetic chat default does not override a known OpenAI specialty model", () => { + assert.equal( + isChatSelectableModel("openai", { + id: "sora-2", + supportedEndpoints: ["chat"], + }), + false + ); +}); + +test("explicit non-chat endpoints are excluded even when the model ID is unknown", () => { + assert.equal( + isChatSelectableModel("openai-compatible", { + id: "vendor-specialty-model", + supportedEndpoints: ["videos/generations"], + }), + false + ); +}); + +test("chat import filtering keeps ordinary OpenAI models only", () => { + assert.deepEqual( + filterChatSelectableModels("openai", [ + { id: "gpt-5.6" }, + { id: "gpt-image-2" }, + { id: "sora-2" }, + ]).map((model) => model.id), + ["gpt-5.6"] + ); +}); diff --git a/tests/unit/model-lifecycle-integration.test.ts b/tests/unit/model-lifecycle-integration.test.ts new file mode 100644 index 000000000000..2676e0d8809b --- /dev/null +++ b/tests/unit/model-lifecycle-integration.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-lifecycle-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "model-lifecycle-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedOpenAiConnection() { + return providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: `openai-${Math.random().toString(16).slice(2, 8)}`, + apiKey: "sk-openai", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("chatCore rejects a shutdown OpenAI model before an upstream request", async () => { + const originalFetch = globalThis.fetch; + let upstreamCalls = 0; + globalThis.fetch = async () => { + upstreamCalls += 1; + return new Response("unexpected", { status: 200 }); + }; + + try { + const body = { + model: "gpt-5.2-codex", + messages: [{ role: "user", content: "hello" }], + }; + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { + provider: "openai", + model: "gpt-5.2-codex", + extendedContext: false, + }, + credentials: { apiKey: "sk-test", providerSpecificData: {} }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body: structuredClone(body), + headers: new Headers({ accept: "application/json" }), + }, + userAgent: "model-lifecycle-test", + }); + + assert.equal(result.success, false); + assert.equal(result.status, 410); + assert.equal(result.errorCode, "model_shutdown"); + assert.equal(upstreamCalls, 0); + + const responseBody = await result.response.json(); + assert.equal(responseBody.error.code, "model_shutdown"); + assert.match(responseBody.error.message, /openai\/gpt-5\.2-codex/); + assert.match(responseBody.error.message, /openai\/gpt-5\.6-sol/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("unified catalog suppresses stale OpenAI chat rows but retains typed media", async () => { + const connection = await seedOpenAiConnection(); + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", connection.id, [ + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", source: "imported" }, + { id: "gpt-image-2", name: "GPT Image 2", source: "imported" }, + { id: "sora-2-pro", name: "Sora 2 Pro", source: "imported" }, + ]); + await modelsDb.replaceCustomModels("openai", [ + { id: "sora-2", name: "Sora 2", source: "imported" }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array<{ id: string; type?: string }> }; + const ids = new Set(body.data.map((item) => item.id)); + + assert.equal(response.status, 200); + assert.equal(ids.has("openai/gpt-5.2-codex"), false); + assert.equal(ids.has("openai/sora-2"), false); + assert.equal(ids.has("openai/sora-2-pro"), false); + assert.equal(ids.has("openai/gpt-5.6-sol"), true); + assert.equal(body.data.find((item) => item.id === "openai/gpt-image-2")?.type, "image"); +}); + +test("chat-only provider catalog excludes OpenAI lifecycle and media models", async () => { + const connection = await seedOpenAiConnection(); + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", connection.id, [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", source: "imported" }, + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", source: "imported" }, + { id: "gpt-5.3-chat-latest", name: "GPT-5.3 Chat", source: "imported" }, + { id: "gpt-image-2", name: "GPT Image 2", source: "imported" }, + { id: "sora-2-pro", name: "Sora 2 Pro", source: "imported" }, + ]); + + const response = await providerModelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?chatOnly=true`), + { params: { id: connection.id } } + ); + const body = (await response.json()) as { models: Array<{ id: string }> }; + + assert.equal(response.status, 200); + assert.deepEqual( + body.models.map((model) => model.id), + ["gpt-5.6-sol"] + ); +}); diff --git a/tests/unit/model-lifecycle.test.ts b/tests/unit/model-lifecycle.test.ts new file mode 100644 index 000000000000..f88a18a1e718 --- /dev/null +++ b/tests/unit/model-lifecycle.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + MODEL_LIFECYCLE_RECORDS, + filterSelectableModels, + formatModelLifecycleMessage, + getModelLifecycleDecision, + isModelSelectable, +} = await import("../../open-sse/services/modelLifecycle.ts"); + +const CURRENT_DATE = new Date("2026-07-26T00:00:00.000Z"); + +test("shutdown OpenAI models are rejected with replacement guidance, not rewritten", () => { + const decision = getModelLifecycleDecision("openai", "gpt-5.2-codex", CURRENT_DATE); + + assert.equal(decision.status, "shutdown"); + assert.equal(decision.action, "reject"); + assert.equal(decision.model, "gpt-5.2-codex"); + assert.deepEqual(decision.replacement, { + provider: "openai", + model: "gpt-5.6-sol", + }); + assert.match(formatModelLifecycleMessage(decision) || "", /cannot be routed automatically/); +}); + +test("upcoming shutdowns warn before the shutdown date", () => { + const decision = getModelLifecycleDecision("openai", "gpt-5.3-chat-latest", CURRENT_DATE); + + assert.equal(decision.status, "deprecated"); + assert.equal(decision.action, "warn"); + assert.equal(decision.shutdownAt, "2026-08-10"); +}); + +test("lifecycle records are provider-scoped", () => { + const decision = getModelLifecycleDecision("opencode-zen", "gpt-5.2-codex", CURRENT_DATE); + + assert.equal(decision.status, "untracked"); + assert.equal(decision.action, "allow"); +}); + +test("catalog filtering hides deprecated and shutdown models by default", () => { + const models = [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { id: "gpt-5.3-chat-latest", name: "GPT-5.3 Chat" }, + ]; + + assert.deepEqual( + filterSelectableModels("openai", models, { asOf: CURRENT_DATE }).map((model) => model.id), + ["gpt-5.6-sol"] + ); + assert.deepEqual( + filterSelectableModels("opencode-zen", models, { asOf: CURRENT_DATE }).map((model) => model.id), + models.map((model) => model.id) + ); +}); + +test("selectability can include deprecated models without reviving shutdown models", () => { + assert.equal( + isModelSelectable("openai", "gpt-5.3-chat-latest", { + asOf: CURRENT_DATE, + includeDeprecated: true, + }), + true + ); + assert.equal( + isModelSelectable("openai", "gpt-5.2-codex", { + asOf: CURRENT_DATE, + includeDeprecated: true, + }), + false + ); +}); + +test("the conflicted gpt-4-1106-preview date is not guessed", () => { + assert.equal( + MODEL_LIFECYCLE_RECORDS.some((record) => record.model === "gpt-4-1106-preview"), + false + ); +}); diff --git a/tests/unit/t30-kiro-400-model-unavailable.test.ts b/tests/unit/t30-kiro-400-model-unavailable.test.ts index e59cde10350a..75af66344b05 100644 --- a/tests/unit/t30-kiro-400-model-unavailable.test.ts +++ b/tests/unit/t30-kiro-400-model-unavailable.test.ts @@ -7,7 +7,8 @@ const { isModelUnavailableError, getNextFamilyFallback } = test("T30: Kiro 'improperly formed request' 400 is treated as model-unavailable", () => { const unavailable = isModelUnavailableError( 400, - "Bad Request: improperly formed request for selected model" + "Bad Request: improperly formed request for selected model", + "kiro" ); assert.equal(unavailable, true); }); @@ -30,10 +31,12 @@ test("T30: a missing Files API resource does not trigger model-family fallback", assert.equal(unavailable, false); }); -test("T30: model family helper returns a sibling candidate when available", () => { - const next = getNextFamilyFallback("gemini-3.1-pro-high", new Set(["gemini-3.1-pro-high"])); - assert.equal(typeof next, "string"); - assert.notEqual(next, "gemini-3.1-pro-high"); +test("T30: bare model IDs require an explicit provider scope", () => { + assert.equal(getNextFamilyFallback("claude-opus-5", new Set(["claude-opus-5"])), null); + assert.equal( + getNextFamilyFallback("claude-opus-5", new Set(["claude-opus-5"]), "anthropic"), + "claude-opus-4.8" + ); }); test('T30: Kiro exact "Invalid model. Please select a different model to continue." 400 is treated as model-unavailable', () => { @@ -43,3 +46,25 @@ test('T30: Kiro exact "Invalid model. Please select a different model to continu ); assert.equal(unavailable, true); }); + +test("T30: Kiro-only malformed-request signal is not global", () => { + assert.equal( + isModelUnavailableError( + 400, + "Bad Request: improperly formed request for selected model", + "anthropic" + ), + false + ); +}); + +test("T30: capability errors do not trigger model substitution", () => { + assert.equal( + isModelUnavailableError(400, "This model does not support tool calling", "anthropic"), + false + ); +}); + +test("T30: retired OpenAI GPT chains are not fallback policies", () => { + assert.equal(getNextFamilyFallback("gpt-5.1", new Set(["gpt-5.1"]), "openai"), null); +});