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
10 changes: 8 additions & 2 deletions packages/llm/src/route/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
33 changes: 33 additions & 0 deletions packages/llm/test/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<html/i)
expect(error.reason.message).not.toMatch(/nginx/i)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
"<html>\n<head><title>503 Service Temporarily Unavailable</title></head>\n<body><center><h1>503 Service Temporarily Unavailable</h1></center><hr><center>nginx</center></body>\n</html>",
{ status: 503, headers: { "retry-after-ms": "0" } },
),
new Response(
"<html>\n<head><title>503 Service Temporarily Unavailable</title></head>\n<body><center><h1>503 Service Temporarily Unavailable</h1></center></body>\n</html>",
{ status: 503, headers: { "retry-after-ms": "0" } },
),
new Response(
"<html>\n<head><title>503 Service Temporarily Unavailable</title></head>\n<body><center><h1>503 Service Temporarily Unavailable</h1></center></body>\n</html>",
{ status: 503 },
),
]),
),
),
)

it.effect("marks 504 and 529 status responses retryable", () =>
Effect.gen(function* () {
const failWith = (status: number) =>
Expand Down
27 changes: 25 additions & 2 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<html>" 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 <your provider URL>` 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
Expand All @@ -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*<!doctype|^\s*<html/i.test(e.responseBody)) {
if (isHtmlMarkup(e.responseBody)) {
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 <your provider URL>` 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
}

Expand Down
55 changes: 55 additions & 0 deletions packages/opencode/test/provider/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html>", () => {
const error = new APICallError({
message: "Invalid request: unexpected <html> 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 <html> 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: "<!doctype html><html><body>401 Unauthorized</body></html>",
isRetryable: false,
})

const parsed = ProviderError.parseAPICallError({
providerID: ProviderV2.ID.make("openai"),
error,
})

expect(parsed.message).not.toContain("<html")
expect(parsed.message).toContain("dcode auth login")
})

test("parseAPICallError does not swallow a genuine JSON error on 503", () => {
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")
})
Loading