Skip to content

Commit 13c803b

Browse files
fix(anthropic): honor custom apiModelId instead of silently defaulting to claude-sonnet-4-5 (#842)
* 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 * style: trim comments to a single line * chore: add changeset for anthropic custom model id fallback fix * Hoist sorted model IDs out of guessModelInfoFromId method; Make model ID matching case insensitive in Anthropic --------- Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent edb6564 commit 13c803b

3 files changed

Lines changed: 97 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"zoo-code": patch
3+
---
4+
5+
Fix Anthropic provider silently replacing a custom/unrecognized `apiModelId` with the hardcoded default model.
6+
7+
`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).
8+
9+
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"}`.
10+
11+
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.

src/api/providers/__tests__/anthropic.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,29 @@ describe("AnthropicHandler", () => {
455455
expect(requestBody?.max_tokens).toBe(32768)
456456
expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31")
457457
})
458+
459+
it("should send the custom model ID as-is and use adaptive thinking for a custom Sonnet-5-family model", async () => {
460+
const customHandler = new AnthropicHandler({
461+
apiKey: "test-api-key",
462+
apiModelId: "claude-sonnet-5-bf",
463+
enableReasoningEffort: true,
464+
})
465+
466+
const stream = customHandler.createMessage(systemPrompt, [
467+
{
468+
role: "user",
469+
content: [{ type: "text" as const, text: "Hello" }],
470+
},
471+
])
472+
473+
for await (const _chunk of stream) {
474+
// Consume stream
475+
}
476+
477+
const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0]
478+
expect(requestBody?.model).toBe("claude-sonnet-5-bf")
479+
expect(requestBody?.thinking).toEqual({ type: "adaptive" })
480+
})
458481
})
459482

460483
describe("completePrompt", () => {
@@ -656,6 +679,38 @@ describe("AnthropicHandler", () => {
656679
expect(model.info.inputPrice).toBe(6.0)
657680
expect(model.info.outputPrice).toBe(22.5)
658681
})
682+
683+
it("should honor a custom/unrecognized model ID instead of silently falling back to anthropicDefaultModelId", () => {
684+
const handler = new AnthropicHandler({
685+
apiKey: "test-api-key",
686+
apiModelId: "claude-sonnet-5-bf",
687+
})
688+
const model = handler.getModel()
689+
expect(model.id).toBe("claude-sonnet-5-bf")
690+
})
691+
692+
it("should guess capabilities for a custom/unrecognized model ID from known model-family substrings", () => {
693+
const handler = new AnthropicHandler({
694+
apiKey: "test-api-key",
695+
apiModelId: "claude-sonnet-5-bf",
696+
})
697+
const model = handler.getModel()
698+
expect(model.info.supportsReasoningBinary).toBe(true)
699+
expect(model.info.maxTokens).toBe(128000)
700+
expect(model.info.contextWindow).toBe(1000000)
701+
})
702+
703+
it("should fall back to anthropicDefaultModelId's info when a custom model ID matches no known family", () => {
704+
const handler = new AnthropicHandler({
705+
apiKey: "test-api-key",
706+
apiModelId: "totally-unknown-custom-model",
707+
})
708+
const model = handler.getModel()
709+
expect(model.id).toBe("totally-unknown-custom-model")
710+
expect(model.info.maxTokens).toBe(64000)
711+
expect(model.info.contextWindow).toBe(200000)
712+
expect(model.info.supportsReasoningBinary).toBeUndefined()
713+
})
659714
})
660715

661716
describe("reasoning block filtering", () => {

src/api/providers/anthropic.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ import {
2929
convertOpenAIToolChoiceToAnthropic,
3030
} from "../../core/prompts/tools/native-tools/converters"
3131

32+
// Pre-sorted list of known Anthropic model IDs (lowercased) by length (descending) for case-insensitive substring matching.
33+
const ANTHROPIC_MODEL_IDS_SORTED_LOWER = (Object.keys(anthropicModels) as AnthropicModelId[])
34+
.map((id) => id.toLowerCase())
35+
.sort((a, b) => b.length - a.length) as string[]
36+
37+
// Original-case mapping: lowercase key → original AnthropicModelId for lookup.
38+
const ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL = Object.fromEntries(
39+
(Object.keys(anthropicModels) as AnthropicModelId[]).map((id) => [id.toLowerCase(), id]),
40+
)
41+
3242
export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler {
3343
private options: ApiHandlerOptions
3444
private client: Anthropic
@@ -353,10 +363,29 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
353363
}
354364
}
355365

366+
// Guesses capabilities for an unrecognized model ID via known-family substring match.
367+
private guessModelInfoFromId(modelId: string): ModelInfo {
368+
const lowerModelId = modelId.toLowerCase()
369+
const matchedLower = ANTHROPIC_MODEL_IDS_SORTED_LOWER.find((knownId) => lowerModelId.includes(knownId))
370+
371+
if (!matchedLower) {
372+
return anthropicModels[anthropicDefaultModelId]
373+
}
374+
375+
const originalId = ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL[matchedLower] as AnthropicModelId
376+
return anthropicModels[originalId]
377+
}
378+
356379
getModel() {
357380
const modelId = this.options.apiModelId
358-
const id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId
359-
let info: ModelInfo = anthropicModels[id]
381+
const isKnownModel = modelId !== undefined && modelId in anthropicModels
382+
383+
// Always honor a user-configured apiModelId, even if it's not a known model.
384+
const id = isKnownModel ? (modelId as AnthropicModelId) : (modelId ?? anthropicDefaultModelId)
385+
386+
let info: ModelInfo = isKnownModel
387+
? anthropicModels[modelId as AnthropicModelId]
388+
: this.guessModelInfoFromId(id)
360389

361390
// If 1M context beta is enabled for supported models, update the model info
362391
if (

0 commit comments

Comments
 (0)