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(/\n
503 Service Temporarily Unavailable\n503 Service Temporarily Unavailable
nginx\n",
+ { status: 503, headers: { "retry-after-ms": "0" } },
+ ),
+ new Response(
+ "\n503 Service Temporarily Unavailable\n503 Service Temporarily Unavailable
\n",
+ { status: 503, headers: { "retry-after-ms": "0" } },
+ ),
+ new Response(
+ "\n503 Service Temporarily Unavailable\n503 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")
+})