mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
15d4a497f0
commit
885d4bc5cf
2 changed files with 153 additions and 57 deletions
68
apps/x/packages/core/src/models/models-dev.test.ts
Normal file
68
apps/x/packages/core/src/models/models-dev.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof ModelsDevResponse>,
|
||||
): Map<string, boolean> {
|
||||
const index = new Map<string, boolean>();
|
||||
const ambiguous = new Set<string>();
|
||||
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<string, boolean>,
|
||||
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<Map<string, boolean> | 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<boolean | undefined> {
|
||||
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<T extends { id: string }>(
|
||||
models: T[],
|
||||
): Promise<Array<T & { reasoning?: boolean }>> {
|
||||
let catalog: z.infer<typeof ModelsDevResponse> | 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<string, boolean>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue