diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index 3eec81b7e..38a92e6a9 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -261,6 +261,19 @@ export const modelFamilyToken = memo((modelId: string): string => { return ""; }); +/** + * True for Claude generations that support extended thinking: Sonnet/Opus 3.7+, + * every 4.x/5+ Opus/Sonnet, and the Fable/Mythos generation. Pre-thinking + * models (Claude 3.5 and older) are excluded so no thinking effort dial is + * fabricated for a model that rejects thinking parameters. Classifier-based, so + * dotted and dashed version forms both match; ids the classifier does not parse + * (e.g. Haiku, bare dated ids) return false. + */ +export const anthropicModelSupportsThinking = memo((modelId: string): boolean => { + const parsed = parseAnthropicModel(bareModelId(modelId)); + return parsed !== null && semverGte(parsed.version, "3.7"); +}); + /** * Adaptive thinking `display` is supported starting with Claude Opus 4.7+, * Sonnet 5+, and the Claude Fable/Mythos 5 generation. Older adaptive-thinking diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index cb278fa2e..18b7bb22c 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -7,6 +7,7 @@ import { import { Effort, THINKING_EFFORTS } from "../effort"; import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-model-id"; import { + anthropicModelSupportsThinking, isGlmVisionModelId, isGrokReasoningEffortCapable, isKimiK3ModelId, @@ -4385,12 +4386,16 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana maxTokens, headers: { ...COPILOT_API_HEADERS }, // Copilot's `/models` advertises no reasoning bit, so a - // Claude model without a bundled reference would fall back - // to `reasoning: false` and lose its thinking dial. Every - // Claude on Copilot's anthropic-messages proxy reasons; - // mark it so `buildModel` derives the adaptive effort - // ladder from the id (e.g. a newly served claude-opus-5). - ...(api === "anthropic-messages" ? { reasoning: true } : {}), + // thinking-capable Claude with no bundled reference would + // fall back to `reasoning: false` and lose its effort dial. + // Gate on the id classifier (not the transport alone) so a + // lagging enterprise catalog serving a pre-thinking Claude + // (<= 3.5) over the Messages proxy is not handed a fabricated + // dial it would reject; a modern reference-less model (e.g. + // claude-opus-5) is marked so `buildModel` derives the ladder. + ...(api === "anthropic-messages" && anthropicModelSupportsThinking(defaults.id) + ? { reasoning: true } + : {}), ...(api === "openai-completions" ? { compat: { diff --git a/packages/catalog/test/issue-6664-repro.test.ts b/packages/catalog/test/issue-6664-repro.test.ts index 987cd3c02..442ec455e 100644 --- a/packages/catalog/test/issue-6664-repro.test.ts +++ b/packages/catalog/test/issue-6664-repro.test.ts @@ -94,4 +94,26 @@ describe("#6664 github-copilot reference-less Claude model", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("does not fabricate a thinking dial for a pre-thinking reference-less Claude", async () => { + // A lagging enterprise catalog could surface an old kind-first Claude on + // the Messages proxy. It must stay non-reasoning so no effort dial is + // offered for thinking parameters the model would reject. + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ data: [tieredEntry("claude-sonnet-3.5", "Claude Sonnet 3.5")] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const options = githubCopilotModelManagerOptions({ apiKey: "copilot-test-key", fetch: fetchMock }); + const specs = (await options.fetchDynamicModels?.()) ?? []; + + const base = specs.find(m => m.id === "claude-sonnet-3.5"); + expect(base).toBeDefined(); + expect(base?.api).toBe("anthropic-messages"); + expect(base?.reasoning).toBe(false); + if (!base) throw new Error("missing base spec"); + expect(buildModel(base).thinking).toBeUndefined(); + }); });