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.
Files changed (2) hide show
  1. package/dist/index.js +352 -0
  2. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.2.4",
3
+ "version": "2.2.6",
4
4
  "description": "Qwen OAuth authentication plugin for opencode - use your Qwen account instead of API keys",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",