Skip to content

Commit 8a22f05

Browse files
ndemiancclaude
andcommitted
fix(ai): stop mislabeling gateway errors as "OpenAI" and dumping raw 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>
1 parent faa8eb5 commit 8a22f05

5 files changed

Lines changed: 104 additions & 13 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ async function runAgent(ctx) {
719719
let streamed = false;
720720
let textChars = 0;
721721
const turnOpts = {
722-
providerId: ctx.providerId, baseURL: ctx.baseURL,
722+
providerId: ctx.providerId, baseURL: ctx.baseURL, label: ctx.label,
723723
apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system,
724724
messages, tools: tools, signal: ctx.signal,
725725
onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); },

extensions/levelcode-ai/extension.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,7 @@ async function compactAgentMemory() {
941941
let summary;
942942
try {
943943
summary = await providers.complete({
944-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL,
944+
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
945945
model: req.model, maxTokens: 1500,
946946
system: COMPACT_SYSTEM,
947947
messages: [{ role: 'user', content: COMPACT_INSTRUCTIONS + flat }]
@@ -1021,6 +1021,8 @@ async function agentFlow(text) {
10211021
messages: agentMessages, // persists across runs → the agent remembers the session
10221022
providerId: req.providerId, // Anthropic native, or an OpenAI-shaped provider via translation (P2)
10231023
baseURL: req.baseURL, // for the custom / Ollama endpoints
1024+
label: req.label, // route name for error attribution — "LevelCode Cloud" on the gateway,
1025+
// else the provider's own label; keeps a 502 from being blamed on "OpenAI"
10241026
apiKey: req.apiKey,
10251027
model: req.model,
10261028
maxSteps: Math.max(1, cfg.get('agent.maxSteps', 25)),
@@ -1116,7 +1118,7 @@ async function handleSend(text) {
11161118
return;
11171119
}
11181120
const doStream = (r) => providers.streamChat({
1119-
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL,
1121+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
11201122
model: r.model, maxTokens: r.maxTokens, system: SYSTEM_PROMPT,
11211123
messages: conversation, signal: abort.signal, onDelta
11221124
});

extensions/levelcode-ai/providers/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ async function streamChat(o) {
155155
});
156156
}
157157
return openai.streamOpenAI({
158-
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label,
158+
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
159159
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages,
160160
signal: o.signal, onDelta: o.onDelta
161161
});
@@ -177,7 +177,7 @@ async function complete(o) {
177177
});
178178
}
179179
return openai.completeOpenAI({
180-
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label,
180+
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
181181
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages,
182182
stop: o.stop, signal: o.signal
183183
});
@@ -209,7 +209,7 @@ async function streamAgentTurn(o) {
209209
});
210210
}
211211
return openai.streamOpenAIAgentTurn({
212-
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label,
212+
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
213213
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, tools: o.tools,
214214
signal: o.signal, onText: o.onText, onToolStart: o.onToolStart
215215
});

extensions/levelcode-ai/providers/openaiCompat.js

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,48 @@ function deltaFromEvent(ev) {
7676
return typeof d.content === 'string' ? d.content : '';
7777
}
7878

79+
// Fallback reason phrases for when fetch leaves res.statusText empty (some HTTP/2 responses do). Not
80+
// exhaustive — just what a model endpoint or the proxy in front of it realistically returns.
81+
const STATUS_REASON = {
82+
400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found',
83+
408: 'Request Timeout', 413: 'Payload Too Large', 429: 'Too Many Requests',
84+
500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout'
85+
};
86+
87+
/**
88+
* Pull a human-readable message out of an error response body, or '' when there isn't one worth showing.
89+
* The body is UNTRUSTED and provider-shaped: a JSON `{error:{message}}` on a normal API rejection, but a
90+
* raw HTML page when a proxy IN FRONT of the model (nginx/Cloudflare) returns a 5xx — dumping that page
91+
* into a chat transcript is pure noise. Return '' for HTML so the caller falls back to the status reason;
92+
* cap anything else so a stray multi-KB body can't flood the UI. Pure — unit-tested.
93+
*/
94+
function extractApiError(body) {
95+
const s = String(body || '').trim();
96+
if (!s) { return ''; }
97+
if (s[0] === '<' || /<html[\s>]/i.test(s)) { return ''; } // HTML proxy page — no useful message
98+
if (s[0] === '{' || s[0] === '[') {
99+
try {
100+
const j = JSON.parse(s);
101+
const m = (j && j.error && (j.error.message || (typeof j.error === 'string' ? j.error : ''))) || (j && j.message) || '';
102+
if (m) { return String(m).slice(0, 500); }
103+
} catch { /* not valid JSON after all — fall through to the capped-text path */ }
104+
}
105+
return s.length > 300 ? s.slice(0, 300) + '…' : s; // short plain text: keep it, capped
106+
}
107+
108+
/**
109+
* Build a clean Error for a failed (`!res.ok`) response: `"<label> API <status>: <detail>"`, where detail
110+
* is the provider's own message when it gave one, else the HTTP status reason — never a dumped HTML page.
111+
* `label` names the ROUTE (e.g. "LevelCode Cloud", "OpenRouter"), so the failure is attributed correctly
112+
* rather than blamed on whichever adapter happens to carry it. Sets `.status` for retry/refresh logic.
113+
*/
114+
function httpError(label, res, body) {
115+
const detail = extractApiError(body) || res.statusText || STATUS_REASON[res.status] || 'request failed';
116+
const e = new Error(`${label} API ${res.status}: ${detail}`);
117+
e.status = res.status;
118+
return e;
119+
}
120+
79121
/**
80122
* Streaming chat over /v1/chat/completions. opts.onDelta(text) per chunk; resolves at end.
81123
* @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string,
@@ -91,8 +133,7 @@ async function streamOpenAI(opts) {
91133
signal: opts.signal
92134
});
93135
if (!res.ok || !res.body) {
94-
const text = await res.text().catch(() => '');
95-
throw new Error(`${label} API ${res.status}: ${text || res.statusText}`);
136+
throw httpError(label, res, await res.text().catch(() => ''));
96137
}
97138
await readLines(res, (line) => {
98139
const s = line.trim();
@@ -122,8 +163,7 @@ async function completeOpenAI(opts) {
122163
signal: opts.signal
123164
});
124165
if (!res.ok) {
125-
const text = await res.text().catch(() => '');
126-
throw new Error(`${label} API ${res.status}: ${text || res.statusText}`);
166+
throw httpError(label, res, await res.text().catch(() => ''));
127167
}
128168
const data = await res.json();
129169
const c = data && data.choices && data.choices[0];
@@ -195,8 +235,7 @@ async function streamOpenAIAgentTurn(opts) {
195235
signal: opts.signal
196236
});
197237
if (!res.ok || !res.body) {
198-
const text = await res.text().catch(() => '');
199-
throw new Error(`${label} API ${res.status}: ${text || res.statusText}`);
238+
throw httpError(label, res, await res.text().catch(() => ''));
200239
}
201240
let text = '';
202241
/** @type {any[]} */
@@ -240,4 +279,4 @@ async function streamOpenAIAgentTurn(opts) {
240279
return { content, stop_reason: stopReason, usage, malformed };
241280
}
242281

243-
module.exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel, isAnthropicFamily, splitOutCachedTokens };
282+
module.exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel, isAnthropicFamily, splitOutCachedTokens, extractApiError, httpError };

extensions/levelcode-ai/test/providers.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,54 @@ test('isInsecureCustomUrl: https anywhere ok; http only to localhost/loopback; r
116116
assert.strictEqual(reg.isInsecureCustomUrl('http://localhost.evil.com/v1'), true);
117117
});
118118

119+
// --- extractApiError: pull a clean message from an error body; '' when there's nothing worth showing ---
120+
test('extractApiError: JSON {error:{message}} -> the message (OpenAI/OpenRouter shape)', () => {
121+
assert.strictEqual(oc.extractApiError('{"error":{"message":"model is overloaded","type":"server_error"}}'), 'model is overloaded');
122+
});
123+
test('extractApiError: {error:"str"} and {message} shapes both yield the text', () => {
124+
assert.strictEqual(oc.extractApiError('{"error":"nope"}'), 'nope');
125+
assert.strictEqual(oc.extractApiError('{"message":"bad key"}'), 'bad key');
126+
});
127+
test('extractApiError: an HTML proxy page yields "" — never dump nginx/Cloudflare markup', () => {
128+
const nginx502 = '<html>\r\n<head><title>502 Bad Gateway</title></head>\r\n<body>\r\n<center><h1>502 Bad Gateway</h1></center>\r\n</body>\r\n</html>';
129+
assert.strictEqual(oc.extractApiError(nginx502), '');
130+
assert.strictEqual(oc.extractApiError('<!DOCTYPE html><html><body>down</body></html>'), '');
131+
});
132+
test('extractApiError: empty / whitespace / null -> ""', () => {
133+
assert.strictEqual(oc.extractApiError(''), '');
134+
assert.strictEqual(oc.extractApiError(' \n '), '');
135+
assert.strictEqual(oc.extractApiError(null), '');
136+
});
137+
test('extractApiError: short plain text kept as-is; an over-long body is hard-capped', () => {
138+
assert.strictEqual(oc.extractApiError('rate limited, retry soon'), 'rate limited, retry soon');
139+
const out = oc.extractApiError('x'.repeat(1000));
140+
assert.strictEqual(out.length, 301); // 300 chars + one ellipsis
141+
assert.strictEqual(out.charCodeAt(300), 0x2026);
142+
});
143+
test('extractApiError: malformed JSON falls through to capped text instead of throwing', () => {
144+
assert.strictEqual(oc.extractApiError('{not valid json'), '{not valid json');
145+
});
146+
147+
// --- httpError: "<label> API <status>: <detail>" — precisely the reported "OpenAI API 502: <html>" bug ---
148+
test('httpError: nginx 502 HTML collapses to "<label> API 502: Bad Gateway", no markup', () => {
149+
const html = '<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>';
150+
const e = oc.httpError('LevelCode Cloud', { status: 502, statusText: 'Bad Gateway' }, html);
151+
assert.strictEqual(e.message, 'LevelCode Cloud API 502: Bad Gateway');
152+
assert.strictEqual(e.status, 502);
153+
assert.ok(!/[<>]/.test(e.message), 'no HTML leaks into the surfaced message');
154+
});
155+
test('httpError: the label names the ROUTE — a gateway failure is not attributed to OpenAI', () => {
156+
const e = oc.httpError('LevelCode Cloud', { status: 502, statusText: 'Bad Gateway' }, '<html>502</html>');
157+
assert.ok(e.message.startsWith('LevelCode Cloud API 502'));
158+
assert.ok(!/OpenAI/.test(e.message));
159+
});
160+
test('httpError: empty statusText falls back to the canonical reason phrase', () => {
161+
const e = oc.httpError('OpenRouter', { status: 503, statusText: '' }, '<html>x</html>');
162+
assert.strictEqual(e.message, 'OpenRouter API 503: Service Unavailable');
163+
});
164+
test('httpError: a genuine JSON API error keeps the provider message verbatim', () => {
165+
const e = oc.httpError('OpenRouter', { status: 429, statusText: 'Too Many Requests' }, '{"error":{"message":"rate limit exceeded"}}');
166+
assert.strictEqual(e.message, 'OpenRouter API 429: rate limit exceeded');
167+
});
168+
119169
console.log('\nproviders: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)