@mars-sea/dsh-commandcode-provider 0.6.0 → 0.6.2

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/lib/index.js CHANGED
@@ -10,6 +10,201 @@ import { existsSync, readFileSync } from "node:fs";
10
10
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
13
+ //#region src/accounts.ts
14
+ /**
15
+ * Multi-account pool for the Command Code provider (host side).
16
+ *
17
+ * One Command Code subscription (e.g. the Go plan's 5-hour window) is
18
+ * metered; a user with several subscriptions wants a request that hits one
19
+ * account's limit to continue on the next account without a visible failure.
20
+ * This module owns that rotation:
21
+ *
22
+ * - {@link CommandCodeAccountPool.resolveKey} hands out the first account
23
+ * whose key is not currently marked exhausted, resolving each slot's key
24
+ * lazily (literal config key → credential seam → launch environment → the
25
+ * official CLI auth file for the default slot only).
26
+ * - {@link CommandCodeAccountPool.markRejected} records a 429 (rate limit,
27
+ * window unknown) or 401 (invalid key, disabled until the config changes)
28
+ * against the exact API key, so several slots sharing one key share one
29
+ * state.
30
+ * - When every account is marked, the pool probes each key's
31
+ * `/alpha/billing/credits` window limits (through the injected
32
+ * {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
33
+ * no longer reports `exceeded` is revived, otherwise the pool throws a
34
+ * `RATE_LIMIT` error naming the earliest reset time.
35
+ *
36
+ * The pool is deliberately cordis-free (like the adapter): every host fact
37
+ * arrives through injected thunks, so node tests can drive it directly.
38
+ *
39
+ * @module dsh-commandcode-provider/accounts
40
+ */
41
+ /**
42
+ * Upper bound on the retry wait this pool attaches to the all-exhausted
43
+ * `RATE_LIMIT` error. Must equal the `backoff.maxDelayMs` in the adapter's
44
+ * `providerRetryPolicy` (which imports it from here): dsh-llm-retry honors a
45
+ * provider-specified wait verbatim only at or below that cap — in normal mode
46
+ * a LONGER attached wait makes the executor abandon the retry entirely
47
+ * instead of falling back to local backoff, which would turn "poll until the
48
+ * window opens" into "fail now".
49
+ */
50
+ const RETRY_MAX_DELAY_MS = 9e5;
51
+ /** A labeled, human-readable clock reading for error messages. */
52
+ function clockLabel(ms) {
53
+ return new Date(ms).toLocaleString();
54
+ }
55
+ /**
56
+ * Whether an account with this rotation state can serve a request right now.
57
+ * `undefined` (never rejected) is usable; a cooldown becomes usable again
58
+ * once its reset time passes; `unknown` (429, reset unprobed) and
59
+ * `disabled` (401) are not.
60
+ */
61
+ function accountUsable(state) {
62
+ if (state === void 0) return true;
63
+ if (state.kind === "cooldown") return state.until > 0 && Date.now() >= state.until;
64
+ return false;
65
+ }
66
+ /**
67
+ * Pick the account that should serve now: the manually preferred slot when it
68
+ * is usable, otherwise the first usable account in rotation order; undefined
69
+ * when no account is usable. Shared by the pool (request path) and the plugin
70
+ * entry (the usage view's active badge) so both always agree.
71
+ */
72
+ function selectActiveAccount(accounts, preferredId) {
73
+ const usable = accounts.filter((account) => accountUsable(account.state));
74
+ if (preferredId !== void 0) {
75
+ const preferred = usable.find((account) => account.slot.id === preferredId);
76
+ if (preferred !== void 0) return preferred;
77
+ }
78
+ return usable[0];
79
+ }
80
+ /**
81
+ * The account pool. Rotation state is keyed by API key (never logged), so two
82
+ * slots resolving to the same credential share one mark, and a key changed in
83
+ * the credentials service starts with a clean slate.
84
+ */
85
+ var CommandCodeAccountPool = class {
86
+ deps;
87
+ /** Rotation state by API key. */
88
+ states = /* @__PURE__ */ new Map();
89
+ constructor(deps) {
90
+ this.deps = deps;
91
+ }
92
+ /**
93
+ * Resolve every slot's key, deduplicated by key (first slot wins). Slots
94
+ * without any resolvable key are omitted — they still appear in the
95
+ * settings page as unconfigured, they just cannot serve requests.
96
+ */
97
+ async resolvedAccounts() {
98
+ const out = [];
99
+ const seen = /* @__PURE__ */ new Set();
100
+ for (const slot of this.deps.slots()) {
101
+ const key = await this.resolveSlotKey(slot);
102
+ if (key === void 0 || seen.has(key)) continue;
103
+ seen.add(key);
104
+ out.push({
105
+ slot,
106
+ key,
107
+ state: this.states.get(key)
108
+ });
109
+ }
110
+ return out;
111
+ }
112
+ /**
113
+ * Every slot paired with its resolved key and rotation state — NOT
114
+ * deduplicated: two slots sharing one credential both appear (the usage
115
+ * view reports them individually), while slots without any resolvable key
116
+ * are omitted. The serving path uses {@link resolvedAccounts} instead.
117
+ */
118
+ async describeAccounts() {
119
+ const out = [];
120
+ for (const slot of this.deps.slots()) {
121
+ const key = await this.resolveSlotKey(slot);
122
+ if (key === void 0) continue;
123
+ out.push({
124
+ slot,
125
+ key,
126
+ state: this.states.get(key)
127
+ });
128
+ }
129
+ return out;
130
+ }
131
+ /**
132
+ * Hand out the first usable account's key (the manually preferred account
133
+ * when usable, else rotation order). Returns `undefined` when no account
134
+ * resolves any key at all (the caller then reports the missing credential).
135
+ * Throws `RATE_LIMIT` — naming the earliest window reset — or
136
+ * `INVALID_CREDENTIAL` when accounts exist but none can serve.
137
+ *
138
+ * `options.exclude` skips one key during the probe-revival pass: the
139
+ * rotation hook excludes the just-rejected key so a probe that clears its
140
+ * window cannot re-offer the same key within the same request (the adapter
141
+ * refuses already-tried keys; the next request picks the revived key up).
142
+ */
143
+ async resolveKey(options) {
144
+ const accounts = await this.resolvedAccounts();
145
+ if (accounts.length === 0) return;
146
+ const chosen = selectActiveAccount(accounts, this.deps.preferredId?.());
147
+ if (chosen !== void 0) return this.pick(chosen);
148
+ await Promise.all(accounts.map(async (account) => {
149
+ if (account.state?.kind === "disabled") return;
150
+ if (options?.exclude !== void 0 && account.key === options.exclude) return;
151
+ const probe = await this.deps.probeWindow(account.key);
152
+ if (probe === void 0) return;
153
+ if (!probe.exceeded) this.states.delete(account.key);
154
+ else this.states.set(account.key, {
155
+ kind: "cooldown",
156
+ reason: account.state?.reason ?? "rate limited (429)",
157
+ until: probe.resetAt
158
+ });
159
+ }));
160
+ const revived = selectActiveAccount(await this.resolvedAccounts(), this.deps.preferredId?.());
161
+ if (revived !== void 0) return this.pick(revived);
162
+ const latest = await this.resolvedAccounts();
163
+ if (latest.filter((account) => account.state?.kind === "disabled").length === latest.length) throw new LlmError(`llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file;已配置的 ${latest.length} 个 Command Code 账户密钥均被拒绝(401)——请在设置页检查存储的 API 密钥,或重新运行 command-code login`, "INVALID_CREDENTIAL");
164
+ const resets = latest.map((account) => account.state).filter((state) => state !== void 0 && state.kind === "cooldown" && state.until > 0).map((state) => state.until);
165
+ const earliest = resets.length > 0 ? Math.min(...resets) : 0;
166
+ const wait = earliest > 0 ? Math.max(1e3, earliest - Date.now()) : 0;
167
+ throw new LlmError(`llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : "") + ` — requests will succeed again after the reset (or add another account);已用尽全部 ${latest.length} 个 Command Code 账户的用量窗口` + (earliest > 0 ? `,最早的重置时间为 ${clockLabel(earliest)}` : "") + "——窗口重置后请求会自动恢复(也可以添加更多账户)", "RATE_LIMIT", wait > 0 && wait <= 9e5 ? { providerRetryAfterMs: wait } : void 0);
168
+ }
169
+ /**
170
+ * Record a rejection against one key. `rate-limit` (429) marks the key
171
+ * exhausted with an unknown reset (probed lazily at the next resolution
172
+ * once every account is marked); `invalid-credential` (401) disables the
173
+ * key until the stored credential changes.
174
+ */
175
+ markRejected(apiKey, rejection) {
176
+ if (rejection === "invalid-credential") this.states.set(apiKey, {
177
+ kind: "disabled",
178
+ reason: "invalid API key (401)",
179
+ until: 0
180
+ });
181
+ else this.states.set(apiKey, {
182
+ kind: "unknown",
183
+ reason: "rate limited (429)",
184
+ until: 0
185
+ });
186
+ }
187
+ /** One account's key: literal → credential seam → auth file (default slot). */
188
+ async resolveSlotKey(slot) {
189
+ if (slot.literal !== void 0 && slot.literal !== "") return slot.literal;
190
+ if (slot.ref !== void 0) {
191
+ const hit = await this.deps.resolveRef(slot.ref);
192
+ if (hit !== void 0 && hit !== "") return hit;
193
+ }
194
+ if (slot.allowAuthFile) {
195
+ const fromFile = this.deps.authFileKey();
196
+ if (fromFile !== void 0 && fromFile !== "") return fromFile;
197
+ }
198
+ }
199
+ /** Hand out the chosen account's key. */
200
+ pick(account) {
201
+ return {
202
+ key: account.key,
203
+ slot: account.slot
204
+ };
205
+ }
206
+ };
207
+ //#endregion
13
208
  //#region src/adapter.ts
14
209
  /**
15
210
  * DeepSeek Harness LLM adapter for the Command Code Provider API.
@@ -18,7 +213,9 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
18
213
  * community-maintained integration; you need your own Command Code account
19
214
  * and API key or subscription, and Command Code's terms apply.
20
215
  *
21
- * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4):
216
+ * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4;
217
+ * re-verified against command-code@1.31.0 — endpoints, request shape, and
218
+ * stream events unchanged):
22
219
  * POST {apiBase}/alpha/generate
23
220
  * body: { config, memory, taste, skills, params: { model, messages, tools,
24
221
  * system, max_tokens, temperature, stream, reasoning_effort? }, threadId }
@@ -223,6 +420,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
223
420
  "moonshotai/Kimi-K2.7-Code-Highspeed",
224
421
  "moonshotai/Kimi-K3",
225
422
  "sakana/fugu-ultra",
423
+ "stealth/ox-alpha",
226
424
  "stepfun/Step-3.7-Flash",
227
425
  "thinkingmachines/inkling",
228
426
  "thinkingmachines/inkling-small",
@@ -230,7 +428,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
230
428
  "xiaomi/mimo-v2.5"
231
429
  ]);
232
430
  /**
233
- * Models the official CLI's model table (`ZA` in command-code@1.28.4) marks
431
+ * Models the official CLI's model table (`ZA` in command-code@1.31.0) marks
234
432
  * `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
235
433
  * think automatically, with Command Code driving the depth. This is the
236
434
  * authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
@@ -238,7 +436,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
238
436
  * effort levels, and this snapshot is not surfaced in the picker's compact
239
437
  * description — it exists for programmatic consumers.
240
438
  *
241
- * Source: the command-code@1.28.4 bundled model table (dist/cli.mjs, the `ZA`
439
+ * Source: the command-code@1.31.0 bundled model table (dist/cli.mjs, the `ZA`
242
440
  * object), cross-checked with https://commandcode.ai/docs/reference/cli/models.
243
441
  * Keep in sync via the dsh-commandcode-upstream skill.
244
442
  */
@@ -261,7 +459,8 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
261
459
  "poolside/laguna-s-2.1-free",
262
460
  "meta/muse-spark-1.1",
263
461
  "meta/muse-spark-1.2",
264
- "meta/muse-spark-1.2-contributor"
462
+ "meta/muse-spark-1.2-contributor",
463
+ "stealth/ox-alpha"
265
464
  ]);
266
465
  /**
267
466
  * The minimum subscription plan a model is included in, per the official plan
@@ -300,6 +499,7 @@ const KNOWN_PLANS = {
300
499
  "moonshotai/Kimi-K3": "go",
301
500
  "nvidia/nemotron-3-ultra-550b-a55b": "go",
302
501
  "poolside/laguna-s-2.1-free": "go",
502
+ "stealth/ox-alpha": "go",
303
503
  "stepfun/Step-3.5-Flash": "go",
304
504
  "stepfun/Step-3.7-Flash": "go",
305
505
  "tencent/hy3-paid": "go",
@@ -356,10 +556,22 @@ const PLAN_ORDER = {
356
556
  max: 4
357
557
  };
358
558
  /**
359
- * Comparator for the model picker: sort by plan tier (lowest first), then by
360
- * model name, then by id as a tiebreak. Models with no known plan sort last.
559
+ * Whether a model is free (requests cost no credits), per the pricing page's
560
+ * deals (`KNOWN_DEALS` `free: true`). Free models lead the picker regardless
561
+ * of tier — they are usable by every account, so they are the best default
562
+ * candidates.
563
+ */
564
+ function isFreeModel(modelId) {
565
+ return KNOWN_DEALS[modelId]?.free === true;
566
+ }
567
+ /**
568
+ * Comparator for the model picker: free models first (zero credit cost, usable
569
+ * by every account), then by plan tier (lowest first), then by model name,
570
+ * then by id as a tiebreak. Models with no known plan sort last.
361
571
  */
362
572
  function compareByPlan(a, b) {
573
+ const freeDelta = Number(isFreeModel(b.id)) - Number(isFreeModel(a.id));
574
+ if (freeDelta !== 0) return freeDelta;
363
575
  const pa = PLAN_ORDER[KNOWN_PLANS[a.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
364
576
  const pb = PLAN_ORDER[KNOWN_PLANS[b.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
365
577
  if (pa !== pb) return pa - pb;
@@ -369,7 +581,7 @@ function compareByPlan(a, b) {
369
581
  }
370
582
  /**
371
583
  * Subscription plan table, synced from the official CLI bundle's plan maps
372
- * (`Nn`/`$n` in command-code@1.28.4 `dist/cli.mjs`): subscription `planId`
584
+ * (`Nn`/`$n` in command-code@1.31.0 `dist/cli.mjs`): subscription `planId`
373
585
  * prefix → display name and the plan's monthly credit total. This is the
374
586
  * account's own subscription (from `/alpha/billing/subscriptions`) — distinct
375
587
  * from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
@@ -461,6 +673,10 @@ const KNOWN_DEALS = {
461
673
  "poolside/laguna-s-2.1-free": {
462
674
  label: "FREE",
463
675
  free: true
676
+ },
677
+ "stealth/ox-alpha": {
678
+ label: "FREE",
679
+ free: true
464
680
  }
465
681
  };
466
682
  /**
@@ -499,7 +715,7 @@ function peakPricingLabel(modelId, now = Date.now()) {
499
715
  if (state === void 0) return void 0;
500
716
  return state === "peak" ? "Peak" : "Half";
501
717
  }
502
- const COMMAND_CODE_CLI_VERSION = "1.28.4";
718
+ const COMMAND_CODE_CLI_VERSION = "1.31.0";
503
719
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
504
720
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
505
721
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -805,6 +1021,8 @@ async function messagesToCC(messages, readImage) {
805
1021
  }
806
1022
  return out;
807
1023
  }
1024
+ /** Account endpoints fetched by one `getUsage()` run (see the classification there). */
1025
+ const USAGE_ENDPOINT_COUNT = 4;
808
1026
  var CommandCodeAdapter = class extends LlmAdapter {
809
1027
  deps;
810
1028
  catalog = [];
@@ -819,15 +1037,39 @@ var CommandCodeAdapter = class extends LlmAdapter {
819
1037
  this.resolveAttachments = deps.resolveAttachments;
820
1038
  }
821
1039
  /**
822
- * Command Code is a metered subscription API: 429 (rate limit) and 5xx
823
- * (transient server errors) are worth retrying at the agent-step boundary,
824
- * which is where dsh-llm-retry executes the policy returned here. The
825
- * default policy already retries `RATE_LIMIT` and `SERVER`; declaring it
826
- * explicitly documents the intent and gives the plugin entry a stable hook
827
- * to override (e.g. a stricter cap for a metered plan).
1040
+ * Near-unbounded retry for transient failures only (`mode: 'normal'` with
1041
+ * an explicit 1000-attempt cap opencode-style persistence without the
1042
+ * unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
1043
+ * `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
1044
+ * and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
1045
+ * recovers in-session instead of failing after two tries. Permanent
1046
+ * failures (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`,
1047
+ * plan rejections) are absent from the whitelist and surface immediately
1048
+ * instead of looping. Waits the pool/adapter attach as
1049
+ * `providerRetryAfterMs` are honored verbatim at or below the 15-minute
1050
+ * cap and never attached above it (in normal mode a longer attached wait
1051
+ * makes the executor abandon the retry outright — see RETRY_MAX_DELAY_MS).
1052
+ *
1053
+ * Captured once at route registration (dsh-llm snapshots this value), so a
1054
+ * future config knob for it would apply on profile restart, not per request.
828
1055
  */
829
1056
  providerRetryPolicy(_provider) {
830
- return resolveRetryPolicy(void 0, "llm-commandcode: retryPolicy");
1057
+ return resolveRetryPolicy({
1058
+ mode: "normal",
1059
+ maxRetries: 1e3,
1060
+ retryableCodes: [
1061
+ "EMPTY_RESPONSE",
1062
+ "RATE_LIMIT",
1063
+ "SERVER",
1064
+ "TIMEOUT",
1065
+ "TRANSPORT"
1066
+ ],
1067
+ backoff: {
1068
+ initialDelayMs: 500,
1069
+ maxDelayMs: RETRY_MAX_DELAY_MS,
1070
+ jitterRatio: .1
1071
+ }
1072
+ }, "llm-commandcode: retryPolicy");
831
1073
  }
832
1074
  /** Refresh the catalog (live fetch, cache fallback) and return it. */
833
1075
  async loadCatalog(signal) {
@@ -978,6 +1220,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
978
1220
  const base = this.deps.options().apiBase;
979
1221
  const headers = await this.accountHeaders(apiKey);
980
1222
  const failures = [];
1223
+ const failedStatuses = [];
981
1224
  const getJson = async (path) => {
982
1225
  try {
983
1226
  const response = await this.fetchImpl(`${base}${path}`, {
@@ -986,12 +1229,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
986
1229
  });
987
1230
  if (!response.ok) {
988
1231
  failures.push(`${path}: HTTP ${response.status}`);
1232
+ failedStatuses.push(response.status);
989
1233
  return;
990
1234
  }
991
1235
  const parsed = await response.json();
992
1236
  return isRecord(parsed) ? parsed : void 0;
993
1237
  } catch (error) {
994
1238
  failures.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
1239
+ failedStatuses.push(void 0);
995
1240
  return;
996
1241
  }
997
1242
  };
@@ -1052,6 +1297,12 @@ var CommandCodeAdapter = class extends LlmAdapter {
1052
1297
  currentPeriodEnd: periodEndValue(subData?.currentPeriodEnd)
1053
1298
  };
1054
1299
  }
1300
+ if (failures.length === USAGE_ENDPOINT_COUNT) {
1301
+ const codes = failedStatuses.filter((status) => status !== void 0);
1302
+ if (codes.length === USAGE_ENDPOINT_COUNT && codes.every((code) => code === 401)) report.blocked = "invalid-key";
1303
+ else if (codes.length === USAGE_ENDPOINT_COUNT && codes.every((code) => code >= 500)) report.blocked = "service-unavailable";
1304
+ else if (codes.length === 0) report.blocked = "network";
1305
+ }
1055
1306
  return report;
1056
1307
  }
1057
1308
  /**
@@ -1178,9 +1429,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
1178
1429
  if (!response.ok) {
1179
1430
  const errText = await response.text().catch(() => "");
1180
1431
  cleanup();
1181
- return {
1432
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
1433
+ return retryAfterMs === void 0 ? {
1182
1434
  status: response.status,
1183
1435
  errText
1436
+ } : {
1437
+ status: response.status,
1438
+ errText,
1439
+ retryAfterMs
1184
1440
  };
1185
1441
  }
1186
1442
  return {
@@ -1205,7 +1461,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
1205
1461
  continue;
1206
1462
  }
1207
1463
  }
1208
- throw generateHttpError(attempt.status, attempt.errText);
1464
+ throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs);
1209
1465
  }
1210
1466
  const { response, cleanup } = connected;
1211
1467
  if (!response.body) {
@@ -1432,17 +1688,44 @@ const MAX_ACCOUNT_ROTATIONS = 16;
1432
1688
  * Map a pre-stream generate HTTP failure onto a stable LlmError. Command
1433
1689
  * Code folds several business rejections into 403 (plan limits, CLI version,
1434
1690
  * model access): prefer the machine-readable `error.code` when present; the
1435
- * status alone cannot distinguish them.
1691
+ * status alone cannot distinguish them. A 429's `Retry-After` rides along as
1692
+ * `providerRetryAfterMs` so dsh-llm-retry can wait exactly that long instead
1693
+ * of guessing at the backoff cadence — capped at RETRY_MAX_DELAY_MS, because
1694
+ * in normal mode a longer attached wait makes the executor abandon the retry
1695
+ * outright instead of falling back to local backoff.
1436
1696
  */
1437
- function generateHttpError(status, errText) {
1697
+ function generateHttpError(status, errText, retryAfterMs) {
1438
1698
  let providerCode;
1439
1699
  try {
1440
1700
  const parsed = JSON.parse(errText);
1441
1701
  if (isRecord(parsed) && isRecord(parsed.error)) providerCode = stringValue(parsed.error.code);
1442
1702
  } catch {}
1443
1703
  const detail = providerCode ?? `HTTP ${status}`;
1444
- if (status === 401) return new LlmError(`Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file`, "INVALID_CREDENTIAL", { status: 401 });
1445
- return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", { status });
1704
+ if (status === 401) return new LlmError(`Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file;Command Code API 返回 401:API 密钥缺失或无效——请在设置页检查 COMMANDCODE_API_KEY 存储的密钥,或检查 auth 文件`, "INVALID_CREDENTIAL", { status: 401 });
1705
+ return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
1706
+ status,
1707
+ ...retryAfterMs !== void 0 && retryAfterMs > 0 && retryAfterMs <= 9e5 ? { providerRetryAfterMs: retryAfterMs } : {}
1708
+ });
1709
+ }
1710
+ /**
1711
+ * Parse an HTTP `Retry-After` value (delay-seconds or an HTTP-date) into
1712
+ * milliseconds; undefined when absent or unparseable. An HTTP-date in the
1713
+ * past yields 0, which the caller drops (LlmError wants a positive delay).
1714
+ * A delay-seconds value whose millisecond product is not finite (e.g. `1e308`)
1715
+ * also yields undefined: LlmError validates its options and would otherwise
1716
+ * replace the provider failure with an internal construction error.
1717
+ */
1718
+ function parseRetryAfterMs(value, now = Date.now()) {
1719
+ if (value === void 0 || value === null) return void 0;
1720
+ const trimmed = value.trim();
1721
+ if (trimmed === "") return void 0;
1722
+ const seconds = Number(trimmed);
1723
+ if (Number.isFinite(seconds) && seconds >= 0) {
1724
+ const ms = seconds * 1e3;
1725
+ return Number.isFinite(ms) ? Math.round(ms) : void 0;
1726
+ }
1727
+ const date = Date.parse(trimmed);
1728
+ if (!Number.isNaN(date)) return Math.max(0, date - now);
1446
1729
  }
1447
1730
  function mapFinishReason(reason) {
1448
1731
  if (reason === "tool-calls") return { kind: "tool-calls" };
@@ -1450,190 +1733,6 @@ function mapFinishReason(reason) {
1450
1733
  return { kind: "stop" };
1451
1734
  }
1452
1735
  //#endregion
1453
- //#region src/accounts.ts
1454
- /**
1455
- * Multi-account pool for the Command Code provider (host side).
1456
- *
1457
- * One Command Code subscription (e.g. the Go plan's 5-hour window) is
1458
- * metered; a user with several subscriptions wants a request that hits one
1459
- * account's limit to continue on the next account without a visible failure.
1460
- * This module owns that rotation:
1461
- *
1462
- * - {@link CommandCodeAccountPool.resolveKey} hands out the first account
1463
- * whose key is not currently marked exhausted, resolving each slot's key
1464
- * lazily (literal config key → credential seam → launch environment → the
1465
- * official CLI auth file for the default slot only).
1466
- * - {@link CommandCodeAccountPool.markRejected} records a 429 (rate limit,
1467
- * window unknown) or 401 (invalid key, disabled until the config changes)
1468
- * against the exact API key, so several slots sharing one key share one
1469
- * state.
1470
- * - When every account is marked, the pool probes each key's
1471
- * `/alpha/billing/credits` window limits (through the injected
1472
- * {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
1473
- * no longer reports `exceeded` is revived, otherwise the pool throws a
1474
- * `RATE_LIMIT` error naming the earliest reset time.
1475
- *
1476
- * The pool is deliberately cordis-free (like the adapter): every host fact
1477
- * arrives through injected thunks, so node tests can drive it directly.
1478
- *
1479
- * @module dsh-commandcode-provider/accounts
1480
- */
1481
- /** A labeled, human-readable clock reading for error messages. */
1482
- function clockLabel(ms) {
1483
- return new Date(ms).toLocaleString();
1484
- }
1485
- /**
1486
- * Whether an account with this rotation state can serve a request right now.
1487
- * `undefined` (never rejected) is usable; a cooldown becomes usable again
1488
- * once its reset time passes; `unknown` (429, reset unprobed) and
1489
- * `disabled` (401) are not.
1490
- */
1491
- function accountUsable(state) {
1492
- if (state === void 0) return true;
1493
- if (state.kind === "cooldown") return state.until > 0 && Date.now() >= state.until;
1494
- return false;
1495
- }
1496
- /**
1497
- * Pick the account that should serve now: the manually preferred slot when it
1498
- * is usable, otherwise the first usable account in rotation order; undefined
1499
- * when no account is usable. Shared by the pool (request path) and the plugin
1500
- * entry (the usage view's active badge) so both always agree.
1501
- */
1502
- function selectActiveAccount(accounts, preferredId) {
1503
- const usable = accounts.filter((account) => accountUsable(account.state));
1504
- if (preferredId !== void 0) {
1505
- const preferred = usable.find((account) => account.slot.id === preferredId);
1506
- if (preferred !== void 0) return preferred;
1507
- }
1508
- return usable[0];
1509
- }
1510
- /**
1511
- * The account pool. Rotation state is keyed by API key (never logged), so two
1512
- * slots resolving to the same credential share one mark, and a key changed in
1513
- * the credentials service starts with a clean slate.
1514
- */
1515
- var CommandCodeAccountPool = class {
1516
- deps;
1517
- /** Rotation state by API key. */
1518
- states = /* @__PURE__ */ new Map();
1519
- constructor(deps) {
1520
- this.deps = deps;
1521
- }
1522
- /**
1523
- * Resolve every slot's key, deduplicated by key (first slot wins). Slots
1524
- * without any resolvable key are omitted — they still appear in the
1525
- * settings page as unconfigured, they just cannot serve requests.
1526
- */
1527
- async resolvedAccounts() {
1528
- const out = [];
1529
- const seen = /* @__PURE__ */ new Set();
1530
- for (const slot of this.deps.slots()) {
1531
- const key = await this.resolveSlotKey(slot);
1532
- if (key === void 0 || seen.has(key)) continue;
1533
- seen.add(key);
1534
- out.push({
1535
- slot,
1536
- key,
1537
- state: this.states.get(key)
1538
- });
1539
- }
1540
- return out;
1541
- }
1542
- /**
1543
- * Every slot paired with its resolved key and rotation state — NOT
1544
- * deduplicated: two slots sharing one credential both appear (the usage
1545
- * view reports them individually), while slots without any resolvable key
1546
- * are omitted. The serving path uses {@link resolvedAccounts} instead.
1547
- */
1548
- async describeAccounts() {
1549
- const out = [];
1550
- for (const slot of this.deps.slots()) {
1551
- const key = await this.resolveSlotKey(slot);
1552
- if (key === void 0) continue;
1553
- out.push({
1554
- slot,
1555
- key,
1556
- state: this.states.get(key)
1557
- });
1558
- }
1559
- return out;
1560
- }
1561
- /**
1562
- * Hand out the first usable account's key (the manually preferred account
1563
- * when usable, else rotation order). Returns `undefined` when no account
1564
- * resolves any key at all (the caller then reports the missing credential).
1565
- * Throws `RATE_LIMIT` — naming the earliest window reset — or
1566
- * `INVALID_CREDENTIAL` when accounts exist but none can serve.
1567
- *
1568
- * `options.exclude` skips one key during the probe-revival pass: the
1569
- * rotation hook excludes the just-rejected key so a probe that clears its
1570
- * window cannot re-offer the same key within the same request (the adapter
1571
- * refuses already-tried keys; the next request picks the revived key up).
1572
- */
1573
- async resolveKey(options) {
1574
- const accounts = await this.resolvedAccounts();
1575
- if (accounts.length === 0) return;
1576
- const chosen = selectActiveAccount(accounts, this.deps.preferredId?.());
1577
- if (chosen !== void 0) return this.pick(chosen);
1578
- await Promise.all(accounts.map(async (account) => {
1579
- if (account.state?.kind === "disabled") return;
1580
- if (options?.exclude !== void 0 && account.key === options.exclude) return;
1581
- const probe = await this.deps.probeWindow(account.key);
1582
- if (probe === void 0) return;
1583
- if (!probe.exceeded) this.states.delete(account.key);
1584
- else this.states.set(account.key, {
1585
- kind: "cooldown",
1586
- reason: account.state?.reason ?? "rate limited (429)",
1587
- until: probe.resetAt
1588
- });
1589
- }));
1590
- const revived = selectActiveAccount(await this.resolvedAccounts(), this.deps.preferredId?.());
1591
- if (revived !== void 0) return this.pick(revived);
1592
- const latest = await this.resolvedAccounts();
1593
- if (latest.filter((account) => account.state?.kind === "disabled").length === latest.length) throw new LlmError(`llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file`, "INVALID_CREDENTIAL");
1594
- const resets = latest.map((account) => account.state).filter((state) => state !== void 0 && state.kind === "cooldown" && state.until > 0).map((state) => state.until);
1595
- const earliest = resets.length > 0 ? Math.min(...resets) : 0;
1596
- throw new LlmError(`llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : "") + " — requests will succeed again after the reset (or add another account)", "RATE_LIMIT");
1597
- }
1598
- /**
1599
- * Record a rejection against one key. `rate-limit` (429) marks the key
1600
- * exhausted with an unknown reset (probed lazily at the next resolution
1601
- * once every account is marked); `invalid-credential` (401) disables the
1602
- * key until the stored credential changes.
1603
- */
1604
- markRejected(apiKey, rejection) {
1605
- if (rejection === "invalid-credential") this.states.set(apiKey, {
1606
- kind: "disabled",
1607
- reason: "invalid API key (401)",
1608
- until: 0
1609
- });
1610
- else this.states.set(apiKey, {
1611
- kind: "unknown",
1612
- reason: "rate limited (429)",
1613
- until: 0
1614
- });
1615
- }
1616
- /** One account's key: literal → credential seam → auth file (default slot). */
1617
- async resolveSlotKey(slot) {
1618
- if (slot.literal !== void 0 && slot.literal !== "") return slot.literal;
1619
- if (slot.ref !== void 0) {
1620
- const hit = await this.deps.resolveRef(slot.ref);
1621
- if (hit !== void 0 && hit !== "") return hit;
1622
- }
1623
- if (slot.allowAuthFile) {
1624
- const fromFile = this.deps.authFileKey();
1625
- if (fromFile !== void 0 && fromFile !== "") return fromFile;
1626
- }
1627
- }
1628
- /** Hand out the chosen account's key. */
1629
- pick(account) {
1630
- return {
1631
- key: account.key,
1632
- slot: account.slot
1633
- };
1634
- }
1635
- };
1636
- //#endregion
1637
1736
  //#region src/commands.ts
1638
1737
  /** Format a dollar amount. */
1639
1738
  function money(value) {
@@ -1643,8 +1742,7 @@ function money(value) {
1643
1742
  function moneyShort(value) {
1644
1743
  return `$${value.toFixed(2)}`;
1645
1744
  }
1646
- /** Format a token count with thousands separators. */
1647
- /** Format a large token count compactly (1.9亿 style). */
1745
+ /** Format a large token count compactly (1.9M style). */
1648
1746
  function tokensCompact(value) {
1649
1747
  if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
1650
1748
  if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
@@ -1678,6 +1776,9 @@ function renderReport(report, title) {
1678
1776
  const lines = [];
1679
1777
  const account = report.account ? ` (${report.account.userName || report.account.name})` : "";
1680
1778
  lines.push(title ?? `📊 Command Code 用量${account}`, "");
1779
+ if (report.blocked === "invalid-key") lines.push("⛔ API 密钥无效或已过期 — 服务端拒绝了全部请求(401),请检查该账户的密钥配置", "");
1780
+ else if (report.blocked === "service-unavailable") lines.push("⚠️ Command Code 服务暂时不可用(5xx),稍后重试", "");
1781
+ else if (report.blocked === "network") lines.push("⚠️ 无法连接 Command Code 服务 — 请检查网络或 API 地址", "");
1681
1782
  if (report.plan && report.plan.name !== "") {
1682
1783
  const p = report.plan;
1683
1784
  const status = p.status !== "" && p.status !== "active" ? ` (${p.status})` : "";
@@ -1788,6 +1889,11 @@ function parseUsageReport(value) {
1788
1889
  const failures = source.failures;
1789
1890
  if (!Array.isArray(failures) || failures.some((entry) => typeof entry !== "string")) reject("failures");
1790
1891
  const report = { failures };
1892
+ if (source.blocked !== void 0) {
1893
+ const blocked = source.blocked;
1894
+ if (blocked !== "invalid-key" && blocked !== "service-unavailable" && blocked !== "network") reject("blocked");
1895
+ report.blocked = blocked;
1896
+ }
1791
1897
  if (source.account !== void 0) {
1792
1898
  const account = record(source.account, "account");
1793
1899
  report.account = {