opencode-qwen-cli-auth 2.2.2 → 2.2.4

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 CHANGED
@@ -14,7 +14,297 @@ import { logError, logInfo, logWarn, LOGGING_ENABLED } from "./lib/logger.js";
14
14
  const CHAT_REQUEST_TIMEOUT_MS = 30000;
15
15
  const CHAT_MAX_RETRIES = 0;
16
16
  const MAX_CONSECUTIVE_POLL_FAILURES = 3;
17
+ const QUOTA_DEGRADE_MAX_TOKENS = 1024;
17
18
  const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.2.1";
19
+ const CLIENT_ONLY_BODY_FIELDS = new Set([
20
+ "providerID",
21
+ "provider",
22
+ "sessionID",
23
+ "modelID",
24
+ "requestBodyValues",
25
+ "metadata",
26
+ "options",
27
+ "debug",
28
+ ]);
29
+ function makeFailFastErrorResponse(status, code, message) {
30
+ return new Response(JSON.stringify({
31
+ error: {
32
+ message,
33
+ type: "invalid_request_error",
34
+ param: null,
35
+ code,
36
+ },
37
+ }), {
38
+ status,
39
+ headers: { "content-type": "application/json" },
40
+ });
41
+ }
42
+ function createRequestSignalWithTimeout(sourceSignal, timeoutMs) {
43
+ const controller = new AbortController();
44
+ const timeoutId = setTimeout(() => controller.abort(new Error("request_timeout")), timeoutMs);
45
+ const onSourceAbort = () => controller.abort(sourceSignal?.reason);
46
+ if (sourceSignal) {
47
+ if (sourceSignal.aborted) {
48
+ controller.abort(sourceSignal.reason);
49
+ }
50
+ else {
51
+ sourceSignal.addEventListener("abort", onSourceAbort, { once: true });
52
+ }
53
+ }
54
+ return {
55
+ signal: controller.signal,
56
+ cleanup: () => {
57
+ clearTimeout(timeoutId);
58
+ if (sourceSignal) {
59
+ sourceSignal.removeEventListener("abort", onSourceAbort);
60
+ }
61
+ },
62
+ };
63
+ }
64
+ function getHeaderValue(headers, headerName) {
65
+ if (!headers) {
66
+ return undefined;
67
+ }
68
+ const normalizedHeader = headerName.toLowerCase();
69
+ if (headers instanceof Headers) {
70
+ return headers.get(headerName) ?? headers.get(normalizedHeader) ?? undefined;
71
+ }
72
+ if (Array.isArray(headers)) {
73
+ const pair = headers.find(([name]) => String(name).toLowerCase() === normalizedHeader);
74
+ return pair ? String(pair[1]) : undefined;
75
+ }
76
+ for (const [name, value] of Object.entries(headers)) {
77
+ if (name.toLowerCase() === normalizedHeader) {
78
+ return value === undefined || value === null ? undefined : String(value);
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+ function applyJsonRequestBody(requestInit, payload) {
84
+ requestInit.body = JSON.stringify(payload);
85
+ if (!requestInit.headers) {
86
+ requestInit.headers = { "content-type": "application/json" };
87
+ return;
88
+ }
89
+ if (requestInit.headers instanceof Headers) {
90
+ if (!requestInit.headers.has("content-type")) {
91
+ requestInit.headers.set("content-type", "application/json");
92
+ }
93
+ return;
94
+ }
95
+ if (Array.isArray(requestInit.headers)) {
96
+ const hasContentType = requestInit.headers.some(([name]) => String(name).toLowerCase() === "content-type");
97
+ if (!hasContentType) {
98
+ requestInit.headers.push(["content-type", "application/json"]);
99
+ }
100
+ return;
101
+ }
102
+ let hasContentType = false;
103
+ for (const name of Object.keys(requestInit.headers)) {
104
+ if (name.toLowerCase() === "content-type") {
105
+ hasContentType = true;
106
+ break;
107
+ }
108
+ }
109
+ if (!hasContentType) {
110
+ requestInit.headers["content-type"] = "application/json";
111
+ }
112
+ }
113
+ function parseJsonRequestBody(requestInit) {
114
+ if (typeof requestInit.body !== "string") {
115
+ return null;
116
+ }
117
+ const contentType = getHeaderValue(requestInit.headers, "content-type");
118
+ if (contentType && !contentType.toLowerCase().includes("application/json")) {
119
+ return null;
120
+ }
121
+ try {
122
+ const parsed = JSON.parse(requestInit.body);
123
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
124
+ return null;
125
+ }
126
+ return parsed;
127
+ }
128
+ catch (_error) {
129
+ return null;
130
+ }
131
+ }
132
+ function sanitizeOutgoingPayload(payload) {
133
+ const sanitized = { ...payload };
134
+ let changed = false;
135
+ for (const field of CLIENT_ONLY_BODY_FIELDS) {
136
+ if (field in sanitized) {
137
+ delete sanitized[field];
138
+ changed = true;
139
+ }
140
+ }
141
+ if ("stream_options" in sanitized && sanitized.stream !== true) {
142
+ delete sanitized.stream_options;
143
+ changed = true;
144
+ }
145
+ return changed ? sanitized : payload;
146
+ }
147
+ function createQuotaDegradedPayload(payload) {
148
+ const degraded = { ...payload };
149
+ let changed = false;
150
+ if ("tools" in degraded) {
151
+ delete degraded.tools;
152
+ changed = true;
153
+ }
154
+ if ("tool_choice" in degraded) {
155
+ delete degraded.tool_choice;
156
+ changed = true;
157
+ }
158
+ if ("parallel_tool_calls" in degraded) {
159
+ delete degraded.parallel_tool_calls;
160
+ changed = true;
161
+ }
162
+ if (degraded.stream !== false) {
163
+ degraded.stream = false;
164
+ changed = true;
165
+ }
166
+ if (typeof degraded.max_tokens !== "number" || degraded.max_tokens > QUOTA_DEGRADE_MAX_TOKENS) {
167
+ degraded.max_tokens = QUOTA_DEGRADE_MAX_TOKENS;
168
+ changed = true;
169
+ }
170
+ if (typeof degraded.max_completion_tokens === "number" && degraded.max_completion_tokens > QUOTA_DEGRADE_MAX_TOKENS) {
171
+ degraded.max_completion_tokens = QUOTA_DEGRADE_MAX_TOKENS;
172
+ changed = true;
173
+ }
174
+ return changed ? degraded : null;
175
+ }
176
+ function isInsufficientQuota(text) {
177
+ if (!text) {
178
+ return false;
179
+ }
180
+ try {
181
+ const parsed = JSON.parse(text);
182
+ const errorCode = parsed?.error?.code;
183
+ return typeof errorCode === "string" && errorCode.toLowerCase() === "insufficient_quota";
184
+ }
185
+ catch (_error) {
186
+ return text.toLowerCase().includes("insufficient_quota");
187
+ }
188
+ }
189
+ function makeQuotaFailFastResponse(text, sourceHeaders, context) {
190
+ const headers = new Headers(sourceHeaders);
191
+ headers.set("content-type", "application/json");
192
+ const body = text || JSON.stringify({
193
+ error: {
194
+ message: "Qwen quota/rate limit reached",
195
+ type: "invalid_request_error",
196
+ param: null,
197
+ code: "insufficient_quota",
198
+ },
199
+ });
200
+ if (LOGGING_ENABLED) {
201
+ logWarn("Qwen request failed with 429", {
202
+ request_id: context.requestId,
203
+ sessionID: context.sessionID,
204
+ modelID: context.modelID,
205
+ status: 429,
206
+ body: body.slice(0, 300),
207
+ });
208
+ }
209
+ return new Response(body, {
210
+ status: 400,
211
+ headers,
212
+ });
213
+ }
214
+ async function sendWithTimeout(input, requestInit) {
215
+ const composed = createRequestSignalWithTimeout(requestInit.signal, CHAT_REQUEST_TIMEOUT_MS);
216
+ try {
217
+ return await fetch(input, {
218
+ ...requestInit,
219
+ signal: composed.signal,
220
+ });
221
+ }
222
+ finally {
223
+ composed.cleanup();
224
+ }
225
+ }
226
+ async function failFastFetch(input, init) {
227
+ const requestInit = init ? { ...init } : {};
228
+ const rawPayload = parseJsonRequestBody(requestInit);
229
+ const sessionID = typeof rawPayload?.sessionID === "string" ? rawPayload.sessionID : undefined;
230
+ let payload = rawPayload;
231
+ if (payload) {
232
+ const sanitized = sanitizeOutgoingPayload(payload);
233
+ if (sanitized !== payload) {
234
+ payload = sanitized;
235
+ applyJsonRequestBody(requestInit, payload);
236
+ }
237
+ }
238
+ const context = {
239
+ requestId: getHeaderValue(requestInit.headers, "x-request-id"),
240
+ sessionID,
241
+ modelID: typeof payload?.model === "string" ? payload.model : undefined,
242
+ };
243
+ if (LOGGING_ENABLED) {
244
+ logInfo("Qwen request dispatch", {
245
+ request_id: context.requestId,
246
+ sessionID: context.sessionID,
247
+ modelID: context.modelID,
248
+ });
249
+ }
250
+ try {
251
+ let response = await sendWithTimeout(input, requestInit);
252
+ if (LOGGING_ENABLED) {
253
+ logInfo("Qwen request response", {
254
+ request_id: context.requestId,
255
+ sessionID: context.sessionID,
256
+ modelID: context.modelID,
257
+ status: response.status,
258
+ attempt: 1,
259
+ });
260
+ }
261
+ if (response.status === 429) {
262
+ const firstBody = await response.text().catch(() => "");
263
+ if (payload && isInsufficientQuota(firstBody)) {
264
+ const degradedPayload = createQuotaDegradedPayload(payload);
265
+ if (degradedPayload) {
266
+ const fallbackInit = { ...requestInit };
267
+ applyJsonRequestBody(fallbackInit, degradedPayload);
268
+ if (LOGGING_ENABLED) {
269
+ logWarn("Retrying once with degraded payload after 429 insufficient_quota", {
270
+ request_id: context.requestId,
271
+ sessionID: context.sessionID,
272
+ modelID: context.modelID,
273
+ attempt: 2,
274
+ });
275
+ }
276
+ response = await sendWithTimeout(input, fallbackInit);
277
+ if (LOGGING_ENABLED) {
278
+ logInfo("Qwen request response", {
279
+ request_id: context.requestId,
280
+ sessionID: context.sessionID,
281
+ modelID: context.modelID,
282
+ status: response.status,
283
+ attempt: 2,
284
+ });
285
+ }
286
+ if (response.status !== 429) {
287
+ return response;
288
+ }
289
+ const fallbackBody = await response.text().catch(() => "");
290
+ return makeQuotaFailFastResponse(fallbackBody, response.headers, context);
291
+ }
292
+ }
293
+ return makeQuotaFailFastResponse(firstBody, response.headers, context);
294
+ }
295
+ return response;
296
+ }
297
+ catch (error) {
298
+ const message = error instanceof Error ? error.message : String(error);
299
+ const lowered = message.toLowerCase();
300
+ if (lowered.includes("aborted") || lowered.includes("timeout")) {
301
+ logWarn("Qwen request timeout (fail-fast)", { timeoutMs: CHAT_REQUEST_TIMEOUT_MS, message });
302
+ return makeFailFastErrorResponse(400, "request_timeout", `Qwen request timed out after ${CHAT_REQUEST_TIMEOUT_MS}ms`);
303
+ }
304
+ logError("Qwen upstream fetch failed", { message });
305
+ return makeFailFastErrorResponse(400, "upstream_unavailable", "Qwen upstream request failed");
306
+ }
307
+ }
18
308
  /**
19
309
  * Get valid access token from SDK auth state, refresh if expired.
20
310
  * Uses getAuth() from SDK instead of reading file directly.
@@ -122,6 +412,7 @@ export const QwenAuthPlugin = async (_input) => {
122
412
  baseURL,
123
413
  timeout: CHAT_REQUEST_TIMEOUT_MS,
124
414
  maxRetries: CHAT_MAX_RETRIES,
415
+ fetch: failFastFetch,
125
416
  };
126
417
  },
127
418
  methods: [
@@ -88,7 +88,7 @@ function toStoredTokenData(data) {
88
88
  : typeof raw.expiry_date === "string"
89
89
  ? Number(raw.expiry_date)
90
90
  : undefined;
91
- const resourceUrl = typeof raw.resource_url === "string" ? raw.resource_url : undefined;
91
+ const resourceUrl = typeof raw.resource_url === "string" ? normalizeResourceUrl(raw.resource_url) : undefined;
92
92
  if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
93
93
  return null;
94
94
  }
@@ -470,7 +470,10 @@ export function loadStoredToken() {
470
470
  logWarn("Invalid token data, re-authentication required");
471
471
  return null;
472
472
  }
473
- const needsRewrite = typeof parsed.expiry_date !== "number" || typeof parsed.token_type !== "string" || typeof parsed.expires === "number";
473
+ const needsRewrite = typeof parsed.expiry_date !== "number" ||
474
+ typeof parsed.token_type !== "string" ||
475
+ typeof parsed.expires === "number" ||
476
+ parsed.resource_url !== normalized.resource_url;
474
477
  if (needsRewrite) {
475
478
  try {
476
479
  writeStoredTokenData(normalized);
@@ -510,7 +513,7 @@ export function saveToken(tokenResult) {
510
513
  refresh_token: tokenResult.refresh,
511
514
  token_type: "Bearer",
512
515
  expiry_date: tokenResult.expires,
513
- resource_url: tokenResult.resourceUrl,
516
+ resource_url: normalizeResourceUrl(tokenResult.resourceUrl),
514
517
  };
515
518
  try {
516
519
  writeStoredTokenData(tokenData);
@@ -551,12 +554,17 @@ export async function getValidToken() {
551
554
  export function getApiBaseUrl(resourceUrl) {
552
555
  if (resourceUrl) {
553
556
  try {
554
- const url = new URL(resourceUrl);
557
+ const normalizedResourceUrl = normalizeResourceUrl(resourceUrl);
558
+ if (!normalizedResourceUrl) {
559
+ logWarn("Invalid resource_url, using default Portal API URL");
560
+ return DEFAULT_QWEN_BASE_URL;
561
+ }
562
+ const url = new URL(normalizedResourceUrl);
555
563
  if (!url.protocol.startsWith("http")) {
556
564
  logWarn("Invalid resource_url protocol, using default Portal API URL");
557
565
  return DEFAULT_QWEN_BASE_URL;
558
566
  }
559
- let baseUrl = resourceUrl.replace(/\/$/, "");
567
+ let baseUrl = normalizedResourceUrl.replace(/\/$/, "");
560
568
  const suffix = "/v1";
561
569
  if (!baseUrl.endsWith(suffix)) {
562
570
  baseUrl = `${baseUrl}${suffix}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
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",