Skip to content
Merged
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
20 changes: 15 additions & 5 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,21 @@ export namespace ProviderError {

try {
const body = JSON.parse(e.responseBody)
// try to extract common error message fields
const errMsg = body.message || body.error || body.error?.message
if (errMsg && typeof errMsg === "string") {
return `${msg}: ${errMsg}`
}
// altimate_change start — upstream_fix: OpenAI errors use {error: {message}} shape;
// the original `body.message || body.error || body.error?.message` short-circuits on
// the parent object, fails the typeof string guard, and dumps the raw body. Use an
// explicit-typeof ternary so a truthy non-string at any level can't block a valid
// string further down the chain (matches parseStreamError's pattern below).
const errMsg =
typeof body.error?.message === "string"
? body.error.message
: typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: undefined
if (errMsg) return `${msg}: ${errMsg}`
// altimate_change end
} catch {}

// If responseBody is HTML (e.g. from a gateway or proxy error page),
Expand Down
121 changes: 121 additions & 0 deletions packages/opencode/test/provider/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,125 @@ describe("ProviderError.parseAPICallError: error message extraction", () => {
expect(result.message).toContain("invalid JSON in request body")
}
})

test("extracts nested error.message from OpenAI-shaped JSON body", () => {
// OpenAI returns 4xx errors with {error: {message, type, code}}. The extractor
// must reach body.error.message — not stop at body.error (which is the object).
const result = ProviderError.parseAPICallError({
providerID: "openai" as any,
error: makeAPICallError({
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({
error: {
message: "The model `gpt-5-codex` does not exist or you do not have access to it.",
type: "invalid_request_error",
code: "model_not_found",
},
}),
}),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
expect(result.message).toContain("Bad Request")
expect(result.message).toContain("gpt-5-codex")
expect(result.message).toContain("does not exist")
// The raw structured body must not be dumped when a clean message extracted.
// Detect a leak by looking for JSON delimiters from the parsed body.
expect(result.message).not.toContain('"error":')
expect(result.message).not.toContain("{")
}
})

test("extracts top-level message field when present", () => {
const result = ProviderError.parseAPICallError({
providerID: "anthropic" as any,
error: makeAPICallError({
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({ message: "Field 'foo' is required" }),
}),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
expect(result.message).toContain("Field 'foo' is required")
}
})

test("falls back to body.error string when it is a plain string", () => {
// Some providers return {error: "string"} rather than {error: {message: ...}}.
const result = ProviderError.parseAPICallError({
providerID: "anthropic" as any,
error: makeAPICallError({
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({ error: "Something went wrong" }),
}),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
expect(result.message).toContain("Something went wrong")
}
})

test("falls back through the chain when body.error has no message but body.message does", () => {
// body.error is an object without a `message` key — the extractor must skip it
// and reach the top-level body.message.
const result = ProviderError.parseAPICallError({
providerID: "openai" as any,
error: makeAPICallError({
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({
error: { code: "rate_limited", type: "throttle" },
message: "Slow down",
}),
}),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
expect(result.message).toContain("Slow down")
}
})

test("non-string body.error.message does not block a valid body.message", () => {
// Same class as the bug we just fixed: a truthy non-string at any level of the
// chain must not short-circuit a valid string further down.
const result = ProviderError.parseAPICallError({
providerID: "openai" as any,
error: makeAPICallError({
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({
error: { message: ["array", "of", "strings"] },
message: "Real human-readable error",
}),
}),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
expect(result.message).toContain("Real human-readable error")
expect(result.message).not.toContain("array")
}
})

test("dumps raw body when no string-typed message field exists anywhere", () => {
// body.error has only non-message fields and no top-level message — the parser
// falls through to the raw responseBody dump (last-resort behavior preserved).
const responseBody = JSON.stringify({ error: { code: "x", type: "y" } })
const result = ProviderError.parseAPICallError({
providerID: "openai" as any,
error: makeAPICallError({
message: "Bad Request",
statusCode: 400,
responseBody,
}),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
// Falls through to `${msg}: ${responseBody}` — preserves existing behavior.
expect(result.message).toContain("Bad Request")
expect(result.message).toContain(responseBody)
}
})
})
Loading