qwenproxy-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { Hono, type Context } from "hono";
|
|
2
|
+
import { config } from "../../core/config.ts";
|
|
3
|
+
import { logger } from "../../core/logger.ts";
|
|
4
|
+
import { validateResponsesRequest } from "./validation.ts";
|
|
5
|
+
import {
|
|
6
|
+
responsesToChatCompletions,
|
|
7
|
+
chatCompletionsToResponses,
|
|
8
|
+
buildInProgressResponse,
|
|
9
|
+
finalizeResponse,
|
|
10
|
+
generateResponseId,
|
|
11
|
+
responsesOutputToChatMessages,
|
|
12
|
+
} from "./adapter.ts";
|
|
13
|
+
import {
|
|
14
|
+
createStreamState,
|
|
15
|
+
processChatChunk,
|
|
16
|
+
buildFinalOutput,
|
|
17
|
+
buildFinalUsage,
|
|
18
|
+
} from "./streaming.ts";
|
|
19
|
+
import {
|
|
20
|
+
storeResponse,
|
|
21
|
+
getResponseHistory,
|
|
22
|
+
getStoredResponse,
|
|
23
|
+
deleteStoredResponse,
|
|
24
|
+
} from "./state.ts";
|
|
25
|
+
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* POST /v1/responses - Create a response (OpenAI Responses API format)
|
|
30
|
+
*/
|
|
31
|
+
app.post("/v1/responses", async (c) => {
|
|
32
|
+
const requestStartedAt = Date.now();
|
|
33
|
+
|
|
34
|
+
// Parse and validate request
|
|
35
|
+
let body: unknown;
|
|
36
|
+
try {
|
|
37
|
+
body = await c.req.json();
|
|
38
|
+
} catch {
|
|
39
|
+
return responsesError(c, "invalid_request_error", "Invalid JSON body", 400);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const validation = validateResponsesRequest(body);
|
|
43
|
+
if (!validation.valid) {
|
|
44
|
+
return responsesError(c, "invalid_request_error", validation.error!, 400);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const req = validation.data!;
|
|
48
|
+
const isStream = req.stream ?? false;
|
|
49
|
+
const requestModel = req.model;
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Retrieve history if previous_response_id is provided
|
|
54
|
+
let historyMessages: any[] = [];
|
|
55
|
+
if (req.previous_response_id) {
|
|
56
|
+
const history = getResponseHistory(req.previous_response_id);
|
|
57
|
+
if (!history) {
|
|
58
|
+
return responsesError(
|
|
59
|
+
c,
|
|
60
|
+
"invalid_request_error",
|
|
61
|
+
`Response '${req.previous_response_id}' not found or expired`,
|
|
62
|
+
404,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
historyMessages = history;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Convert to Chat Completions format
|
|
69
|
+
const chatRequest = responsesToChatCompletions(req, historyMessages);
|
|
70
|
+
|
|
71
|
+
if (isStream) {
|
|
72
|
+
// ============ STREAMING MODE ============
|
|
73
|
+
const socket =
|
|
74
|
+
(c.env as any)?.incoming?.socket || (c.req.raw as any)?.socket;
|
|
75
|
+
if (socket && typeof socket.setNoDelay === "function") {
|
|
76
|
+
socket.setNoDelay(true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
c.header("Content-Type", "text/event-stream");
|
|
80
|
+
c.header("Cache-Control", "no-cache, no-transform");
|
|
81
|
+
c.header("Connection", "keep-alive");
|
|
82
|
+
c.header("X-Accel-Buffering", "no");
|
|
83
|
+
const responseId = generateResponseId();
|
|
84
|
+
const inProgressResponse = buildInProgressResponse(
|
|
85
|
+
responseId,
|
|
86
|
+
requestModel,
|
|
87
|
+
req,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Build a ReadableStream that emits SSE events
|
|
91
|
+
const readable = new ReadableStream({
|
|
92
|
+
async start(controller) {
|
|
93
|
+
const encoder = new TextEncoder();
|
|
94
|
+
let streamClosed = false;
|
|
95
|
+
let sequenceNumber = 0;
|
|
96
|
+
|
|
97
|
+
// Proper SSE: event: <type>\ndata: {...}\n\n
|
|
98
|
+
const enqueue = (_event: string, data: any) => {
|
|
99
|
+
if (streamClosed) return;
|
|
100
|
+
try {
|
|
101
|
+
const payload = { ...data, sequence_number: sequenceNumber++ };
|
|
102
|
+
controller.enqueue(
|
|
103
|
+
encoder.encode(
|
|
104
|
+
`event: ${_event}\ndata: ${JSON.stringify(payload)}\n\n`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
streamClosed = true;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const streamState = createStreamState(responseId, requestModel);
|
|
113
|
+
let completionTokens = 0;
|
|
114
|
+
let streamError: Error | null = null;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Emit response.created
|
|
118
|
+
enqueue("response.created", {
|
|
119
|
+
type: "response.created",
|
|
120
|
+
response: inProgressResponse,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Emit response.in_progress
|
|
124
|
+
enqueue("response.in_progress", {
|
|
125
|
+
type: "response.in_progress",
|
|
126
|
+
response: inProgressResponse,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Make request to internal Chat Completions endpoint
|
|
130
|
+
// Always request usage in stream for real token counts
|
|
131
|
+
const response = await fetch(
|
|
132
|
+
`http://127.0.0.1:${config.server.port}/v1/chat/completions`,
|
|
133
|
+
{
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: {
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
Authorization: `Bearer ${process.env.API_KEY || config.apiKey || ""}`,
|
|
138
|
+
"x-qwenproxy-route": "Responses",
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
...chatRequest,
|
|
142
|
+
stream: true,
|
|
143
|
+
stream_options: { include_usage: true },
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const errorText = await response.text();
|
|
150
|
+
console.error(
|
|
151
|
+
`[Responses] Upstream error: ${response.status} ${errorText}`,
|
|
152
|
+
);
|
|
153
|
+
throw new Error(`Upstream service error: ${response.status}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const reader = response.body?.getReader();
|
|
157
|
+
if (!reader) {
|
|
158
|
+
throw new Error("No response body");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const decoder = new TextDecoder();
|
|
162
|
+
let responseBuffer = "";
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
|
|
169
|
+
responseBuffer += decoder.decode(value, { stream: true });
|
|
170
|
+
const lines = responseBuffer.split("\n");
|
|
171
|
+
responseBuffer = lines.pop() || "";
|
|
172
|
+
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
if (!line.startsWith("data: ")) continue;
|
|
175
|
+
const data = line.slice(6);
|
|
176
|
+
if (data === "[DONE]") continue;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const chunk = JSON.parse(data);
|
|
180
|
+
|
|
181
|
+
if (chunk.usage?.completion_tokens !== undefined) {
|
|
182
|
+
completionTokens = chunk.usage.completion_tokens;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const events = processChatChunk(
|
|
186
|
+
chunk,
|
|
187
|
+
streamState,
|
|
188
|
+
);
|
|
189
|
+
for (const event of events) {
|
|
190
|
+
enqueue(event.type, event);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Ignore parse errors
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
reader.releaseLock();
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
streamError =
|
|
202
|
+
error instanceof Error ? error : new Error(String(error));
|
|
203
|
+
// Client disconnect is normal, not an error
|
|
204
|
+
if (
|
|
205
|
+
streamError.message?.includes("ERR_INVALID_STATE") ||
|
|
206
|
+
streamError.message?.includes("aborted") ||
|
|
207
|
+
streamError.message?.includes("cancelled")
|
|
208
|
+
) {
|
|
209
|
+
streamClosed = true;
|
|
210
|
+
} else {
|
|
211
|
+
console.error(
|
|
212
|
+
"❌ [Responses] Stream error:",
|
|
213
|
+
streamError.message,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
// ALWAYS emit final event (if stream is still open)
|
|
218
|
+
if (!streamClosed) {
|
|
219
|
+
try {
|
|
220
|
+
const finalOutput = buildFinalOutput(streamState);
|
|
221
|
+
const finalUsage = buildFinalUsage(
|
|
222
|
+
streamState,
|
|
223
|
+
completionTokens,
|
|
224
|
+
);
|
|
225
|
+
const finalResponse = finalizeResponse(
|
|
226
|
+
inProgressResponse,
|
|
227
|
+
finalOutput,
|
|
228
|
+
finalUsage,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Attach last_response_id for client memory
|
|
232
|
+
finalResponse.last_response_id = responseId;
|
|
233
|
+
|
|
234
|
+
if (streamError) {
|
|
235
|
+
enqueue("response.failed", {
|
|
236
|
+
type: "response.failed",
|
|
237
|
+
response: {
|
|
238
|
+
...finalResponse,
|
|
239
|
+
status: "failed",
|
|
240
|
+
error: {
|
|
241
|
+
code: "api_error",
|
|
242
|
+
message: streamError.message,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
enqueue("response.completed", {
|
|
248
|
+
type: "response.completed",
|
|
249
|
+
response: finalResponse,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (req.store !== false) {
|
|
253
|
+
// Responses `instructions` are request-scoped. Do not persist
|
|
254
|
+
// the synthetic system message, otherwise previous_response_id
|
|
255
|
+
// repeats it on every turn and silently inflates context.
|
|
256
|
+
const persistedInput = req.instructions
|
|
257
|
+
? chatRequest.messages.slice(1)
|
|
258
|
+
: chatRequest.messages;
|
|
259
|
+
storeResponse(responseId, finalResponse, [
|
|
260
|
+
...persistedInput,
|
|
261
|
+
...responsesOutputToChatMessages(finalOutput),
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
}
|
|
266
|
+
} catch (finalError) {
|
|
267
|
+
console.error(
|
|
268
|
+
"[Responses] Failed to emit final event:",
|
|
269
|
+
finalError,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Close the stream
|
|
274
|
+
try {
|
|
275
|
+
controller.close();
|
|
276
|
+
} catch {
|
|
277
|
+
// Already closed
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return new Response(readable, {
|
|
285
|
+
headers: {
|
|
286
|
+
"Content-Type": "text/event-stream",
|
|
287
|
+
"Cache-Control": "no-cache, no-transform",
|
|
288
|
+
Connection: "keep-alive",
|
|
289
|
+
"X-Accel-Buffering": "no",
|
|
290
|
+
"Transfer-Encoding": "chunked",
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
} else {
|
|
294
|
+
// ============ NON-STREAMING MODE ============
|
|
295
|
+
const response = await fetch(
|
|
296
|
+
`http://127.0.0.1:${config.server.port}/v1/chat/completions`,
|
|
297
|
+
{
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: {
|
|
300
|
+
"Content-Type": "application/json",
|
|
301
|
+
Authorization: `Bearer ${process.env.API_KEY || config.apiKey || ""}`,
|
|
302
|
+
"x-qwenproxy-route": "Responses",
|
|
303
|
+
},
|
|
304
|
+
body: JSON.stringify(chatRequest),
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (!response.ok) {
|
|
309
|
+
const errorText = await response.text();
|
|
310
|
+
console.error(
|
|
311
|
+
`[Responses] Upstream error: ${response.status} ${errorText}`,
|
|
312
|
+
);
|
|
313
|
+
return responsesError(c, "api_error", "Upstream service error", 502);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const chatResponse = await response.json();
|
|
317
|
+
const responsesResponse = chatCompletionsToResponses(
|
|
318
|
+
chatResponse,
|
|
319
|
+
requestModel,
|
|
320
|
+
req,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Attach last_response_id for client memory
|
|
324
|
+
responsesResponse.last_response_id = responsesResponse.id;
|
|
325
|
+
|
|
326
|
+
// Store response for stateful conversations
|
|
327
|
+
if (req.store !== false) {
|
|
328
|
+
// `instructions` applies only to this response; keep it out of the
|
|
329
|
+
// persisted chain used by a later previous_response_id turn.
|
|
330
|
+
const persistedInput = req.instructions
|
|
331
|
+
? chatRequest.messages.slice(1)
|
|
332
|
+
: chatRequest.messages;
|
|
333
|
+
storeResponse(responsesResponse.id, responsesResponse, [
|
|
334
|
+
...persistedInput,
|
|
335
|
+
...responsesOutputToChatMessages(responsesResponse.output),
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const duration = Date.now() - requestStartedAt;
|
|
340
|
+
|
|
341
|
+
return c.json(responsesResponse);
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error("❌ [Responses] Error:", error);
|
|
345
|
+
return responsesError(c, "api_error", "Internal server error", 500);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* GET /v1/responses/:response_id - Retrieve a stored response
|
|
351
|
+
*/
|
|
352
|
+
app.get("/v1/responses/:response_id", async (c) => {
|
|
353
|
+
const responseId = c.req.param("response_id");
|
|
354
|
+
|
|
355
|
+
const stored = getStoredResponse(responseId);
|
|
356
|
+
if (!stored) {
|
|
357
|
+
return responsesError(
|
|
358
|
+
c,
|
|
359
|
+
"invalid_request_error",
|
|
360
|
+
`Response '${responseId}' not found`,
|
|
361
|
+
404,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return c.json(stored);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* DELETE /v1/responses/:response_id - Delete a stored response
|
|
370
|
+
*/
|
|
371
|
+
app.delete("/v1/responses/:response_id", async (c) => {
|
|
372
|
+
const responseId = c.req.param("response_id");
|
|
373
|
+
|
|
374
|
+
const existed = deleteStoredResponse(responseId);
|
|
375
|
+
return c.json({
|
|
376
|
+
id: responseId,
|
|
377
|
+
object: "response.deleted",
|
|
378
|
+
deleted: existed,
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Responses API error response helper — OpenAI-shaped envelope.
|
|
384
|
+
* Format: { error: { message, type, param, code } }
|
|
385
|
+
*/
|
|
386
|
+
function responsesError(
|
|
387
|
+
c: Context,
|
|
388
|
+
type: string,
|
|
389
|
+
message: string,
|
|
390
|
+
statusCode: number,
|
|
391
|
+
) {
|
|
392
|
+
return c.json(
|
|
393
|
+
{
|
|
394
|
+
error: {
|
|
395
|
+
message,
|
|
396
|
+
type,
|
|
397
|
+
param: null,
|
|
398
|
+
code: type === "invalid_request_error" ? "invalid_request" : type,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
statusCode as any,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export { app as responsesApp };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ResponsesResponse } from "./types.ts";
|
|
2
|
+
import { getDatabase } from "../../core/database.ts";
|
|
3
|
+
|
|
4
|
+
// ============ State management for previous_response_id ============
|
|
5
|
+
//
|
|
6
|
+
// The Responses API supports stateful conversations via `previous_response_id`.
|
|
7
|
+
// We store completed responses in SQLite (durable) with an in-memory LRU cache
|
|
8
|
+
// for fast lookups. This ensures memory persists across server restarts.
|
|
9
|
+
|
|
10
|
+
export interface StoredResponse {
|
|
11
|
+
response: ResponsesResponse;
|
|
12
|
+
/** The full list of Chat Completions messages sent to the upstream for this response */
|
|
13
|
+
chatMessages: Array<{
|
|
14
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
15
|
+
content: string | null;
|
|
16
|
+
tool_calls?: Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
type: "function";
|
|
19
|
+
function: { name: string; arguments: string };
|
|
20
|
+
}>;
|
|
21
|
+
tool_call_id?: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** Timestamp of storage */
|
|
24
|
+
storedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// In-memory LRU cache (responseId → StoredResponse)
|
|
28
|
+
const cache = new Map<string, StoredResponse>();
|
|
29
|
+
const MAX_CACHE_SIZE = 500;
|
|
30
|
+
// Max age (ms) - 7 days for SQLite, 24h for cache
|
|
31
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
let tableReady = false;
|
|
35
|
+
|
|
36
|
+
function ensureTable(): void {
|
|
37
|
+
if (tableReady) return;
|
|
38
|
+
try {
|
|
39
|
+
const db = getDatabase();
|
|
40
|
+
db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS responses_store (
|
|
42
|
+
response_id TEXT PRIMARY KEY,
|
|
43
|
+
response_json TEXT NOT NULL,
|
|
44
|
+
chat_messages_json TEXT NOT NULL,
|
|
45
|
+
stored_at INTEGER NOT NULL
|
|
46
|
+
)
|
|
47
|
+
`);
|
|
48
|
+
db.exec(`
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_responses_store_stored_at
|
|
50
|
+
ON responses_store(stored_at)
|
|
51
|
+
`);
|
|
52
|
+
tableReady = true;
|
|
53
|
+
} catch {
|
|
54
|
+
// Database not available — fall back to memory-only
|
|
55
|
+
tableReady = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Store a completed response for future `previous_response_id` lookups.
|
|
61
|
+
* Persists to SQLite + in-memory cache.
|
|
62
|
+
*/
|
|
63
|
+
export function storeResponse(
|
|
64
|
+
responseId: string,
|
|
65
|
+
response: ResponsesResponse,
|
|
66
|
+
chatMessages: StoredResponse["chatMessages"],
|
|
67
|
+
): void {
|
|
68
|
+
const entry: StoredResponse = {
|
|
69
|
+
response,
|
|
70
|
+
chatMessages,
|
|
71
|
+
storedAt: Date.now(),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// In-memory cache (LRU eviction)
|
|
75
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
76
|
+
const oldest = [...cache.entries()]
|
|
77
|
+
.sort((a, b) => a[1].storedAt - b[1].storedAt)
|
|
78
|
+
.slice(0, Math.floor(MAX_CACHE_SIZE * 0.1));
|
|
79
|
+
for (const [key] of oldest) {
|
|
80
|
+
cache.delete(key);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
cache.set(responseId, entry);
|
|
84
|
+
|
|
85
|
+
// SQLite persistence
|
|
86
|
+
ensureTable();
|
|
87
|
+
if (tableReady) {
|
|
88
|
+
try {
|
|
89
|
+
const db = getDatabase();
|
|
90
|
+
db.prepare(
|
|
91
|
+
`INSERT OR REPLACE INTO responses_store (response_id, response_json, chat_messages_json, stored_at)
|
|
92
|
+
VALUES (?, ?, ?, ?)`,
|
|
93
|
+
).run(
|
|
94
|
+
responseId,
|
|
95
|
+
JSON.stringify(response),
|
|
96
|
+
JSON.stringify(chatMessages),
|
|
97
|
+
entry.storedAt,
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
// Non-fatal: memory cache still works
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Retrieve stored history for a `previous_response_id`.
|
|
107
|
+
* Returns null if not found.
|
|
108
|
+
*/
|
|
109
|
+
export function getResponseHistory(
|
|
110
|
+
previousResponseId: string,
|
|
111
|
+
): StoredResponse["chatMessages"] | null {
|
|
112
|
+
// Check memory cache first
|
|
113
|
+
const cached = cache.get(previousResponseId);
|
|
114
|
+
if (cached) {
|
|
115
|
+
if (Date.now() - cached.storedAt > CACHE_MAX_AGE_MS) {
|
|
116
|
+
cache.delete(previousResponseId);
|
|
117
|
+
} else {
|
|
118
|
+
return cached.chatMessages;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fall back to SQLite
|
|
123
|
+
ensureTable();
|
|
124
|
+
if (!tableReady) return null;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const db = getDatabase();
|
|
128
|
+
const row = db
|
|
129
|
+
.prepare(
|
|
130
|
+
`SELECT chat_messages_json, stored_at FROM responses_store WHERE response_id = ?`,
|
|
131
|
+
)
|
|
132
|
+
.get(previousResponseId) as
|
|
133
|
+
| { chat_messages_json: string; stored_at: number }
|
|
134
|
+
| undefined;
|
|
135
|
+
|
|
136
|
+
if (!row) return null;
|
|
137
|
+
|
|
138
|
+
if (Date.now() - row.stored_at > MAX_AGE_MS) {
|
|
139
|
+
db.prepare(`DELETE FROM responses_store WHERE response_id = ?`).run(
|
|
140
|
+
previousResponseId,
|
|
141
|
+
);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const chatMessages = JSON.parse(row.chat_messages_json);
|
|
146
|
+
// Re-populate cache
|
|
147
|
+
const responseRow = db
|
|
148
|
+
.prepare(`SELECT response_json FROM responses_store WHERE response_id = ?`)
|
|
149
|
+
.get(previousResponseId) as { response_json: string } | undefined;
|
|
150
|
+
if (responseRow) {
|
|
151
|
+
cache.set(previousResponseId, {
|
|
152
|
+
response: JSON.parse(responseRow.response_json),
|
|
153
|
+
chatMessages,
|
|
154
|
+
storedAt: row.stored_at,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return chatMessages;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Retrieve the full stored response (for GET /v1/responses/:id).
|
|
166
|
+
* Returns null if not found.
|
|
167
|
+
*/
|
|
168
|
+
export function getStoredResponse(
|
|
169
|
+
responseId: string,
|
|
170
|
+
): ResponsesResponse | null {
|
|
171
|
+
// Check memory cache first
|
|
172
|
+
const cached = cache.get(responseId);
|
|
173
|
+
if (cached) {
|
|
174
|
+
if (Date.now() - cached.storedAt > CACHE_MAX_AGE_MS) {
|
|
175
|
+
cache.delete(responseId);
|
|
176
|
+
} else {
|
|
177
|
+
return cached.response;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fall back to SQLite
|
|
182
|
+
ensureTable();
|
|
183
|
+
if (!tableReady) return null;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const db = getDatabase();
|
|
187
|
+
const row = db
|
|
188
|
+
.prepare(
|
|
189
|
+
`SELECT response_json, chat_messages_json, stored_at FROM responses_store WHERE response_id = ?`,
|
|
190
|
+
)
|
|
191
|
+
.get(responseId) as
|
|
192
|
+
| { response_json: string; chat_messages_json: string; stored_at: number }
|
|
193
|
+
| undefined;
|
|
194
|
+
|
|
195
|
+
if (!row) return null;
|
|
196
|
+
|
|
197
|
+
if (Date.now() - row.stored_at > MAX_AGE_MS) {
|
|
198
|
+
db.prepare(`DELETE FROM responses_store WHERE response_id = ?`).run(
|
|
199
|
+
responseId,
|
|
200
|
+
);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return JSON.parse(row.response_json);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Delete a stored response.
|
|
212
|
+
*/
|
|
213
|
+
export function deleteStoredResponse(responseId: string): boolean {
|
|
214
|
+
const fromCache = cache.delete(responseId);
|
|
215
|
+
|
|
216
|
+
ensureTable();
|
|
217
|
+
if (tableReady) {
|
|
218
|
+
try {
|
|
219
|
+
const db = getDatabase();
|
|
220
|
+
const result = db
|
|
221
|
+
.prepare(`DELETE FROM responses_store WHERE response_id = ?`)
|
|
222
|
+
.run(responseId);
|
|
223
|
+
return fromCache || result.changes > 0;
|
|
224
|
+
} catch {
|
|
225
|
+
return fromCache;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return fromCache;
|
|
230
|
+
}
|