From 885d4bc5cf1194ad0996b344ed6dddd96c87703c Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:20:56 +0530 Subject: [PATCH] fix(x): join gateway model ids to models.dev across id dialects The reasoning-capability join failed for most gateway models, hiding the effort chip for everything except the Gemini default. Two id dialects broke it: OpenRouter-style ids spell versions with dots ("anthropic/claude-opus-4.8") where models.dev uses dashes ("claude-opus-4-8"), and the rowboat gateway serves OpenAI models with no vendor prefix at all ("gpt-5.4"). The lookup is now a pure index (buildReasoningIndex/lookupReasoningFlag, unit-tested) that normalizes ids case-insensitively with dots folded to dashes, keyed both vendor-qualified and bare; bare ids that clash across vendors with different flags are dropped rather than guessed. Verified against the live gateway list: all 11 models now resolve. Co-Authored-By: Claude Fable 5 --- .../core/src/models/models-dev.test.ts | 68 +++++++++ apps/x/packages/core/src/models/models-dev.ts | 142 +++++++++++------- 2 files changed, 153 insertions(+), 57 deletions(-) create mode 100644 apps/x/packages/core/src/models/models-dev.test.ts diff --git a/apps/x/packages/core/src/models/models-dev.test.ts b/apps/x/packages/core/src/models/models-dev.test.ts new file mode 100644 index 00000000..9bb0bce5 --- /dev/null +++ b/apps/x/packages/core/src/models/models-dev.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildReasoningIndex, lookupReasoningFlag } from "./models-dev.js"; + +// Mirrors the real-world shapes that broke the join: models.dev spells +// versions with dashes ("claude-opus-4-8") while the gateway serves +// OpenRouter-style dotted ids ("anthropic/claude-opus-4.8") and bare +// unprefixed OpenAI ids ("gpt-5.4"). +const CATALOG = { + openai: { + name: "OpenAI", + models: { + "gpt-5.4": { id: "gpt-5.4", reasoning: true }, + "gpt-4.1": { id: "gpt-4.1", reasoning: false }, + }, + }, + anthropic: { + name: "Anthropic", + models: { + "claude-opus-4-8": { id: "claude-opus-4-8", reasoning: true }, + "claude-haiku-4-5": { id: "claude-haiku-4-5", reasoning: false }, + }, + }, + google: { + name: "Google", + models: { + "gemini-3.5-flash": { id: "gemini-3.5-flash", reasoning: true }, + }, + }, +} as never; + +describe("reasoning capability index", () => { + const index = buildReasoningIndex(CATALOG); + + it("joins dotted gateway ids against dashed catalog ids", () => { + expect(lookupReasoningFlag(index, "rowboat", "anthropic/claude-opus-4.8")).toBe(true); + expect(lookupReasoningFlag(index, "rowboat", "anthropic/claude-haiku-4.5")).toBe(false); + }); + + it("matches bare unprefixed ids on gateway flavors", () => { + expect(lookupReasoningFlag(index, "rowboat", "gpt-5.4")).toBe(true); + expect(lookupReasoningFlag(index, "rowboat", "gpt-4.1")).toBe(false); + }); + + it("matches strict flavors by their own namespace", () => { + expect(lookupReasoningFlag(index, "anthropic", "claude-opus-4-8")).toBe(true); + expect(lookupReasoningFlag(index, "openai", "gpt-5.4")).toBe(true); + expect(lookupReasoningFlag(index, "google", "gemini-3.5-flash")).toBe(true); + }); + + it("returns undefined for unknown models and unknown vendors", () => { + expect(lookupReasoningFlag(index, "rowboat", "mistralai/mistral-large")).toBeUndefined(); + expect(lookupReasoningFlag(index, "rowboat", "some-local-model")).toBeUndefined(); + expect(lookupReasoningFlag(index, "openai", "gpt-99")).toBeUndefined(); + }); + + it("drops bare ids that are ambiguous across vendors", () => { + const clashing = { + openai: { name: "OpenAI", models: { shared: { id: "shared", reasoning: true } } }, + anthropic: { name: "Anthropic", models: { shared: { id: "shared", reasoning: false } } }, + google: { name: "Google", models: {} }, + } as never; + const clashIndex = buildReasoningIndex(clashing); + expect(lookupReasoningFlag(clashIndex, "rowboat", "shared")).toBeUndefined(); + // Vendor-qualified lookups still work. + expect(lookupReasoningFlag(clashIndex, "rowboat", "openai/shared")).toBe(true); + expect(lookupReasoningFlag(clashIndex, "anthropic", "shared")).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/models/models-dev.ts b/apps/x/packages/core/src/models/models-dev.ts index 14286a08..1f954801 100644 --- a/apps/x/packages/core/src/models/models-dev.ts +++ b/apps/x/packages/core/src/models/models-dev.ts @@ -229,82 +229,110 @@ export async function listOnboardingModels(): Promise<{ providers: ProviderSumma return { providers, lastUpdated: fetchedAt }; } +// Gateways spell model ids differently from models.dev: OpenRouter-style ids +// use dots in versions ("claude-opus-4.8") where models.dev uses dashes +// ("claude-opus-4-8"), and the rowboat gateway serves OpenAI models with no +// vendor prefix at all ("gpt-5.4"). Ids are joined case-insensitively with +// dots folded to dashes. +function normalizeModelId(id: string): string { + return id.toLowerCase().replace(/\./g, "-"); +} + +const REASONING_VENDORS = ["openai", "anthropic", "google"] as const; + +/** + * Pure reasoning-capability index over a parsed models.dev catalog. + * Keys: `${vendor}/${normalizedId}` always; bare `normalizedId` too, unless + * two vendors disagree on the flag for the same bare id (then ambiguous ids + * are dropped rather than guessed). + */ +export function buildReasoningIndex( + data: z.infer, +): Map { + const index = new Map(); + const ambiguous = new Set(); + for (const vendor of REASONING_VENDORS) { + const provider = pickProvider(data, vendor); + if (!provider) continue; + for (const [key, model] of Object.entries(provider.models)) { + if (typeof model.reasoning !== "boolean") continue; + const norm = normalizeModelId(model.id ?? key); + index.set(`${vendor}/${norm}`, model.reasoning); + if (ambiguous.has(norm)) continue; + const bare = index.get(norm); + if (bare === undefined) { + index.set(norm, model.reasoning); + } else if (bare !== model.reasoning) { + index.delete(norm); + ambiguous.add(norm); + } + } + } + return index; +} + +/** Pure lookup against buildReasoningIndex. undefined = unknown. */ +export function lookupReasoningFlag( + index: Map, + flavor: string, + modelId: string, +): boolean | undefined { + const slash = modelId.indexOf("/"); + if (slash > 0) { + const vendor = modelId.slice(0, slash).toLowerCase(); + if ((REASONING_VENDORS as readonly string[]).includes(vendor)) { + return index.get(`${vendor}/${normalizeModelId(modelId.slice(slash + 1))}`); + } + return undefined; + } + if ((REASONING_VENDORS as readonly string[]).includes(flavor)) { + return index.get(`${flavor}/${normalizeModelId(modelId)}`); + } + // Unprefixed id on a gateway-ish flavor (rowboat serves "gpt-5.4" bare): + // match by bare id across vendors. + return index.get(normalizeModelId(modelId)); +} + +async function readReasoningIndex(): Promise | null> { + try { + const cached = await readCache(); + if (!cached) return null; + const parsed = ModelsDevResponse.safeParse(cached.data); + if (!parsed.success) return null; + return buildReasoningIndex(parsed.data); + } catch { + return null; + } +} + /** * Whether a model supports reasoning/extended thinking, per the models.dev * catalog. Reads ONLY the on-disk cache (stale is fine) — this sits on the * turn-start path and must never block on the network. Returns undefined * when the model or provider is unknown or no cache exists; callers treat * unknown as "don't send reasoning parameters" (fail closed). - * - * Accepts gateway/OpenRouter-style "vendor/model" ids by splitting on the - * first slash and matching the vendor against the catalog's providers. */ export async function isReasoningModel( flavor: string, modelId: string, ): Promise { - let vendor = flavor; - let id = modelId; - if ((flavor === "rowboat" || flavor === "openrouter" || flavor === "aigateway") && modelId.includes("/")) { - const slash = modelId.indexOf("/"); - vendor = modelId.slice(0, slash); - id = modelId.slice(slash + 1); - } - if (vendor !== "openai" && vendor !== "anthropic" && vendor !== "google") { - return undefined; - } - try { - const cached = await readCache(); - if (!cached) return undefined; - const parsed = ModelsDevResponse.safeParse(cached.data); - if (!parsed.success) return undefined; - const provider = pickProvider(parsed.data, vendor); - if (!provider) return undefined; - for (const [key, model] of Object.entries(provider.models)) { - if ((model.id ?? key) === id) { - return model.reasoning === true; - } - } - return undefined; - } catch { - return undefined; - } + const index = await readReasoningIndex(); + if (!index) return undefined; + return lookupReasoningFlag(index, flavor, modelId); } /** - * Annotate gateway-style "vendor/model" ids with the models.dev reasoning - * flag. Reads the cache once for the whole batch; ids whose vendor or model - * is unknown keep `reasoning` absent (= unknown). Cache-only, like - * isReasoningModel. + * Annotate gateway model ids ("vendor/model" or bare) with the models.dev + * reasoning flag. Reads the cache once for the whole batch; unknown ids keep + * `reasoning` absent (= unknown). Cache-only, like isReasoningModel. */ export async function annotateReasoningFlags( models: T[], ): Promise> { - let catalog: z.infer | null = null; - try { - const cached = await readCache(); - if (cached) { - const parsed = ModelsDevResponse.safeParse(cached.data); - if (parsed.success) catalog = parsed.data; - } - } catch { - catalog = null; - } - if (!catalog) return models; - - const flags = new Map(); - for (const vendor of ["openai", "anthropic", "google"] as const) { - const provider = pickProvider(catalog, vendor); - if (!provider) continue; - for (const [key, model] of Object.entries(provider.models)) { - if (typeof model.reasoning === "boolean") { - flags.set(`${vendor}/${model.id ?? key}`, model.reasoning); - } - } - } - + const index = await readReasoningIndex(); + if (!index) return models; return models.map((model) => { - const reasoning = flags.get(model.id); + const reasoning = lookupReasoningFlag(index, "rowboat", model.id); return reasoning === undefined ? model : { ...model, reasoning }; }); }