opencode-qwen-cli-auth 2.2.4 → 2.2.6
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/dist/index.js +352 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @repository https://github.com/TVD-00/opencode-qwen-cli-auth
|
|
9
9
|
*/
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
11
13
|
import { createPKCE, requestDeviceCode, pollForToken, getApiBaseUrl, saveToken, refreshAccessToken, loadStoredToken, getValidToken } from "./lib/auth/auth.js";
|
|
12
14
|
import { PROVIDER_ID, AUTH_LABELS, DEVICE_FLOW, PORTAL_HEADERS } from "./lib/constants.js";
|
|
13
15
|
import { logError, logInfo, logWarn, LOGGING_ENABLED } from "./lib/logger.js";
|
|
@@ -15,6 +17,8 @@ const CHAT_REQUEST_TIMEOUT_MS = 30000;
|
|
|
15
17
|
const CHAT_MAX_RETRIES = 0;
|
|
16
18
|
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
17
19
|
const QUOTA_DEGRADE_MAX_TOKENS = 1024;
|
|
20
|
+
const CLI_FALLBACK_TIMEOUT_MS = 8000;
|
|
21
|
+
const CLI_FALLBACK_MAX_BUFFER_CHARS = 1024 * 1024;
|
|
18
22
|
const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.2.1";
|
|
19
23
|
const CLIENT_ONLY_BODY_FIELDS = new Set([
|
|
20
24
|
"providerID",
|
|
@@ -26,6 +30,31 @@ const CLIENT_ONLY_BODY_FIELDS = new Set([
|
|
|
26
30
|
"options",
|
|
27
31
|
"debug",
|
|
28
32
|
]);
|
|
33
|
+
function resolveQwenCliCommand() {
|
|
34
|
+
const fromEnv = process.env.QWEN_CLI_PATH;
|
|
35
|
+
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
36
|
+
return fromEnv.trim();
|
|
37
|
+
}
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
const candidates = [];
|
|
40
|
+
if (process.env.APPDATA) {
|
|
41
|
+
candidates.push(`${process.env.APPDATA}\\npm\\qwen.cmd`);
|
|
42
|
+
}
|
|
43
|
+
if (process.env.USERPROFILE) {
|
|
44
|
+
candidates.push(`${process.env.USERPROFILE}\\AppData\\Roaming\\npm\\qwen.cmd`);
|
|
45
|
+
}
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
if (existsSync(candidate)) {
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return "qwen";
|
|
53
|
+
}
|
|
54
|
+
const QWEN_CLI_COMMAND = resolveQwenCliCommand();
|
|
55
|
+
function shouldUseShell(command) {
|
|
56
|
+
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
|
57
|
+
}
|
|
29
58
|
function makeFailFastErrorResponse(status, code, message) {
|
|
30
59
|
return new Response(JSON.stringify({
|
|
31
60
|
error: {
|
|
@@ -61,6 +90,13 @@ function createRequestSignalWithTimeout(sourceSignal, timeoutMs) {
|
|
|
61
90
|
},
|
|
62
91
|
};
|
|
63
92
|
}
|
|
93
|
+
function appendLimitedText(current, chunk) {
|
|
94
|
+
const next = current + chunk;
|
|
95
|
+
if (next.length <= CLI_FALLBACK_MAX_BUFFER_CHARS) {
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
return next.slice(next.length - CLI_FALLBACK_MAX_BUFFER_CHARS);
|
|
99
|
+
}
|
|
64
100
|
function getHeaderValue(headers, headerName) {
|
|
65
101
|
if (!headers) {
|
|
66
102
|
return undefined;
|
|
@@ -186,6 +222,289 @@ function isInsufficientQuota(text) {
|
|
|
186
222
|
return text.toLowerCase().includes("insufficient_quota");
|
|
187
223
|
}
|
|
188
224
|
}
|
|
225
|
+
function extractMessageText(content) {
|
|
226
|
+
if (typeof content === "string") {
|
|
227
|
+
return content.trim();
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(content)) {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
return content.map((part) => {
|
|
233
|
+
if (typeof part === "string") {
|
|
234
|
+
return part;
|
|
235
|
+
}
|
|
236
|
+
if (part && typeof part === "object" && typeof part.text === "string") {
|
|
237
|
+
return part.text;
|
|
238
|
+
}
|
|
239
|
+
return "";
|
|
240
|
+
}).filter(Boolean).join("\n").trim();
|
|
241
|
+
}
|
|
242
|
+
function buildQwenCliPrompt(payload) {
|
|
243
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
244
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
245
|
+
const message = messages[index];
|
|
246
|
+
if (message?.role !== "user") {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const text = extractMessageText(message.content);
|
|
250
|
+
if (text) {
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const merged = messages.slice(-6).map((message) => {
|
|
255
|
+
const text = extractMessageText(message?.content);
|
|
256
|
+
if (!text) {
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
const role = typeof message?.role === "string" ? message.role.toUpperCase() : "UNKNOWN";
|
|
260
|
+
return `${role}: ${text}`;
|
|
261
|
+
}).filter(Boolean).join("\n\n");
|
|
262
|
+
return merged || "Please respond to the latest user request.";
|
|
263
|
+
}
|
|
264
|
+
function parseQwenCliEvents(rawOutput) {
|
|
265
|
+
const trimmed = rawOutput.trim();
|
|
266
|
+
if (!trimmed) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const candidates = [trimmed];
|
|
270
|
+
const start = trimmed.indexOf("[");
|
|
271
|
+
const end = trimmed.lastIndexOf("]");
|
|
272
|
+
if (start >= 0 && end > start) {
|
|
273
|
+
candidates.push(trimmed.slice(start, end + 1));
|
|
274
|
+
}
|
|
275
|
+
for (const candidate of candidates) {
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(candidate);
|
|
278
|
+
if (Array.isArray(parsed)) {
|
|
279
|
+
return parsed;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (_error) {
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
function extractQwenCliText(events) {
|
|
288
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
289
|
+
const event = events[index];
|
|
290
|
+
if (event?.type === "result" && typeof event.result === "string" && event.result.trim()) {
|
|
291
|
+
return event.result.trim();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
295
|
+
const event = events[index];
|
|
296
|
+
const content = event?.message?.content;
|
|
297
|
+
if (!Array.isArray(content)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const text = content.map((part) => {
|
|
301
|
+
if (part && typeof part === "object" && typeof part.text === "string") {
|
|
302
|
+
return part.text;
|
|
303
|
+
}
|
|
304
|
+
return "";
|
|
305
|
+
}).filter(Boolean).join("\n").trim();
|
|
306
|
+
if (text) {
|
|
307
|
+
return text;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
function createSseResponseChunk(data) {
|
|
313
|
+
return `data: ${JSON.stringify(data)}\n\n`;
|
|
314
|
+
}
|
|
315
|
+
function makeQwenCliCompletionResponse(model, content, context, streamMode) {
|
|
316
|
+
if (LOGGING_ENABLED) {
|
|
317
|
+
logInfo("Qwen CLI fallback returned completion", {
|
|
318
|
+
request_id: context.requestId,
|
|
319
|
+
sessionID: context.sessionID,
|
|
320
|
+
modelID: model,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (streamMode) {
|
|
324
|
+
const completionId = `chatcmpl-${randomUUID()}`;
|
|
325
|
+
const created = Math.floor(Date.now() / 1000);
|
|
326
|
+
const encoder = new TextEncoder();
|
|
327
|
+
const stream = new ReadableStream({
|
|
328
|
+
start(controller) {
|
|
329
|
+
controller.enqueue(encoder.encode(createSseResponseChunk({
|
|
330
|
+
id: completionId,
|
|
331
|
+
object: "chat.completion.chunk",
|
|
332
|
+
created,
|
|
333
|
+
model,
|
|
334
|
+
choices: [
|
|
335
|
+
{
|
|
336
|
+
index: 0,
|
|
337
|
+
delta: { role: "assistant", content },
|
|
338
|
+
finish_reason: null,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
})));
|
|
342
|
+
controller.enqueue(encoder.encode(createSseResponseChunk({
|
|
343
|
+
id: completionId,
|
|
344
|
+
object: "chat.completion.chunk",
|
|
345
|
+
created,
|
|
346
|
+
model,
|
|
347
|
+
choices: [
|
|
348
|
+
{
|
|
349
|
+
index: 0,
|
|
350
|
+
delta: {},
|
|
351
|
+
finish_reason: "stop",
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
})));
|
|
355
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
356
|
+
controller.close();
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
return new Response(stream, {
|
|
360
|
+
status: 200,
|
|
361
|
+
headers: {
|
|
362
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
363
|
+
"cache-control": "no-cache",
|
|
364
|
+
"x-qwen-cli-fallback": "1",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const body = {
|
|
369
|
+
id: `chatcmpl-${randomUUID()}`,
|
|
370
|
+
object: "chat.completion",
|
|
371
|
+
created: Math.floor(Date.now() / 1000),
|
|
372
|
+
model,
|
|
373
|
+
choices: [
|
|
374
|
+
{
|
|
375
|
+
index: 0,
|
|
376
|
+
message: {
|
|
377
|
+
role: "assistant",
|
|
378
|
+
content,
|
|
379
|
+
},
|
|
380
|
+
finish_reason: "stop",
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
usage: {
|
|
384
|
+
prompt_tokens: 0,
|
|
385
|
+
completion_tokens: 0,
|
|
386
|
+
total_tokens: 0,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
return new Response(JSON.stringify(body), {
|
|
390
|
+
status: 200,
|
|
391
|
+
headers: {
|
|
392
|
+
"content-type": "application/json",
|
|
393
|
+
"x-qwen-cli-fallback": "1",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function runQwenCliFallback(payload, context, abortSignal) {
|
|
398
|
+
const model = typeof payload?.model === "string" && payload.model.length > 0 ? payload.model : "coder-model";
|
|
399
|
+
const streamMode = payload?.stream === true;
|
|
400
|
+
const prompt = buildQwenCliPrompt(payload);
|
|
401
|
+
const args = [prompt, "-o", "json", "--max-session-turns", "1", "--model", model];
|
|
402
|
+
if (LOGGING_ENABLED) {
|
|
403
|
+
logWarn("Attempting qwen CLI fallback after quota error", {
|
|
404
|
+
request_id: context.requestId,
|
|
405
|
+
sessionID: context.sessionID,
|
|
406
|
+
modelID: model,
|
|
407
|
+
command: QWEN_CLI_COMMAND,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return await new Promise((resolve) => {
|
|
411
|
+
let settled = false;
|
|
412
|
+
let stdout = "";
|
|
413
|
+
let stderr = "";
|
|
414
|
+
let timer = null;
|
|
415
|
+
let child = undefined;
|
|
416
|
+
let abortHandler = undefined;
|
|
417
|
+
const useShell = shouldUseShell(QWEN_CLI_COMMAND);
|
|
418
|
+
const finalize = (result) => {
|
|
419
|
+
if (settled) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
settled = true;
|
|
423
|
+
if (timer) {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
}
|
|
426
|
+
if (abortSignal && abortHandler) {
|
|
427
|
+
abortSignal.removeEventListener("abort", abortHandler);
|
|
428
|
+
}
|
|
429
|
+
resolve(result);
|
|
430
|
+
};
|
|
431
|
+
if (abortSignal?.aborted) {
|
|
432
|
+
finalize({
|
|
433
|
+
ok: false,
|
|
434
|
+
reason: "cli_aborted",
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
child = spawn(QWEN_CLI_COMMAND, args, {
|
|
440
|
+
shell: useShell,
|
|
441
|
+
windowsHide: true,
|
|
442
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
finalize({
|
|
447
|
+
ok: false,
|
|
448
|
+
reason: `cli_spawn_throw:${error instanceof Error ? error.message : String(error)}`,
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (abortSignal) {
|
|
453
|
+
abortHandler = () => {
|
|
454
|
+
try {
|
|
455
|
+
child?.kill();
|
|
456
|
+
}
|
|
457
|
+
catch (_killError) {
|
|
458
|
+
}
|
|
459
|
+
finalize({
|
|
460
|
+
ok: false,
|
|
461
|
+
reason: "cli_aborted",
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
abortSignal.addEventListener("abort", abortHandler, { once: true });
|
|
465
|
+
}
|
|
466
|
+
timer = setTimeout(() => {
|
|
467
|
+
try {
|
|
468
|
+
child.kill();
|
|
469
|
+
}
|
|
470
|
+
catch (_killError) {
|
|
471
|
+
}
|
|
472
|
+
finalize({
|
|
473
|
+
ok: false,
|
|
474
|
+
reason: "cli_timeout",
|
|
475
|
+
});
|
|
476
|
+
}, CLI_FALLBACK_TIMEOUT_MS);
|
|
477
|
+
child.stdout.on("data", (chunk) => {
|
|
478
|
+
stdout = appendLimitedText(stdout, chunk.toString());
|
|
479
|
+
});
|
|
480
|
+
child.stderr.on("data", (chunk) => {
|
|
481
|
+
stderr = appendLimitedText(stderr, chunk.toString());
|
|
482
|
+
});
|
|
483
|
+
child.on("error", (error) => {
|
|
484
|
+
finalize({
|
|
485
|
+
ok: false,
|
|
486
|
+
reason: `cli_spawn_error:${error instanceof Error ? error.message : String(error)}`,
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
child.on("close", (exitCode) => {
|
|
490
|
+
const events = parseQwenCliEvents(stdout);
|
|
491
|
+
const content = events ? extractQwenCliText(events) : null;
|
|
492
|
+
if (content) {
|
|
493
|
+
finalize({
|
|
494
|
+
ok: true,
|
|
495
|
+
response: makeQwenCliCompletionResponse(model, content, context, streamMode),
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
finalize({
|
|
500
|
+
ok: false,
|
|
501
|
+
reason: `cli_exit_${exitCode ?? -1}`,
|
|
502
|
+
stderr: stderr.slice(-300),
|
|
503
|
+
stdout: stdout.slice(-300),
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
}
|
|
189
508
|
function makeQuotaFailFastResponse(text, sourceHeaders, context) {
|
|
190
509
|
const headers = new Headers(sourceHeaders);
|
|
191
510
|
headers.set("content-type", "application/json");
|
|
@@ -225,6 +544,7 @@ async function sendWithTimeout(input, requestInit) {
|
|
|
225
544
|
}
|
|
226
545
|
async function failFastFetch(input, init) {
|
|
227
546
|
const requestInit = init ? { ...init } : {};
|
|
547
|
+
const sourceSignal = requestInit.signal;
|
|
228
548
|
const rawPayload = parseJsonRequestBody(requestInit);
|
|
229
549
|
const sessionID = typeof rawPayload?.sessionID === "string" ? rawPayload.sessionID : undefined;
|
|
230
550
|
let payload = rawPayload;
|
|
@@ -287,8 +607,40 @@ async function failFastFetch(input, init) {
|
|
|
287
607
|
return response;
|
|
288
608
|
}
|
|
289
609
|
const fallbackBody = await response.text().catch(() => "");
|
|
610
|
+
const cliFallback = await runQwenCliFallback(payload, context, sourceSignal);
|
|
611
|
+
if (cliFallback.ok) {
|
|
612
|
+
return cliFallback.response;
|
|
613
|
+
}
|
|
614
|
+
if (cliFallback.reason === "cli_aborted") {
|
|
615
|
+
return makeFailFastErrorResponse(400, "request_aborted", "Qwen request was aborted");
|
|
616
|
+
}
|
|
617
|
+
if (LOGGING_ENABLED) {
|
|
618
|
+
logWarn("Qwen CLI fallback failed", {
|
|
619
|
+
request_id: context.requestId,
|
|
620
|
+
sessionID: context.sessionID,
|
|
621
|
+
modelID: context.modelID,
|
|
622
|
+
reason: cliFallback.reason,
|
|
623
|
+
stderr: cliFallback.stderr,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
290
626
|
return makeQuotaFailFastResponse(fallbackBody, response.headers, context);
|
|
291
627
|
}
|
|
628
|
+
const cliFallback = await runQwenCliFallback(payload, context, sourceSignal);
|
|
629
|
+
if (cliFallback.ok) {
|
|
630
|
+
return cliFallback.response;
|
|
631
|
+
}
|
|
632
|
+
if (cliFallback.reason === "cli_aborted") {
|
|
633
|
+
return makeFailFastErrorResponse(400, "request_aborted", "Qwen request was aborted");
|
|
634
|
+
}
|
|
635
|
+
if (LOGGING_ENABLED) {
|
|
636
|
+
logWarn("Qwen CLI fallback failed", {
|
|
637
|
+
request_id: context.requestId,
|
|
638
|
+
sessionID: context.sessionID,
|
|
639
|
+
modelID: context.modelID,
|
|
640
|
+
reason: cliFallback.reason,
|
|
641
|
+
stderr: cliFallback.stderr,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
292
644
|
}
|
|
293
645
|
return makeQuotaFailFastResponse(firstBody, response.headers, context);
|
|
294
646
|
}
|
package/package.json
CHANGED