Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion apps/web/app/api/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const gatewayModels: MockGatewayModel[] = [];
const requestedUrls: string[] = [];

let modelsDevApiData: unknown = {};
let gatewayGetAvailableModels = async () => ({ models: gatewayModels });

const originalFetch = globalThis.fetch;

Expand All @@ -25,7 +26,7 @@ function getRequestUrl(input: RequestInfo | URL): string {

mock.module("ai", () => ({
gateway: {
getAvailableModels: async () => ({ models: gatewayModels }),
getAvailableModels: () => gatewayGetAvailableModels(),
},
}));

Expand All @@ -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));
Expand Down Expand Up @@ -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");
});
});
116 changes: 112 additions & 4 deletions apps/web/lib/models-with-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,109 @@ interface ModelsDevMetadata {
cost?: AvailableModelCost;
}

type GatewayPricing = NonNullable<AvailableModel["pricing"]>;

type GatewaySpecification = AvailableModel["specification"];

function isRecord(value: unknown): value is Record<string, unknown> {
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<AvailableModel["modelType"]> {
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
Expand Down Expand Up @@ -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<
Expand Down
1 change: 1 addition & 0 deletions docs/agents/lessons-learned.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down