Skip to content

Commit a7debe6

Browse files
committed
feat(proxy): Responses API previous_response_id with TTL session store
Learned from lucasliet/opencode-openai-proxy: stateful continuation via previous_response_id. Our variant stores response id -> OpenCode session for 30 minutes and the sweep also deletes expired upstream sessions once no live entry references them, so statefulness never leaks sessions. Also switch chatcmpl/resp ids to crypto.randomUUID to avoid collisions.
1 parent 54918ac commit a7debe6

1 file changed

Lines changed: 80 additions & 19 deletions

File tree

‎src/proxy.js‎

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express from 'express';
22
import cors from 'cors';
33
import bodyParser from 'body-parser';
44
import { spawn } from 'child_process';
5+
import crypto from 'crypto';
56
import { createOpencodeClient } from '@opencode-ai/sdk';
67
import http from 'http';
78
import 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

Comments
 (0)