pi-provider-qoder-cn 0.2.21 → 0.2.22

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 (3) hide show
  1. package/README.md +80 -0
  2. package/dist/index.js +317 -83
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,9 @@ pi install npm:pi-provider-qoder-cn
26
26
  | Enterprise (VPC) selection during `/login qoder-cn` | ❌ | ✅ |
27
27
  | Current CN model catalog (`qwen3.8-max` / `qwen3.8-flash`, …) | ❌ | ✅ |
28
28
  | Stable CN model ids for `enabledModels` | ❌ | ✅ |
29
+ | Capacity-queue detection (`isQueued` / `RESOURCE_LIMIT` / lane) | ❌ | ✅ (recognised, honoured, adjustable) |
30
+ | Per-turn wait budget + lane cooldown knobs | ❌ | ✅ |
31
+ | Cache ownership tag (foreign catalogs discarded, not half-read) | ❌ | ✅ |
29
32
 
30
33
  Both `qoder` (Global) and `qoder-cn` (Domestic) providers are registered.
31
34
  - To use Global: run `/login qoder` and use `/model qoder/<id>`. Commands: `/qoder.usage`, `/qoder.claim`.
@@ -259,6 +262,83 @@ the offline fallback and the source of model ids.
259
262
  the whitespace-stripped display name, so `enabledModels` entries such as
260
263
  `qoder-cn/qwen3.8-flash` keep working across upgrades.
261
264
 
265
+ **Thinking switch.** A model whose catalog advertises no `disabled` thinking
266
+ option (`thinkingLevelMap.off === null`, e.g. GLM-5.3 / GLM-5.3-Flash on CN)
267
+ is always-thinking: sending `enable_thinking=false` is answered HTTP 400 code
268
+ 1210 `该模型始终思考`, so no switch is sent at all. Note the TUI's thinking-off
269
+ toggle does not reach that path — pi-ai's `clampThinkingLevel()` promotes `off`
270
+ to the model's lowest advertised effort; the switch is omitted for requests that
271
+ carry no thinking level.
272
+
273
+ ## Capacity queue and wait policy
274
+
275
+ When Qoder's gateway has no capacity it does not reject the request. It answers
276
+ HTTP 200 and puts the verdict in the first SSE frame, three JSON layers deep:
277
+
278
+ ```json
279
+ { "isQueued": true, "modelKey": "auto", "queueCount": 0, "queueType": "p3",
280
+ "retryAfterSeconds": 30, "serviceAvailable": false, "subCode": "RESOURCE_LIMIT", "waitTime": 30 }
281
+ ```
282
+
283
+ Two verdicts look alike and mean opposite things, so they get different handling
284
+ and different wording:
285
+
286
+ | Gateway says | Meaning | Default handling |
287
+ |---|---|---|
288
+ | `serviceAvailable: true` | in line; the line drains | wait per `Retry-After`, up to 3 retries |
289
+ | `serviceAvailable: false` | this lane has nothing to serve | honour `Retry-After` **once**, then stop and name the lane |
290
+
291
+ Measured against `api3.qoder.sh` on an account whose plan quota is 0
292
+ (`personal_standard`, add-on credits only), 2026-10-17/18:
293
+
294
+ - The refusal is **account-lane, not model**: `auto`, `qmodel_38max`, `efficient`
295
+ and `ultimate` all answered `queueType:"p3"` + `RESOURCE_LIMIT`. Switching
296
+ model inside one provider does not dodge it; switching provider does
297
+ (`qoder-cn` answered in 2–5 s at the same moment).
298
+ - `queueCount` is `0` — there is no line to report a position in.
299
+ - **Queued attempts are not billed**: 19 chat requests refused this way left
300
+ `addOnQuota.used` unchanged. Waiting costs time, never credits.
301
+ - The release rate is correlated with the time window, not per-attempt random:
302
+ 6 of 21 attempts served in one window, 0 of 19 in another. That is why extra
303
+ patience is opt-in instead of a default.
304
+
305
+ ### Knobs
306
+
307
+ All defaults reproduce the pre-0.2.22 behaviour; each knob is read per request.
308
+
309
+ | Env var | Default | Effect |
310
+ |---|---|---|
311
+ | `QODER_QUEUE_MAX_ATTEMPTS` | `3` | Retries for an ordinary queue (`serviceAvailable: true`). |
312
+ | `QODER_QUEUE_UNAVAILABLE_ATTEMPTS` | `1` | Retries after `serviceAvailable: false`. Raise it only when you would rather wait than switch. |
313
+ | `QODER_QUEUE_MAX_TOTAL_WAIT_MS` | `90000` | Wall-clock ceiling for queue waits **in one turn** (it is not restarted by the 105 credential self-heal). `0` means never wait. |
314
+ | `QODER_QUEUE_SINGLE_WAIT_MS` | `120000` | Ceiling for one sleep, clamped further by the total budget. |
315
+ | `QODER_QUEUE_JITTER_MS` | `2000` | Random spread added to each wait so parallel sessions do not retry in lockstep. |
316
+ | `QODER_QUEUE_LANE_COOLDOWN_MS` | `0` (off) | After a hard refusal, skip later requests on that lane for N ms instead of spending another ~31 s. The skip says so explicitly — the request was never sent. |
317
+ | `QODER_DEBUG` | off | Logs one line per queue notice (`lane`, `subCode`, attempt, waited ms) and when a foreign model cache is discarded. |
318
+
319
+ Verified live on the built bundle: default = refusal in ~31 s after 2 requests;
320
+ `QODER_QUEUE_LANE_COOLDOWN_MS=20000` = first request 2 requests/32 s, then
321
+ 0 network calls and 0 ms; `QODER_QUEUE_MAX_TOTAL_WAIT_MS=0` = refusal after a
322
+ single request in 0.6 s.
323
+
324
+ Garbage values fall back to the default, out-of-range values are clamped, and a
325
+ negative value falls back rather than silently meaning "zero".
326
+
327
+ The error text is deliberately worded so pi-ai's `isRetryableAssistantError()`
328
+ does not classify it as transient: every number passes through
329
+ `formatQueueFigure`, which keeps at most two contiguous digits (a real
330
+ `queueCount` of 5204 renders as `5.2k`, because bare `520`/`503` would re-run the
331
+ whole turn against a gateway that already refused).
332
+
333
+ ### Model cache ownership
334
+
335
+ The catalog is cached at `~/.pi/agent/qoder-models-cache.json` (and
336
+ `qoder-cn-models-cache.json`), a path other Qoder providers also use. Each write
337
+ is now tagged `"writer": "pi-provider-qoder-cn"`. A cache tagged by a different
338
+ implementation is discarded and re-fetched instead of being half-believed — its
339
+ model ids are not the ones this package registers. A cache with **no** tag stays
340
+ readable, because every release up to 0.2.21 wrote that shape.
341
+
262
342
  ## Development
263
343
 
264
344
  ```bash
package/dist/index.js CHANGED
@@ -464,6 +464,13 @@ function readParsedModelCache(mode) {
464
464
  }
465
465
  try {
466
466
  const data = JSON.parse(readFileSync3(cachePath, "utf8"));
467
+ if (data.writer !== void 0 && data.writer !== QODER_CACHE_WRITER) {
468
+ if (process.env.QODER_DEBUG) {
469
+ console.error(`[qoder] model cache ${cachePath} was written by "${data.writer}"; ignoring and refetching`);
470
+ }
471
+ modelCacheMem.set(cachePath, null);
472
+ return null;
473
+ }
467
474
  modelCacheMem.set(cachePath, data);
468
475
  return data;
469
476
  } catch {
@@ -474,8 +481,8 @@ function readParsedModelCache(mode) {
474
481
  function writeParsedModelCache(mode, data) {
475
482
  const cachePath = getQoderCachePath(mode);
476
483
  mkdirSync3(dirname2(cachePath), { recursive: true });
477
- writeFileSync3(cachePath, JSON.stringify(data, null, 2), "utf-8");
478
- modelCacheMem.set(cachePath, data);
484
+ writeFileSync3(cachePath, JSON.stringify({ ...data, writer: QODER_CACHE_WRITER }, null, 2), "utf-8");
485
+ modelCacheMem.set(cachePath, { ...data, writer: QODER_CACHE_WRITER });
479
486
  }
480
487
  function toQoderModelId(displayName) {
481
488
  return (displayName || "QoderModel").replace(/\s+/g, "");
@@ -667,7 +674,7 @@ async function updateQoderModelsCache(authToken, userID, name, email, mode) {
667
674
  } catch {
668
675
  }
669
676
  }
670
- var ZERO_COST, MAX_OUTPUT_TOKENS, DEFAULT_CONTEXT_WINDOW, modelCacheMem, staticModels, staticCnModels, PI_THINKING_LEVELS;
677
+ var ZERO_COST, MAX_OUTPUT_TOKENS, DEFAULT_CONTEXT_WINDOW, QODER_CACHE_WRITER, modelCacheMem, staticModels, staticCnModels, PI_THINKING_LEVELS;
671
678
  var init_catalog = __esm({
672
679
  "src/catalog.ts"() {
673
680
  "use strict";
@@ -677,6 +684,7 @@ var init_catalog = __esm({
677
684
  ZERO_COST = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
678
685
  MAX_OUTPUT_TOKENS = 131072;
679
686
  DEFAULT_CONTEXT_WINDOW = 1e6;
687
+ QODER_CACHE_WRITER = "pi-provider-qoder-cn";
680
688
  modelCacheMem = /* @__PURE__ */ new Map();
681
689
  staticModels = [
682
690
  {
@@ -2243,7 +2251,149 @@ async function runClaimCommand(mode, args, ctx) {
2243
2251
  }
2244
2252
 
2245
2253
  // src/protocol/errors.ts
2254
+ init_region();
2246
2255
  init_vpc();
2256
+
2257
+ // src/protocol/queue.ts
2258
+ var MAX_WAIT_MS = 12e4;
2259
+ var DEFAULT_RETRY_SECONDS = 30;
2260
+ function digForQueue(node, depth) {
2261
+ if (depth > 6) return null;
2262
+ if (typeof node === "string") {
2263
+ const trimmed = node.trim();
2264
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
2265
+ try {
2266
+ return digForQueue(JSON.parse(trimmed), depth + 1);
2267
+ } catch {
2268
+ return null;
2269
+ }
2270
+ }
2271
+ if (node === null || typeof node !== "object") return null;
2272
+ if (Array.isArray(node)) {
2273
+ for (const item of node) {
2274
+ const found = digForQueue(item, depth + 1);
2275
+ if (found) return found;
2276
+ }
2277
+ return null;
2278
+ }
2279
+ const record = node;
2280
+ if (record.isQueued === true) return record;
2281
+ for (const value of Object.values(record)) {
2282
+ const found = digForQueue(value, depth + 1);
2283
+ if (found) return found;
2284
+ }
2285
+ return null;
2286
+ }
2287
+ function positiveNumber(value, fallback) {
2288
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
2289
+ }
2290
+ function textOf(value) {
2291
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
2292
+ }
2293
+ function parseQueueNotice(envelope) {
2294
+ const payload = digForQueue(envelope, 0);
2295
+ if (!payload) return null;
2296
+ const retryMs = positiveNumber(payload.retry_after_ms, 0) || positiveNumber(payload.retryAfterSeconds, DEFAULT_RETRY_SECONDS) * 1e3;
2297
+ return {
2298
+ retryAfterMs: Math.min(retryMs, MAX_WAIT_MS),
2299
+ waitTimeSeconds: positiveNumber(payload.waitTime, 0),
2300
+ queueCount: positiveNumber(payload.queueCount, 0),
2301
+ // Only a real boolean is surfaced; a missing field must not be read as
2302
+ // "unavailable" (that would collapse the normal queue into a hard stop).
2303
+ serviceAvailable: typeof payload.serviceAvailable === "boolean" ? payload.serviceAvailable : void 0,
2304
+ // `subCode` sits inside the same record as `isQueued` in the payloads seen
2305
+ // live, so one source is enough; the wrapper levels carry no extra names.
2306
+ subCode: textOf(payload.subCode),
2307
+ queueType: textOf(payload.queueType),
2308
+ modelKey: textOf(payload.modelKey)
2309
+ };
2310
+ }
2311
+ function formatWaitSecondsShort(ms) {
2312
+ if (!Number.isFinite(ms) || ms <= 0) return "0";
2313
+ const seconds = Math.round(ms / 1e3);
2314
+ if (seconds < 100) return String(seconds);
2315
+ return `${formatWaitMinutes(seconds)}\u5206\u949F`;
2316
+ }
2317
+ function formatQueueFailureMessage(notice, ctx) {
2318
+ const isCn = ctx.mode === "cn";
2319
+ const lane = notice.queueType ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2320
+ const sub = notice.subCode ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2321
+ const modelKey = notice.modelKey ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2322
+ const hard = notice.serviceAvailable === false;
2323
+ const waited = formatWaitSecondsShort(ctx.waitedMs ?? 0);
2324
+ const queueLabel = formatQueueFigure(notice.queueCount ?? 0);
2325
+ const tried = ctx.attempts === void 0 ? void 0 : isCn ? `\u5DF2\u5C1D\u8BD5 ${formatQueueFigure(ctx.attempts)} \u6B21\uFF08\u7D2F\u8BA1\u7B49\u5F85 ${waited} \u79D2\uFF09\uFF0C\u7ED3\u8BBA\u76F8\u540C\u3002` : `${formatQueueFigure(ctx.attempts)} attempts (about ${waited} seconds of waiting) ended in the same verdict. `;
2326
+ if (hard) {
2327
+ return isCn ? [
2328
+ `[Qoder CN \u65E0\u53EF\u7528\u5BB9\u91CF] \u7F51\u5173\u56DE serviceAvailable:false\uFF08\u5B50\u7801 ${sub}\uFF0C\u961F\u5217\u957F\u5EA6 ${queueLabel}\uFF0C\u4F18\u5148\u7EA7\u901A\u9053 ${lane}\uFF0C\u6A21\u578B ${modelKey}\uFF09`,
2329
+ `- \u8BF4\u660E\uFF1A${tried ?? ""}\u8FD9\u662F\u8D26\u53F7\u4F18\u5148\u7EA7\u901A\u9053\u7684\u5BB9\u91CF\u9650\u5236\uFF0C\u4E0D\u662F\u51ED\u8BC1\u95EE\u9898\u3002\u6392\u961F\u5C1D\u8BD5\u4E0D\u4EA7\u751F\u8F93\u51FA\uFF0C\u5B9E\u6D4B\u4E5F\u4E0D\u6263 credits\uFF08\u4EE3\u4EF7\u53EA\u6709\u65F6\u5EF6\uFF09\u3002`,
2330
+ `- \u6362\u6A21\u578B\u65E0\u6548\uFF1A\u540C\u4E00\u8D26\u53F7\u5728 ${lane} \u901A\u9053\u4E0B\u5404\u6A21\u578B\u8FD4\u56DE\u540C\u4E00\u7ED3\u8BBA\uFF08\u5B9E\u6D4B auto / qmodel_38max / efficient / ultimate \u4E00\u81F4\uFF09\uFF0C\u8BF7\u52FF\u5728\u540C\u4E00 Provider \u5185\u53CD\u590D\u6311\u6A21\u578B\u3002`,
2331
+ `- \u89E3\u51B3\u5EFA\u8BAE\uFF1A\u7528 /model \u5207\u6362\u5230 ${ctx.otherProviderID} \u6216\u5176\u4ED6 Provider\uFF0C\u6216\u7A0D\u540E\u91CD\u8BD5\u3002\u989D\u5EA6\u4E0E\u8BA1\u5212\uFF1A${ctx.manageUrl}`
2332
+ ].join("\n") : [
2333
+ `[Qoder international has no spare capacity] The gateway answered serviceAvailable:false (sub code ${sub}, queue length ${queueLabel}, priority lane ${lane}, model ${modelKey})`,
2334
+ `- Meaning: ${tried ?? ""}This is the account's priority-lane capacity, not a credential problem. Queued attempts produce no output and are not billed (measured: 19 queued requests, credits unchanged) \u2014 the only cost is latency.`,
2335
+ `- Switching model will not help: every model of this account answers on the same ${lane} lane (measured across auto / qmodel_38max / efficient / ultimate).`,
2336
+ `- Next step: use /model to switch to ${ctx.otherProviderID} or another provider, or retry later. Plan and quota: ${ctx.manageUrl}`
2337
+ ].join("\n");
2338
+ }
2339
+ return isCn ? [
2340
+ `[Qoder CN \u4E0A\u6E38\u6392\u961F] \u6A21\u578B\u7F51\u5173\u5F53\u524D\u5BB9\u91CF\u5DF2\u6EE1\uFF0C\u8BF7\u6C42\u8FDB\u5165\u7B49\u5F85\u961F\u5217\uFF08\u961F\u5217\u957F\u5EA6 ${queueLabel}\uFF0C\u4F18\u5148\u7EA7\u901A\u9053 ${lane}\uFF0C\u6A21\u578B ${modelKey}\uFF09`,
2341
+ `- \u8BF4\u660E\uFF1A${tried ?? ""}\u961F\u5217\u5C1A\u672A\u653E\u884C\u3002\u51ED\u8BC1\u4E0E\u989D\u5EA6\u6B63\u5E38\uFF0C\u7EAF\u7CB9\u662F\u4E0A\u6E38\u6392\u961F\uFF1B\u6392\u961F\u5C1D\u8BD5\u4E0D\u6D88\u8017 credits\u3002`,
2342
+ `- \u89E3\u51B3\u5EFA\u8BAE\uFF1A\u7A0D\u540E\u91CD\u8BD5\uFF0C\u6216\u7528 /model \u5207\u6362\u5230 ${ctx.otherProviderID} \u7B49\u5176\u4ED6 Provider\u3002\u989D\u5EA6\u4E0E\u8BA1\u5212\uFF1A${ctx.manageUrl}`
2343
+ ].join("\n") : [
2344
+ // "is at capacity" is the established wording for the drainable case and
2345
+ // the only queue text allowed to carry it; the hard refusal above must
2346
+ // never read as "wait it out".
2347
+ `[Qoder upstream queue] The model gateway is at capacity and parked this request (queue length ${queueLabel}, priority lane ${lane}, model ${modelKey})`,
2348
+ `- Meaning: ${tried ?? ""}the queue has not cleared yet. Credentials and quota are fine; queued attempts are not billed.`,
2349
+ `- Next step: retry shortly, or switch provider with /model to ${ctx.otherProviderID} while the queue drains. Plan and quota: ${ctx.manageUrl}`
2350
+ ].join("\n");
2351
+ }
2352
+ function formatLaneCooldownMessage(notice, remainingMs, ctx) {
2353
+ const isCn = ctx.mode === "cn";
2354
+ const lane = notice.queueType ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2355
+ const sub = notice.subCode ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2356
+ const left = Math.min(99, Math.max(1, Math.ceil(remainingMs / 1e3)));
2357
+ return isCn ? [
2358
+ `[Qoder CN \u5DF2\u8DF3\u8FC7\u672C\u6B21\u8BF7\u6C42] \u4F18\u5148\u7EA7\u901A\u9053 ${lane} \u521A\u521A\u56DE\u8FC7 serviceAvailable:false\uFF08\u5B50\u7801 ${sub}\uFF09\uFF0C${left} \u79D2\u5185\u4E0D\u518D\u91CD\u590D\u7B49\u5F85\u3002`,
2359
+ `- \u8BF4\u660E\uFF1A\u8FD9\u6B21\u8BF7\u6C42\u5C1A\u672A\u53D1\u51FA\u3002\u4E0A\u4E00\u8F6E\u540C\u901A\u9053\u8BF7\u6C42\u6309 Retry-After \u7B49\u5F85\u540E\u4ECD\u672A\u653E\u884C\uFF0C\u6392\u961F\u53EA\u4F1A\u518D\u6D88\u8017\u540C\u6837\u7684\u65F6\u5EF6\u3002`,
2360
+ `- \u89E3\u51B3\u5EFA\u8BAE\uFF1A\u7528 /model \u5207\u6362\u5230 ${ctx.otherProviderID} \u6216\u5176\u4ED6 Provider\uFF0C\u6216 ${left} \u79D2\u540E\u91CD\u8BD5\u3002`
2361
+ ].join("\n") : [
2362
+ `[Qoder request skipped] Priority lane ${lane} just refused service (sub code ${sub}, serviceAvailable:false), so this request was not sent; repeats are skipped for ${left} more seconds.`,
2363
+ `- Meaning: the previous request on this lane already waited through Retry-After and was still refused, so waiting again buys the same verdict.`,
2364
+ `- Next step: use /model to switch to ${ctx.otherProviderID} or another provider, or retry in ${left} seconds.`
2365
+ ].join("\n");
2366
+ }
2367
+ function formatQueueFigure(n) {
2368
+ if (!Number.isFinite(n) || n <= 0) return "0";
2369
+ if (n < 100) return String(Math.round(n));
2370
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
2371
+ return `${(n / 1e3).toFixed(1)}k`;
2372
+ }
2373
+ function formatWaitMinutes(seconds) {
2374
+ if (!Number.isFinite(seconds) || seconds <= 0) return "<1";
2375
+ return String(Math.min(99, Math.max(1, Math.round(seconds / 60))));
2376
+ }
2377
+ function sleep(ms, signal) {
2378
+ return new Promise((resolve, reject) => {
2379
+ if (signal?.aborted) {
2380
+ reject(new Error("Aborted while waiting for the Qoder queue."));
2381
+ return;
2382
+ }
2383
+ let timer;
2384
+ const onAbort = () => {
2385
+ clearTimeout(timer);
2386
+ reject(new Error("Aborted while waiting for the Qoder queue."));
2387
+ };
2388
+ timer = setTimeout(() => {
2389
+ signal?.removeEventListener("abort", onAbort);
2390
+ resolve();
2391
+ }, ms);
2392
+ signal?.addEventListener("abort", onAbort, { once: true });
2393
+ });
2394
+ }
2395
+
2396
+ // src/protocol/errors.ts
2247
2397
  function parseQoderErrorPayload(payload) {
2248
2398
  if (!payload) return null;
2249
2399
  let parsed = payload;
@@ -2302,6 +2452,7 @@ function formatQoderStreamError(statusCode, rawBody, now = Date.now(), mode = "c
2302
2452
  const parsed = parseQoderErrorPayload(rawBody);
2303
2453
  const code = parsed?.code;
2304
2454
  const message = parsed?.message || (typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody));
2455
+ const bodyForMatch = `${message} ${typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody ?? "")}`;
2305
2456
  const countdown = getBeijingDailyResetCountdown(now);
2306
2457
  const isCn = mode !== "global";
2307
2458
  if (code === "110" || /billing daily count exceeded/i.test(message) || /daily usage limit reached/i.test(message)) {
@@ -2399,7 +2550,17 @@ function formatQoderStreamError(statusCode, rawBody, now = Date.now(), mode = "c
2399
2550
  `- Next step: every retry must be re-signed; if it keeps repeating, switch provider with /model and try again shortly.`
2400
2551
  ].join("\n");
2401
2552
  }
2402
- if (code === "10605" || /"isQueued"\s*:\s*true|isQueued/.test(message)) {
2553
+ if (code === "10605" || /"isQueued"\s*:\s*true/.test(message)) {
2554
+ const notice = parseQueueNotice(rawBody) ?? parseQueueNotice(message);
2555
+ if (notice) {
2556
+ return formatQueueFailureMessage(notice, {
2557
+ mode,
2558
+ // This call does not own the retry loop, so it claims no attempt count.
2559
+ waitedMs: 0,
2560
+ manageUrl: getQoderRegionConfig(mode).manageUrl,
2561
+ otherProviderID: mode === "cn" ? "qoder" : "qoder-cn"
2562
+ });
2563
+ }
2403
2564
  return isCn ? [
2404
2565
  `[Qoder CN \u4E0A\u6E38\u6392\u961F] \u6A21\u578B\u7F51\u5173\u5F53\u524D\u5BB9\u91CF\u5DF2\u6EE1\uFF0C\u8BF7\u6C42\u8FDB\u5165\u7B49\u5F85\u961F\u5217 (\u9519\u8BEF\u7801 10605)`,
2405
2566
  `- \u8BF4\u660E\uFF1A\u51ED\u8BC1\u4E0E\u989D\u5EA6\u5747\u6B63\u5E38\uFF0C\u7EAF\u7CB9\u662F\u4E0A\u6E38\u6392\u961F\uFF1B\u5DF2\u6309\u7F51\u5173\u7ED9\u51FA\u7684 Retry-After \u91CD\u8BD5\u4ECD\u672A\u653E\u884C\u3002`,
@@ -2410,6 +2571,17 @@ function formatQoderStreamError(statusCode, rawBody, now = Date.now(), mode = "c
2410
2571
  `- Next step: retry shortly, or switch provider with /model while the queue drains.`
2411
2572
  ].join("\n");
2412
2573
  }
2574
+ if (code === "1210" || /不支持关闭思考|cannot disable thinking|always.{0,12}think/i.test(bodyForMatch)) {
2575
+ return isCn ? [
2576
+ `[Qoder CN \u4E0D\u652F\u6301\u5173\u95ED\u601D\u8003] \u8BE5\u6A21\u578B\u59CB\u7EC8\u601D\u8003 (\u9519\u8BEF\u7801 1210)`,
2577
+ `- \u8BF4\u660E\uFF1A\u8FD9\u4E2A\u6A21\u578B\u6CA1\u6709\u201C\u5173\u95ED\u601D\u8003\u201D\u9009\u9879\uFF0C\u8BF7\u6C42\u91CC\u4E0D\u80FD\u5E26 enable_thinking=false\u3002`,
2578
+ `- \u89E3\u51B3\u5EFA\u8BAE\uFF1A\u7528 thinking \u5F00\u5173\u9009 low / medium / high / max \u4EFB\u4E00\u6863\u4F4D\uFF1B\u5BA2\u6237\u7AEF\u5E94\u4E3A\u8BE5\u6A21\u578B\u7701\u7565\u601D\u8003\u5F00\u5173\u3002`
2579
+ ].join("\n") : [
2580
+ `[Qoder model always thinks] This model cannot disable thinking (code 1210)`,
2581
+ `- Meaning: the model has no "off" switch, so a request carrying enable_thinking=false is refused. This will not change on retry.`,
2582
+ `- Next step: pick a thinking level (low / medium / high / max) instead of turning thinking off.`
2583
+ ].join("\n");
2584
+ }
2413
2585
  if (rawBody === void 0 || rawBody === null) {
2414
2586
  return `Upstream status ${statusCode}`;
2415
2587
  }
@@ -2789,80 +2961,112 @@ function qoderEncodeBody(plaintext) {
2789
2961
  return out;
2790
2962
  }
2791
2963
 
2792
- // src/protocol/queue.ts
2793
- var MAX_QUEUE_RETRIES = 3;
2794
- var MAX_WAIT_MS = 12e4;
2795
- var DEFAULT_RETRY_SECONDS = 30;
2796
- function digForQueue(node, depth) {
2797
- if (depth > 6) return null;
2798
- if (typeof node === "string") {
2799
- const trimmed = node.trim();
2800
- if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
2801
- try {
2802
- return digForQueue(JSON.parse(trimmed), depth + 1);
2803
- } catch {
2804
- return null;
2805
- }
2806
- }
2807
- if (node === null || typeof node !== "object") return null;
2808
- if (Array.isArray(node)) {
2809
- for (const item of node) {
2810
- const found = digForQueue(item, depth + 1);
2811
- if (found) return found;
2812
- }
2813
- return null;
2814
- }
2815
- const record = node;
2816
- if (record.isQueued === true) return record;
2817
- for (const value of Object.values(record)) {
2818
- const found = digForQueue(value, depth + 1);
2819
- if (found) return found;
2820
- }
2821
- return null;
2822
- }
2823
- function positiveNumber(value, fallback) {
2824
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
2825
- }
2826
- function parseQueueNotice(envelope) {
2827
- const payload = digForQueue(envelope, 0);
2828
- if (!payload) return null;
2829
- const retryMs = positiveNumber(payload.retry_after_ms, 0) || positiveNumber(payload.retryAfterSeconds, DEFAULT_RETRY_SECONDS) * 1e3;
2964
+ // src/protocol/queue-policy.ts
2965
+ var DEFAULT_QUEUE_POLICY = Object.freeze({
2966
+ unavailableAttempts: 1,
2967
+ maxAttempts: 3,
2968
+ totalWaitBudgetMs: 9e4,
2969
+ singleWaitCapMs: 12e4,
2970
+ jitterMs: 2e3,
2971
+ /**
2972
+ * OFF by default. A skipped request is a request the user expected to be sent,
2973
+ * and "the lane refused a moment ago" is an inference, not a gateway answer:
2974
+ * it holds on the international service (every model of the account answered
2975
+ * `p3`/`RESOURCE_LIMIT`), but it would also suppress a request that was about
2976
+ * to be served. Opt in with `QODER_QUEUE_LANE_COOLDOWN_MS=20000` when a whole
2977
+ * agent turn is otherwise spending 31s per call on a dead lane.
2978
+ */
2979
+ laneCooldownMs: 0
2980
+ });
2981
+ var LIMITS = {
2982
+ attempts: { min: 0, max: 10 },
2983
+ budgetMs: { min: 0, max: 15 * 6e4 },
2984
+ singleMs: { min: 1e3, max: 5 * 6e4 },
2985
+ jitterMs: { min: 0, max: 3e4 },
2986
+ cooldownMs: { min: 0, max: 5 * 6e4 }
2987
+ };
2988
+ function clampInt(raw, min, max, fallback) {
2989
+ if (raw === void 0) return fallback;
2990
+ const trimmed = raw.trim();
2991
+ if (!trimmed) return fallback;
2992
+ const value = Number(trimmed);
2993
+ if (!Number.isFinite(value) || !Number.isInteger(value)) return fallback;
2994
+ if (value < 0) return fallback;
2995
+ return Math.min(max, Math.max(min, value));
2996
+ }
2997
+ function readQueuePolicy(env = process.env) {
2830
2998
  return {
2831
- retryAfterMs: Math.min(retryMs, MAX_WAIT_MS),
2832
- waitTimeSeconds: positiveNumber(payload.waitTime, 0),
2833
- queueCount: positiveNumber(payload.queueCount, 0),
2834
- // Only a real boolean is surfaced; a missing field must not be read as
2835
- // "unavailable" (that would collapse the normal queue into a hard stop).
2836
- serviceAvailable: typeof payload.serviceAvailable === "boolean" ? payload.serviceAvailable : void 0
2999
+ unavailableAttempts: clampInt(
3000
+ env.QODER_QUEUE_UNAVAILABLE_ATTEMPTS,
3001
+ LIMITS.attempts.min,
3002
+ LIMITS.attempts.max,
3003
+ DEFAULT_QUEUE_POLICY.unavailableAttempts
3004
+ ),
3005
+ maxAttempts: clampInt(
3006
+ env.QODER_QUEUE_MAX_ATTEMPTS,
3007
+ LIMITS.attempts.min,
3008
+ LIMITS.attempts.max,
3009
+ DEFAULT_QUEUE_POLICY.maxAttempts
3010
+ ),
3011
+ totalWaitBudgetMs: clampInt(
3012
+ env.QODER_QUEUE_MAX_TOTAL_WAIT_MS,
3013
+ LIMITS.budgetMs.min,
3014
+ LIMITS.budgetMs.max,
3015
+ DEFAULT_QUEUE_POLICY.totalWaitBudgetMs
3016
+ ),
3017
+ singleWaitCapMs: clampInt(
3018
+ env.QODER_QUEUE_SINGLE_WAIT_MS,
3019
+ LIMITS.singleMs.min,
3020
+ LIMITS.singleMs.max,
3021
+ DEFAULT_QUEUE_POLICY.singleWaitCapMs
3022
+ ),
3023
+ jitterMs: clampInt(
3024
+ env.QODER_QUEUE_JITTER_MS,
3025
+ LIMITS.jitterMs.min,
3026
+ LIMITS.jitterMs.max,
3027
+ DEFAULT_QUEUE_POLICY.jitterMs
3028
+ ),
3029
+ laneCooldownMs: clampInt(
3030
+ env.QODER_QUEUE_LANE_COOLDOWN_MS,
3031
+ LIMITS.cooldownMs.min,
3032
+ LIMITS.cooldownMs.max,
3033
+ DEFAULT_QUEUE_POLICY.laneCooldownMs
3034
+ )
2837
3035
  };
2838
3036
  }
2839
- function formatQueueFigure(n) {
2840
- if (!Number.isFinite(n) || n <= 0) return "0";
2841
- if (n < 100) return String(Math.round(n));
2842
- if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
2843
- return `${(n / 1e3).toFixed(1)}k`;
3037
+ function allowedAttemptsFor(policy, notice) {
3038
+ return notice.serviceAvailable === false ? policy.unavailableAttempts : policy.maxAttempts;
2844
3039
  }
2845
- function formatWaitMinutes(seconds) {
2846
- if (!Number.isFinite(seconds) || seconds <= 0) return "<1";
2847
- return String(Math.min(99, Math.max(1, Math.round(seconds / 60))));
3040
+ function nextSleepMs(policy, notice, waitedMs, random = Math.random) {
3041
+ if (!Number.isFinite(notice.retryAfterMs) || notice.retryAfterMs <= 0) return 0;
3042
+ const remaining = Math.max(0, policy.totalWaitBudgetMs - waitedMs);
3043
+ if (remaining === 0) return 0;
3044
+ const base = Math.min(notice.retryAfterMs, policy.singleWaitCapMs, remaining);
3045
+ const jitter = policy.jitterMs > 0 ? random() * policy.jitterMs : 0;
3046
+ return Math.max(0, Math.min(policy.singleWaitCapMs, remaining, base + jitter));
2848
3047
  }
2849
- function sleep(ms, signal) {
2850
- return new Promise((resolve, reject) => {
2851
- if (signal?.aborted) {
2852
- reject(new Error("Aborted while waiting for the Qoder queue."));
2853
- return;
2854
- }
2855
- let timer;
2856
- const onAbort = () => {
2857
- clearTimeout(timer);
2858
- reject(new Error("Aborted while waiting for the Qoder queue."));
2859
- };
2860
- timer = setTimeout(() => {
2861
- signal?.removeEventListener("abort", onAbort);
2862
- resolve();
2863
- }, ms);
2864
- signal?.addEventListener("abort", onAbort, { once: true });
2865
- });
3048
+ function isHardUnavailable(notice) {
3049
+ return notice.serviceAvailable === false;
3050
+ }
3051
+ function laneKey(mode, notice) {
3052
+ return `${mode}:${notice.queueType ?? "unknown"}:${notice.subCode ?? "queued"}`;
3053
+ }
3054
+ var armedLanes = /* @__PURE__ */ new Map();
3055
+ function armLaneCooldown(key, ttlMs, notice, now = Date.now()) {
3056
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
3057
+ armedLanes.delete(key);
3058
+ return;
3059
+ }
3060
+ armedLanes.set(key, { until: now + ttlMs, notice });
3061
+ }
3062
+ function pendingLaneCooldown(mode, now = Date.now()) {
3063
+ for (const [key, entry] of armedLanes) {
3064
+ if (!key.startsWith(`${mode}:`)) continue;
3065
+ const remaining = entry.until - now;
3066
+ if (remaining > 0) return { key, remainingMs: remaining, notice: entry.notice };
3067
+ armedLanes.delete(key);
3068
+ }
3069
+ return null;
2866
3070
  }
2867
3071
 
2868
3072
  // src/protocol/thinking.ts
@@ -3372,6 +3576,8 @@ function streamQoder(model, context, options) {
3372
3576
  const providerMode = model.provider === "qoder-cn" ? "cn" : "global";
3373
3577
  let reader;
3374
3578
  let started = false;
3579
+ const queuePolicy = readQueuePolicy();
3580
+ let turnWaitedMs = 0;
3375
3581
  const region = getQoderRegionConfig(providerMode);
3376
3582
  const runAttempt = async (apiKey) => {
3377
3583
  const accessToken = apiKey ?? options?.apiKey;
@@ -3419,6 +3625,7 @@ function streamQoder(model, context, options) {
3419
3625
  const requestedLevel = options?.reasoning;
3420
3626
  const clamped = requestedLevel ? clampThinkingLevel(model, requestedLevel) : void 0;
3421
3627
  const reasoningLevel = clamped === "off" ? void 0 : clamped;
3628
+ const cannotDisableThinking = model.thinkingLevelMap?.off === null;
3422
3629
  const parameters = { max_tokens: maxTokens };
3423
3630
  if (reasoningLevel) {
3424
3631
  parameters.enable_thinking = true;
@@ -3427,6 +3634,7 @@ function streamQoder(model, context, options) {
3427
3634
  if (modelConfig?.thinking_config?.enabled?.efforts && typeof effort === "string") {
3428
3635
  parameters.reasoning_effort = effort;
3429
3636
  }
3637
+ } else if (cannotDisableThinking) {
3430
3638
  } else {
3431
3639
  parameters.enable_thinking = false;
3432
3640
  }
@@ -3507,6 +3715,16 @@ function streamQoder(model, context, options) {
3507
3715
  };
3508
3716
  };
3509
3717
  let queuedAttempt = 0;
3718
+ const cooled = pendingLaneCooldown(providerMode);
3719
+ if (cooled) {
3720
+ throw new Error(
3721
+ formatLaneCooldownMessage(cooled.notice, cooled.remainingMs, {
3722
+ mode: providerMode,
3723
+ manageUrl: region.manageUrl,
3724
+ otherProviderID: providerMode === "cn" ? "qoder" : "qoder-cn"
3725
+ })
3726
+ );
3727
+ }
3510
3728
  await reader?.cancel().catch(() => {
3511
3729
  });
3512
3730
  reader = void 0;
@@ -3535,19 +3753,35 @@ function streamQoder(model, context, options) {
3535
3753
  if (queue) {
3536
3754
  await reader.cancel().catch(() => {
3537
3755
  });
3538
- const stopEarly = queue.serviceAvailable === false;
3539
- if (queuedAttempt >= (stopEarly ? 1 : MAX_QUEUE_RETRIES)) {
3540
- if (stopEarly) {
3541
- throw new Error(
3542
- `Qoder \u4E0A\u6E38\u6682\u4E0D\u53EF\u670D\u52A1\uFF08\u7F51\u5173\u62A5 serviceAvailable:false\uFF1B\u961F\u5217\u4F4D\u7F6E ${formatQueueFigure(queue.queueCount)}\uFF0C\u9884\u8BA1 ${formatWaitMinutes(queue.waitTimeSeconds)} \u5206\u949F\u5185\u653E\u884C\uFF09\u3002\u5DF2\u6309 Retry-After \u7B49\u5F85\u5E76\u91CD\u8BD5 1 \u6B21\uFF0C\u4ECD\u672A\u653E\u884C\u3002\u8FD9\u4E0D\u662F\u51ED\u8BC1\u6216\u989D\u5EA6\u95EE\u9898\uFF0C\u8BF7\u7528 /model \u5207\u6362\u5176\u4ED6 Provider\uFF0C\u6216\u7A0D\u540E\u91CD\u8BD5\u3002`
3543
- );
3544
- }
3545
- throw new Error(
3546
- `Qoder is at capacity: the request stayed queued after ${queuedAttempt + 1} attempts (~${formatWaitMinutes(queue.waitTimeSeconds)} min wait reported). Try again shortly.`
3756
+ const queueCtx = {
3757
+ mode: providerMode,
3758
+ attempts: queuedAttempt + 1,
3759
+ waitedMs: turnWaitedMs,
3760
+ manageUrl: region.manageUrl,
3761
+ otherProviderID: providerMode === "cn" ? "qoder" : "qoder-cn"
3762
+ };
3763
+ if (process.env.QODER_DEBUG) {
3764
+ console.error(
3765
+ `[qoder] queued lane=${queue.queueType ?? "?"} sub=${queue.subCode ?? "?"} model=${queue.modelKey ?? "?"} serviceAvailable=${String(queue.serviceAvailable ?? "?")} attempt=${queuedAttempt + 1} waited=${turnWaitedMs}ms`
3547
3766
  );
3548
3767
  }
3768
+ const maxAttempts = allowedAttemptsFor(queuePolicy, queue);
3769
+ if (queuedAttempt >= maxAttempts) {
3770
+ if (isHardUnavailable(queue)) {
3771
+ armLaneCooldown(laneKey(providerMode, queue), queuePolicy.laneCooldownMs, queue);
3772
+ }
3773
+ throw new Error(formatQueueFailureMessage(queue, queueCtx));
3774
+ }
3775
+ const waitMs = nextSleepMs(queuePolicy, queue, turnWaitedMs);
3776
+ if (waitMs <= 0) {
3777
+ if (isHardUnavailable(queue)) {
3778
+ armLaneCooldown(laneKey(providerMode, queue), queuePolicy.laneCooldownMs, queue);
3779
+ }
3780
+ throw new Error(formatQueueFailureMessage(queue, queueCtx));
3781
+ }
3549
3782
  queuedAttempt++;
3550
- await sleep(queue.retryAfterMs, options?.signal);
3783
+ turnWaitedMs += waitMs;
3784
+ await sleep(waitMs, options?.signal);
3551
3785
  continue;
3552
3786
  }
3553
3787
  if (await peekCredentialExpired(reader, decoder)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-qoder-cn",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "Pi extension for Qoder CN — PAT/OAuth auth, /qoder-cn.usage quota command, and enterprise VPC endpoint support",
5
5
  "type": "module",
6
6
  "license": "MIT",