fix(ai): gateway error handling — relabel, sanitize the body, and retry transient 5xx - #45
Merged
Merged
Conversation
…HTML into chat
A gateway run that hit an upstream 502 surfaced, verbatim:
OpenAI API 502: <html> ... <center><h1>502 Bad Gateway</h1></center> ... </html>
Two defects in one line:
1. Mislabeled. The gateway resolves to providerId 'openai' to reuse the OpenAI-
compatible adapter, so streamAgentTurn stamped the error with the OpenAI provider
row's label — even for Opus 5 over LevelCode Cloud. prepProviderRequest already
computes the right label ('LevelCode Cloud'); it was just never threaded past the
provider lookup. Thread req.label through runAgent/turnOpts, doStream, and compact,
and have the router prefer o.label over p.label. BYOK still falls back to the
provider's own label, so an OpenRouter failure still reads "OpenRouter".
2. Raw HTML dumped. The three throw sites appended the raw response body; a proxy 5xx
is an HTML page, not JSON, so the whole nginx document landed in the transcript.
Two pure helpers in openaiCompat: extractApiError() returns a provider's JSON
{error:{message}} when present, '' for an HTML page, and a hard-capped string
otherwise; httpError() composes "<label> API <status>: <detail>", falling back to
the status reason ("Bad Gateway") when there's no usable message, and sets e.status.
The 502 itself is a transient upstream blip we can't fix — this is about surfacing it
honestly. The same failure now reads:
LevelCode Cloud API 502: Bad Gateway
providers.test.js +10 (extractApiError / httpError, incl. the exact nginx body).
Verified end-to-end with a stubbed fetch: the label threads through the real
index.js -> openaiCompat.js chain and BYOK's p.label fallback is intact.
Full gate: 24 suites, 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes misleading provider attribution for gateway-routed requests and prevents raw HTML proxy error pages from being surfaced in chat/agent error messages, improving error clarity in the extensions/levelcode-ai multi-provider layer.
Changes:
- Thread a route label override (
req.label) through chat, agent turns, and memory compaction so gateway errors are attributed to the route (e.g., “LevelCode Cloud”) instead of the underlying adapter/provider row. - Add
extractApiError()andhttpError()helpers to sanitize/cap HTTP error bodies (dropping HTML pages) and improve fallback to canonical reason phrases whenstatusTextis empty. - Expand provider-layer unit tests to cover the new helpers and the reported 502/HTML regression.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| extensions/levelcode-ai/test/providers.test.js | Adds unit tests for error extraction/sanitization and route label attribution. |
| extensions/levelcode-ai/providers/openaiCompat.js | Introduces extractApiError/httpError and uses them at HTTP failure sites to avoid dumping HTML into transcripts. |
| extensions/levelcode-ai/providers/index.js | Prefers an override label (`o.label |
| extensions/levelcode-ai/extension.js | Threads label through chat streaming and agent memory compaction, and includes it in agent context. |
| extensions/levelcode-ai/agent.js | Passes ctx.label into per-turn provider dispatch so agent-turn errors attribute correctly. |
Comments suppressed due to low confidence (2)
extensions/levelcode-ai/providers/index.js:180
completenow forwardso.labelbut the JSDoc type forodoesn’t declarelabel. Under// @ts-checkthis can cause a type error. Addlabel?: stringto the@paramobject type.
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
extensions/levelcode-ai/providers/index.js:212
streamAgentTurnnow forwardso.label, but the JSDoc type forodoesn’t includelabel. With// @ts-check, this is a type-check violation. Update the@paramtype to includelabel?: string.
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…s, so the run survives The 502 that killed the run in the screenshot was a transient upstream blip — the proxy in front of the model briefly couldn't reach a healthy backend. Reporting it cleanly (the previous commit) is good; recovering from it is better. New postChat() centralizes POST /chat/completions for all three OpenAI-shaped entry points (stream, complete, agent turn) and retries ONCE on 502/503/504. It is safe because it runs BEFORE any SSE line is read: on a transient status the response carries no model output, so nothing has streamed or been metered and a retry cannot duplicate output or double-bill the UI. Deliberately NOT retried: 429 (needs Retry-After), 500 (usually a real error), other 4xx, and thrown network/abort errors. A 401 still throws straight through to the agent's existing token-refresh path. The backoff wakes early on abort so Stop stays instant. The agent surfaces a visible "upstream busy (502) — retrying…" status via a new onRetry hook (threaded through the router) rather than a mystery pause; chat/complete retry silently. providers.test.js +6 (recover / exhaust / no-retry-on-4xx / onRetry / clean-200 / abort-not-retried). Verified end-to-end with a stubbed fetch: an agent turn that gets a 502 then a good SSE stream retries once and streams the text, onRetry carrying 502. Full gate: 24 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#45 review) streamChat/complete/streamAgentTurn read o.label and o.onRetry, and pass onRetry as an object literal into the openaiCompat adapters — but the @param typedefs for both didn't list them. Under // @ts-check that is 9 real diagnostics: TS2339 (property does not exist) on each o.label / o.onRetry read, plus TS2353 (excess property) on each adapter literal. Added label?:string and onRetry?:(info:{attempt,retries,status})=>void to the three router typedefs, and onRetry? to the three adapter typedefs (they already carried label?). The reviewer flagged label; onRetry has the same defect (added by the retry commit) and is fixed in the same pass. Verified with a real tsc 5.6 checkJs run — and not the vacuous kind: a synthetic probe first confirmed tsc actually enforces TS2339/TS2353 here (an earlier npx form was silently not running tsc at all), then stashing the fix gave BEFORE=9 label/onRetry errors, restoring it gave AFTER=0. JSDoc-only; runtime unchanged (full gate 24 suites, 28 provider tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A Gateway run on Opus 5 that hit an upstream 502 surfaced this, verbatim, in the chat — and killed the run:
Three things wrong, fixed in three reviewable commits.
1 — Wrong provider (
d…relabel)The failure was an Anthropic model over LevelCode Cloud, yet it read OpenAI. The gateway resolves to
providerId: 'openai'to reuse the OpenAI-compatible adapter, sostreamAgentTurnstamped the error with the OpenAI provider row's label.prepProviderRequestalready computes the correct label —'LevelCode Cloud'— it just never got threaded past the provider lookup. Now it flows throughrunAgent→turnOpts→ the router, which preferso.label || p.label. BYOK is unchanged (falls back to the provider row, so OpenRouter still reads "OpenRouter").2 — Raw HTML in the transcript (sanitize)
All three throw sites appended the raw response body, and a proxy 5xx is an nginx HTML page. Two pure helpers in
openaiCompat.js:extractApiError()returns a provider's JSON{error:{message}}when present,''for an HTML page, a hard-capped string otherwise;httpError()composes"<label> API <status>: <detail>", falling back to the status reason, and sets.status.The same failure now reports as:
LevelCode Cloud API 502: Bad Gateway3 — It failed the run instead of recovering (retry)
A 502/503/504 is a transient upstream blip — the proxy briefly couldn't reach a healthy backend. New
postChat()centralizesPOST /chat/completionsfor all three OpenAI-shaped entry points and retries once on 502/503/504.Safe by construction: it runs before any SSE line is read, so on a transient status the response carries no model output — nothing has streamed or been metered, and a retry cannot duplicate output or double-bill the UI. Deliberately not retried: 429 (needs
Retry-After), 500 (usually a real error), other 4xx, and thrown network/abort errors. A 401 still throws straight through to the existing token-refresh path. The backoff wakes early on abort so Stop stays instant, and the agent shows a visibleupstream busy (502) — retrying…status (newonRetryhook) instead of a mystery pause.The run in the screenshot would now survive.
Verification
OpenAI API 502: <html>…, NEW →LevelCode Cloud API 502: Bad Gateway.providers.test.js12 → 28:extractApiError/httpError(incl. the literal nginx page), andpostChatretry (recover / exhaust / no-retry-on-4xx / onRetry / clean-200 / abort-not-retried). Async cases use an awaited runner so a rejected assertion can't false-green.fetchthrough the realindex.js → openaiCompat.jschain: (a) label threads for the gateway shape and BYOK falls back; (b) an agent turn that gets a 502 then a good SSE stream retries once and streams the text,onRetrycarrying 502,stop_reason: end_turn.Still out of scope
anthropic.jsparity — the native Anthropic adapter has the same raw-body-append pattern (already correctly labeled; Anthropic returns JSON, so low HTML risk). SharinghttpError/postChatthere is a clean follow-up.🤖 Generated with Claude Code