qwenproxy-cli 1.0.0 → 1.0.2
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 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +77 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2878 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +259 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/update-cli.ts +121 -0
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- package/src/utils/types.ts +109 -109
|
@@ -1,405 +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 };
|
|
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 };
|