pi-provider-qoder-cn 0.2.21 → 0.2.23

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 +84 -0
  2. package/dist/index.js +353 -117
  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`.
@@ -141,6 +144,10 @@ Notes:
141
144
  latest grant`, and an account that has never claimed prints no expiry line at
142
145
  all, because with no grant there is nothing to date from, and inventing one
143
146
  would show dates the API never sent.
147
+ - `Enterprise` is the org-wide pool. It names its allowance `cap` and sends no
148
+ `total`, so the row is keyed on any of `cap`, `used` or `remaining` carrying a
149
+ value: a freshly refreshed cycle reports `used: 0`, and that used to drop the
150
+ whole row and leave the view saying `Buckets none returned`.
144
151
  - `Expires never` means the API reported the year-9999 sentinel, i.e. the
145
152
  allowance has no expiry date.
146
153
  - `Note plan quota is prorated` appears after a mid-cycle plan change.
@@ -259,6 +266,83 @@ the offline fallback and the source of model ids.
259
266
  the whitespace-stripped display name, so `enabledModels` entries such as
260
267
  `qoder-cn/qwen3.8-flash` keep working across upgrades.
261
268
 
269
+ **Thinking switch.** A model whose catalog advertises no `disabled` thinking
270
+ option (`thinkingLevelMap.off === null`, e.g. GLM-5.3 / GLM-5.3-Flash on CN)
271
+ is always-thinking: sending `enable_thinking=false` is answered HTTP 400 code
272
+ 1210 `该模型始终思考`, so no switch is sent at all. Note the TUI's thinking-off
273
+ toggle does not reach that path — pi-ai's `clampThinkingLevel()` promotes `off`
274
+ to the model's lowest advertised effort; the switch is omitted for requests that
275
+ carry no thinking level.
276
+
277
+ ## Capacity queue and wait policy
278
+
279
+ When Qoder's gateway has no capacity it does not reject the request. It answers
280
+ HTTP 200 and puts the verdict in the first SSE frame, three JSON layers deep:
281
+
282
+ ```json
283
+ { "isQueued": true, "modelKey": "auto", "queueCount": 0, "queueType": "p3",
284
+ "retryAfterSeconds": 30, "serviceAvailable": false, "subCode": "RESOURCE_LIMIT", "waitTime": 30 }
285
+ ```
286
+
287
+ Two verdicts look alike and mean opposite things, so they get different handling
288
+ and different wording:
289
+
290
+ | Gateway says | Meaning | Default handling |
291
+ |---|---|---|
292
+ | `serviceAvailable: true` | in line; the line drains | wait per `Retry-After`, up to 3 retries |
293
+ | `serviceAvailable: false` | this lane has nothing to serve | honour `Retry-After` **once**, then stop and name the lane |
294
+
295
+ Measured against `api3.qoder.sh` on an account whose plan quota is 0
296
+ (`personal_standard`, add-on credits only), 2026-10-17/18:
297
+
298
+ - The refusal is **account-lane, not model**: `auto`, `qmodel_38max`, `efficient`
299
+ and `ultimate` all answered `queueType:"p3"` + `RESOURCE_LIMIT`. Switching
300
+ model inside one provider does not dodge it; switching provider does
301
+ (`qoder-cn` answered in 2–5 s at the same moment).
302
+ - `queueCount` is `0` — there is no line to report a position in.
303
+ - **Queued attempts are not billed**: 19 chat requests refused this way left
304
+ `addOnQuota.used` unchanged. Waiting costs time, never credits.
305
+ - The release rate is correlated with the time window, not per-attempt random:
306
+ 6 of 21 attempts served in one window, 0 of 19 in another. That is why extra
307
+ patience is opt-in instead of a default.
308
+
309
+ ### Knobs
310
+
311
+ All defaults reproduce the pre-0.2.22 behaviour; each knob is read per request.
312
+
313
+ | Env var | Default | Effect |
314
+ |---|---|---|
315
+ | `QODER_QUEUE_MAX_ATTEMPTS` | `3` | Retries for an ordinary queue (`serviceAvailable: true`). |
316
+ | `QODER_QUEUE_UNAVAILABLE_ATTEMPTS` | `1` | Retries after `serviceAvailable: false`. Raise it only when you would rather wait than switch. |
317
+ | `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. |
318
+ | `QODER_QUEUE_SINGLE_WAIT_MS` | `120000` | Ceiling for one sleep, clamped further by the total budget. |
319
+ | `QODER_QUEUE_JITTER_MS` | `2000` | Random spread added to each wait so parallel sessions do not retry in lockstep. |
320
+ | `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. |
321
+ | `QODER_DEBUG` | off | Logs one line per queue notice (`lane`, `subCode`, attempt, waited ms) and when a foreign model cache is discarded. |
322
+
323
+ Verified live on the built bundle: default = refusal in ~31 s after 2 requests;
324
+ `QODER_QUEUE_LANE_COOLDOWN_MS=20000` = first request 2 requests/32 s, then
325
+ 0 network calls and 0 ms; `QODER_QUEUE_MAX_TOTAL_WAIT_MS=0` = refusal after a
326
+ single request in 0.6 s.
327
+
328
+ Garbage values fall back to the default, out-of-range values are clamped, and a
329
+ negative value falls back rather than silently meaning "zero".
330
+
331
+ The error text is deliberately worded so pi-ai's `isRetryableAssistantError()`
332
+ does not classify it as transient: every number passes through
333
+ `formatQueueFigure`, which keeps at most two contiguous digits (a real
334
+ `queueCount` of 5204 renders as `5.2k`, because bare `520`/`503` would re-run the
335
+ whole turn against a gateway that already refused).
336
+
337
+ ### Model cache ownership
338
+
339
+ The catalog is cached at `~/.pi/agent/qoder-models-cache.json` (and
340
+ `qoder-cn-models-cache.json`), a path other Qoder providers also use. Each write
341
+ is now tagged `"writer": "pi-provider-qoder-cn"`. A cache tagged by a different
342
+ implementation is discarded and re-fetched instead of being half-believed — its
343
+ model ids are not the ones this package registers. A cache with **no** tag stays
344
+ readable, because every release up to 0.2.21 wrote that shape.
345
+
262
346
  ## Development
263
347
 
264
348
  ```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
  {
@@ -1783,29 +1791,36 @@ var init_oauth = __esm({
1783
1791
  // src/index.ts
1784
1792
  init_oauth();
1785
1793
 
1786
- // src/auth/usage.ts
1787
- init_region();
1788
- function num(value) {
1794
+ // src/quota.ts
1795
+ function quotaNumber(value) {
1789
1796
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1790
1797
  }
1791
- function round2(value) {
1792
- return Math.round(value * 100) / 100;
1798
+ function bucketLimit(bucket) {
1799
+ return quotaNumber(bucket.total ?? bucket.cap);
1793
1800
  }
1794
1801
  function hasAllowance(bucket) {
1795
1802
  if (!bucket) return false;
1796
- return num(bucket.total ?? bucket.cap) > 0 || num(bucket.used) > 0 || num(bucket.remaining) > 0;
1803
+ return bucketLimit(bucket) > 0 || quotaNumber(bucket.used) > 0 || quotaNumber(bucket.remaining) > 0;
1804
+ }
1805
+ function pickBucket(raw, camel) {
1806
+ if (!raw) return void 0;
1807
+ const source = raw;
1808
+ const snake = camel.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
1809
+ return source[snake] ?? source[camel];
1810
+ }
1811
+
1812
+ // src/auth/usage.ts
1813
+ init_region();
1814
+ function round2(value) {
1815
+ return Math.round(value * 100) / 100;
1797
1816
  }
1798
1817
  function resetTimestamp(expiresAt) {
1799
- const ms = num(expiresAt);
1818
+ const ms = quotaNumber(expiresAt);
1800
1819
  if (ms <= 0) return void 0;
1801
1820
  const timestamp = ms < 1e12 ? ms * 1e3 : ms;
1802
1821
  if (new Date(timestamp).getUTCFullYear() >= 9999) return void 0;
1803
1822
  return new Date(timestamp).toISOString();
1804
1823
  }
1805
- function pickBucket(raw, camel) {
1806
- const snake = camel.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
1807
- return raw[snake] ?? raw[camel];
1808
- }
1809
1824
  async function fetchQoderUsageForMode(credentials, mode) {
1810
1825
  const region = getQoderRegionConfig(mode);
1811
1826
  const response = await fetch(getQoderUsageURL(mode), {
@@ -1830,12 +1845,12 @@ async function fetchQoderUsageForMode(credentials, mode) {
1830
1845
  const usageBuckets = buckets.map(({ id, label, quota }) => ({
1831
1846
  id,
1832
1847
  label,
1833
- usedDisplay: round2(num(quota?.used)).toFixed(2),
1834
- limitDisplay: round2(num(quota?.total ?? quota?.cap)).toFixed(2),
1848
+ usedDisplay: round2(quotaNumber(quota?.used)).toFixed(2),
1849
+ limitDisplay: round2(quotaNumber(quota?.total ?? quota?.cap)).toFixed(2),
1835
1850
  unit: quota?.unit || "credits",
1836
1851
  resetAt
1837
1852
  }));
1838
- const remaining = buckets.reduce((sum, { quota }) => sum + Math.max(0, num(quota?.remaining)), 0);
1853
+ const remaining = buckets.reduce((sum, { quota }) => sum + Math.max(0, quotaNumber(quota?.remaining)), 0);
1839
1854
  const unit = buckets[0]?.quota?.unit || "credits";
1840
1855
  return {
1841
1856
  summary: buckets.length > 0 ? `${round2(remaining)} ${unit} remaining` : "",
@@ -2243,7 +2258,149 @@ async function runClaimCommand(mode, args, ctx) {
2243
2258
  }
2244
2259
 
2245
2260
  // src/protocol/errors.ts
2261
+ init_region();
2246
2262
  init_vpc();
2263
+
2264
+ // src/protocol/queue.ts
2265
+ var MAX_WAIT_MS = 12e4;
2266
+ var DEFAULT_RETRY_SECONDS = 30;
2267
+ function digForQueue(node, depth) {
2268
+ if (depth > 6) return null;
2269
+ if (typeof node === "string") {
2270
+ const trimmed = node.trim();
2271
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
2272
+ try {
2273
+ return digForQueue(JSON.parse(trimmed), depth + 1);
2274
+ } catch {
2275
+ return null;
2276
+ }
2277
+ }
2278
+ if (node === null || typeof node !== "object") return null;
2279
+ if (Array.isArray(node)) {
2280
+ for (const item of node) {
2281
+ const found = digForQueue(item, depth + 1);
2282
+ if (found) return found;
2283
+ }
2284
+ return null;
2285
+ }
2286
+ const record = node;
2287
+ if (record.isQueued === true) return record;
2288
+ for (const value of Object.values(record)) {
2289
+ const found = digForQueue(value, depth + 1);
2290
+ if (found) return found;
2291
+ }
2292
+ return null;
2293
+ }
2294
+ function positiveNumber(value, fallback) {
2295
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
2296
+ }
2297
+ function textOf(value) {
2298
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
2299
+ }
2300
+ function parseQueueNotice(envelope) {
2301
+ const payload = digForQueue(envelope, 0);
2302
+ if (!payload) return null;
2303
+ const retryMs = positiveNumber(payload.retry_after_ms, 0) || positiveNumber(payload.retryAfterSeconds, DEFAULT_RETRY_SECONDS) * 1e3;
2304
+ return {
2305
+ retryAfterMs: Math.min(retryMs, MAX_WAIT_MS),
2306
+ waitTimeSeconds: positiveNumber(payload.waitTime, 0),
2307
+ queueCount: positiveNumber(payload.queueCount, 0),
2308
+ // Only a real boolean is surfaced; a missing field must not be read as
2309
+ // "unavailable" (that would collapse the normal queue into a hard stop).
2310
+ serviceAvailable: typeof payload.serviceAvailable === "boolean" ? payload.serviceAvailable : void 0,
2311
+ // `subCode` sits inside the same record as `isQueued` in the payloads seen
2312
+ // live, so one source is enough; the wrapper levels carry no extra names.
2313
+ subCode: textOf(payload.subCode),
2314
+ queueType: textOf(payload.queueType),
2315
+ modelKey: textOf(payload.modelKey)
2316
+ };
2317
+ }
2318
+ function formatWaitSecondsShort(ms) {
2319
+ if (!Number.isFinite(ms) || ms <= 0) return "0";
2320
+ const seconds = Math.round(ms / 1e3);
2321
+ if (seconds < 100) return String(seconds);
2322
+ return `${formatWaitMinutes(seconds)}\u5206\u949F`;
2323
+ }
2324
+ function formatQueueFailureMessage(notice, ctx) {
2325
+ const isCn = ctx.mode === "cn";
2326
+ const lane = notice.queueType ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2327
+ const sub = notice.subCode ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2328
+ const modelKey = notice.modelKey ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2329
+ const hard = notice.serviceAvailable === false;
2330
+ const waited = formatWaitSecondsShort(ctx.waitedMs ?? 0);
2331
+ const queueLabel = formatQueueFigure(notice.queueCount ?? 0);
2332
+ 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. `;
2333
+ if (hard) {
2334
+ return isCn ? [
2335
+ `[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`,
2336
+ `- \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`,
2337
+ `- \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`,
2338
+ `- \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}`
2339
+ ].join("\n") : [
2340
+ `[Qoder international has no spare capacity] The gateway answered serviceAvailable:false (sub code ${sub}, queue length ${queueLabel}, priority lane ${lane}, model ${modelKey})`,
2341
+ `- 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.`,
2342
+ `- Switching model will not help: every model of this account answers on the same ${lane} lane (measured across auto / qmodel_38max / efficient / ultimate).`,
2343
+ `- Next step: use /model to switch to ${ctx.otherProviderID} or another provider, or retry later. Plan and quota: ${ctx.manageUrl}`
2344
+ ].join("\n");
2345
+ }
2346
+ return isCn ? [
2347
+ `[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`,
2348
+ `- \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`,
2349
+ `- \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}`
2350
+ ].join("\n") : [
2351
+ // "is at capacity" is the established wording for the drainable case and
2352
+ // the only queue text allowed to carry it; the hard refusal above must
2353
+ // never read as "wait it out".
2354
+ `[Qoder upstream queue] The model gateway is at capacity and parked this request (queue length ${queueLabel}, priority lane ${lane}, model ${modelKey})`,
2355
+ `- Meaning: ${tried ?? ""}the queue has not cleared yet. Credentials and quota are fine; queued attempts are not billed.`,
2356
+ `- Next step: retry shortly, or switch provider with /model to ${ctx.otherProviderID} while the queue drains. Plan and quota: ${ctx.manageUrl}`
2357
+ ].join("\n");
2358
+ }
2359
+ function formatLaneCooldownMessage(notice, remainingMs, ctx) {
2360
+ const isCn = ctx.mode === "cn";
2361
+ const lane = notice.queueType ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2362
+ const sub = notice.subCode ?? (isCn ? "\u672A\u63D0\u4F9B" : "unset");
2363
+ const left = Math.min(99, Math.max(1, Math.ceil(remainingMs / 1e3)));
2364
+ return isCn ? [
2365
+ `[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`,
2366
+ `- \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`,
2367
+ `- \u89E3\u51B3\u5EFA\u8BAE\uFF1A\u7528 /model \u5207\u6362\u5230 ${ctx.otherProviderID} \u6216\u5176\u4ED6 Provider\uFF0C\u6216 ${left} \u79D2\u540E\u91CD\u8BD5\u3002`
2368
+ ].join("\n") : [
2369
+ `[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.`,
2370
+ `- Meaning: the previous request on this lane already waited through Retry-After and was still refused, so waiting again buys the same verdict.`,
2371
+ `- Next step: use /model to switch to ${ctx.otherProviderID} or another provider, or retry in ${left} seconds.`
2372
+ ].join("\n");
2373
+ }
2374
+ function formatQueueFigure(n) {
2375
+ if (!Number.isFinite(n) || n <= 0) return "0";
2376
+ if (n < 100) return String(Math.round(n));
2377
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
2378
+ return `${(n / 1e3).toFixed(1)}k`;
2379
+ }
2380
+ function formatWaitMinutes(seconds) {
2381
+ if (!Number.isFinite(seconds) || seconds <= 0) return "<1";
2382
+ return String(Math.min(99, Math.max(1, Math.round(seconds / 60))));
2383
+ }
2384
+ function sleep(ms, signal) {
2385
+ return new Promise((resolve, reject) => {
2386
+ if (signal?.aborted) {
2387
+ reject(new Error("Aborted while waiting for the Qoder queue."));
2388
+ return;
2389
+ }
2390
+ let timer;
2391
+ const onAbort = () => {
2392
+ clearTimeout(timer);
2393
+ reject(new Error("Aborted while waiting for the Qoder queue."));
2394
+ };
2395
+ timer = setTimeout(() => {
2396
+ signal?.removeEventListener("abort", onAbort);
2397
+ resolve();
2398
+ }, ms);
2399
+ signal?.addEventListener("abort", onAbort, { once: true });
2400
+ });
2401
+ }
2402
+
2403
+ // src/protocol/errors.ts
2247
2404
  function parseQoderErrorPayload(payload) {
2248
2405
  if (!payload) return null;
2249
2406
  let parsed = payload;
@@ -2302,6 +2459,7 @@ function formatQoderStreamError(statusCode, rawBody, now = Date.now(), mode = "c
2302
2459
  const parsed = parseQoderErrorPayload(rawBody);
2303
2460
  const code = parsed?.code;
2304
2461
  const message = parsed?.message || (typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody));
2462
+ const bodyForMatch = `${message} ${typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody ?? "")}`;
2305
2463
  const countdown = getBeijingDailyResetCountdown(now);
2306
2464
  const isCn = mode !== "global";
2307
2465
  if (code === "110" || /billing daily count exceeded/i.test(message) || /daily usage limit reached/i.test(message)) {
@@ -2399,7 +2557,17 @@ function formatQoderStreamError(statusCode, rawBody, now = Date.now(), mode = "c
2399
2557
  `- Next step: every retry must be re-signed; if it keeps repeating, switch provider with /model and try again shortly.`
2400
2558
  ].join("\n");
2401
2559
  }
2402
- if (code === "10605" || /"isQueued"\s*:\s*true|isQueued/.test(message)) {
2560
+ if (code === "10605" || /"isQueued"\s*:\s*true/.test(message)) {
2561
+ const notice = parseQueueNotice(rawBody) ?? parseQueueNotice(message);
2562
+ if (notice) {
2563
+ return formatQueueFailureMessage(notice, {
2564
+ mode,
2565
+ // This call does not own the retry loop, so it claims no attempt count.
2566
+ waitedMs: 0,
2567
+ manageUrl: getQoderRegionConfig(mode).manageUrl,
2568
+ otherProviderID: mode === "cn" ? "qoder" : "qoder-cn"
2569
+ });
2570
+ }
2403
2571
  return isCn ? [
2404
2572
  `[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
2573
  `- \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 +2578,17 @@ function formatQoderStreamError(statusCode, rawBody, now = Date.now(), mode = "c
2410
2578
  `- Next step: retry shortly, or switch provider with /model while the queue drains.`
2411
2579
  ].join("\n");
2412
2580
  }
2581
+ if (code === "1210" || /不支持关闭思考|cannot disable thinking|always.{0,12}think/i.test(bodyForMatch)) {
2582
+ return isCn ? [
2583
+ `[Qoder CN \u4E0D\u652F\u6301\u5173\u95ED\u601D\u8003] \u8BE5\u6A21\u578B\u59CB\u7EC8\u601D\u8003 (\u9519\u8BEF\u7801 1210)`,
2584
+ `- \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`,
2585
+ `- \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`
2586
+ ].join("\n") : [
2587
+ `[Qoder model always thinks] This model cannot disable thinking (code 1210)`,
2588
+ `- Meaning: the model has no "off" switch, so a request carrying enable_thinking=false is refused. This will not change on retry.`,
2589
+ `- Next step: pick a thinking level (low / medium / high / max) instead of turning thinking off.`
2590
+ ].join("\n");
2591
+ }
2413
2592
  if (rawBody === void 0 || rawBody === null) {
2414
2593
  return `Upstream status ${statusCode}`;
2415
2594
  }
@@ -2572,33 +2751,28 @@ function bucketRow(label, bucket, danger) {
2572
2751
  danger
2573
2752
  };
2574
2753
  }
2575
- function hasBucket(bucket) {
2576
- if (!bucket) return false;
2577
- return (bucket.total ?? 0) > 0 || (bucket.used ?? 0) > 0;
2578
- }
2579
- function pickBucket2(raw, camel) {
2580
- if (!raw) return void 0;
2581
- const snake = camel.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
2582
- return raw[snake] ?? raw[camel];
2583
- }
2584
2754
  function formatQoderUsage(raw, mode, now = Date.now(), options = {}) {
2585
2755
  const color = options.color ?? false;
2586
2756
  const region = getQoderRegionConfig(mode);
2587
2757
  const danger = Boolean(raw?.isQuotaExceeded);
2588
2758
  const userType = raw?.userType ? ` (${raw.userType})` : "";
2589
- const showUserQuota = hasBucket(pickBucket2(raw, "userQuota"));
2590
- const showAddOnQuota = hasBucket(pickBucket2(raw, "addOnQuota"));
2591
- const org = pickBucket2(raw, "orgResourcePackage") ?? pickBucket2(raw, "sharedQuota");
2592
- const showOrg = hasBucket(org);
2593
- const orgPercent = typeof org?.used === "number" && typeof org?.cap === "number" && org.cap > 0 ? formatPercent(org.used / org.cap * 100) : void 0;
2759
+ const showUserQuota = hasAllowance(pickBucket(raw, "userQuota"));
2760
+ const showAddOnQuota = hasAllowance(pickBucket(raw, "addOnQuota"));
2761
+ const org = pickBucket(raw, "orgResourcePackage") ?? pickBucket(raw, "sharedQuota");
2762
+ const showOrg = hasAllowance(org);
2763
+ const orgLimit = org ? bucketLimit(org) : 0;
2764
+ const orgPercent = org && orgLimit > 0 ? formatPercent(quotaNumber(org.used) / orgLimit * 100) : void 0;
2594
2765
  const overallPercent = formatPercent(
2595
- showOrg ? normalizePercent(raw?.totalUsagePercentage) || orgPercent : normalizePercent(raw?.totalUsagePercentage)
2766
+ showOrg ? orgPercent ?? normalizePercent(raw?.totalUsagePercentage) : normalizePercent(raw?.totalUsagePercentage)
2596
2767
  );
2597
2768
  const rows = [{ label: "Overall", percent: overallPercent, tail: "used", danger }];
2598
- if (showUserQuota) rows.push(bucketRow("Plan quota", pickBucket2(raw, "userQuota"), danger));
2599
- if (showAddOnQuota) rows.push(bucketRow("Add-on", pickBucket2(raw, "addOnQuota"), danger));
2600
- if (showOrg && org)
2601
- rows.push(bucketRow("Enterprise", { ...org, percentage: void 0, total: org.cap ?? org.total }, danger));
2769
+ if (showUserQuota) rows.push(bucketRow("Plan quota", pickBucket(raw, "userQuota"), danger));
2770
+ if (showAddOnQuota) rows.push(bucketRow("Add-on", pickBucket(raw, "addOnQuota"), danger));
2771
+ if (showOrg && org) {
2772
+ rows.push(
2773
+ bucketRow("Enterprise", { ...org, percentage: void 0, total: orgLimit, used: quotaNumber(org.used) }, danger)
2774
+ );
2775
+ }
2602
2776
  const isPersonalStandard = raw?.userType === "personal_standard" || raw?.userType === "free";
2603
2777
  const notes = [];
2604
2778
  if (danger) {
@@ -2636,7 +2810,7 @@ function formatQoderUsage(raw, mode, now = Date.now(), options = {}) {
2636
2810
  });
2637
2811
  }
2638
2812
  }
2639
- const addOn = pickBucket2(raw, "addOnQuota");
2813
+ const addOn = pickBucket(raw, "addOnQuota");
2640
2814
  if (showAddOnQuota && (addOn?.remaining ?? 0) > 0 && checkin) {
2641
2815
  const expiryMs = checkin.expiresAt ? Date.parse(checkin.expiresAt) : void 0;
2642
2816
  const grant = { expiryMs, amount: checkin.amount, validityDays: checkin.validityDays };
@@ -2789,80 +2963,112 @@ function qoderEncodeBody(plaintext) {
2789
2963
  return out;
2790
2964
  }
2791
2965
 
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;
2966
+ // src/protocol/queue-policy.ts
2967
+ var DEFAULT_QUEUE_POLICY = Object.freeze({
2968
+ unavailableAttempts: 1,
2969
+ maxAttempts: 3,
2970
+ totalWaitBudgetMs: 9e4,
2971
+ singleWaitCapMs: 12e4,
2972
+ jitterMs: 2e3,
2973
+ /**
2974
+ * OFF by default. A skipped request is a request the user expected to be sent,
2975
+ * and "the lane refused a moment ago" is an inference, not a gateway answer:
2976
+ * it holds on the international service (every model of the account answered
2977
+ * `p3`/`RESOURCE_LIMIT`), but it would also suppress a request that was about
2978
+ * to be served. Opt in with `QODER_QUEUE_LANE_COOLDOWN_MS=20000` when a whole
2979
+ * agent turn is otherwise spending 31s per call on a dead lane.
2980
+ */
2981
+ laneCooldownMs: 0
2982
+ });
2983
+ var LIMITS = {
2984
+ attempts: { min: 0, max: 10 },
2985
+ budgetMs: { min: 0, max: 15 * 6e4 },
2986
+ singleMs: { min: 1e3, max: 5 * 6e4 },
2987
+ jitterMs: { min: 0, max: 3e4 },
2988
+ cooldownMs: { min: 0, max: 5 * 6e4 }
2989
+ };
2990
+ function clampInt(raw, min, max, fallback) {
2991
+ if (raw === void 0) return fallback;
2992
+ const trimmed = raw.trim();
2993
+ if (!trimmed) return fallback;
2994
+ const value = Number(trimmed);
2995
+ if (!Number.isFinite(value) || !Number.isInteger(value)) return fallback;
2996
+ if (value < 0) return fallback;
2997
+ return Math.min(max, Math.max(min, value));
2998
+ }
2999
+ function readQueuePolicy(env = process.env) {
2830
3000
  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
3001
+ unavailableAttempts: clampInt(
3002
+ env.QODER_QUEUE_UNAVAILABLE_ATTEMPTS,
3003
+ LIMITS.attempts.min,
3004
+ LIMITS.attempts.max,
3005
+ DEFAULT_QUEUE_POLICY.unavailableAttempts
3006
+ ),
3007
+ maxAttempts: clampInt(
3008
+ env.QODER_QUEUE_MAX_ATTEMPTS,
3009
+ LIMITS.attempts.min,
3010
+ LIMITS.attempts.max,
3011
+ DEFAULT_QUEUE_POLICY.maxAttempts
3012
+ ),
3013
+ totalWaitBudgetMs: clampInt(
3014
+ env.QODER_QUEUE_MAX_TOTAL_WAIT_MS,
3015
+ LIMITS.budgetMs.min,
3016
+ LIMITS.budgetMs.max,
3017
+ DEFAULT_QUEUE_POLICY.totalWaitBudgetMs
3018
+ ),
3019
+ singleWaitCapMs: clampInt(
3020
+ env.QODER_QUEUE_SINGLE_WAIT_MS,
3021
+ LIMITS.singleMs.min,
3022
+ LIMITS.singleMs.max,
3023
+ DEFAULT_QUEUE_POLICY.singleWaitCapMs
3024
+ ),
3025
+ jitterMs: clampInt(
3026
+ env.QODER_QUEUE_JITTER_MS,
3027
+ LIMITS.jitterMs.min,
3028
+ LIMITS.jitterMs.max,
3029
+ DEFAULT_QUEUE_POLICY.jitterMs
3030
+ ),
3031
+ laneCooldownMs: clampInt(
3032
+ env.QODER_QUEUE_LANE_COOLDOWN_MS,
3033
+ LIMITS.cooldownMs.min,
3034
+ LIMITS.cooldownMs.max,
3035
+ DEFAULT_QUEUE_POLICY.laneCooldownMs
3036
+ )
2837
3037
  };
2838
3038
  }
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`;
3039
+ function allowedAttemptsFor(policy, notice) {
3040
+ return notice.serviceAvailable === false ? policy.unavailableAttempts : policy.maxAttempts;
2844
3041
  }
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))));
3042
+ function nextSleepMs(policy, notice, waitedMs, random = Math.random) {
3043
+ if (!Number.isFinite(notice.retryAfterMs) || notice.retryAfterMs <= 0) return 0;
3044
+ const remaining = Math.max(0, policy.totalWaitBudgetMs - waitedMs);
3045
+ if (remaining === 0) return 0;
3046
+ const base = Math.min(notice.retryAfterMs, policy.singleWaitCapMs, remaining);
3047
+ const jitter = policy.jitterMs > 0 ? random() * policy.jitterMs : 0;
3048
+ return Math.max(0, Math.min(policy.singleWaitCapMs, remaining, base + jitter));
2848
3049
  }
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
- });
3050
+ function isHardUnavailable(notice) {
3051
+ return notice.serviceAvailable === false;
3052
+ }
3053
+ function laneKey(mode, notice) {
3054
+ return `${mode}:${notice.queueType ?? "unknown"}:${notice.subCode ?? "queued"}`;
3055
+ }
3056
+ var armedLanes = /* @__PURE__ */ new Map();
3057
+ function armLaneCooldown(key, ttlMs, notice, now = Date.now()) {
3058
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
3059
+ armedLanes.delete(key);
3060
+ return;
3061
+ }
3062
+ armedLanes.set(key, { until: now + ttlMs, notice });
3063
+ }
3064
+ function pendingLaneCooldown(mode, now = Date.now()) {
3065
+ for (const [key, entry] of armedLanes) {
3066
+ if (!key.startsWith(`${mode}:`)) continue;
3067
+ const remaining = entry.until - now;
3068
+ if (remaining > 0) return { key, remainingMs: remaining, notice: entry.notice };
3069
+ armedLanes.delete(key);
3070
+ }
3071
+ return null;
2866
3072
  }
2867
3073
 
2868
3074
  // src/protocol/thinking.ts
@@ -3372,6 +3578,8 @@ function streamQoder(model, context, options) {
3372
3578
  const providerMode = model.provider === "qoder-cn" ? "cn" : "global";
3373
3579
  let reader;
3374
3580
  let started = false;
3581
+ const queuePolicy = readQueuePolicy();
3582
+ let turnWaitedMs = 0;
3375
3583
  const region = getQoderRegionConfig(providerMode);
3376
3584
  const runAttempt = async (apiKey) => {
3377
3585
  const accessToken = apiKey ?? options?.apiKey;
@@ -3419,6 +3627,7 @@ function streamQoder(model, context, options) {
3419
3627
  const requestedLevel = options?.reasoning;
3420
3628
  const clamped = requestedLevel ? clampThinkingLevel(model, requestedLevel) : void 0;
3421
3629
  const reasoningLevel = clamped === "off" ? void 0 : clamped;
3630
+ const cannotDisableThinking = model.thinkingLevelMap?.off === null;
3422
3631
  const parameters = { max_tokens: maxTokens };
3423
3632
  if (reasoningLevel) {
3424
3633
  parameters.enable_thinking = true;
@@ -3427,6 +3636,7 @@ function streamQoder(model, context, options) {
3427
3636
  if (modelConfig?.thinking_config?.enabled?.efforts && typeof effort === "string") {
3428
3637
  parameters.reasoning_effort = effort;
3429
3638
  }
3639
+ } else if (cannotDisableThinking) {
3430
3640
  } else {
3431
3641
  parameters.enable_thinking = false;
3432
3642
  }
@@ -3507,6 +3717,16 @@ function streamQoder(model, context, options) {
3507
3717
  };
3508
3718
  };
3509
3719
  let queuedAttempt = 0;
3720
+ const cooled = pendingLaneCooldown(providerMode);
3721
+ if (cooled) {
3722
+ throw new Error(
3723
+ formatLaneCooldownMessage(cooled.notice, cooled.remainingMs, {
3724
+ mode: providerMode,
3725
+ manageUrl: region.manageUrl,
3726
+ otherProviderID: providerMode === "cn" ? "qoder" : "qoder-cn"
3727
+ })
3728
+ );
3729
+ }
3510
3730
  await reader?.cancel().catch(() => {
3511
3731
  });
3512
3732
  reader = void 0;
@@ -3535,19 +3755,35 @@ function streamQoder(model, context, options) {
3535
3755
  if (queue) {
3536
3756
  await reader.cancel().catch(() => {
3537
3757
  });
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.`
3758
+ const queueCtx = {
3759
+ mode: providerMode,
3760
+ attempts: queuedAttempt + 1,
3761
+ waitedMs: turnWaitedMs,
3762
+ manageUrl: region.manageUrl,
3763
+ otherProviderID: providerMode === "cn" ? "qoder" : "qoder-cn"
3764
+ };
3765
+ if (process.env.QODER_DEBUG) {
3766
+ console.error(
3767
+ `[qoder] queued lane=${queue.queueType ?? "?"} sub=${queue.subCode ?? "?"} model=${queue.modelKey ?? "?"} serviceAvailable=${String(queue.serviceAvailable ?? "?")} attempt=${queuedAttempt + 1} waited=${turnWaitedMs}ms`
3547
3768
  );
3548
3769
  }
3770
+ const maxAttempts = allowedAttemptsFor(queuePolicy, queue);
3771
+ if (queuedAttempt >= maxAttempts) {
3772
+ if (isHardUnavailable(queue)) {
3773
+ armLaneCooldown(laneKey(providerMode, queue), queuePolicy.laneCooldownMs, queue);
3774
+ }
3775
+ throw new Error(formatQueueFailureMessage(queue, queueCtx));
3776
+ }
3777
+ const waitMs = nextSleepMs(queuePolicy, queue, turnWaitedMs);
3778
+ if (waitMs <= 0) {
3779
+ if (isHardUnavailable(queue)) {
3780
+ armLaneCooldown(laneKey(providerMode, queue), queuePolicy.laneCooldownMs, queue);
3781
+ }
3782
+ throw new Error(formatQueueFailureMessage(queue, queueCtx));
3783
+ }
3549
3784
  queuedAttempt++;
3550
- await sleep(queue.retryAfterMs, options?.signal);
3785
+ turnWaitedMs += waitMs;
3786
+ await sleep(waitMs, options?.signal);
3551
3787
  continue;
3552
3788
  }
3553
3789
  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.23",
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",