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
22 changes: 22 additions & 0 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export namespace ProviderError {
/too large for model with \d+ maximum context length/i, // Mistral
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
]
const CLAUDE_CODE_CREDENTIAL_PATTERN =
/This credential is only authorized for use with Claude Code and cannot be used for other API requests\./i

function isOpenAiErrorRetryable(e: APICallError) {
const status = e.statusCode
Expand All @@ -46,6 +48,13 @@ export namespace ProviderError {
return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
}

function isClaudeCodeCredential(providerID: ProviderID, message: string, responseBody?: string) {
if (providerID !== "anthropic") return false
if (CLAUDE_CODE_CREDENTIAL_PATTERN.test(message)) return true
if (!responseBody) return false
return CLAUDE_CODE_CREDENTIAL_PATTERN.test(responseBody)
}

function message(providerID: ProviderID, e: APICallError) {
return iife(() => {
const msg = e.message
Expand All @@ -67,10 +76,23 @@ export namespace ProviderError {
// try to extract common error message fields
const errMsg = body.message || body.error || body.error?.message
if (errMsg && typeof errMsg === "string") {
if (isClaudeCodeCredential(providerID, errMsg, e.responseBody)) {
return [
"This Anthropic credential is restricted to Claude Code and cannot be used with OpenCode.",
"Use a standard Anthropic API key instead, then run `opencode auth login anthropic` to update your credentials.",
].join(" ")
}
return `${msg}: ${errMsg}`
}
} catch {}

if (isClaudeCodeCredential(providerID, msg, e.responseBody)) {
return [
"This Anthropic credential is restricted to Claude Code and cannot be used with OpenCode.",
"Use a standard Anthropic API key instead, then run `opencode auth login anthropic` to update your credentials.",
].join(" ")
}

// If responseBody is HTML (e.g. from a gateway or proxy error page),
// provide a human-readable message instead of dumping raw markup
if (/^\s*<!doctype|^\s*<html/i.test(e.responseBody)) {
Expand Down
59 changes: 59 additions & 0 deletions packages/opencode/test/provider/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import { APICallError } from "ai"
import { ProviderError } from "../../src/provider/error"
import { ProviderID } from "../../src/provider/schema"

describe("provider.error", () => {
test("clarifies Claude Code-only Anthropic credentials", () => {
const result = ProviderError.parseAPICallError({
providerID: ProviderID.make("anthropic"),
error: new APICallError({
message: "Unauthorized",
url: "https://api.anthropic.com/v1/messages",
requestBodyValues: {},
statusCode: 401,
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({
type: "error",
error: {
type: "authentication_error",
message: "This credential is only authorized for use with Claude Code and cannot be used for other API requests.",
},
}),
isRetryable: false,
}),
})

expect(result.type).toBe("api_error")
if (result.type !== "api_error") return
expect(result.message).toContain("restricted to Claude Code")
expect(result.message).toContain("standard Anthropic API key")
expect(result.message).toContain("opencode auth login anthropic")
})

test("keeps generic Anthropic auth errors unchanged", () => {
const result = ProviderError.parseAPICallError({
providerID: ProviderID.make("anthropic"),
error: new APICallError({
message: "Unauthorized",
url: "https://api.anthropic.com/v1/messages",
requestBodyValues: {},
statusCode: 401,
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({
type: "error",
error: {
type: "authentication_error",
message: "Invalid API key",
},
}),
isRetryable: false,
}),
})

expect(result.type).toBe("api_error")
if (result.type !== "api_error") return
expect(result.message).not.toContain("restricted to Claude Code")
expect(result.message).not.toContain("standard Anthropic API key")
})
})
Loading