From 5947a1a7ab5157641ae50d3bdfeb4766b4b17aff Mon Sep 17 00:00:00 2001 From: 8dazo Date: Tue, 14 Apr 2026 10:20:32 +0530 Subject: [PATCH] fix: tolerate unknown gateway model types in /api/models --- apps/web/app/api/models/route.test.ts | 68 ++++++++++++++- apps/web/lib/models-with-context.ts | 116 +++++++++++++++++++++++++- docs/agents/lessons-learned.md | 1 + 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/apps/web/app/api/models/route.test.ts b/apps/web/app/api/models/route.test.ts index 85bc4ad2d..c750dfbda 100644 --- a/apps/web/app/api/models/route.test.ts +++ b/apps/web/app/api/models/route.test.ts @@ -10,6 +10,7 @@ const gatewayModels: MockGatewayModel[] = []; const requestedUrls: string[] = []; let modelsDevApiData: unknown = {}; +let gatewayGetAvailableModels = async () => ({ models: gatewayModels }); const originalFetch = globalThis.fetch; @@ -25,7 +26,7 @@ function getRequestUrl(input: RequestInfo | URL): string { mock.module("ai", () => ({ gateway: { - getAvailableModels: async () => ({ models: gatewayModels }), + getAvailableModels: () => gatewayGetAvailableModels(), }, })); @@ -42,6 +43,7 @@ describe("/api/models context window enrichment", () => { gatewayModels.length = 0; requestedUrls.length = 0; modelsDevApiData = {}; + gatewayGetAvailableModels = async () => ({ models: gatewayModels }); globalThis.fetch = mock((input: RequestInfo | URL, _init?: RequestInit) => { requestedUrls.push(getRequestUrl(input)); @@ -146,4 +148,68 @@ describe("/api/models context window enrichment", () => { expect(body.models).toHaveLength(1); expect(body.models[0]?.context_window).toBe(200_000); }); + + test("falls back to valid language models when gateway returns an unknown modelType", async () => { + const gatewayResponseError = new Error( + "Invalid response from Gateway", + ) as Error & { + response?: unknown; + }; + + gatewayResponseError.response = { + models: [ + { + id: "openai/gpt-5.4-mini", + name: "GPT-5.4 Mini", + modelType: "language", + specification: { + specificationVersion: "v3", + provider: "openai", + modelId: "openai/gpt-5.4-mini", + }, + pricing: { + input: "0.00000025", + output: "0.000002", + input_cache_read: "0.000000025", + }, + }, + { + id: "openai/gpt-5.4-audio", + name: "GPT-5.4 Audio", + modelType: "audio", + specification: { + specificationVersion: "v3", + provider: "openai", + modelId: "openai/gpt-5.4-audio", + }, + }, + ], + }; + + gatewayGetAvailableModels = async () => { + throw gatewayResponseError; + }; + + const { GET } = await routeModulePromise; + const response = await GET(); + + expect(response.ok).toBe(true); + + const body = (await response.json()) as { + models: Array<{ + id: string; + modelType?: string; + pricing?: { + input: string; + output: string; + cachedInputTokens?: string; + }; + }>; + }; + + expect(body.models).toHaveLength(1); + expect(body.models[0]?.id).toBe("openai/gpt-5.4-mini"); + expect(body.models[0]?.modelType).toBe("language"); + expect(body.models[0]?.pricing?.cachedInputTokens).toBe("0.000000025"); + }); }); diff --git a/apps/web/lib/models-with-context.ts b/apps/web/lib/models-with-context.ts index 3ca1b8c22..82a9859ba 100644 --- a/apps/web/lib/models-with-context.ts +++ b/apps/web/lib/models-with-context.ts @@ -16,10 +16,109 @@ interface ModelsDevMetadata { cost?: AvailableModelCost; } +type GatewayPricing = NonNullable; + +type GatewaySpecification = AvailableModel["specification"]; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function isGatewaySpecification(value: unknown): value is GatewaySpecification { + return ( + isRecord(value) && + value.specificationVersion === "v3" && + typeof value.provider === "string" && + typeof value.modelId === "string" + ); +} + +function isSupportedModelType( + value: unknown, +): value is NonNullable { + return ( + value === "embedding" || + value === "image" || + value === "language" || + value === "video" + ); +} + +function toGatewayPricing(value: unknown): GatewayPricing | undefined { + if (!isRecord(value)) { + return undefined; + } + + const input = value.input; + const output = value.output; + + if (typeof input !== "string" || typeof output !== "string") { + return undefined; + } + + const pricing: GatewayPricing = { input, output }; + + if (typeof value.input_cache_read === "string") { + pricing.cachedInputTokens = value.input_cache_read; + } + + if (typeof value.input_cache_write === "string") { + pricing.cacheCreationInputTokens = value.input_cache_write; + } + + return pricing; +} + +function toLanguageModelFromGatewayEntry( + value: unknown, +): AvailableModel | null { + if (!isRecord(value)) { + return null; + } + + const { id, name, description, modelType, specification } = value; + if (typeof id !== "string" || typeof name !== "string") { + return null; + } + + if (!isSupportedModelType(modelType) || modelType !== "language") { + return null; + } + + if (!isGatewaySpecification(specification)) { + return null; + } + + const pricing = toGatewayPricing(value.pricing); + + return { + id, + name, + description: typeof description === "string" ? description : null, + modelType, + specification, + ...(pricing ? { pricing } : {}), + }; +} + +function extractLanguageModelsFromGatewayError( + error: unknown, +): AvailableModel[] { + if (!isRecord(error) || !isRecord(error.response)) { + return []; + } + + const models = error.response.models; + if (!Array.isArray(models)) { + return []; + } + + return models.flatMap((model) => { + const parsed = toLanguageModelFromGatewayEntry(model); + return parsed ? [parsed] : []; + }); +} + function toOptionalNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value @@ -173,10 +272,19 @@ function addModelsDevMetadata( export async function fetchAvailableLanguageModels(): Promise< AvailableModel[] > { - const { models } = await gateway.getAvailableModels(); - return filterDisabledModels( - models.filter((model) => model.modelType === "language"), - ); + try { + const { models } = await gateway.getAvailableModels(); + return filterDisabledModels( + models.filter((model) => model.modelType === "language"), + ); + } catch (error) { + const fallbackModels = extractLanguageModelsFromGatewayError(error); + if (fallbackModels.length > 0) { + return filterDisabledModels(fallbackModels); + } + + throw error; + } } export async function fetchAvailableLanguageModelsWithContext(): Promise< diff --git a/docs/agents/lessons-learned.md b/docs/agents/lessons-learned.md index c0e1af7d2..341c43148 100644 --- a/docs/agents/lessons-learned.md +++ b/docs/agents/lessons-learned.md @@ -15,6 +15,7 @@ Hard-won knowledge from building this codebase. When you make a mistake or disco - After schema edits, review generated Drizzle migrations for unrelated schema drift changes before committing (for example defaults on untouched columns), since `drizzle-kit generate` can include those alongside intended changes. - `bunx @vercel/config validate` executes the CLI under Node via its shebang and cannot parse TypeScript-style `vercel.ts` imports; use `bunx --bun @vercel/config validate` (or `bun node_modules/@vercel/config/dist/cli.js validate`) for reliable local validation. - Successful Vercel CLI auth (`vercel whoami`, team/project REST APIs, `.vercel` linking) does **not** guarantee Workflow observability access. `workflow inspect ... --backend vercel` can still fail with `401 {"error":{"code":"unauthorized","message":"You are not allowed to access this endpoint."}}` when the user/token lacks the Vercel product permission documented as `Vercel Workflow` (and possibly related Observability access), even if `WORKFLOW_VERCEL_AUTH_TOKEN` is passed explicitly from the Vercel CLI auth file. +- AI Gateway `gateway.getAvailableModels()` can fail hard when the upstream `/config` payload includes a newly introduced `modelType` outside the current SDK enum. For `/api/models`, fall back to parsing the raw error response and keep only valid `language` entries so the model picker stays usable during Gateway/schema rollouts. ## Next.js