diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index f57fd33..29acaf1 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -719,7 +719,7 @@ async function runAgent(ctx) { let streamed = false; let textChars = 0; const turnOpts = { - providerId: ctx.providerId, baseURL: ctx.baseURL, + providerId: ctx.providerId, baseURL: ctx.baseURL, label: ctx.label, apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system, messages, tools: tools, signal: ctx.signal, onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); }, @@ -729,7 +729,10 @@ async function runAgent(ctx) { : name === 'delete_file' ? 'deleting a file…' : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…'; ctx.post({ type: 'agentStatus', text: verb }); - } + }, + // A transient upstream 5xx (502/503/504) is retried once before it can fail the run — surface it + // as a status rather than a mystery pause, and log it. Nothing has streamed yet when this fires. + onRetry: (info) => { dbg('turn.retry', info); ctx.post({ type: 'agentStatus', text: 'upstream busy (' + info.status + ') — retrying…' }); } }; let turn; try { diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 6dc7b04..b664d0c 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -941,7 +941,7 @@ async function compactAgentMemory() { let summary; try { summary = await providers.complete({ - providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, + providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label, model: req.model, maxTokens: 1500, system: COMPACT_SYSTEM, messages: [{ role: 'user', content: COMPACT_INSTRUCTIONS + flat }] @@ -1021,6 +1021,8 @@ async function agentFlow(text) { messages: agentMessages, // persists across runs → the agent remembers the session providerId: req.providerId, // Anthropic native, or an OpenAI-shaped provider via translation (P2) baseURL: req.baseURL, // for the custom / Ollama endpoints + label: req.label, // route name for error attribution — "LevelCode Cloud" on the gateway, + // else the provider's own label; keeps a 502 from being blamed on "OpenAI" apiKey: req.apiKey, model: req.model, maxSteps: Math.max(1, cfg.get('agent.maxSteps', 25)), @@ -1116,7 +1118,7 @@ async function handleSend(text) { return; } const doStream = (r) => providers.streamChat({ - providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, + providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label, model: r.model, maxTokens: r.maxTokens, system: SYSTEM_PROMPT, messages: conversation, signal: abort.signal, onDelta }); diff --git a/extensions/levelcode-ai/providers/index.js b/extensions/levelcode-ai/providers/index.js index d9d7f51..bd5df62 100644 --- a/extensions/levelcode-ai/providers/index.js +++ b/extensions/levelcode-ai/providers/index.js @@ -142,8 +142,9 @@ function secretStorageKey(id) { /** * Unified streaming chat across providers. `messages` is the shared {role,content:string} shape * (valid for both Anthropic and OpenAI). Anthropic → native adapter; everything else → openaiCompat. - * @param {{providerId:string, apiKey?:string, baseURL?:string, model:string, maxTokens?:number, - * system:string, messages:any[], signal?:AbortSignal, onDelta:(t:string)=>void}} o + * @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number, + * system:string, messages:any[], signal?:AbortSignal, onDelta:(t:string)=>void, + * onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o */ async function streamChat(o) { const p = getProvider(o.providerId); @@ -155,16 +156,17 @@ async function streamChat(o) { }); } return openai.streamOpenAI({ - baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label, + baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label, model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, - signal: o.signal, onDelta: o.onDelta + signal: o.signal, onDelta: o.onDelta, onRetry: o.onRetry }); } /** * Unified one-shot completion across providers (inline ghost-text / edit). Returns full text. - * @param {{providerId:string, apiKey?:string, baseURL?:string, model:string, maxTokens?:number, - * system:string, messages:any[], stop?:string[], signal?:AbortSignal}} o + * @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number, + * system:string, messages:any[], stop?:string[], signal?:AbortSignal, + * onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o * @returns {Promise} */ async function complete(o) { @@ -177,9 +179,9 @@ async function complete(o) { }); } return openai.completeOpenAI({ - baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label, + baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label, model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, - stop: o.stop, signal: o.signal + stop: o.stop, signal: o.signal, onRetry: o.onRetry }); } @@ -195,9 +197,10 @@ function supportsTools(id) { * translation); every other provider → the OpenAI adapter, which translates the Anthropic-shaped * transcript/tools in and the streamed tool-calls back out. Returns the SAME * {content, stop_reason, usage, malformed} shape for both, so agent.js is provider-agnostic. - * @param {{providerId:string, apiKey?:string, baseURL?:string, model:string, maxTokens?:number, + * @param {{providerId:string, apiKey?:string, baseURL?:string, label?:string, model:string, maxTokens?:number, * system:string, messages:any[], tools?:any[], signal?:AbortSignal, - * onText?:(t:string)=>void, onToolStart?:(name:string)=>void}} o + * onText?:(t:string)=>void, onToolStart?:(name:string)=>void, + * onRetry?:(info:{attempt:number,retries:number,status:number})=>void}} o */ async function streamAgentTurn(o) { const p = getProvider(o.providerId); @@ -209,9 +212,9 @@ async function streamAgentTurn(o) { }); } return openai.streamOpenAIAgentTurn({ - baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: p.label, + baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label, model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, tools: o.tools, - signal: o.signal, onText: o.onText, onToolStart: o.onToolStart + signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, onRetry: o.onRetry }); } diff --git a/extensions/levelcode-ai/providers/openaiCompat.js b/extensions/levelcode-ai/providers/openaiCompat.js index a2488ff..54043b0 100644 --- a/extensions/levelcode-ai/providers/openaiCompat.js +++ b/extensions/levelcode-ai/providers/openaiCompat.js @@ -76,24 +76,111 @@ function deltaFromEvent(ev) { return typeof d.content === 'string' ? d.content : ''; } +// Fallback reason phrases for when fetch leaves res.statusText empty (some HTTP/2 responses do). Not +// exhaustive — just what a model endpoint or the proxy in front of it realistically returns. +const STATUS_REASON = { + 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', + 408: 'Request Timeout', 413: 'Payload Too Large', 429: 'Too Many Requests', + 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout' +}; + +/** + * Pull a human-readable message out of an error response body, or '' when there isn't one worth showing. + * The body is UNTRUSTED and provider-shaped: a JSON `{error:{message}}` on a normal API rejection, but a + * raw HTML page when a proxy IN FRONT of the model (nginx/Cloudflare) returns a 5xx — dumping that page + * into a chat transcript is pure noise. Return '' for HTML so the caller falls back to the status reason; + * cap anything else so a stray multi-KB body can't flood the UI. Pure — unit-tested. + */ +function extractApiError(body) { + const s = String(body || '').trim(); + if (!s) { return ''; } + if (s[0] === '<' || /]/i.test(s)) { return ''; } // HTML proxy page — no useful message + if (s[0] === '{' || s[0] === '[') { + try { + const j = JSON.parse(s); + const m = (j && j.error && (j.error.message || (typeof j.error === 'string' ? j.error : ''))) || (j && j.message) || ''; + if (m) { return String(m).slice(0, 500); } + } catch { /* not valid JSON after all — fall through to the capped-text path */ } + } + return s.length > 300 ? s.slice(0, 300) + '…' : s; // short plain text: keep it, capped +} + +/** + * Build a clean Error for a failed (`!res.ok`) response: `"