FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Merge pull request #45 from levelcodeai/fix/gateway-error-label · levelcodeai/levelcode@a0850f4 · GitHub

Commit a0850f4

Browse files
authored
Merge pull request #45 from levelcodeai/fix/gateway-error-label
fix(ai): gateway error handling — relabel, sanitize the body, and retry transient 5xx
2 parents 85df68b + c0ca4ed commit a0850f4

5 files changed

Lines changed: 243 additions & 52 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 5 additions & 2 deletions
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 }); },
@@ -729,7 +729,10 @@ async function runAgent(ctx) {
729729
: name === 'delete_file' ? 'deleting a file…'
730730
: name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…';
731731
ctx.post({ type: 'agentStatus', text: verb });
732-
}
732+
},
733+
// A transient upstream 5xx (502/503/504) is retried once before it can fail the run — surface it
734+
// as a status rather than a mystery pause, and log it. Nothing has streamed yet when this fires.
735+
onRetry: (info) => { dbg('turn.retry', info); ctx.post({ type: 'agentStatus', text: 'upstream busy (' + info.status + ') — retrying…' }); }
733736
};
734737
let turn;
735738
try {

‎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: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ function secretStorageKey(id) {
142142
/**
143143
* Unified streaming chat across providers. `messages` is the shared {role,content:string} shape
144144
* (valid for both Anthropic and OpenAI). Anthropic → native adapter; everything else → openaiCompat.
145-
* @param {{providerId:string, apiKey?:string, baseURL?:string, model:string, maxTokens?:number,
146-
* system:string, messages:any[], signal?:AbortSignal, onDelta:(t:string)=>void}} o
145+
* @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number,
146+
* system:string, messages:any[], signal?:AbortSignal, onDelta:(t:string)=>void,
147+
* onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o
147148
*/
148149
async function streamChat(o) {
149150
const p = getProvider(o.providerId);
@@ -155,16 +156,17 @@ async function streamChat(o) {
155156
});
156157
}
157158
return openai.streamOpenAI({
158-
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label,
159+
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
159160
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages,
160-
signal: o.signal, onDelta: o.onDelta
161+
signal: o.signal, onDelta: o.onDelta, onRetry: o.onRetry
161162
});
162163
}
163164

164165
/**
165166
* Unified one-shot completion across providers (inline ghost-text / edit). Returns full text.
166-
* @param {{providerId:string, apiKey?:string, baseURL?:string, model:string, maxTokens?:number,
167-
* system:string, messages:any[], stop?:string[], signal?:AbortSignal}} o
167+
* @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number,
168+
* system:string, messages:any[], stop?:string[], signal?:AbortSignal,
169+
* onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o
168170
* @returns {Promise<string>}
169171
*/
170172
async function complete(o) {
@@ -177,9 +179,9 @@ async function complete(o) {
177179
});
178180
}
179181
return openai.completeOpenAI({
180-
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label,
182+
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
181183
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages,
182-
stop: o.stop, signal: o.signal
184+
stop: o.stop, signal: o.signal, onRetry: o.onRetry
183185
});
184186
}
185187

@@ -195,9 +197,10 @@ function supportsTools(id) {
195197
* translation); every other provider → the OpenAI adapter, which translates the Anthropic-shaped
196198
* transcript/tools in and the streamed tool-calls back out. Returns the SAME
197199
* {content, stop_reason, usage, malformed} shape for both, so agent.js is provider-agnostic.
198-
* @param {{providerId:string, apiKey?:string, baseURL?:string, model:string, maxTokens?:number,
200+
* @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number,
199201
* system:string, messages:any[], tools?:any[], signal?:AbortSignal,
200-
* onText?:(t:string)=>void, onToolStart?:(name:string)=>void}} o
202+
* onText?:(t:string)=>void, onToolStart?:(name:string)=>void,
203+
* onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o
201204
*/
202205
async function streamAgentTurn(o) {
203206
const p = getProvider(o.providerId);
@@ -209,9 +212,9 @@ async function streamAgentTurn(o) {
209212
});
210213
}
211214
return openai.streamOpenAIAgentTurn({
212-
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label,
215+
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
213216
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, tools: o.tools,
214-
signal: o.signal, onText: o.onText, onToolStart: o.onToolStart
217+
signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, onRetry: o.onRetry
215218
});
216219
}
217220

‎extensions/levelcode-ai/providers/openaiCompat.js‎

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -76,24 +76,111 @@ 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+
121+
// Upstream statuses worth ONE automatic retry: a proxy in front of the model (nginx/Cloudflare/the gateway)
122+
// briefly couldn't reach a healthy backend. These almost always clear within a second. Deliberately NOT
123+
// retried: 429 (rate limit — needs Retry-After, and hammering makes it worse), 500 (usually a real request
124+
// error, not a blip), and every other 4xx. A thrown fetch error (network drop, abort) is not retried either
125+
// — only an HTTP response whose status is in this set.
126+
const TRANSIENT_STATUS = new Set([502, 503, 504]);
127+
const TRANSIENT_RETRIES = 1; // one extra attempt after the first — a single pre-stream retry
128+
const RETRY_DELAY_MS = 700; // backoff before the retry (RETRY_DELAY_MS * attempt); overridable per call
129+
130+
/**
131+
* A backoff that wakes early the instant the turn is aborted, so Stop stays responsive. Resolves — never
132+
* rejects: the caller's next `fetch` sees the aborted signal and rejects with the native AbortError, which
133+
* is exactly how a normal aborted request already surfaces. Works with no signal too.
134+
*/
135+
function retryDelay(ms, signal) {
136+
return new Promise((resolve) => {
137+
if (signal && signal.aborted) { return resolve(); }
138+
const timer = setTimeout(done, ms);
139+
function done() { clearTimeout(timer); if (signal) { signal.removeEventListener('abort', done); } resolve(); }
140+
if (signal) { signal.addEventListener('abort', done, { once: true }); }
141+
});
142+
}
143+
144+
/**
145+
* POST /chat/completions with a single pre-stream retry on a transient upstream status (502/503/504).
146+
*
147+
* This is the ONLY place a chat request is retried, and it is safe precisely because it runs BEFORE any SSE
148+
* line is read: on a transient status the response carries no model output, so nothing has been shown to the
149+
* user or metered, and re-issuing the request cannot duplicate output or double-bill the UI. A failure that
150+
* happens mid-stream is a different code path and is never retried here. A 401 is not transient, so it is
151+
* thrown straight through for the agent's token-refresh path. Non-transient statuses and an exhausted retry
152+
* throw a clean httpError. `opts.onRetry({attempt,retries,status})` fires just before each backoff (for a
153+
* visible "retrying…" hint); `opts.retryDelayMs` overrides the backoff (0 in tests). Returns res.ok===true.
154+
*/
155+
async function postChat(opts, body) {
156+
const label = opts.label || 'OpenAI-compatible';
157+
const base = opts.retryDelayMs != null ? opts.retryDelayMs : RETRY_DELAY_MS;
158+
const init = { method: 'POST', headers: authHeaders(opts), body: JSON.stringify(body), signal: opts.signal };
159+
const url = baseUrl(opts) + '/chat/completions';
160+
for (let attempt = 0; ; attempt++) {
161+
const res = await fetch(url, init);
162+
if (res.ok) { return res; }
163+
const text = await res.text().catch(() => '');
164+
if (attempt < TRANSIENT_RETRIES && TRANSIENT_STATUS.has(res.status)) {
165+
if (typeof opts.onRetry === 'function') { opts.onRetry({ attempt: attempt + 1, retries: TRANSIENT_RETRIES, status: res.status }); }
166+
await retryDelay(base * (attempt + 1), opts.signal);
167+
continue;
168+
}
169+
throw httpError(label, res, text);
170+
}
171+
}
172+
79173
/**
80174
* Streaming chat over /v1/chat/completions. opts.onDelta(text) per chunk; resolves at end.
81175
* @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string,
82176
* maxTokens?:number, system?:string, messages:any[], stop?:string[],
83-
* signal?:AbortSignal, onDelta:(t:string)=>void}} opts
177+
* signal?:AbortSignal, onDelta:(t:string)=>void,
178+
* onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} opts
84179
*/
85180
async function streamOpenAI(opts) {
86181
const label = opts.label || 'OpenAI-compatible';
87-
const res = await fetch(baseUrl(opts) + '/chat/completions', {
88-
method: 'POST',
89-
headers: authHeaders(opts),
90-
body: JSON.stringify(buildChatBody(Object.assign({}, opts, { stream: true }))),
91-
signal: opts.signal
92-
});
93-
if (!res.ok || !res.body) {
94-
const text = await res.text().catch(() => '');
95-
throw new Error(`${label} API ${res.status}: ${text || res.statusText}`);
96-
}
182+
const res = await postChat(opts, buildChatBody(Object.assign({}, opts, { stream: true })));
183+
if (!res.body) { throw new Error(label + ' API ' + res.status + ': empty response stream'); }
97184
await readLines(res, (line) => {
98185
const s = line.trim();
99186
if (!s.startsWith('data:')) { return; }
@@ -110,21 +197,12 @@ async function streamOpenAI(opts) {
110197
/**
111198
* Non-streaming single completion (inline ghost-text / edit). Returns the full text.
112199
* @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string,
113-
* maxTokens?:number, system?:string, messages:any[], stop?:string[], signal?:AbortSignal}} opts
200+
* maxTokens?:number, system?:string, messages:any[], stop?:string[], signal?:AbortSignal,
201+
* onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} opts
114202
* @returns {Promise<string>}
115203
*/
116204
async function completeOpenAI(opts) {
117-
const label = opts.label || 'OpenAI-compatible';
118-
const res = await fetch(baseUrl(opts) + '/chat/completions', {
119-
method: 'POST',
120-
headers: authHeaders(opts),
121-
body: JSON.stringify(buildChatBody(Object.assign({}, opts, { stream: false }))),
122-
signal: opts.signal
123-
});
124-
if (!res.ok) {
125-
const text = await res.text().catch(() => '');
126-
throw new Error(`${label} API ${res.status}: ${text || res.statusText}`);
127-
}
205+
const res = await postChat(opts, buildChatBody(Object.assign({}, opts, { stream: false })));
128206
const data = await res.json();
129207
const c = data && data.choices && data.choices[0];
130208
return (c && c.message && typeof c.message.content === 'string') ? c.message.content : '';
@@ -172,7 +250,8 @@ async function listOpenAIModels(opts) {
172250
* to {type:'text'} / {type:'tool_use', id, name, input} blocks.
173251
* @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string,
174252
* maxTokens?:number, system:string, messages:any[], tools?:any[], signal?:AbortSignal,
175-
* onText?:(t:string)=>void, onToolStart?:(name:string)=>void}} opts
253+
* onText?:(t:string)=>void, onToolStart?:(name:string)=>void,
254+
* onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} opts
176255
* @returns {Promise<{content:any[], stop_reason:string, usage:any, malformed:Set<string>}>}
177256
*/
178257
async function streamOpenAIAgentTurn(opts) {
@@ -188,16 +267,8 @@ async function streamOpenAIAgentTurn(opts) {
188267
// on OpenAI-shaped providers — they omit usage from streams unless include_usage is set. Mainstream
189268
// providers (OpenAI/OpenRouter/Groq/Together/Fireworks/DeepSeek/xAI/Mistral) honor it.
190269
body.stream_options = { include_usage: true };
191-
const res = await fetch(baseUrl(opts) + '/chat/completions', {
192-
method: 'POST',
193-
headers: authHeaders(opts),
194-
body: JSON.stringify(body),
195-
signal: opts.signal
196-
});
197-
if (!res.ok || !res.body) {
198-
const text = await res.text().catch(() => '');
199-
throw new Error(`${label} API ${res.status}: ${text || res.statusText}`);
200-
}
270+
const res = await postChat(opts, body);
271+
if (!res.body) { throw new Error(label + ' API ' + res.status + ': empty response stream'); }
201272
let text = '';
202273
/** @type {any[]} */
203274
const acc = [];
@@ -240,4 +311,4 @@ async function streamOpenAIAgentTurn(opts) {
240311
return { content, stop_reason: stopReason, usage, malformed };
241312
}
242313

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

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL