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,290 @@
1
+ /**
2
+ * Legacy OpenAI Completions API (POST /v1/completions) — thin adapter over the
3
+ * internal Chat Completions pipeline. The legacy surface uses a freeform
4
+ * `prompt` (string | array) instead of messages, and returns
5
+ * `{"object":"text_completion","choices":[{"text":...}]}` with `cmpl-` ids.
6
+ *
7
+ * The internal dispatch follows the same self-fetch pattern as the Responses
8
+ * API: the request is adapted to a chat body and re-entered through
9
+ * /v1/chat/completions so all the battle-tested machinery (threads, tool
10
+ * parsing, failover, usage accounting) applies unchanged.
11
+ */
12
+
13
+ import { Hono, type Context } from "hono";
14
+ import { config } from "../core/config.ts";
15
+
16
+ const app = new Hono();
17
+
18
+ function makeCompletionId(): string {
19
+ return `cmpl-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
20
+ }
21
+
22
+ interface CompletionsBody {
23
+ model?: unknown;
24
+ prompt?: unknown;
25
+ stream?: unknown;
26
+ max_tokens?: unknown;
27
+ temperature?: unknown;
28
+ top_p?: unknown;
29
+ stop?: unknown;
30
+ presence_penalty?: unknown;
31
+ frequency_penalty?: unknown;
32
+ }
33
+
34
+ function completionsError(
35
+ c: Context,
36
+ type: string,
37
+ message: string,
38
+ statusCode: number,
39
+ ) {
40
+ return c.json(
41
+ {
42
+ error: {
43
+ message,
44
+ type,
45
+ param: null,
46
+ code: type === "invalid_request_error" ? "invalid_request" : type,
47
+ },
48
+ },
49
+ statusCode as any,
50
+ );
51
+ }
52
+
53
+ export async function completionsLegacy(c: Context) {
54
+ let body: CompletionsBody;
55
+ try {
56
+ body = await c.req.json();
57
+ } catch {
58
+ return completionsError(c, "invalid_request_error", "Invalid JSON body", 400);
59
+ }
60
+
61
+ if (typeof body.model !== "string" || body.model.trim().length === 0) {
62
+ return completionsError(
63
+ c,
64
+ "invalid_request_error",
65
+ "The 'model' parameter is required",
66
+ 400,
67
+ );
68
+ }
69
+ if (body.prompt === undefined || body.prompt === null) {
70
+ return completionsError(
71
+ c,
72
+ "invalid_request_error",
73
+ "The 'prompt' parameter is required",
74
+ 400,
75
+ );
76
+ }
77
+
78
+ const promptText = Array.isArray(body.prompt)
79
+ ? body.prompt.map((part) => String(part)).join("\n")
80
+ : String(body.prompt);
81
+ const isStream = body.stream === true;
82
+
83
+ const chatBody: Record<string, unknown> = {
84
+ model: body.model,
85
+ messages: [{ role: "user", content: promptText }],
86
+ stream: isStream,
87
+ };
88
+ for (const key of [
89
+ "max_tokens",
90
+ "temperature",
91
+ "top_p",
92
+ "stop",
93
+ "presence_penalty",
94
+ "frequency_penalty",
95
+ ] as const) {
96
+ if (body[key] !== undefined) chatBody[key] = body[key];
97
+ }
98
+
99
+ const dispatchToChat = () =>
100
+ fetch(`http://127.0.0.1:${config.server.port}/v1/chat/completions`, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ Authorization: `Bearer ${process.env.API_KEY || config.apiKey || ""}`,
105
+ "x-qwenproxy-route": "Completions",
106
+ },
107
+ body: JSON.stringify(chatBody),
108
+ signal: c.req.raw.signal,
109
+ });
110
+
111
+ try {
112
+ if (isStream) {
113
+ // ============ STREAMING MODE ============
114
+ const socket =
115
+ (c.env as any)?.incoming?.socket || (c.req.raw as any)?.socket;
116
+ if (socket && typeof socket.setNoDelay === "function") {
117
+ socket.setNoDelay(true);
118
+ }
119
+
120
+ c.header("Content-Type", "text/event-stream");
121
+ c.header("Cache-Control", "no-cache, no-transform");
122
+ c.header("Connection", "keep-alive");
123
+ c.header("X-Accel-Buffering", "no");
124
+ const completionId = makeCompletionId();
125
+ const created = Math.floor(Date.now() / 1000);
126
+ const model = body.model;
127
+
128
+ const response = await dispatchToChat();
129
+ if (!response.ok) {
130
+ return completionsError(
131
+ c,
132
+ "api_error",
133
+ `Upstream service error: ${response.status}`,
134
+ 502,
135
+ );
136
+ }
137
+ const reader = response.body?.getReader();
138
+ if (!reader) {
139
+ return completionsError(c, "api_error", "No response body", 502);
140
+ }
141
+
142
+ const encoder = new TextEncoder();
143
+ const decoder = new TextDecoder();
144
+ const stream = new ReadableStream({
145
+ async start(controller) {
146
+ let buffer = "";
147
+ let closed = false;
148
+ const enqueue = (data: unknown) => {
149
+ if (closed) return;
150
+ try {
151
+ controller.enqueue(
152
+ encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
153
+ );
154
+ } catch {
155
+ closed = true;
156
+ }
157
+ };
158
+
159
+ try {
160
+ while (true) {
161
+ const { done, value } = await reader.read();
162
+ if (done) break;
163
+ buffer += decoder.decode(value, { stream: true });
164
+ const lines = buffer.split("\n");
165
+ buffer = lines.pop() || "";
166
+
167
+ for (const line of lines) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed.startsWith("data:")) continue;
170
+ const dataStr = trimmed.slice(5).trim();
171
+ if (!dataStr || dataStr === "[DONE]") continue;
172
+
173
+ let chunk: any;
174
+ try {
175
+ chunk = JSON.parse(dataStr);
176
+ } catch {
177
+ continue;
178
+ }
179
+
180
+ const choice = chunk.choices?.[0];
181
+ const delta = choice?.delta || {};
182
+ if (delta.content) {
183
+ enqueue({
184
+ id: completionId,
185
+ object: "text_completion",
186
+ created,
187
+ model,
188
+ choices: [
189
+ {
190
+ text: delta.content,
191
+ index: 0,
192
+ logprobs: null,
193
+ finish_reason: null,
194
+ },
195
+ ],
196
+ });
197
+ } else if (choice?.finish_reason) {
198
+ enqueue({
199
+ id: completionId,
200
+ object: "text_completion",
201
+ created,
202
+ model,
203
+ choices: [
204
+ {
205
+ text: "",
206
+ index: 0,
207
+ logprobs: null,
208
+ finish_reason: choice.finish_reason,
209
+ },
210
+ ],
211
+ });
212
+ } else if (
213
+ Array.isArray(chunk.choices) &&
214
+ chunk.choices.length === 0 &&
215
+ chunk.usage
216
+ ) {
217
+ enqueue({
218
+ id: completionId,
219
+ object: "text_completion",
220
+ created,
221
+ model,
222
+ choices: [],
223
+ usage: chunk.usage,
224
+ });
225
+ }
226
+ }
227
+ }
228
+
229
+ if (!closed) {
230
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
231
+ controller.close();
232
+ }
233
+ } catch {
234
+ // Client disconnect — the stream is gone; nothing else to emit.
235
+ } finally {
236
+ reader.releaseLock();
237
+ }
238
+ },
239
+ });
240
+
241
+ return new Response(stream, {
242
+ headers: {
243
+ "Content-Type": "text/event-stream",
244
+ "Cache-Control": "no-cache, no-transform",
245
+ Connection: "keep-alive",
246
+ "X-Accel-Buffering": "no",
247
+ "Transfer-Encoding": "chunked",
248
+ },
249
+ });
250
+ }
251
+
252
+ // ============ NON-STREAMING MODE ============
253
+ const response = await dispatchToChat();
254
+ if (!response.ok) {
255
+ return completionsError(
256
+ c,
257
+ "api_error",
258
+ `Upstream service error: ${response.status}`,
259
+ 502,
260
+ );
261
+ }
262
+ const chatRes: any = await response.json();
263
+ const choice = chatRes.choices?.[0];
264
+
265
+ return c.json({
266
+ id: makeCompletionId(),
267
+ object: "text_completion",
268
+ created: chatRes.created ?? Math.floor(Date.now() / 1000),
269
+ model: body.model,
270
+ choices: [
271
+ {
272
+ text: choice?.message?.content ?? "",
273
+ index: 0,
274
+ logprobs: null,
275
+ finish_reason: choice?.finish_reason ?? "stop",
276
+ },
277
+ ],
278
+ usage: chatRes.usage ?? null,
279
+ });
280
+ } catch (error) {
281
+ // Client closed the request mid-flight: silent, mirrors the 499 handling
282
+ // of the chat route. Anything else is a server-side failure.
283
+ if (c.req.raw.signal.aborted) {
284
+ return new Response(null, { status: 499 });
285
+ }
286
+ return completionsError(c, "api_error", "Internal server error", 500);
287
+ }
288
+ }
289
+
290
+ export { app as completionsApp };
@@ -0,0 +1,139 @@
1
+ import type { Context } from "hono";
2
+ import {
3
+ generateImage,
4
+ isSupportedMediaSize,
5
+ MEDIA_SIZE_OPTIONS,
6
+ supportsPromptMediaGeneration,
7
+ } from "../services/media-generation.ts";
8
+ import { logger } from "../core/logger.ts";
9
+ import { sendOpenAIError } from "../api/error-helpers.ts";
10
+ import { ValidationError } from "../core/errors.ts";
11
+
12
+ const DEFAULT_SIZE = "auto";
13
+
14
+ interface ImageDataItem {
15
+ url?: string;
16
+ b64_json?: string;
17
+ revised_prompt?: string;
18
+ }
19
+
20
+ interface ImagesGenerationsRequest {
21
+ model?: unknown;
22
+ prompt?: unknown;
23
+ n?: unknown;
24
+ size?: unknown;
25
+ /** Accepted for OpenAI compatibility but ignored — Qwen has no quality parameter. */
26
+ quality?: unknown;
27
+ response_format?: unknown;
28
+ }
29
+
30
+ function validationError(message: string, param: string): ValidationError {
31
+ const err = new ValidationError(message);
32
+ err.param = param;
33
+ return err;
34
+ }
35
+
36
+ async function urlToBase64(url: string): Promise<string> {
37
+ const response = await fetch(url);
38
+ if (!response.ok) {
39
+ throw new Error(`Failed to download generated image: HTTP ${response.status}`);
40
+ }
41
+ const buffer = Buffer.from(await response.arrayBuffer());
42
+ return buffer.toString("base64");
43
+ }
44
+
45
+ export async function imagesGenerations(c: Context): Promise<Response> {
46
+ let body: ImagesGenerationsRequest;
47
+ try {
48
+ body = await c.req.json();
49
+ } catch {
50
+ return sendOpenAIError(c, new ValidationError("Request body must be valid JSON"));
51
+ }
52
+
53
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
54
+ if (!prompt) {
55
+ return sendOpenAIError(
56
+ c,
57
+ validationError("`prompt` must be a non-empty string", "prompt"),
58
+ );
59
+ }
60
+
61
+ const n = body.n === undefined ? 1 : body.n;
62
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
63
+ return sendOpenAIError(c, validationError("`n` must be a positive integer", "n"));
64
+ }
65
+
66
+ const size = body.size === undefined ? DEFAULT_SIZE : body.size;
67
+ if (!isSupportedMediaSize(size)) {
68
+ return sendOpenAIError(
69
+ c,
70
+ validationError(
71
+ `\`size\` must be one of: ${MEDIA_SIZE_OPTIONS.join(", ")}`,
72
+ "size",
73
+ ),
74
+ );
75
+ }
76
+
77
+ const responseFormat = body.response_format ?? "url";
78
+ if (responseFormat !== "url" && responseFormat !== "b64_json") {
79
+ return sendOpenAIError(
80
+ c,
81
+ validationError('`response_format` must be "url" or "b64_json"', "response_format"),
82
+ );
83
+ }
84
+
85
+ const model =
86
+ typeof body.model === "string" && body.model.trim()
87
+ ? body.model.trim()
88
+ : "";
89
+ if (!model) {
90
+ return sendOpenAIError(
91
+ c,
92
+ validationError("`model` must be the model selected by the client", "model"),
93
+ );
94
+ }
95
+ if (!supportsPromptMediaGeneration(model, "image")) {
96
+ return sendOpenAIError(
97
+ c,
98
+ validationError(
99
+ `Model \`${model}\` requires a reference image and is not available through prompt-only image generation yet`,
100
+ "model",
101
+ ),
102
+ );
103
+ }
104
+
105
+ logger.info("Image generation request", {
106
+ model,
107
+ n,
108
+ size,
109
+ response_format: responseFormat,
110
+ });
111
+
112
+ try {
113
+ const results = await Promise.all(
114
+ Array.from({ length: n }, () => generateImage({ model, prompt, size })),
115
+ );
116
+
117
+ const data: ImageDataItem[] = await Promise.all(
118
+ results.map(async (result): Promise<ImageDataItem> => {
119
+ const item: ImageDataItem =
120
+ responseFormat === "b64_json"
121
+ ? { b64_json: await urlToBase64(result.url) }
122
+ : { url: result.url };
123
+ if (result.revised_prompt) {
124
+ item.revised_prompt = result.revised_prompt;
125
+ }
126
+ return item;
127
+ }),
128
+ );
129
+
130
+ logger.info("Image generation completed", { model, n });
131
+ return c.json({ created: Math.floor(Date.now() / 1000), data });
132
+ } catch (error) {
133
+ logger.error("Image generation failed", {
134
+ model,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ });
137
+ return sendOpenAIError(c, error, 500);
138
+ }
139
+ }