@mars-sea/dsh-commandcode-provider 0.4.1 → 0.5.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.1):
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.1) 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.1 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.1 `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.1";
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;
@@ -685,14 +692,28 @@ async function writeModelsCache(cachePath, models) {
685
692
  await rm(tmp, { force: true }).catch(() => void 0);
686
693
  }
687
694
  }
688
- function pairedToolCallIds(messages) {
695
+ /**
696
+ * Collect the tool calls that have a paired tool result, plus each call's
697
+ * name. The name map feeds the `toolName` of replayed tool results: some
698
+ * backends (e.g. Google Gemini `functionResponse`) reject a result whose
699
+ * function name is empty, so the real name must round-trip (the official
700
+ * CLI does the same via its `tool_use_id -> toolName` map).
701
+ */
702
+ function pairedToolCalls(messages) {
689
703
  const callIds = /* @__PURE__ */ new Set();
704
+ const names = /* @__PURE__ */ new Map();
690
705
  const resultIds = /* @__PURE__ */ new Set();
691
706
  for (const message of messages) for (const block of message.content) {
692
- if (message.role === "assistant" && block.type === "tool-call") callIds.add(block.id);
707
+ if (message.role === "assistant" && block.type === "tool-call") {
708
+ callIds.add(block.id);
709
+ names.set(block.id, block.name);
710
+ }
693
711
  if (block.type === "tool-result") resultIds.add(block.toolCallId);
694
712
  }
695
- return new Set([...callIds].filter((id) => resultIds.has(id)));
713
+ return {
714
+ ids: new Set([...callIds].filter((id) => resultIds.has(id))),
715
+ names
716
+ };
696
717
  }
697
718
  function blockText(block) {
698
719
  return block.type === "text" || block.type === "reasoning" ? block.text : "";
@@ -723,7 +744,7 @@ async function imageToCommandCode(ref, readImage) {
723
744
  }
724
745
  async function messagesToCC(messages, readImage) {
725
746
  const out = [];
726
- const paired = pairedToolCallIds(messages);
747
+ const { ids: paired, names: toolNames } = pairedToolCalls(messages);
727
748
  for (const message of messages) {
728
749
  if (message.role === "system") continue;
729
750
  if (message.role === "user" && message.source.kind !== "tool") {
@@ -770,7 +791,7 @@ async function messagesToCC(messages, readImage) {
770
791
  content: [{
771
792
  type: "tool-result",
772
793
  toolCallId: block.toolCallId,
773
- toolName: "",
794
+ toolName: toolNames.get(block.toolCallId) || "unknown",
774
795
  output: block.isError ? {
775
796
  type: "error-text",
776
797
  value: toolResultText(block)
@@ -789,8 +810,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
789
810
  catalog = [];
790
811
  fetchImpl;
791
812
  resolveAttachments;
792
- billingAccess;
793
- billingAccessInflight;
813
+ billingAccess = /* @__PURE__ */ new Map();
814
+ billingAccessInflight = /* @__PURE__ */ new Map();
794
815
  constructor(deps) {
795
816
  super();
796
817
  this.deps = deps;
@@ -863,10 +884,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
863
884
  };
864
885
  }
865
886
  /** The headers every authenticated account endpoint shares. */
866
- async accountHeaders() {
887
+ async accountHeaders(apiKey) {
867
888
  const connection = this.deps.options();
868
889
  return {
869
- Authorization: `Bearer ${await this.deps.resolveApiKey(connection)}`,
890
+ Authorization: `Bearer ${apiKey ?? await this.deps.resolveApiKey(connection)}`,
870
891
  "x-command-code-version": COMMAND_CODE_CLI_VERSION,
871
892
  "x-cli-environment": "production",
872
893
  ...attributionHeaders()
@@ -878,18 +899,27 @@ var CommandCodeAdapter = class extends LlmAdapter {
878
899
  * `undefined` means "unknown — show everything" (fail-open).
879
900
  */
880
901
  async loadBillingAccess() {
881
- 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);
882
909
  if (cached !== void 0 && Date.now() - cached.at < 3e5) return cached.value;
883
- this.billingAccessInflight ??= this.fetchBillingAccess().then((value) => {
884
- 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, {
885
914
  value,
886
915
  at: Date.now()
887
- };
916
+ });
888
917
  return value;
889
918
  }).finally(() => {
890
- this.billingAccessInflight = void 0;
919
+ this.billingAccessInflight.delete(apiKey);
891
920
  });
892
- return this.billingAccessInflight;
921
+ this.billingAccessInflight.set(apiKey, inflight);
922
+ return inflight;
893
923
  }
894
924
  /**
895
925
  * The billing facts behind the picker's plan filter, mirroring the CLI's
@@ -900,10 +930,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
900
930
  * fallback (the CLI stamps plan identity from it too). Any failure resolves
901
931
  * to `undefined` (fail-open) rather than breaking the picker.
902
932
  */
903
- async fetchBillingAccess() {
933
+ async fetchBillingAccess(apiKey) {
904
934
  try {
905
935
  const connection = this.deps.options();
906
- const headers = await this.accountHeaders();
936
+ const headers = await this.accountHeaders(apiKey);
907
937
  const base = connection.apiBase;
908
938
  const getJson = async (path) => {
909
939
  const response = await this.fetchImpl(`${base}${path}`, {
@@ -941,10 +971,12 @@ var CommandCodeAdapter = class extends LlmAdapter {
941
971
  * Each endpoint degrades independently: a failed one lands in `failures`
942
972
  * while the rest still report, so a transient outage never blanks the whole
943
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.
944
976
  */
945
- async getUsage() {
977
+ async getUsage(apiKey) {
946
978
  const base = this.deps.options().apiBase;
947
- const headers = await this.accountHeaders();
979
+ const headers = await this.accountHeaders(apiKey);
948
980
  const failures = [];
949
981
  const getJson = async (path) => {
950
982
  try {
@@ -1022,6 +1054,35 @@ var CommandCodeAdapter = class extends LlmAdapter {
1022
1054
  }
1023
1055
  return report;
1024
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
+ }
1025
1086
  async *stream(options) {
1026
1087
  if (options.stop?.length) throw new LlmError("Command Code adapter does not support stop sequences", "UNSUPPORTED_OPTION");
1027
1088
  const hasImages = options.messages.some(hasImageContent);
@@ -1033,7 +1094,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
1033
1094
  readImage = (ref) => attachments.readImage(ref).then((stored) => stored.data);
1034
1095
  }
1035
1096
  const connection = this.deps.options();
1036
- const apiKey = await this.deps.resolveApiKey(connection);
1097
+ let apiKey = await this.deps.resolveApiKey(connection);
1037
1098
  const modelMax = this.catalog.find((m) => m.id === options.model)?.maxTokens ?? 65536;
1038
1099
  const maxTokens = Math.min(options.maxTokens ?? modelMax, modelMax, DEFAULT_GENERATE_MAX_TOKENS);
1039
1100
  const effort = options.reasoningEffort;
@@ -1072,58 +1133,83 @@ var CommandCodeAdapter = class extends LlmAdapter {
1072
1133
  },
1073
1134
  threadId: randomUUID()
1074
1135
  };
1075
- const connectAbort = new AbortController();
1076
- let connectTimedOut = false;
1077
- const connectTimer = setTimeout(() => {
1078
- connectTimedOut = true;
1079
- connectAbort.abort(new DOMException(`Command Code API request to ${connection.apiBase}/alpha/generate did not respond within ${connection.requestTimeoutMs}ms`, "TimeoutError"));
1080
- }, connection.requestTimeoutMs);
1081
- const onCallerAbort = () => {
1082
- connectAbort.abort(options.signal?.reason);
1083
- };
1084
- if (options.signal) {
1085
- if (options.signal.aborted) onCallerAbort();
1086
- else options.signal.addEventListener("abort", onCallerAbort, { once: true });
1087
- }
1088
- let response;
1089
- try {
1090
- response = await this.fetchImpl(`${connection.apiBase}/alpha/generate`, {
1091
- method: "POST",
1092
- headers: {
1093
- "Content-Type": "application/json",
1094
- Authorization: `Bearer ${apiKey}`,
1095
- "x-command-code-version": COMMAND_CODE_CLI_VERSION,
1096
- "x-cli-environment": "production",
1097
- "x-project-slug": projectSlugFromPath(connection.workingDir),
1098
- "x-taste-learning": "true",
1099
- "x-co-flag": "false",
1100
- ...attributionHeaders()
1101
- },
1102
- body: JSON.stringify(body),
1103
- signal: connectAbort.signal
1104
- });
1105
- clearTimeout(connectTimer);
1106
- } catch (error) {
1107
- clearTimeout(connectTimer);
1108
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1109
- if (options.signal?.aborted) throw error;
1110
- 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 });
1111
- throw new LlmError(`Command Code API request to ${connection.apiBase}/alpha/generate failed: ${errorChain(error)}`, "TRANSPORT", { cause: error });
1112
- }
1113
- if (!response.ok) {
1114
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1115
- const errText = await response.text().catch(() => "");
1116
- 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;
1117
1155
  try {
1118
- const parsed = JSON.parse(errText);
1119
- if (isRecord(parsed) && isRecord(parsed.error)) providerCode = stringValue(parsed.error.code);
1120
- } catch {}
1121
- const detail = providerCode ?? `HTTP ${response.status}`;
1122
- 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 });
1123
- 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);
1124
1209
  }
1210
+ const { response, cleanup } = connected;
1125
1211
  if (!response.body) {
1126
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1212
+ cleanup();
1127
1213
  throw new LlmError("Command Code API returned no response body", "PROVIDER_PROTOCOL_ERROR");
1128
1214
  }
1129
1215
  const reader = response.body.getReader();
@@ -1334,18 +1420,220 @@ var CommandCodeAdapter = class extends LlmAdapter {
1334
1420
  }
1335
1421
  } finally {
1336
1422
  clearIdle();
1337
- if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
1423
+ cleanup();
1338
1424
  await reader.cancel().catch(() => void 0);
1339
1425
  reader.releaseLock();
1340
1426
  }
1341
1427
  }
1342
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
+ }
1343
1447
  function mapFinishReason(reason) {
1344
1448
  if (reason === "tool-calls") return { kind: "tool-calls" };
1345
1449
  if (reason === "length" || reason === "max_tokens" || reason === "max-tokens" || reason === "max_output_tokens") return { kind: "max-tokens" };
1346
1450
  return { kind: "stop" };
1347
1451
  }
1348
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
1349
1637
  //#region src/commands.ts
1350
1638
  /** Format a dollar amount. */
1351
1639
  function money(value) {
@@ -1378,11 +1666,18 @@ function bar(used, cap) {
1378
1666
  const filled = Math.round(ratio * 10);
1379
1667
  return "█".repeat(filled) + "░".repeat(10 - filled);
1380
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
+ }
1381
1676
  /** Render the usage report as a structured, aligned, bar-chart text view. */
1382
- function renderReport(report) {
1677
+ function renderReport(report, title) {
1383
1678
  const lines = [];
1384
1679
  const account = report.account ? ` (${report.account.userName || report.account.name})` : "";
1385
- lines.push(`📊 Command Code 用量${account}`, "");
1680
+ lines.push(title ?? `📊 Command Code 用量${account}`, "");
1386
1681
  if (report.plan && report.plan.name !== "") {
1387
1682
  const p = report.plan;
1388
1683
  const status = p.status !== "" && p.status !== "active" ? ` (${p.status})` : "";
@@ -1411,6 +1706,18 @@ function commandDefinition(deps) {
1411
1706
  input: { hint: "[status]" },
1412
1707
  handler: async () => {
1413
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
+ }
1414
1721
  return {
1415
1722
  kind: "success",
1416
1723
  text: renderReport(await adapter.getUsage())
@@ -1527,12 +1834,31 @@ function parseUsageReport(value) {
1527
1834
  }
1528
1835
  return report;
1529
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
+ }
1530
1856
  /**
1531
1857
  * The strict result codec both halves attach to the descriptor. Hand-rolled:
1532
1858
  * the client bundle may not require a schema library, and `TypertSchema` is
1533
1859
  * deliberately minimal so one `parse` function satisfies it.
1534
1860
  */
1535
- const usageReportSchema = { parse: parseUsageReport };
1861
+ const usageReportSchema = { parse: parseAccountsReport };
1536
1862
  /** The Host-face contribution registered on `ctx.typert`. */
1537
1863
  const USAGE_HOST_CONTRIBUTION = {
1538
1864
  package: USAGE_REMOTE_PACKAGE,
@@ -1547,7 +1873,7 @@ const USAGE_HOST_CONTRIBUTION = {
1547
1873
  parameters: [],
1548
1874
  result: {
1549
1875
  mode: "strict",
1550
- typeSymbol: `${USAGE_REMOTE_PACKAGE}#CommandCodeUsageReport`,
1876
+ typeSymbol: `${USAGE_REMOTE_PACKAGE}#CommandCodeAccountsReport`,
1551
1877
  schema: usageReportSchema
1552
1878
  }
1553
1879
  }]
@@ -1568,13 +1894,24 @@ var CommandCodeUsageService = class extends TypertRemoteService {
1568
1894
  this.deps = deps;
1569
1895
  }
1570
1896
  /**
1571
- * Account, usage, and credit state for the settings page's account card.
1572
- * Degrades per endpoint like the `/commandcode` command (failures land in
1573
- * `report.failures`); throws `MISSING_CREDENTIAL` when no key resolves, which
1574
- * 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.
1575
1903
  */
1576
1904
  async report() {
1577
- 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
+ }] };
1578
1915
  }
1579
1916
  };
1580
1917
  /**
@@ -1634,7 +1971,13 @@ const Config = z.object({
1634
1971
  modelsCachePath: z.string(),
1635
1972
  requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
1636
1973
  streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
1637
- 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()
1638
1981
  });
1639
1982
  /**
1640
1983
  * The one explicit resolve step from raw config to validated connection
@@ -1666,25 +2009,60 @@ function apply(ctx, config) {
1666
2009
  return next;
1667
2010
  };
1668
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
+ });
1669
2052
  const resolveApiKey = async (connection) => {
1670
- const literal = current().apiKey;
1671
- 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)`);
1672
2055
  const ref = connection.apiKeyEnv;
1673
- const credentials = ctx.get("credentials");
1674
- if (credentials !== void 0) {
1675
- const hit = await credentials.resolve(ref);
1676
- if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-commandcode", ref);
1677
- } else {
1678
- const ambient = launchEnvironmentOf(ctx).get(ref);
1679
- if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-commandcode", ref);
1680
- }
1681
- const authFileKey = resolveAuthFileApiKey();
1682
- if (authFileKey) return assertUsableApiKey(authFileKey, "llm-commandcode", "~/.commandcode/auth.json");
1683
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");
1684
2057
  };
1685
2058
  const adapter = new CommandCodeAdapter({
1686
2059
  options,
1687
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
+ },
1688
2066
  resolveAttachments: () => {
1689
2067
  const attachments = ctx.get("attachments");
1690
2068
  return attachments === void 0 ? void 0 : attachments;
@@ -1697,10 +2075,42 @@ function apply(ctx, config) {
1697
2075
  settingsPath: []
1698
2076
  }]);
1699
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
+ };
1700
2104
  ctx.inject(["commands"], (commandCtx) => {
1701
- applyCommands(commandCtx, { adapter });
2105
+ applyCommands(commandCtx, {
2106
+ adapter,
2107
+ reports: usageReports
2108
+ });
2109
+ });
2110
+ applyUsageRemote(ctx, {
2111
+ adapter,
2112
+ reports: usageReports
1702
2113
  });
1703
- applyUsageRemote(ctx, { adapter });
1704
2114
  installSettingsSection(ctx, NS, Config, config, {
1705
2115
  setSource: (source) => {
1706
2116
  current = source;
@@ -1709,6 +2119,6 @@ function apply(ctx, config) {
1709
2119
  });
1710
2120
  }
1711
2121
  //#endregion
1712
- 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 };
1713
2123
 
1714
2124
  //# sourceMappingURL=index.js.map