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

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
@@ -18,7 +18,7 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
18
18
  * community-maintained integration; you need your own Command Code account
19
19
  * and API key or subscription, and Command Code's terms apply.
20
20
  *
21
- * Wire protocol (reverse-engineered by the pi plugin, command-code@1.27.1):
21
+ * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4):
22
22
  * POST {apiBase}/alpha/generate
23
23
  * body: { config, memory, taste, skills, params: { model, messages, tools,
24
24
  * system, max_tokens, temperature, stream, reasoning_effort? }, threadId }
@@ -36,6 +36,11 @@ const KNOWN_EFFORTS = {
36
36
  "medium",
37
37
  "xhigh"
38
38
  ],
39
+ "Qwen/Qwen3.8-27B": [
40
+ "low",
41
+ "medium",
42
+ "xhigh"
43
+ ],
39
44
  "claude-fable-5": [
40
45
  "low",
41
46
  "medium",
@@ -188,6 +193,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
188
193
  "Qwen/Qwen3.6-Plus",
189
194
  "Qwen/Qwen3.7-Flash",
190
195
  "Qwen/Qwen3.7-Plus",
196
+ "Qwen/Qwen3.8-27B",
191
197
  "Qwen/Qwen3.8-Max",
192
198
  "claude-fable-5",
193
199
  "claude-haiku-4-5-20251001",
@@ -224,7 +230,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
224
230
  "xiaomi/mimo-v2.5"
225
231
  ]);
226
232
  /**
227
- * Models the official CLI's model table (`ZA` in command-code@1.27.1) marks
233
+ * Models the official CLI's model table (`ZA` in command-code@1.28.4) marks
228
234
  * `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
229
235
  * think automatically, with Command Code driving the depth. This is the
230
236
  * authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
@@ -232,7 +238,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
232
238
  * effort levels, and this snapshot is not surfaced in the picker's compact
233
239
  * description — it exists for programmatic consumers.
234
240
  *
235
- * Source: the command-code@1.27.1 bundled model table (dist/cli.mjs, the `ZA`
241
+ * Source: the command-code@1.28.4 bundled model table (dist/cli.mjs, the `ZA`
236
242
  * object), cross-checked with https://commandcode.ai/docs/reference/cli/models.
237
243
  * Keep in sync via the dsh-commandcode-upstream skill.
238
244
  */
@@ -281,6 +287,7 @@ const KNOWN_PLANS = {
281
287
  "Qwen/Qwen3.7-Flash": "go",
282
288
  "Qwen/Qwen3.7-Max": "go",
283
289
  "Qwen/Qwen3.7-Plus": "go",
290
+ "Qwen/Qwen3.8-27B": "go",
284
291
  "Qwen/Qwen3.8-Max": "go",
285
292
  "deepseek/deepseek-v4-flash": "go",
286
293
  "deepseek/deepseek-v4-pro": "go",
@@ -362,7 +369,7 @@ function compareByPlan(a, b) {
362
369
  }
363
370
  /**
364
371
  * Subscription plan table, synced from the official CLI bundle's plan maps
365
- * (`Nn`/`$n` in command-code@1.27.1 `dist/cli.mjs`): subscription `planId`
372
+ * (`Nn`/`$n` in command-code@1.28.4 `dist/cli.mjs`): subscription `planId`
366
373
  * prefix → display name and the plan's monthly credit total. This is the
367
374
  * account's own subscription (from `/alpha/billing/subscriptions`) — distinct
368
375
  * from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
@@ -492,7 +499,7 @@ function peakPricingLabel(modelId, now = Date.now()) {
492
499
  if (state === void 0) return void 0;
493
500
  return state === "peak" ? "Peak" : "Half";
494
501
  }
495
- const COMMAND_CODE_CLI_VERSION = "1.27.1";
502
+ const COMMAND_CODE_CLI_VERSION = "1.28.4";
496
503
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
497
504
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
498
505
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -803,8 +810,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
803
810
  catalog = [];
804
811
  fetchImpl;
805
812
  resolveAttachments;
806
- billingAccess;
807
- billingAccessInflight;
813
+ billingAccess = /* @__PURE__ */ new Map();
814
+ billingAccessInflight = /* @__PURE__ */ new Map();
808
815
  constructor(deps) {
809
816
  super();
810
817
  this.deps = deps;
@@ -877,10 +884,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
877
884
  };
878
885
  }
879
886
  /** The headers every authenticated account endpoint shares. */
880
- async accountHeaders() {
887
+ async accountHeaders(apiKey) {
881
888
  const connection = this.deps.options();
882
889
  return {
883
- Authorization: `Bearer ${await this.deps.resolveApiKey(connection)}`,
890
+ Authorization: `Bearer ${apiKey ?? await this.deps.resolveApiKey(connection)}`,
884
891
  "x-command-code-version": COMMAND_CODE_CLI_VERSION,
885
892
  "x-cli-environment": "production",
886
893
  ...attributionHeaders()
@@ -892,18 +899,27 @@ var CommandCodeAdapter = class extends LlmAdapter {
892
899
  * `undefined` means "unknown — show everything" (fail-open).
893
900
  */
894
901
  async loadBillingAccess() {
895
- const cached = this.billingAccess;
902
+ let apiKey;
903
+ try {
904
+ apiKey = await this.deps.resolveApiKey(this.deps.options());
905
+ } catch {
906
+ return;
907
+ }
908
+ const cached = this.billingAccess.get(apiKey);
896
909
  if (cached !== void 0 && Date.now() - cached.at < 3e5) return cached.value;
897
- this.billingAccessInflight ??= this.fetchBillingAccess().then((value) => {
898
- this.billingAccess = {
910
+ const existing = this.billingAccessInflight.get(apiKey);
911
+ if (existing !== void 0) return existing;
912
+ const inflight = this.fetchBillingAccess(apiKey).then((value) => {
913
+ this.billingAccess.set(apiKey, {
899
914
  value,
900
915
  at: Date.now()
901
- };
916
+ });
902
917
  return value;
903
918
  }).finally(() => {
904
- this.billingAccessInflight = void 0;
919
+ this.billingAccessInflight.delete(apiKey);
905
920
  });
906
- return this.billingAccessInflight;
921
+ this.billingAccessInflight.set(apiKey, inflight);
922
+ return inflight;
907
923
  }
908
924
  /**
909
925
  * The billing facts behind the picker's plan filter, mirroring the CLI's
@@ -914,10 +930,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
914
930
  * fallback (the CLI stamps plan identity from it too). Any failure resolves
915
931
  * to `undefined` (fail-open) rather than breaking the picker.
916
932
  */
917
- async fetchBillingAccess() {
933
+ async fetchBillingAccess(apiKey) {
918
934
  try {
919
935
  const connection = this.deps.options();
920
- const headers = await this.accountHeaders();
936
+ const headers = await this.accountHeaders(apiKey);
921
937
  const base = connection.apiBase;
922
938
  const getJson = async (path) => {
923
939
  const response = await this.fetchImpl(`${base}${path}`, {
@@ -955,10 +971,12 @@ var CommandCodeAdapter = class extends LlmAdapter {
955
971
  * Each endpoint degrades independently: a failed one lands in `failures`
956
972
  * while the rest still report, so a transient outage never blanks the whole
957
973
  * view. Requires a usable API key (throws `MISSING_CREDENTIAL` otherwise).
974
+ * Pass `apiKey` to report on a specific account of a multi-account pool;
975
+ * the default resolves the currently active account.
958
976
  */
959
- async getUsage() {
977
+ async getUsage(apiKey) {
960
978
  const base = this.deps.options().apiBase;
961
- const headers = await this.accountHeaders();
979
+ const headers = await this.accountHeaders(apiKey);
962
980
  const failures = [];
963
981
  const getJson = async (path) => {
964
982
  try {
@@ -1036,6 +1054,35 @@ var CommandCodeAdapter = class extends LlmAdapter {
1036
1054
  }
1037
1055
  return report;
1038
1056
  }
1057
+ /**
1058
+ * Probe one account's five-hour window from `/alpha/billing/credits`. The
1059
+ * multi-account pool calls this when every account is marked exhausted: an
1060
+ * account whose window no longer reports `exceeded` is revived, and the
1061
+ * `resetAt` values feed the "earliest reset" error message. Returns
1062
+ * `undefined` when the probe itself failed (transport, non-200, or a
1063
+ * payload without window limits) — a failed probe never changes pool state.
1064
+ */
1065
+ async probeFiveHourWindow(apiKey) {
1066
+ try {
1067
+ const connection = this.deps.options();
1068
+ const response = await this.fetchImpl(`${connection.apiBase}/alpha/billing/credits`, {
1069
+ headers: await this.accountHeaders(apiKey),
1070
+ signal: AbortSignal.timeout(MODELS_TIMEOUT_MS)
1071
+ });
1072
+ if (!response.ok) return void 0;
1073
+ const parsed = await response.json();
1074
+ if (!isRecord(parsed)) return void 0;
1075
+ const windowLimits = isRecord(parsed.windowLimits) ? parsed.windowLimits : void 0;
1076
+ const fiveHour = windowLimits && isRecord(windowLimits.fiveHour) ? windowLimits.fiveHour : void 0;
1077
+ if (fiveHour === void 0) return void 0;
1078
+ return {
1079
+ exceeded: fiveHour.exceeded === true,
1080
+ resetAt: numberValue(fiveHour.resetAt) ?? 0
1081
+ };
1082
+ } catch {
1083
+ return;
1084
+ }
1085
+ }
1039
1086
  async *stream(options) {
1040
1087
  if (options.stop?.length) throw new LlmError("Command Code adapter does not support stop sequences", "UNSUPPORTED_OPTION");
1041
1088
  const hasImages = options.messages.some(hasImageContent);
@@ -1047,7 +1094,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
1047
1094
  readImage = (ref) => attachments.readImage(ref).then((stored) => stored.data);
1048
1095
  }
1049
1096
  const connection = this.deps.options();
1050
- const apiKey = await this.deps.resolveApiKey(connection);
1097
+ let apiKey = await this.deps.resolveApiKey(connection);
1051
1098
  const modelMax = this.catalog.find((m) => m.id === options.model)?.maxTokens ?? 65536;
1052
1099
  const maxTokens = Math.min(options.maxTokens ?? modelMax, modelMax, DEFAULT_GENERATE_MAX_TOKENS);
1053
1100
  const effort = options.reasoningEffort;
@@ -1086,58 +1133,83 @@ var CommandCodeAdapter = class extends LlmAdapter {
1086
1133
  },
1087
1134
  threadId: randomUUID()
1088
1135
  };
1089
- const connectAbort = new AbortController();
1090
- let connectTimedOut = false;
1091
- const connectTimer = setTimeout(() => {
1092
- connectTimedOut = true;
1093
- connectAbort.abort(new DOMException(`Command Code API request to ${connection.apiBase}/alpha/generate did not respond within ${connection.requestTimeoutMs}ms`, "TimeoutError"));
1094
- }, connection.requestTimeoutMs);
1095
- const onCallerAbort = () => {
1096
- connectAbort.abort(options.signal?.reason);
1097
- };
1098
- if (options.signal) {
1099
- if (options.signal.aborted) onCallerAbort();
1100
- else options.signal.addEventListener("abort", onCallerAbort, { once: true });
1101
- }
1102
- let response;
1103
- try {
1104
- response = await this.fetchImpl(`${connection.apiBase}/alpha/generate`, {
1105
- method: "POST",
1106
- headers: {
1107
- "Content-Type": "application/json",
1108
- Authorization: `Bearer ${apiKey}`,
1109
- "x-command-code-version": COMMAND_CODE_CLI_VERSION,
1110
- "x-cli-environment": "production",
1111
- "x-project-slug": projectSlugFromPath(connection.workingDir),
1112
- "x-taste-learning": "true",
1113
- "x-co-flag": "false",
1114
- ...attributionHeaders()
1115
- },
1116
- body: JSON.stringify(body),
1117
- signal: connectAbort.signal
1118
- });
1119
- clearTimeout(connectTimer);
1120
- } catch (error) {
1121
- clearTimeout(connectTimer);
1122
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1123
- if (options.signal?.aborted) throw error;
1124
- if (connectTimedOut || error instanceof DOMException && error.name === "TimeoutError") throw new LlmError(`Command Code API request to ${connection.apiBase}/alpha/generate did not respond within ${connection.requestTimeoutMs}ms: ${errorChain(error)}`, "TIMEOUT", { cause: error });
1125
- throw new LlmError(`Command Code API request to ${connection.apiBase}/alpha/generate failed: ${errorChain(error)}`, "TRANSPORT", { cause: error });
1126
- }
1127
- if (!response.ok) {
1128
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1129
- const errText = await response.text().catch(() => "");
1130
- let providerCode;
1136
+ const connect = async (key) => {
1137
+ const connectAbort = new AbortController();
1138
+ let connectTimedOut = false;
1139
+ const connectTimer = setTimeout(() => {
1140
+ connectTimedOut = true;
1141
+ connectAbort.abort(new DOMException(`Command Code API request to ${connection.apiBase}/alpha/generate did not respond within ${connection.requestTimeoutMs}ms`, "TimeoutError"));
1142
+ }, connection.requestTimeoutMs);
1143
+ const onCallerAbort = () => {
1144
+ connectAbort.abort(options.signal?.reason);
1145
+ };
1146
+ if (options.signal) {
1147
+ if (options.signal.aborted) onCallerAbort();
1148
+ else options.signal.addEventListener("abort", onCallerAbort, { once: true });
1149
+ }
1150
+ const cleanup = () => {
1151
+ clearTimeout(connectTimer);
1152
+ if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1153
+ };
1154
+ let response;
1131
1155
  try {
1132
- const parsed = JSON.parse(errText);
1133
- if (isRecord(parsed) && isRecord(parsed.error)) providerCode = stringValue(parsed.error.code);
1134
- } catch {}
1135
- const detail = providerCode ?? `HTTP ${response.status}`;
1136
- if (response.status === 401) throw 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 });
1137
- throw new LlmError(`Command Code API error ${response.status}${detail === `HTTP ${response.status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, response.status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", { status: response.status });
1156
+ response = await this.fetchImpl(`${connection.apiBase}/alpha/generate`, {
1157
+ method: "POST",
1158
+ headers: {
1159
+ "Content-Type": "application/json",
1160
+ Authorization: `Bearer ${key}`,
1161
+ "x-command-code-version": COMMAND_CODE_CLI_VERSION,
1162
+ "x-cli-environment": "production",
1163
+ "x-project-slug": projectSlugFromPath(connection.workingDir),
1164
+ "x-taste-learning": "true",
1165
+ "x-co-flag": "false",
1166
+ ...attributionHeaders()
1167
+ },
1168
+ body: JSON.stringify(body),
1169
+ signal: connectAbort.signal
1170
+ });
1171
+ clearTimeout(connectTimer);
1172
+ } catch (error) {
1173
+ cleanup();
1174
+ if (options.signal?.aborted) throw error;
1175
+ if (connectTimedOut || error instanceof DOMException && error.name === "TimeoutError") throw new LlmError(`Command Code API request to ${connection.apiBase}/alpha/generate did not respond within ${connection.requestTimeoutMs}ms: ${errorChain(error)}`, "TIMEOUT", { cause: error });
1176
+ throw new LlmError(`Command Code API request to ${connection.apiBase}/alpha/generate failed: ${errorChain(error)}`, "TRANSPORT", { cause: error });
1177
+ }
1178
+ if (!response.ok) {
1179
+ const errText = await response.text().catch(() => "");
1180
+ cleanup();
1181
+ return {
1182
+ status: response.status,
1183
+ errText
1184
+ };
1185
+ }
1186
+ return {
1187
+ response,
1188
+ cleanup
1189
+ };
1190
+ };
1191
+ const tried = /* @__PURE__ */ new Set();
1192
+ let connected;
1193
+ for (;;) {
1194
+ tried.add(apiKey);
1195
+ const attempt = await connect(apiKey);
1196
+ if ("response" in attempt) {
1197
+ connected = attempt;
1198
+ break;
1199
+ }
1200
+ const rotate = this.deps.rotateApiKey;
1201
+ if ((attempt.status === 429 || attempt.status === 401) && rotate !== void 0 && options.signal?.aborted !== true && tried.size < MAX_ACCOUNT_ROTATIONS) {
1202
+ const next = await rotate(apiKey, attempt.status === 429 ? "rate-limit" : "invalid-credential", connection);
1203
+ if (next !== void 0 && !tried.has(next)) {
1204
+ apiKey = next;
1205
+ continue;
1206
+ }
1207
+ }
1208
+ throw generateHttpError(attempt.status, attempt.errText);
1138
1209
  }
1210
+ const { response, cleanup } = connected;
1139
1211
  if (!response.body) {
1140
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1212
+ cleanup();
1141
1213
  throw new LlmError("Command Code API returned no response body", "PROVIDER_PROTOCOL_ERROR");
1142
1214
  }
1143
1215
  const reader = response.body.getReader();
@@ -1348,18 +1420,220 @@ var CommandCodeAdapter = class extends LlmAdapter {
1348
1420
  }
1349
1421
  } finally {
1350
1422
  clearIdle();
1351
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1423
+ cleanup();
1352
1424
  await reader.cancel().catch(() => void 0);
1353
1425
  reader.releaseLock();
1354
1426
  }
1355
1427
  }
1356
1428
  };
1429
+ /** Hard cap on account rotations within one request (one attempt per distinct key). */
1430
+ const MAX_ACCOUNT_ROTATIONS = 16;
1431
+ /**
1432
+ * Map a pre-stream generate HTTP failure onto a stable LlmError. Command
1433
+ * Code folds several business rejections into 403 (plan limits, CLI version,
1434
+ * model access): prefer the machine-readable `error.code` when present; the
1435
+ * status alone cannot distinguish them.
1436
+ */
1437
+ function generateHttpError(status, errText) {
1438
+ let providerCode;
1439
+ try {
1440
+ const parsed = JSON.parse(errText);
1441
+ if (isRecord(parsed) && isRecord(parsed.error)) providerCode = stringValue(parsed.error.code);
1442
+ } catch {}
1443
+ 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 });
1446
+ }
1357
1447
  function mapFinishReason(reason) {
1358
1448
  if (reason === "tool-calls") return { kind: "tool-calls" };
1359
1449
  if (reason === "length" || reason === "max_tokens" || reason === "max-tokens" || reason === "max_output_tokens") return { kind: "max-tokens" };
1360
1450
  return { kind: "stop" };
1361
1451
  }
1362
1452
  //#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
1363
1637
  //#region src/commands.ts
1364
1638
  /** Format a dollar amount. */
1365
1639
  function money(value) {
@@ -1392,11 +1666,18 @@ function bar(used, cap) {
1392
1666
  const filled = Math.round(ratio * 10);
1393
1667
  return "█".repeat(filled) + "░".repeat(10 - filled);
1394
1668
  }
1669
+ /** Render one account's rotation mark / cooldown as a short badge. */
1670
+ function markLabel(entry) {
1671
+ if (entry.mark === "invalid-credential") return " ⛔ 密钥无效";
1672
+ if (entry.cooldownUntil > 0) return ` ⏳ 限额冷却中,重置 ${resetLabel(entry.cooldownUntil)}`;
1673
+ if (entry.mark === "rate-limit") return " ⏳ 已达限额(等待窗口探测)";
1674
+ return "";
1675
+ }
1395
1676
  /** Render the usage report as a structured, aligned, bar-chart text view. */
1396
- function renderReport(report) {
1677
+ function renderReport(report, title) {
1397
1678
  const lines = [];
1398
1679
  const account = report.account ? ` (${report.account.userName || report.account.name})` : "";
1399
- lines.push(`📊 Command Code 用量${account}`, "");
1680
+ lines.push(title ?? `📊 Command Code 用量${account}`, "");
1400
1681
  if (report.plan && report.plan.name !== "") {
1401
1682
  const p = report.plan;
1402
1683
  const status = p.status !== "" && p.status !== "active" ? ` (${p.status})` : "";
@@ -1425,6 +1706,18 @@ function commandDefinition(deps) {
1425
1706
  input: { hint: "[status]" },
1426
1707
  handler: async () => {
1427
1708
  try {
1709
+ if (deps.reports !== void 0) {
1710
+ const { accounts } = await deps.reports();
1711
+ return {
1712
+ kind: "success",
1713
+ text: accounts.map((entry) => {
1714
+ const badges = `${entry.active ? " ✅ 当前使用" : ""}${markLabel(entry)}`;
1715
+ const title = `📊 ${entry.label}${badges}`;
1716
+ if (!entry.configured) return `${title}\n\n (未配置 API 密钥)`;
1717
+ return renderReport(entry.report, title);
1718
+ }).join("\n\n────────────────────\n\n")
1719
+ };
1720
+ }
1428
1721
  return {
1429
1722
  kind: "success",
1430
1723
  text: renderReport(await adapter.getUsage())
@@ -1541,12 +1834,31 @@ function parseUsageReport(value) {
1541
1834
  }
1542
1835
  return report;
1543
1836
  }
1837
+ /** Parse one untrusted boundary value into a {@link CommandCodeAccountUsage}. */
1838
+ function parseAccountUsage(value) {
1839
+ const source = record(value, "account");
1840
+ return {
1841
+ id: stringField(source, "id", "account.id"),
1842
+ label: stringField(source, "label", "account.label"),
1843
+ configured: booleanField(source, "configured", "account.configured"),
1844
+ active: booleanField(source, "active", "account.active"),
1845
+ mark: stringField(source, "mark", "account.mark"),
1846
+ cooldownUntil: numberField(source, "cooldownUntil", "account.cooldownUntil"),
1847
+ report: parseUsageReport(source.report)
1848
+ };
1849
+ }
1850
+ /** Parse the wire result into a {@link CommandCodeAccountsReport}. */
1851
+ function parseAccountsReport(value) {
1852
+ const accounts = record(value, "result").accounts;
1853
+ if (!Array.isArray(accounts)) reject("accounts");
1854
+ return { accounts: accounts.map(parseAccountUsage) };
1855
+ }
1544
1856
  /**
1545
1857
  * The strict result codec both halves attach to the descriptor. Hand-rolled:
1546
1858
  * the client bundle may not require a schema library, and `TypertSchema` is
1547
1859
  * deliberately minimal so one `parse` function satisfies it.
1548
1860
  */
1549
- const usageReportSchema = { parse: parseUsageReport };
1861
+ const usageReportSchema = { parse: parseAccountsReport };
1550
1862
  /** The Host-face contribution registered on `ctx.typert`. */
1551
1863
  const USAGE_HOST_CONTRIBUTION = {
1552
1864
  package: USAGE_REMOTE_PACKAGE,
@@ -1561,7 +1873,7 @@ const USAGE_HOST_CONTRIBUTION = {
1561
1873
  parameters: [],
1562
1874
  result: {
1563
1875
  mode: "strict",
1564
- typeSymbol: `${USAGE_REMOTE_PACKAGE}#CommandCodeUsageReport`,
1876
+ typeSymbol: `${USAGE_REMOTE_PACKAGE}#CommandCodeAccountsReport`,
1565
1877
  schema: usageReportSchema
1566
1878
  }
1567
1879
  }]
@@ -1582,13 +1894,24 @@ var CommandCodeUsageService = class extends TypertRemoteService {
1582
1894
  this.deps = deps;
1583
1895
  }
1584
1896
  /**
1585
- * Account, usage, and credit state for the settings page's account card.
1586
- * Degrades per endpoint like the `/commandcode` command (failures land in
1587
- * `report.failures`); throws `MISSING_CREDENTIAL` when no key resolves, which
1588
- * the Gateway folds into the failure branch the page renders as a hint.
1897
+ * Account, usage, and credit state for the settings page's account card
1898
+ * one entry per pool account when the plugin entry wired `reports`, a
1899
+ * single default-account entry otherwise. Degrades per endpoint like the
1900
+ * `/commandcode` command (failures land in `report.failures`); throws
1901
+ * `MISSING_CREDENTIAL` when no key resolves, which the Gateway folds into
1902
+ * the failure branch the page renders as a hint.
1589
1903
  */
1590
1904
  async report() {
1591
- return this.deps.adapter.getUsage();
1905
+ if (this.deps.reports !== void 0) return this.deps.reports();
1906
+ return { accounts: [{
1907
+ id: "default",
1908
+ label: "Default",
1909
+ configured: true,
1910
+ active: true,
1911
+ mark: "",
1912
+ cooldownUntil: 0,
1913
+ report: await this.deps.adapter.getUsage()
1914
+ }] };
1592
1915
  }
1593
1916
  };
1594
1917
  /**
@@ -1648,7 +1971,13 @@ const Config = z.object({
1648
1971
  modelsCachePath: z.string(),
1649
1972
  requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
1650
1973
  streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
1651
- filterModelsByPlan: z.boolean()
1974
+ filterModelsByPlan: z.boolean(),
1975
+ accounts: z.array(z.object({
1976
+ label: z.string(),
1977
+ apiKeyEnv: z.string().role("credential-ref"),
1978
+ apiKey: z.string()
1979
+ })),
1980
+ activeAccount: z.string()
1652
1981
  });
1653
1982
  /**
1654
1983
  * The one explicit resolve step from raw config to validated connection
@@ -1680,25 +2009,60 @@ function apply(ctx, config) {
1680
2009
  return next;
1681
2010
  };
1682
2011
  options();
2012
+ const slots = () => {
2013
+ const raw = current();
2014
+ const list = [{
2015
+ id: "default",
2016
+ label: "Default",
2017
+ ref: credentialRef(raw.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
2018
+ literal: raw.apiKey,
2019
+ allowAuthFile: true
2020
+ }];
2021
+ for (const [index, account] of (raw.accounts ?? []).entries()) {
2022
+ const refName = typeof account.apiKeyEnv === "string" && account.apiKeyEnv.trim() !== "" ? account.apiKeyEnv.trim() : void 0;
2023
+ const literal = typeof account.apiKey === "string" && account.apiKey !== "" ? account.apiKey : void 0;
2024
+ if (refName === void 0 && literal === void 0) continue;
2025
+ list.push({
2026
+ id: refName ?? `account-${index + 2}`,
2027
+ label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `Account ${index + 2}`,
2028
+ ref: refName === void 0 ? void 0 : credentialRef(refName),
2029
+ literal,
2030
+ allowAuthFile: false
2031
+ });
2032
+ }
2033
+ return list;
2034
+ };
2035
+ const preferredId = () => {
2036
+ const raw = current().activeAccount;
2037
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
2038
+ };
2039
+ const resolveRef = async (ref) => {
2040
+ const credentials = ctx.get("credentials");
2041
+ if (credentials !== void 0) return (await credentials.resolve(ref))?.value;
2042
+ const ambient = launchEnvironmentOf(ctx).get(ref);
2043
+ return ambient !== void 0 && ambient.value.length > 0 ? ambient.value : void 0;
2044
+ };
2045
+ const pool = new CommandCodeAccountPool({
2046
+ slots,
2047
+ resolveRef,
2048
+ authFileKey: resolveAuthFileApiKey,
2049
+ probeWindow: (apiKey) => adapter.probeFiveHourWindow(apiKey),
2050
+ preferredId
2051
+ });
1683
2052
  const resolveApiKey = async (connection) => {
1684
- const literal = current().apiKey;
1685
- if (literal) return assertUsableApiKey(literal, "llm-commandcode", "config.apiKey");
2053
+ const resolved = await pool.resolveKey();
2054
+ if (resolved !== void 0) return assertUsableApiKey(resolved.key, "llm-commandcode", resolved.slot.ref ?? `${resolved.slot.label} (config.apiKey)`);
1686
2055
  const ref = connection.apiKeyEnv;
1687
- const credentials = ctx.get("credentials");
1688
- if (credentials !== void 0) {
1689
- const hit = await credentials.resolve(ref);
1690
- if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-commandcode", ref);
1691
- } else {
1692
- const ambient = launchEnvironmentOf(ctx).get(ref);
1693
- if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-commandcode", ref);
1694
- }
1695
- const authFileKey = resolveAuthFileApiKey();
1696
- if (authFileKey) return assertUsableApiKey(authFileKey, "llm-commandcode", "~/.commandcode/auth.json");
1697
2056
  throw new LlmError(`llm-commandcode: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials service (the web Models page writes it), export it in the launching environment, set config.apiKey, or run \`command-code login\` to write ~/.commandcode/auth.json`, "MISSING_CREDENTIAL");
1698
2057
  };
1699
2058
  const adapter = new CommandCodeAdapter({
1700
2059
  options,
1701
2060
  resolveApiKey,
2061
+ rotateApiKey: async (rejectedKey, rejection) => {
2062
+ pool.markRejected(rejectedKey, rejection);
2063
+ const resolved = await pool.resolveKey({ exclude: rejectedKey });
2064
+ return resolved === void 0 ? void 0 : assertUsableApiKey(resolved.key, "llm-commandcode", resolved.slot.ref ?? `${resolved.slot.label} (config.apiKey)`);
2065
+ },
1702
2066
  resolveAttachments: () => {
1703
2067
  const attachments = ctx.get("attachments");
1704
2068
  return attachments === void 0 ? void 0 : attachments;
@@ -1711,10 +2075,42 @@ function apply(ctx, config) {
1711
2075
  settingsPath: []
1712
2076
  }]);
1713
2077
  ctx.llm.registerAdapter([PROVIDER], adapter);
2078
+ const usageReports = async () => {
2079
+ const described = await pool.describeAccounts();
2080
+ const byId = new Map(described.map((account) => [account.slot.id, account]));
2081
+ const active = selectActiveAccount(await pool.resolvedAccounts(), preferredId());
2082
+ return { accounts: await Promise.all(slots().map(async (slot) => {
2083
+ const account = byId.get(slot.id);
2084
+ let report;
2085
+ if (account === void 0) report = { failures: [] };
2086
+ else try {
2087
+ report = await adapter.getUsage(account.key);
2088
+ } catch (error) {
2089
+ report = { failures: [error instanceof Error ? error.message : String(error)] };
2090
+ }
2091
+ const state = account?.state;
2092
+ const usable = accountUsable(state);
2093
+ return {
2094
+ id: slot.id,
2095
+ label: slot.label,
2096
+ configured: account !== void 0,
2097
+ active: account !== void 0 && active?.slot.id === slot.id,
2098
+ mark: usable ? "" : state?.kind === "disabled" ? "invalid-credential" : "rate-limit",
2099
+ cooldownUntil: !usable && state?.kind === "cooldown" ? state.until : 0,
2100
+ report
2101
+ };
2102
+ })) };
2103
+ };
1714
2104
  ctx.inject(["commands"], (commandCtx) => {
1715
- applyCommands(commandCtx, { adapter });
2105
+ applyCommands(commandCtx, {
2106
+ adapter,
2107
+ reports: usageReports
2108
+ });
2109
+ });
2110
+ applyUsageRemote(ctx, {
2111
+ adapter,
2112
+ reports: usageReports
1716
2113
  });
1717
- applyUsageRemote(ctx, { adapter });
1718
2114
  installSettingsSection(ctx, NS, Config, config, {
1719
2115
  setSource: (source) => {
1720
2116
  current = source;
@@ -1723,6 +2119,6 @@ function apply(ctx, config) {
1723
2119
  });
1724
2120
  }
1725
2121
  //#endregion
1726
- export { BILLING_ACCESS_TTL_MS, COMMAND_CODE_CLI_VERSION, CommandCodeAdapter, CommandCodeUsageService, Config, DEFAULT_API_BASE, DEFAULT_GENERATE_MAX_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MODELS_CACHE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, KNOWN_DEALS, KNOWN_EFFORTS, KNOWN_IMAGE_MODELS, KNOWN_PEAK_PRICING, KNOWN_PLANS, KNOWN_SUBSCRIPTION_PLANS, KNOWN_THINKING_MODELS, PLAN_LABELS, PLAN_ORDER, PROVIDER, USAGE_REPORT_ENDPOINT, apply, applyCommands, applyUsageRemote, capabilityDescription, commandDefinition, compareByPlan, dealLabel, formatContext, inject, modelVisibleInPlan, name, peakPricingLabel, peakPricingState, planLabel, projectSlugFromPath, resolveAdapterOptions, resolveAuthFileApiKey, subscriptionPlanInfo, usageReportSchema };
2122
+ export { BILLING_ACCESS_TTL_MS, COMMAND_CODE_CLI_VERSION, CommandCodeAccountPool, CommandCodeAdapter, CommandCodeUsageService, Config, DEFAULT_API_BASE, DEFAULT_GENERATE_MAX_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MODELS_CACHE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, KNOWN_DEALS, KNOWN_EFFORTS, KNOWN_IMAGE_MODELS, KNOWN_PEAK_PRICING, KNOWN_PLANS, KNOWN_SUBSCRIPTION_PLANS, KNOWN_THINKING_MODELS, PLAN_LABELS, PLAN_ORDER, PROVIDER, USAGE_REPORT_ENDPOINT, accountUsable, apply, applyCommands, applyUsageRemote, capabilityDescription, commandDefinition, compareByPlan, dealLabel, formatContext, inject, modelVisibleInPlan, name, peakPricingLabel, peakPricingState, planLabel, projectSlugFromPath, resolveAdapterOptions, resolveAuthFileApiKey, selectActiveAccount, subscriptionPlanInfo, usageReportSchema };
1727
2123
 
1728
2124
  //# sourceMappingURL=index.js.map