diff --git a/src/cli/models.ts b/src/cli/models.ts index 6a6e6e0d5c..0f91796408 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; +import { configuredContextWindow } from "../codex/catalog/provider-fetch"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; import { canonicalizeReasoningEfforts, @@ -86,6 +87,11 @@ interface ModelEntry { reasoningEfforts: string[] | null; } +/** + * Collect static configured models for all providers or one selected provider. + * Keep each provider's default model first and resolve metadata through shared helpers. + * Live-discovered models are not fetched by this listing. + */ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] { const entries: ModelEntry[] = []; const providers = providerFilter @@ -95,10 +101,9 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] for (const [provName, prov] of Object.entries(providers)) { if (!prov) continue; const seen = new Set(); - const contextWindows = prov.modelContextWindows ?? {}; const inputModalities = prov.modelInputModalities ?? {}; - const globalContext = prov.contextWindow ?? null; + /** Append one model with resolved metadata, ignoring duplicates within this provider. */ const addModel = (model: string, isDefault: boolean) => { if (seen.has(model)) return; seen.add(model); @@ -124,7 +129,7 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] provider: provName, model, isDefault, - contextWindow: modelRecordValue(contextWindows, model) ?? globalContext, + contextWindow: configuredContextWindow(prov, model) ?? null, inputModalities: modalities, reasoningEfforts: efforts, }); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a759126e3c..eadd7445d4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -626,8 +626,36 @@ export function clearGatherRoutedModelsInflight(): void { gatherInflight.clear(); } +const NUMERIC_MODEL_ID_SEGMENT = /^\d+$/; + +/** + * Resolve an unknown Claude point release or date pin from the nearest configured + * family row. Only numeric tail segments are removed so unrelated model families + * cannot inherit one another's limits. + */ +function anthropicFamilyContextWindow( + record: Record | undefined, + id: string, +): number | undefined { + if (!record || !id.toLowerCase().startsWith("claude-")) return undefined; + let candidate = id; + while (true) { + const cut = candidate.lastIndexOf("-"); + if (cut <= 0 || !NUMERIC_MODEL_ID_SEGMENT.test(candidate.slice(cut + 1))) return undefined; + candidate = candidate.slice(0, cut); + const value = modelRecordValue(record, candidate); + if (typeof value === "number" && value > 0) return value; + } +} + +/** + * Resolve the configured context window in exact-model, Anthropic numeric-family, + * then provider-wide order. Return undefined when the selected value is not positive. + */ export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { - const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow; + const configured = modelRecordValue(prov.modelContextWindows, id) + ?? (prov.adapter === "anthropic" ? anthropicFamilyContextWindow(prov.modelContextWindows, id) : undefined) + ?? prov.contextWindow; return typeof configured === "number" && configured > 0 ? configured : undefined; } diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index e6788116a4..967dcb15e5 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -148,6 +148,13 @@ describe("ocx models richer metadata", () => { noVisionModels: ["model-b"], reasoningEfforts: ["low", "medium", "high"], }, + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + models: ["claude-fable-5-2", "Claude-fable-5-3", "claude-fable-5-1", "unknown-model"], + contextWindow: 128000, + modelContextWindows: { "claude-fable-5": 1000000, "claude-fable-5-1": 800000 }, + }, }, defaultProvider: "test", }; @@ -164,6 +171,16 @@ describe("ocx models richer metadata", () => { const modelB = parsed.models.find((m: { model: string }) => m.model === "model-b"); expect(modelB.contextWindow).toBe(32000); expect(modelB.inputModalities).toEqual(["text"]); + + const anthropicWindows = Object.fromEntries(parsed.models + .filter((m: { provider: string }) => m.provider === "anthropic") + .map((m: { model: string; contextWindow: number }) => [m.model, m.contextWindow])); + expect(anthropicWindows).toMatchObject({ + "claude-fable-5-2": 1000000, + "Claude-fable-5-3": 1000000, + "claude-fable-5-1": 800000, + "unknown-model": 128000, + }); } finally { removeTreeWithRetry(dir); } diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index d190661522..be84701e0b 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -830,6 +830,36 @@ describe("provider registry parity", () => { } }); + test("unknown Claude numeric variants inherit the nearest configured family context window", () => { + const anthropic = PROVIDER_REGISTRY.find(entry => entry.id === "anthropic"); + const seed = providerConfigSeed(anthropic!); + const contextWindow = (id: string) => applyProviderConfigHints("anthropic", seed, { + id, + provider: "anthropic", + }).contextWindow; + + expect(contextWindow("claude-fable-5-2")).toBe(1_000_000); + expect(contextWindow("Claude-fable-5-2")).toBe(1_000_000); + expect(contextWindow("claude-haiku-4-5-20251001")).toBe(200_000); + expect(contextWindow("CLAUDE-HAIKU-4-5-20251001")).toBe(200_000); + expect(contextWindow("claude-opus-4-1-20250805")).toBeUndefined(); + expect(contextWindow("claude-3-7-sonnet-20250219")).toBeUndefined(); + }); + + test("context-window family inheritance stays scoped to the Anthropic adapter", () => { + const minimax = PROVIDER_REGISTRY.find(entry => entry.id === "minimax"); + const seed = { + ...providerConfigSeed(minimax!), + modelContextWindows: { "claude-fable-5": 1_000_000 }, + }; + const model = applyProviderConfigHints("minimax", seed, { + id: "claude-fable-5-2", + provider: "minimax", + }); + + expect(model.contextWindow).toBeUndefined(); + }); + test("GUI preset projection preserves current featured set plus key catalog and custom", () => { const featured = deriveFeaturedProviderIds(); expect(featured).toEqual([