fix(sdk): make model-not-found API errors actionable in the webview (#11378)

When a provider returns a model-not-found error (e.g. Anthropic's HTTP 404
for a retired model such as claude-3-haiku-20240307), the SDK strips the
status and delivers only the terse body, which collapses to the bare label
"model: <id>". reshapeErrorForWebview fell through to returning that raw
string, so ErrorRow rendered a label-like fragment in red with no hint that
the model is gone or how to recover.

Detect these in the plain-text branch of reshapeErrorForWebview and rewrite
them into a sentence that names the model and tells the user to switch models
in API Configuration settings, then retry. The model switch is framed as a
precondition rather than a parallel option so users don't loop on Retry.

Detection is text-based because the HTTP status is unavailable at this point.
The keyword match is anchored to the word "model" with a not-found signal in
the same sentence, so unrelated errors that merely mention a model (plan
gating, deprecated features) are left untouched. Adds tests for the bare
label form, a generic "does not exist" form, and two negative cases (plan
gating and an auth error mentioning a model) that must pass through unchanged.
This commit is contained in:
Mikołaj Kondratek
2026-06-24 14:11:32 +09:00
committed by Dominic Cooney
parent ee0c63a216
commit 82c3d8d771
2 changed files with 115 additions and 8 deletions
@@ -1113,6 +1113,89 @@ describe("translateSessionEvent — agent_event error", () => {
expect(parsed.code).toBe("SPEND_LIMIT_EXCEEDED")
expect(parsed.providerId).toBe("cline")
})
it("rewrites Anthropic bare 'model: <id>' 404 into an actionable message", () => {
const state = new MessageTranslatorState(undefined, () => "anthropic")
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "error",
error: { message: "model: claude-3-haiku-20240307" },
} as AgentEvent,
},
}
const result = translateSessionEvent(event, state)
expect(result.messages).toHaveLength(2)
const failedText = result.messages[1].text!
expect(failedText).toContain("claude-3-haiku-20240307")
expect(failedText).toContain("was not found")
expect(failedText).toContain("API Configuration settings")
})
it("rewrites a 'model not found' message into an actionable message", () => {
const state = new MessageTranslatorState()
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "error",
error: { message: "The model `gpt-foo` does not exist" },
} as AgentEvent,
},
}
const result = translateSessionEvent(event, state)
expect(result.messages).toHaveLength(2)
const failedText = result.messages[1].text!
expect(failedText).toContain("gpt-foo")
expect(failedText).toContain("API Configuration settings")
})
it("does not rewrite an unrelated error that merely mentions a model", () => {
const state = new MessageTranslatorState()
const rawMessage = "The requested feature model is not available in your plan"
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "error",
error: { message: rawMessage },
} as AgentEvent,
},
}
const result = translateSessionEvent(event, state)
const failedText = result.messages[1].text!
expect(failedText).toBe(rawMessage)
expect(failedText).not.toContain("API Configuration settings")
})
it("leaves an auth error mentioning a model untouched", () => {
const state = new MessageTranslatorState()
const rawMessage = "Invalid API key for model gpt-4"
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "error",
error: { message: rawMessage },
} as AgentEvent,
},
}
const result = translateSessionEvent(event, state)
const failedText = result.messages[1].text!
expect(failedText).toBe(rawMessage)
expect(failedText).not.toContain("API Configuration settings")
})
})
// ---------------------------------------------------------------------------
+32 -8
View File
@@ -1876,15 +1876,35 @@ export function historyItemToSessionFields(item: {
}
}
const MODEL_NOT_FOUND_GUIDANCE =
"This model may be retired or unavailable on your account. Switch to a different model in API Configuration settings, then retry."
/**
* Reshape an SDK error into the serialized ClineError JSON format the webview
* expects. The webview's ErrorRow uses ClineError.parse() which needs specific
* fields (`code`, `providerId`, `details`) to detect error types and render
* appropriate UI (e.g. "Buy Credits" button for insufficient credits).
*
* The SDK error's `message` may contain embedded JSON from the API response.
* We try to extract it and produce a proper ClineError-serialized payload.
* If parsing fails, we fall back to the raw error message.
* Rewrite a model-not-found error into actionable guidance, or undefined if the
* message is not one. The provider's HTTP status is stripped upstream, so this
* matches on text rather than a status code.
*/
function describeModelNotFoundError(rawMessage: string): string | undefined {
// Anthropic's 404 body collapses to a bare "model: <id>" label.
const bareModelLabel = rawMessage.match(/^\s*model:\s*(\S+)\s*$/i)
if (bareModelLabel) {
return `Model "${bareModelLabel[1]}" was not found. ${MODEL_NOT_FOUND_GUIDANCE}`
}
// Keep the not-found signal in the same clause as "model" so errors that
// merely mention one (plan gating, deprecated features) are left untouched.
const modelNotFound = /\bmodel\b[^.,;:]*\b(not[ _]?found|does not exist|no such model|unknown model)\b/i
if (modelNotFound.test(rawMessage)) {
return `${rawMessage} ${MODEL_NOT_FOUND_GUIDANCE}`
}
return undefined
}
/**
* Reshape an SDK error into the serialized ClineError JSON the webview's
* ErrorRow expects (`code`, `providerId`, `details`), extracting structured
* info from the error message when present and falling back to raw text.
*/
export function reshapeErrorForWebview(
error: { message?: string; status?: number; code?: string },
@@ -1949,6 +1969,10 @@ export function reshapeErrorForWebview(
},
})
}
const notFoundMessage = describeModelNotFoundError(rawMessage)
if (notFoundMessage) {
return notFoundMessage
}
return rawMessage
}