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.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. 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
+ }