@@ -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 ] === '<' || / < h t m l [ \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 */
85180async 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 */
116204async 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 */
178257async 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