diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index b2f679c68354..11f9fa39713b 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -201,9 +201,15 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } } +// HTML/gateway error pages are useless (and noisy) in TUI retry notices (#35640). +const isHtmlErrorBody = (body: string) => /^\s*<(!doctype\s+html|html[\s>])/i.test(body) + const providerMessage = (status: number, body: { readonly body?: string }) => { - if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}` - return `Provider request failed with HTTP ${status}` + const text = body.body + if (!text || text.length > 500 || isHtmlErrorBody(text)) { + return `Provider request failed with HTTP ${status}` + } + return `Provider request failed with HTTP ${status}: ${text}` } const responseHttp = (input: { diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts index 811f7a9ffe7b..bcbc6e6a6800 100644 --- a/packages/llm/test/executor.test.ts +++ b/packages/llm/test/executor.test.ts @@ -265,6 +265,39 @@ describe("RequestExecutor", () => { ), ) + it.effect("omits HTML error bodies from user-facing provider messages", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ + _tag: "ProviderInternal", + status: 503, + message: "Provider request failed with HTTP 503", + }) + expect(error.reason.message).not.toMatch(/\n503 Service Temporarily Unavailable\n

503 Service Temporarily Unavailable


nginx
\n", + { status: 503, headers: { "retry-after-ms": "0" } }, + ), + new Response( + "\n503 Service Temporarily Unavailable\n

503 Service Temporarily Unavailable

\n", + { status: 503, headers: { "retry-after-ms": "0" } }, + ), + new Response( + "\n503 Service Temporarily Unavailable\n

503 Service Temporarily Unavailable

\n", + { status: 503 }, + ), + ]), + ), + ), + ) + it.effect("marks 504 and 529 status responses retryable", () => Effect.gen(function* () { const failWith = (status: number) => diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index f745a1899da4..ab31369b1c8d 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -29,11 +29,31 @@ function isOpenAiErrorRetryable(e: APICallError) { // Providers not reliably handled in this function: // - z.ai: can accept overflow silently (needs token-count/context-window checks) +// Anchored so a legitimate error message that merely mentions "" isn't +// misclassified as a gateway page (matches the executor's detector). +const isHtmlMarkup = (value: string) => /^\s*<(!doctype\s+html|html[\s>])/i.test(value) + function message(providerID: ProviderV2.ID, e: APICallError) { return iife(() => { const msg = e.message + + // Message may already embed an HTML error page (e.g. LLM executor appends body). + // Never surface raw markup in TUI/retry notices (#35640 / #15406). + if (isHtmlMarkup(msg)) { + if (e.statusCode === 401) { + return "Unauthorized: request was blocked by a gateway or proxy. Your authentication token may be missing or expired — try running `dcode auth login ` to re-authenticate." + } + if (e.statusCode === 403) { + return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings." + } + if (e.statusCode === 502 || e.statusCode === 503 || e.statusCode === 504) { + return `Provider temporarily unavailable (HTTP ${e.statusCode})` + } + return e.statusCode ? `Provider request failed with HTTP ${e.statusCode}` : "Provider request failed" + } + if (msg === "") { - if (e.responseBody) return e.responseBody + if (e.responseBody && !isHtmlMarkup(e.responseBody)) return e.responseBody if (e.statusCode) { const err = STATUS_CODES[e.statusCode] if (err) return err @@ -60,13 +80,16 @@ function message(providerID: ProviderV2.ID, e: APICallError) { // 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*` to re-authenticate." } if (e.statusCode === 403) { return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings." } + if (e.statusCode === 502 || e.statusCode === 503 || e.statusCode === 504) { + return `Provider temporarily unavailable (HTTP ${e.statusCode})` + } return msg } diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 4a3bc4ade53c..620f8ed12d04 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -47,3 +47,58 @@ test("parseAPICallError still extracts string body.error", () => { expect(parsed.type).toBe("api_error") expect(parsed.message).toBe("Bad Request: model not found") }) + +test("parseAPICallError surfaces a real message that merely mentions ", () => { + const error = new APICallError({ + message: "Invalid request: unexpected tag in prompt content", + statusCode: 400, + url: "https://example.com/v1/chat", + requestBodyValues: {}, + isRetryable: false, + }) + + const parsed = ProviderError.parseAPICallError({ + providerID: ProviderV2.ID.make("openai"), + error, + }) + + // Anchored HTML detection must NOT misclassify this as a gateway page. + expect(parsed.message).toBe("Invalid request: unexpected tag in prompt content") +}) + +test("parseAPICallError replaces an HTML gateway body with a friendly 401 message", () => { + const error = new APICallError({ + message: "Unauthorized", + statusCode: 401, + url: "https://gateway.example.com/v1/chat", + requestBodyValues: {}, + responseBody: "401 Unauthorized", + isRetryable: false, + }) + + const parsed = ProviderError.parseAPICallError({ + providerID: ProviderV2.ID.make("openai"), + error, + }) + + expect(parsed.message).not.toContain(" { + const error = new APICallError({ + message: "Service Unavailable", + statusCode: 503, + url: "https://example.com/v1/chat", + requestBodyValues: {}, + responseBody: JSON.stringify({ error: { message: "upstream model overloaded" } }), + isRetryable: true, + }) + + const parsed = ProviderError.parseAPICallError({ + providerID: ProviderV2.ID.make("openai"), + error, + }) + + expect(parsed.message).toContain("upstream model overloaded") +})