@@ -193,7 +193,10 @@ const STARTING_WAIT_ITERATIONS = 120;
193193const STARTING_WAIT_INTERVAL_MS = 1000 ;
194194const DEFAULT_REQUEST_TIMEOUT_MS = 300000 ;
195195const DEFAULT_POLL_INTERVAL_MS = 500 ;
196- const DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS = 4000 ;
196+ // Reasoning models can take well over 10s before emitting their first token.
197+ // A short window here makes the event stream give up and fall back to polling on
198+ // every request, which loses true streaming. Configurable for slow backends.
199+ const DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS = Number ( process . env . OPENCODE2API_EVENT_FIRST_DELTA_TIMEOUT_MS ) || 30000 ;
197200const DEFAULT_EVENT_IDLE_TIMEOUT_MS = Number ( process . env . OPENCODE2API_EVENT_IDLE_TIMEOUT_MS ) || 8000 ;
198201
199202const OPENCODE_BASENAME = 'opencode' ;
@@ -1078,15 +1081,22 @@ export function createApp(config) {
10781081 }
10791082
10801083 function extractFromParts ( parts ) {
1081- if ( ! Array . isArray ( parts ) ) return { content : '' , reasoning : '' } ;
1084+ if ( ! Array . isArray ( parts ) ) return { content : '' , reasoning : '' , toolParts : [ ] } ;
10821085 const content = parts . filter ( p => p . type === 'text' ) . map ( p => p . text ) . join ( '' ) ;
10831086 const reasoning = parts . filter ( p => p . type === 'reasoning' ) . map ( p => p . text ) . join ( '' ) ;
1084- return { content, reasoning } ;
1087+ const toolParts = parts . filter ( p => p . type === 'tool' ) ;
1088+ return { content, reasoning, toolParts } ;
10851089 }
10861090
10871091 async function pollForAssistantResponse ( sessionId , timeoutMs , intervalMs = DEFAULT_POLL_INTERVAL_MS ) {
10881092 const pollStart = Date . now ( ) ;
10891093 const startedAt = Date . now ( ) ;
1094+ // Best-effort snapshot of the most recent in-flight assistant message. Polling
1095+ // observes partial messages: a reasoning model emits its reasoning part first and
1096+ // the text part only afterwards, so returning on the first non-empty snapshot
1097+ // truncates the answer to the reasoning alone. Keep the partial around purely as
1098+ // a timeout fallback and otherwise wait for the message to actually finish.
1099+ let lastPartial = null ;
10901100 while ( Date . now ( ) - startedAt < timeoutMs ) {
10911101 const messagesRes = await client . session . messages ( { path : { id : sessionId } } ) ;
10921102 const messages = messagesRes ?. data || messagesRes || [ ] ;
@@ -1095,10 +1105,25 @@ export function createApp(config) {
10951105 const entry = messages [ i ] ;
10961106 const info = entry ?. info ;
10971107 if ( info ?. role !== 'assistant' ) continue ;
1098- const { content, reasoning } = extractFromParts ( entry ?. parts || [ ] ) ;
1108+ const { content, reasoning, toolParts } = extractFromParts ( entry ?. parts || [ ] ) ;
10991109 const error = info ?. error || null ;
1100- const done = Boolean ( info . finish || info . time ?. completed || error ) ;
1101- if ( done || content || reasoning ) {
1110+ // finish === 'tool' marks an intermediate turn that pauses for a tool
1111+ // result; the assistant is not done producing output yet.
1112+ const finished = info . finish && info . finish !== 'tool' ;
1113+ const done = Boolean ( finished || info . time ?. completed || error ) ;
1114+ if ( toolParts . length > 0 ) {
1115+ logDebug ( 'Polling found tool parts' , {
1116+ sessionId,
1117+ count : toolParts . length ,
1118+ parts : toolParts . map ( p => ( {
1119+ id : p . id ,
1120+ tool : p . tool ,
1121+ status : p . state ?. status ,
1122+ input : p . state ?. input
1123+ } ) )
1124+ } ) ;
1125+ }
1126+ if ( done ) {
11021127 if ( error ) {
11031128 console . error ( '[Proxy] OpenCode assistant error:' , error ) ;
11041129 }
@@ -1112,10 +1137,23 @@ export function createApp(config) {
11121137 } ) ;
11131138 return { content, reasoning, error } ;
11141139 }
1140+ if ( content || reasoning ) {
1141+ lastPartial = { content, reasoning, error : null } ;
1142+ }
1143+ break ;
11151144 }
11161145 }
11171146 await sleep ( intervalMs ) ;
11181147 }
1148+ if ( lastPartial ) {
1149+ logDebug ( 'Polling timeout with partial response' , {
1150+ sessionId,
1151+ ms : Date . now ( ) - pollStart ,
1152+ contentLen : lastPartial . content . length ,
1153+ reasoningLen : lastPartial . reasoning . length
1154+ } ) ;
1155+ return lastPartial ;
1156+ }
11191157 logDebug ( 'Polling timeout' , { sessionId, ms : Date . now ( ) - pollStart } ) ;
11201158 throw new Error ( `Request timeout after ${ timeoutMs } ms` ) ;
11211159 }
@@ -1236,6 +1274,22 @@ export function createApp(config) {
12361274 event . properties . info . sessionID === sessionId ) {
12371275 const info = event . properties . info ;
12381276 const finish = info . finish ;
1277+ // An aborted or failed message never produces another delta. Without
1278+ // this, the collector waits out the whole first-delta window before
1279+ // polling rediscovers the same error.
1280+ if ( info . error && ! finished ) {
1281+ finished = true ;
1282+ clearTimeout ( timeoutId ) ;
1283+ if ( firstDeltaTimer ) clearTimeout ( firstDeltaTimer ) ;
1284+ if ( idleTimer ) clearTimeout ( idleTimer ) ;
1285+ logDebug ( 'SSE upstream message error' , {
1286+ sessionId,
1287+ ms : Date . now ( ) - startedAt ,
1288+ error : info . error . name || 'UnknownError'
1289+ } ) ;
1290+ resolve ( { content, reasoning, error : info . error } ) ;
1291+ break ;
1292+ }
12391293 // Reconcile active tool calls from the full message snapshot so we
12401294 // detect pending tools even when only message.updated fires.
12411295 if ( Array . isArray ( info . parts ) ) {
@@ -1523,7 +1577,13 @@ export function createApp(config) {
15231577 body : {
15241578 model : { providerID : pID , modelID : mID } ,
15251579 system : systemWithGuard ,
1526- parts : parts ,
1580+ // Append a short contract reminder as the last part so the model
1581+ // sees it immediately before generating. With the contract only in
1582+ // the 16KB+ system prompt it gets buried; position matters a lot for
1583+ // compliance. deepseek-v4-flash-free: 50% → 100% call rate.
1584+ parts : externalToolContext . reminder
1585+ ? [ ...parts , { type : 'text' , text : externalToolContext . reminder } ]
1586+ : parts ,
15271587 ...( requestParams . max_tokens && { max_tokens : requestParams . max_tokens } ) ,
15281588 ...( requestParams . temperature !== undefined && { temperature : requestParams . temperature } ) ,
15291589 ...( requestParams . top_p !== undefined && { top_p : requestParams . top_p } ) ,
@@ -1740,11 +1800,32 @@ export function createApp(config) {
17401800 } ) } \n\n`) ;
17411801 }
17421802
1803+ // Flush held buffers from the stream parsers and filters before final batch parse.
1804+ const flushedReasoningCalls = parseReasoningToolCalls . flush ? parseReasoningToolCalls . flush ( ) : [ ] ;
1805+ const flushedContentCalls = parseContentToolCalls . flush ? parseContentToolCalls . flush ( ) : [ ] ;
1806+ const flushedReasoningText = filterReasoningDelta . flush ? filterReasoningDelta . flush ( ) : '' ;
1807+ const flushedContentText = filterContentDelta . flush ? filterContentDelta . flush ( ) : '' ;
1808+ const finalReasoningText = rawStreamedReasoning + flushedReasoningText ;
1809+ const finalContentText = rawStreamedContent + flushedContentText ;
1810+
1811+ // Parse each channel, then retry on the two joined. Models sometimes open a
1812+ // block in reasoning and close it in content, leaving neither channel with a
1813+ // complete block. The joined retry only runs when nothing was found, so a
1814+ // block contained in one channel is never counted twice.
1815+ const parseStreamedToolCalls = ( ) => {
1816+ if ( externalToolRegistry . length === 0 ) return [ ] ;
1817+ const perChannel = [
1818+ ...flushedReasoningCalls ,
1819+ ...flushedContentCalls ,
1820+ ...parseExternalToolCallsFromText ( externalToolRegistry , finalReasoningText , finalContentText )
1821+ ] ;
1822+ if ( perChannel . length > 0 ) return perChannel ;
1823+ return parseExternalToolCallsFromText ( externalToolRegistry , finalReasoningText + finalContentText ) ;
1824+ } ;
1825+
17431826 let parsedToolCalls = streamedToolCalls . length > 0
17441827 ? streamedToolCalls
1745- : ( externalToolRegistry . length > 0
1746- ? parseExternalToolCallsFromText ( externalToolRegistry , rawStreamedReasoning , rawStreamedContent )
1747- : [ ] ) ;
1828+ : parseStreamedToolCalls ( ) ;
17481829 if ( parsedToolCalls . length === 0 && externalToolChoice . mode === 'required' ) {
17491830 const forcedResponse = await requestForcedChatToolCall ( ) ;
17501831 if ( forcedResponse ) {
@@ -2218,7 +2299,9 @@ export function createApp(config) {
22182299 body : {
22192300 model : { providerID : pID , modelID : mID } ,
22202301 ...( systemWithGuard ? { system : systemWithGuard } : { } ) ,
2221- parts,
2302+ parts : externalToolContext . reminder
2303+ ? [ ...parts , { type : 'text' , text : externalToolContext . reminder } ]
2304+ : parts ,
22222305 ...( max_output_tokens && { max_tokens : max_output_tokens } ) ,
22232306 ...( temperature !== undefined && { temperature } ) ,
22242307 ...( top_p !== undefined && { top_p } )
@@ -2505,15 +2588,30 @@ export function createApp(config) {
25052588 } catch ( e ) { }
25062589 }
25072590
2591+ // Flush held buffers from the stream parsers and filters before final batch parse.
2592+ const flushedReasoningCalls = parseReasoningToolCalls . flush ? parseReasoningToolCalls . flush ( ) : [ ] ;
2593+ const flushedContentCalls = parseContentToolCalls . flush ? parseContentToolCalls . flush ( ) : [ ] ;
2594+ const flushedReasoningText = filterReasoningDelta . flush ? filterReasoningDelta . flush ( ) : '' ;
2595+ const flushedContentText = filterContentDelta . flush ? filterContentDelta . flush ( ) : '' ;
2596+ const finalReasoningText = ( polledForToolCalls ?. reasoning || rawReasoning ) + flushedReasoningText ;
2597+ const finalContentText = ( polledForToolCalls ?. content || rawContent ) + flushedContentText ;
2598+
2599+ // Parse each channel, then retry on the two joined. See the matching comment
2600+ // in /v1/chat/completions for why the joined retry is gated on finding nothing.
2601+ const parseStreamedToolCalls = ( ) => {
2602+ if ( externalToolRegistry . length === 0 ) return [ ] ;
2603+ const perChannel = [
2604+ ...flushedReasoningCalls ,
2605+ ...flushedContentCalls ,
2606+ ...parseExternalToolCallsFromText ( externalToolRegistry , finalReasoningText , finalContentText )
2607+ ] ;
2608+ if ( perChannel . length > 0 ) return perChannel ;
2609+ return parseExternalToolCallsFromText ( externalToolRegistry , finalReasoningText + finalContentText ) ;
2610+ } ;
2611+
25082612 let parsedToolCalls = streamedToolCalls . length > 0
25092613 ? streamedToolCalls
2510- : ( externalToolRegistry . length > 0
2511- ? parseExternalToolCallsFromText (
2512- externalToolRegistry ,
2513- polledForToolCalls ?. reasoning || rawReasoning ,
2514- polledForToolCalls ?. content || rawContent
2515- )
2516- : [ ] ) ;
2614+ : parseStreamedToolCalls ( ) ;
25172615 if ( parsedToolCalls . length === 0 && externalToolChoice . mode === 'required' ) {
25182616 const forcedResponse = await requestForcedResponsesToolCall ( ) ;
25192617 if ( forcedResponse ) {
@@ -2643,9 +2741,25 @@ export function createApp(config) {
26432741
26442742 return res . json ( response ) ;
26452743 } catch ( error ) {
2646- console . error ( '[Proxy] Responses API Error:' , error . message ) ;
2744+ console . error ( '[Proxy] Responses API Error:' , error ? .message || error ?. data ?. message || error ?. name || error ) ;
26472745 const transformed = transformUpstreamError ( error ) ;
2648- res . status ( transformed . statusCode ) . json ( transformed . error ) ;
2746+ // Once the SSE headers are out, res.json() throws ERR_HTTP_HEADERS_SENT. That throw
2747+ // escapes this async handler as an unhandled rejection, which terminates the whole
2748+ // process under Node's default --unhandled-rejections=throw. Report the failure on
2749+ // the already-open stream instead.
2750+ if ( res . headersSent ) {
2751+ try {
2752+ res . write ( `data: ${ JSON . stringify ( {
2753+ type : 'response.failed' ,
2754+ response : { error : transformed . error . error || transformed . error }
2755+ } ) } \n\n`) ;
2756+ res . write ( 'data: [DONE]\n\n' ) ;
2757+ } catch ( writeError ) {
2758+ logDebug ( 'Failed to report error on open response stream' , { error : writeError . message } ) ;
2759+ }
2760+ return res . end ( ) ;
2761+ }
2762+ return res . status ( transformed . statusCode ) . json ( transformed . error ) ;
26492763 }
26502764 } ) ;
26512765
0 commit comments