From 614d6936a796320640d73c3a0e377a17f39f62a3 Mon Sep 17 00:00:00 2001 From: Konstantin Krastev Date: Mon, 6 Jul 2026 15:06:40 +0300 Subject: [PATCH 1/3] fix(anthropic): honor custom apiModelId instead of silently defaulting to claude-sonnet-4-5 Unrecognized model IDs were coerced to the hardcoded default before being sent to the API and for capability lookups, breaking custom deployments and picking the wrong thinking config. Fixes #418 --- src/api/providers/__tests__/anthropic.spec.ts | 65 +++++++++++++++++++ src/api/providers/anthropic.ts | 37 ++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 2b944b7db..5900f42c6 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -455,6 +455,35 @@ describe("AnthropicHandler", () => { expect(requestBody?.max_tokens).toBe(32768) expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31") }) + + it("should send the custom model ID as-is and use adaptive thinking for a custom Sonnet-5-family model", async () => { + // Regression test for the combined bug: a custom deployment name + // (e.g. "claude-sonnet-5-bf") must be sent verbatim as `model`, + // and since it matches the Sonnet 5 family it must use + // `thinking: {type: "adaptive"}` rather than the legacy + // `{type: "enabled", budget_tokens}` shape, which real Sonnet + // 5-class deployments reject with a 400. + const customHandler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-5-bf", + enableReasoningEffort: true, + }) + + const stream = customHandler.createMessage(systemPrompt, [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + ]) + + for await (const _chunk of stream) { + // Consume stream + } + + const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0] + expect(requestBody?.model).toBe("claude-sonnet-5-bf") + expect(requestBody?.thinking).toEqual({ type: "adaptive" }) + }) }) describe("completePrompt", () => { @@ -656,6 +685,42 @@ describe("AnthropicHandler", () => { expect(model.info.inputPrice).toBe(6.0) expect(model.info.outputPrice).toBe(22.5) }) + + it("should honor a custom/unrecognized model ID instead of silently falling back to anthropicDefaultModelId", () => { + // Regression test: a custom Anthropic-compatible deployment name + // (e.g. Azure AI Foundry) that isn't a key in `anthropicModels` must + // still be sent to the API as-is, not replaced with the hardcoded + // default (see https://github.com/Zoo-Code-Org/Zoo-Code/issues/418). + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-5-bf", + }) + const model = handler.getModel() + expect(model.id).toBe("claude-sonnet-5-bf") + }) + + it("should guess capabilities for a custom/unrecognized model ID from known model-family substrings", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-5-bf", + }) + const model = handler.getModel() + expect(model.info.supportsReasoningBinary).toBe(true) + expect(model.info.maxTokens).toBe(128000) + expect(model.info.contextWindow).toBe(1000000) + }) + + it("should fall back to anthropicDefaultModelId's info when a custom model ID matches no known family", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "totally-unknown-custom-model", + }) + const model = handler.getModel() + expect(model.id).toBe("totally-unknown-custom-model") + expect(model.info.maxTokens).toBe(64000) + expect(model.info.contextWindow).toBe(200000) + expect(model.info.supportsReasoningBinary).toBeUndefined() + }) }) describe("reasoning block filtering", () => { diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index f9e2ee7d2..c14ad85e5 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -353,10 +353,43 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } } + /** + * Best-effort capability lookup for a custom/unrecognized model ID (e.g. a + * custom Anthropic-compatible deployment name that isn't a key in + * `anthropicModels`). Matches known model-family substrings so an + * unrecognized ID still gets an accurate capability profile (e.g. + * `supportsReasoningBinary` for Sonnet 5 / Opus 4.7+ style deployments) + * instead of silently inheriting `anthropicDefaultModelId`'s capabilities, + * which can be from an older model generation with a different API + * contract (see `AnthropicHandler.getModel`). Mirrors the equivalent + * `guessModelInfoFromId` heuristic already used by `BedrockHandler`. + */ + private guessModelInfoFromId(modelId: string): ModelInfo { + const matchedId = (Object.keys(anthropicModels) as AnthropicModelId[]) + .sort((a, b) => b.length - a.length) + .find((knownId) => modelId.includes(knownId)) + + return matchedId ? anthropicModels[matchedId] : anthropicModels[anthropicDefaultModelId] + } + getModel() { const modelId = this.options.apiModelId - const id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId - let info: ModelInfo = anthropicModels[id] + const isKnownModel = modelId !== undefined && modelId in anthropicModels + + // The model ID actually sent to the API must always honor a + // user-configured apiModelId - including custom Anthropic-compatible + // deployment names that aren't in our static `anthropicModels` table + // (e.g. Azure AI Foundry deployment names). Falling back to + // `anthropicDefaultModelId` here silently ignores the user's setting + // and sends requests for a model they never selected. + const id = isKnownModel ? (modelId as AnthropicModelId) : (modelId ?? anthropicDefaultModelId) + + // Capability/pricing info can only be looked up directly for known + // models; for unrecognized custom IDs, best-effort guess instead of + // defaulting to `anthropicDefaultModelId`'s (possibly older) info. + let info: ModelInfo = isKnownModel + ? anthropicModels[modelId as AnthropicModelId] + : this.guessModelInfoFromId(id) // If 1M context beta is enabled for supported models, update the model info if ( From d21cec56449ea351c8834f95ba7f641b035e85b0 Mon Sep 17 00:00:00 2001 From: Konstantin Krastev Date: Mon, 6 Jul 2026 15:13:35 +0300 Subject: [PATCH 2/3] style: trim comments to a single line --- src/api/providers/__tests__/anthropic.spec.ts | 10 --------- src/api/providers/anthropic.ts | 22 ++----------------- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 5900f42c6..c17e48618 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -457,12 +457,6 @@ describe("AnthropicHandler", () => { }) it("should send the custom model ID as-is and use adaptive thinking for a custom Sonnet-5-family model", async () => { - // Regression test for the combined bug: a custom deployment name - // (e.g. "claude-sonnet-5-bf") must be sent verbatim as `model`, - // and since it matches the Sonnet 5 family it must use - // `thinking: {type: "adaptive"}` rather than the legacy - // `{type: "enabled", budget_tokens}` shape, which real Sonnet - // 5-class deployments reject with a 400. const customHandler = new AnthropicHandler({ apiKey: "test-api-key", apiModelId: "claude-sonnet-5-bf", @@ -687,10 +681,6 @@ describe("AnthropicHandler", () => { }) it("should honor a custom/unrecognized model ID instead of silently falling back to anthropicDefaultModelId", () => { - // Regression test: a custom Anthropic-compatible deployment name - // (e.g. Azure AI Foundry) that isn't a key in `anthropicModels` must - // still be sent to the API as-is, not replaced with the hardcoded - // default (see https://github.com/Zoo-Code-Org/Zoo-Code/issues/418). const handler = new AnthropicHandler({ apiKey: "test-api-key", apiModelId: "claude-sonnet-5-bf", diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index c14ad85e5..4a6549105 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -353,17 +353,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } } - /** - * Best-effort capability lookup for a custom/unrecognized model ID (e.g. a - * custom Anthropic-compatible deployment name that isn't a key in - * `anthropicModels`). Matches known model-family substrings so an - * unrecognized ID still gets an accurate capability profile (e.g. - * `supportsReasoningBinary` for Sonnet 5 / Opus 4.7+ style deployments) - * instead of silently inheriting `anthropicDefaultModelId`'s capabilities, - * which can be from an older model generation with a different API - * contract (see `AnthropicHandler.getModel`). Mirrors the equivalent - * `guessModelInfoFromId` heuristic already used by `BedrockHandler`. - */ + // Guesses capabilities for an unrecognized model ID via known-family substring match. private guessModelInfoFromId(modelId: string): ModelInfo { const matchedId = (Object.keys(anthropicModels) as AnthropicModelId[]) .sort((a, b) => b.length - a.length) @@ -376,17 +366,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const modelId = this.options.apiModelId const isKnownModel = modelId !== undefined && modelId in anthropicModels - // The model ID actually sent to the API must always honor a - // user-configured apiModelId - including custom Anthropic-compatible - // deployment names that aren't in our static `anthropicModels` table - // (e.g. Azure AI Foundry deployment names). Falling back to - // `anthropicDefaultModelId` here silently ignores the user's setting - // and sends requests for a model they never selected. + // Always honor a user-configured apiModelId, even if it's not a known model. const id = isKnownModel ? (modelId as AnthropicModelId) : (modelId ?? anthropicDefaultModelId) - // Capability/pricing info can only be looked up directly for known - // models; for unrecognized custom IDs, best-effort guess instead of - // defaulting to `anthropicDefaultModelId`'s (possibly older) info. let info: ModelInfo = isKnownModel ? anthropicModels[modelId as AnthropicModelId] : this.guessModelInfoFromId(id) From 4726f8043aaace1bc36cb29c2cfb8dab579a055d Mon Sep 17 00:00:00 2001 From: Konstantin Krastev Date: Mon, 6 Jul 2026 15:13:55 +0300 Subject: [PATCH 3/3] chore: add changeset for anthropic custom model id fallback fix --- .changeset/fix-anthropic-custom-model-id-fallback.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/fix-anthropic-custom-model-id-fallback.md diff --git a/.changeset/fix-anthropic-custom-model-id-fallback.md b/.changeset/fix-anthropic-custom-model-id-fallback.md new file mode 100644 index 000000000..c84d0134a --- /dev/null +++ b/.changeset/fix-anthropic-custom-model-id-fallback.md @@ -0,0 +1,11 @@ +--- +"zoo-code": patch +--- + +Fix Anthropic provider silently replacing a custom/unrecognized `apiModelId` with the hardcoded default model. + +`AnthropicHandler.getModel()` coerced any `apiModelId` not present in the static `anthropicModels` table down to `anthropicDefaultModelId` ("claude-sonnet-4-5"), and that coerced id was what actually got sent as `model` in the API request -- silently ignoring a user-configured custom model name (e.g. a custom Anthropic-compatible deployment or proxy). This produced confusing "model does not exist" errors for the default model instead of the model the user actually selected (#418). + +The same fallback also affected capability lookups used to build the `thinking` request parameter: an unrecognized id fell back to the default model's info, which can be from an older model generation with a different API contract, causing the request to use the legacy `thinking: {type: "enabled", budget_tokens}` shape and get rejected with a 400 by models that require `{type: "adaptive"}`. + +The model id sent to the API now always honors a user-configured `apiModelId`. For unrecognized values, capabilities are best-effort guessed by matching known model-family substrings (mirroring the existing `BedrockHandler.guessModelInfoFromId` heuristic) instead of defaulting to `anthropicDefaultModelId`'s info.