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 0000000000..c84d0134ad --- /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. diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 2b944b7db5..c17e486184 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -455,6 +455,29 @@ 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 () => { + 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 +679,38 @@ 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", () => { + 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 f9e2ee7d2b..4a65491053 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -353,10 +353,25 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } } + // 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) + .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 + + // Always honor a user-configured apiModelId, even if it's not a known model. + const id = isKnownModel ? (modelId as AnthropicModelId) : (modelId ?? anthropicDefaultModelId) + + 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 (