@@ -2,6 +2,7 @@ import express from 'express';
22import cors from 'cors' ;
33import bodyParser from 'body-parser' ;
44import { spawn } from 'child_process' ;
5+ import crypto from 'crypto' ;
56import { createOpencodeClient } from '@opencode-ai/sdk' ;
67import http from 'http' ;
78import https from 'https' ;
@@ -647,6 +648,56 @@ export function createApp(config) {
647648 }
648649 } ;
649650
651+ // Responses API state store (previous_response_id): maps a returned response id
652+ // to the OpenCode session that produced it, so stateful clients can continue a
653+ // conversation without resending full history. Entries expire after
654+ // RESPONSE_STATE_TTL_MS; the sweep then best-effort deletes the upstream session
655+ // once no live entry still references it, so statefulness never leaks sessions.
656+ const responseState = new Map ( ) ;
657+ const RESPONSE_STATE_TTL_MS = 30 * 60 * 1000 ;
658+ const RESPONSE_STATE_SWEEP_INTERVAL_MS = 60 * 1000 ;
659+ const getResponseState = ( responseId ) => {
660+ const state = responseState . get ( responseId ) ;
661+ if ( ! state ) return null ;
662+ if ( state . expiresAt <= Date . now ( ) ) {
663+ responseState . delete ( responseId ) ;
664+ return null ;
665+ }
666+ return state ;
667+ } ;
668+ const storeResponseState = ( responseId , sessionId , model ) => {
669+ if ( ! responseId || ! sessionId ) return ;
670+ responseState . set ( responseId , {
671+ sessionId,
672+ model,
673+ expiresAt : Date . now ( ) + RESPONSE_STATE_TTL_MS
674+ } ) ;
675+ } ;
676+ const sweepResponseState = async ( ) => {
677+ const now = Date . now ( ) ;
678+ const expired = [ ] ;
679+ for ( const [ id , state ] of responseState . entries ( ) ) {
680+ if ( state . expiresAt <= now ) {
681+ expired . push ( state ) ;
682+ responseState . delete ( id ) ;
683+ }
684+ }
685+ if ( ! expired . length ) return ;
686+ const liveSessionIds = new Set ( [ ...responseState . values ( ) ] . map ( ( s ) => s . sessionId ) ) ;
687+ for ( const state of expired ) {
688+ if ( liveSessionIds . has ( state . sessionId ) ) continue ;
689+ try {
690+ await client . session . delete ( { path : { id : state . sessionId } } ) ;
691+ } catch ( e ) {
692+ logDebug ( 'Failed to delete expired response session' , { sessionId : state . sessionId , error : e . message } ) ;
693+ }
694+ }
695+ } ;
696+ const responseStateSweepTimer = setInterval ( ( ) => {
697+ sweepResponseState ( ) . catch ( ( ) => { } ) ;
698+ } , RESPONSE_STATE_SWEEP_INTERVAL_MS ) ;
699+ if ( typeof responseStateSweepTimer . unref === 'function' ) responseStateSweepTimer . unref ( ) ;
700+
650701 const TOOL_MODE = Object . freeze ( {
651702 DISABLED : 'disabled' ,
652703 EXTERNAL_BRIDGE : 'external-bridge' ,
@@ -1446,7 +1497,7 @@ export function createApp(config) {
14461497 let stream = false ;
14471498 let pID = 'opencode' ;
14481499 let mID = 'kimi-k2.5-free' ;
1449- let id = `chatcmpl-${ Date . now ( ) } ` ;
1500+ let id = `chatcmpl-${ crypto . randomUUID ( ) } ` ;
14501501 let keepaliveInterval = null ;
14511502
14521503 try {
@@ -1646,7 +1697,7 @@ export function createApp(config) {
16461697 if ( ! sessionId ) throw new Error ( 'Failed to create OpenCode session' ) ;
16471698 logDebug ( 'Session created' , { sessionId } ) ;
16481699
1649- id = `chatcmpl-${ Date . now ( ) } ` ;
1700+ id = `chatcmpl-${ crypto . randomUUID ( ) } ` ;
16501701 keepaliveInterval = null ;
16511702 let completionTokens = 0 ;
16521703 let reasoningTokens = 0 ;
@@ -2064,7 +2115,7 @@ export function createApp(config) {
20642115 }
20652116
20662117 res . json ( {
2067- id : `chatcmpl-${ Date . now ( ) } ` ,
2118+ id : `chatcmpl-${ crypto . randomUUID ( ) } ` ,
20682119 object : 'chat.completion' ,
20692120 created : Math . floor ( Date . now ( ) / 1000 ) ,
20702121 model : `${ pID } /${ mID } ` ,
@@ -2205,9 +2256,9 @@ export function createApp(config) {
22052256
22062257 app . post ( '/v1/responses' , async ( req , res ) => {
22072258 try {
2208- const {
2209- model,
2210- input,
2259+ const {
2260+ model,
2261+ input,
22112262 reasoning_effort,
22122263 reasoning : requestReasoning ,
22132264 max_output_tokens,
@@ -2219,9 +2270,17 @@ export function createApp(config) {
22192270 stream = false ,
22202271 messages : chatMessages ,
22212272 prompt,
2273+ previous_response_id : previousResponseId ,
22222274 opencode : requestOpencodeConfig
22232275 } = req . body ;
22242276
2277+ // Stateful continuation: reuse the session behind a previous response so the
2278+ // client only needs to send the new turn (OpenAI Responses API semantics).
2279+ const previousState = previousResponseId ? getResponseState ( previousResponseId ) : null ;
2280+ if ( previousResponseId && ! previousState ) {
2281+ return res . status ( 400 ) . json ( { error : { message : 'Invalid or expired previous_response_id' } } ) ;
2282+ }
2283+
22252284 const reasoningLevel = normalizeReasoningEffort (
22262285 reasoning_effort || requestReasoning ?. effort ,
22272286 null
@@ -2360,7 +2419,7 @@ export function createApp(config) {
23602419 return res . status ( 400 ) . json ( { error : { message : 'input is required' } } ) ;
23612420 }
23622421
2363- const resolvedModel = await resolveRequestedModel ( model ) ;
2422+ const resolvedModel = await resolveRequestedModel ( model || previousState ?. model ) ;
23642423 const pID = resolvedModel . providerID ;
23652424 const mID = resolvedModel . modelID ;
23662425
@@ -2372,10 +2431,15 @@ export function createApp(config) {
23722431 } ) ;
23732432 } catch ( e ) { }
23742433
2375- const sessionRes = await client . session . create ( ) ;
2376- const sessionId = sessionRes . data ?. id ;
2434+ // Continue the stored session when chaining from previous_response_id;
2435+ // otherwise start a fresh one.
2436+ let sessionId = previousState ?. sessionId || null ;
23772437 if ( ! sessionId ) {
2378- throw new Error ( 'Failed to create OpenCode session' ) ;
2438+ const sessionRes = await client . session . create ( ) ;
2439+ sessionId = sessionRes . data ?. id ;
2440+ if ( ! sessionId ) {
2441+ throw new Error ( 'Failed to create OpenCode session' ) ;
2442+ }
23792443 }
23802444
23812445 const parts = [ ] ;
@@ -2464,11 +2528,11 @@ export function createApp(config) {
24642528 res . setHeader ( 'Content-Type' , 'text/event-stream' ) ;
24652529 res . setHeader ( 'Cache-Control' , 'no-cache' ) ;
24662530 res . setHeader ( 'Connection' , 'keep-alive' ) ;
2467- const responseId = `resp_${ Date . now ( ) } ` ;
2531+ const responseId = `resp_${ crypto . randomUUID ( ) } ` ;
24682532 const messageOutputIndex = 0 ;
24692533 const reasoningOutputIndex = 1 ;
24702534 const contentIndex = 0 ;
2471- const outputItemId = `msg_${ Date . now ( ) } ` ;
2535+ const outputItemId = `msg_${ crypto . randomUUID ( ) } ` ;
24722536 const reasoningItemId = 'reasoning-0' ;
24732537 let nextOutputIndex = 2 ;
24742538 let sequenceNumber = 0 ;
@@ -2778,9 +2842,7 @@ export function createApp(config) {
27782842 } ;
27792843 emit ( { type : 'response.completed' , sequence_number : nextSeq ( ) , response } ) ;
27802844 res . write ( 'data: [DONE]\n\n' ) ;
2781- try {
2782- await client . session . delete ( { path : { id : sessionId } } ) ;
2783- } catch ( e ) { }
2845+ storeResponseState ( responseId , sessionId , `${ pID } /${ mID } ` ) ;
27842846 return res . end ( ) ;
27852847 }
27862848
@@ -2841,8 +2903,9 @@ export function createApp(config) {
28412903 output . push ( buildResponsesFunctionCallOutputItem ( toolCall ) ) ;
28422904 } ) ;
28432905
2906+ const responseId = `resp_${ crypto . randomUUID ( ) } ` ;
28442907 const response = {
2845- id : `resp_ ${ Date . now ( ) } ` ,
2908+ id : responseId ,
28462909 object : 'response' ,
28472910 created : Math . floor ( Date . now ( ) / 1000 ) ,
28482911 model : `${ pID } /${ mID } ` ,
@@ -2857,9 +2920,7 @@ export function createApp(config) {
28572920 }
28582921 } ;
28592922
2860- try {
2861- await client . session . delete ( { path : { id : sessionId } } ) ;
2862- } catch ( e ) { }
2923+ storeResponseState ( responseId , sessionId , `${ pID } /${ mID } ` ) ;
28632924
28642925 return res . json ( response ) ;
28652926 } catch ( error ) {
0 commit comments