diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 0acf74ad6..691de93bd 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -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), diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index b2b96c878..e5387b06a 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -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) + } + }) })