| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -719,7 +719,7 @@ async function runAgent(ctx) { | |||
| 719 | 719 | let streamed = false; | |
| 720 | 720 | let textChars = 0; | |
| 721 | 721 | const turnOpts = { | |
| 722 | - providerId: ctx.providerId, baseURL: ctx.baseURL, | ||
| 722 | + providerId: ctx.providerId, baseURL: ctx.baseURL, label: ctx.label, | ||
| 723 | 723 | apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system, | |
| 724 | 724 | messages, tools: tools, signal: ctx.signal, | |
| 725 | 725 | onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); }, | |
@@ -729,7 +729,10 @@ async function runAgent(ctx) { | |||
| 729 | 729 | : name === 'delete_file' ? 'deleting a file…' | |
| 730 | 730 | : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…'; | |
| 731 | 731 | 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…' }); } | ||
| 733 | 736 | }; | |
| 734 | 737 | let turn; | |
| 735 | 738 | try { | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -941,7 +941,7 @@ async function compactAgentMemory() { | |||
| 941 | 941 | let summary; | |
| 942 | 942 | try { | |
| 943 | 943 | 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, | ||
| 945 | 945 | model: req.model, maxTokens: 1500, | |
| 946 | 946 | system: COMPACT_SYSTEM, | |
| 947 | 947 | messages: [{ role: 'user', content: COMPACT_INSTRUCTIONS + flat }] | |
@@ -1021,6 +1021,8 @@ async function agentFlow(text) { | |||
| 1021 | 1021 | messages: agentMessages, // persists across runs → the agent remembers the session | |
| 1022 | 1022 | providerId: req.providerId, // Anthropic native, or an OpenAI-shaped provider via translation (P2) | |
| 1023 | 1023 | 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" | ||
| 1024 | 1026 | apiKey: req.apiKey, | |
| 1025 | 1027 | model: req.model, | |
| 1026 | 1028 | maxSteps: Math.max(1, cfg.get('agent.maxSteps', 25)), | |
@@ -1116,7 +1118,7 @@ async function handleSend(text) { | |||
| 1116 | 1118 | return; | |
| 1117 | 1119 | } | |
| 1118 | 1120 | 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, | ||
| 1120 | 1122 | model: r.model, maxTokens: r.maxTokens, system: SYSTEM_PROMPT, | |
| 1121 | 1123 | messages: conversation, signal: abort.signal, onDelta | |
| 1122 | 1124 | }); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -142,8 +142,9 @@ function secretStorageKey(id) { | |||
| 142 | 142 | /** | |
| 143 | 143 | * Unified streaming chat across providers. `messages` is the shared {role,content:string} shape | |
| 144 | 144 | * (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 | ||
| 147 | 148 | */ | |
| 148 | 149 | async function streamChat(o) { | |
| 149 | 150 | const p = getProvider(o.providerId); | |
@@ -155,16 +156,17 @@ async function streamChat(o) { | |||
| 155 | 156 | }); | |
| 156 | 157 | } | |
| 157 | 158 | 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, | ||
| 159 | 160 | 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 | ||
| 161 | 162 | }); | |
| 162 | 163 | } | |
| 163 | 164 | ||
| 164 | 165 | /** | |
| 165 | 166 | * 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 | ||
| 168 | 170 | * @returns {Promise<string>} | |
| 169 | 171 | */ | |
| 170 | 172 | async function complete(o) { | |
@@ -177,9 +179,9 @@ async function complete(o) { | |||
| 177 | 179 | }); | |
| 178 | 180 | } | |
| 179 | 181 | 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, | ||
| 181 | 183 | 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 | ||
| 183 | 185 | }); | |
| 184 | 186 | } | |
| 185 | 187 | ||
@@ -195,9 +197,10 @@ function supportsTools(id) { | |||
| 195 | 197 | * translation); every other provider → the OpenAI adapter, which translates the Anthropic-shaped | |
| 196 | 198 | * transcript/tools in and the streamed tool-calls back out. Returns the SAME | |
| 197 | 199 | * {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, | ||
| 199 | 201 | * 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 | ||
| 201 | 204 | */ | |
| 202 | 205 | async function streamAgentTurn(o) { | |
| 203 | 206 | const p = getProvider(o.providerId); | |
@@ -209,9 +212,9 @@ async function streamAgentTurn(o) { | |||
| 209 | 212 | }); | |
| 210 | 213 | } | |
| 211 | 214 | 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, | ||
| 213 | 216 | 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 | ||
| 215 | 218 | }); | |
| 216 | 219 | } | |
| 217 | 220 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -76,24 +76,111 @@ function deltaFromEvent(ev) { | |||
| 76 | 76 | return typeof d.content === 'string' ? d.content : ''; | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 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 | + | ||
| 79 | 173 | /** | |
| 80 | 174 | * Streaming chat over /v1/chat/completions. opts.onDelta(text) per chunk; resolves at end. | |
| 81 | 175 | * @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string, | |
| 82 | 176 | * 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 | ||
| 84 | 179 | */ | |
| 85 | 180 | async function streamOpenAI(opts) { | |
| 86 | 181 | 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'); } | ||
| 97 | 184 | await readLines(res, (line) => { | |
| 98 | 185 | const s = line.trim(); | |
| 99 | 186 | if (!s.startsWith('data:')) { return; } | |
@@ -110,21 +197,12 @@ async function streamOpenAI(opts) { | |||
| 110 | 197 | /** | |
| 111 | 198 | * Non-streaming single completion (inline ghost-text / edit). Returns the full text. | |
| 112 | 199 | * @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 | ||
| 114 | 202 | * @returns {Promise<string>} | |
| 115 | 203 | */ | |
| 116 | 204 | 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 }))); | ||
| 128 | 206 | const data = await res.json(); | |
| 129 | 207 | const c = data && data.choices && data.choices[0]; | |
| 130 | 208 | return (c && c.message && typeof c.message.content === 'string') ? c.message.content : ''; | |
@@ -172,7 +250,8 @@ async function listOpenAIModels(opts) { | |||
| 172 | 250 | * to {type:'text'} / {type:'tool_use', id, name, input} blocks. | |
| 173 | 251 | * @param {{baseURL:string, apiKey?:string, headers?:object, label?:string, model:string, | |
| 174 | 252 | * 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 | ||
| 176 | 255 | * @returns {Promise<{content:any[], stop_reason:string, usage:any, malformed:Set<string>}>} | |
| 177 | 256 | */ | |
| 178 | 257 | async function streamOpenAIAgentTurn(opts) { | |
@@ -188,16 +267,8 @@ async function streamOpenAIAgentTurn(opts) { | |||
| 188 | 267 | // on OpenAI-shaped providers — they omit usage from streams unless include_usage is set. Mainstream | |
| 189 | 268 | // providers (OpenAI/OpenRouter/Groq/Together/Fireworks/DeepSeek/xAI/Mistral) honor it. | |
| 190 | 269 | 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'); } | ||
| 201 | 272 | let text = ''; | |
| 202 | 273 | /** @type {any[]} */ | |
| 203 | 274 | const acc = []; | |
@@ -240,4 +311,4 @@ async function streamOpenAIAgentTurn(opts) { | |||
| 240 | 311 | return { content, stop_reason: stopReason, usage, malformed }; | |
| 241 | 312 | } | |
| 242 | 313 | ||
| 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 }; | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments