Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-anthropic-custom-model-id-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions src/api/providers/__tests__/anthropic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
19 changes: 17 additions & 2 deletions src/api/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading