@mars-sea/dsh-commandcode-provider 0.5.0 → 0.6.1

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.d.ts CHANGED
@@ -24,7 +24,7 @@ declare const KNOWN_EFFORTS: Readonly<Record<string, readonly string[]>>;
24
24
  */
25
25
  declare const KNOWN_IMAGE_MODELS: ReadonlySet<string>;
26
26
  /**
27
- * Models the official CLI's model table (`ZA` in command-code@1.28.1) marks
27
+ * Models the official CLI's model table (`ZA` in command-code@1.31.0) marks
28
28
  * `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
29
29
  * think automatically, with Command Code driving the depth. This is the
30
30
  * authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
@@ -32,7 +32,7 @@ declare const KNOWN_IMAGE_MODELS: ReadonlySet<string>;
32
32
  * effort levels, and this snapshot is not surfaced in the picker's compact
33
33
  * description — it exists for programmatic consumers.
34
34
  *
35
- * Source: the command-code@1.28.1 bundled model table (dist/cli.mjs, the `ZA`
35
+ * Source: the command-code@1.31.0 bundled model table (dist/cli.mjs, the `ZA`
36
36
  * object), cross-checked with https://commandcode.ai/docs/reference/cli/models.
37
37
  * Keep in sync via the dsh-commandcode-upstream skill.
38
38
  */
@@ -73,7 +73,7 @@ declare function compareByPlan(a: {
73
73
  }): number;
74
74
  /**
75
75
  * Subscription plan table, synced from the official CLI bundle's plan maps
76
- * (`Nn`/`$n` in command-code@1.28.1 `dist/cli.mjs`): subscription `planId`
76
+ * (`Nn`/`$n` in command-code@1.31.0 `dist/cli.mjs`): subscription `planId`
77
77
  * prefix → display name and the plan's monthly credit total. This is the
78
78
  * account's own subscription (from `/alpha/billing/subscriptions`) — distinct
79
79
  * from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
@@ -172,7 +172,7 @@ declare function peakPricingState(modelId: string, now?: number): 'peak' | 'off-
172
172
  * for models without time-of-day pricing.
173
173
  */
174
174
  declare function peakPricingLabel(modelId: string, now?: number): string | undefined;
175
- declare const COMMAND_CODE_CLI_VERSION = "1.28.1";
175
+ declare const COMMAND_CODE_CLI_VERSION = "1.31.0";
176
176
  declare const DEFAULT_API_BASE = "https://api.commandcode.ai";
177
177
  declare const DEFAULT_GENERATE_MAX_TOKENS = 64000;
178
178
  declare const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -331,12 +331,21 @@ declare class CommandCodeAdapter<C extends CommandCodeConnectionOptions = Comman
331
331
  private readonly billingAccessInflight;
332
332
  constructor(deps: CommandCodeAdapterDeps<C>);
333
333
  /**
334
- * Command Code is a metered subscription API: 429 (rate limit) and 5xx
335
- * (transient server errors) are worth retrying at the agent-step boundary,
336
- * which is where dsh-llm-retry executes the policy returned here. The
337
- * default policy already retries `RATE_LIMIT` and `SERVER`; declaring it
338
- * explicitly documents the intent and gives the plugin entry a stable hook
339
- * to override (e.g. a stricter cap for a metered plan).
334
+ * Near-unbounded retry for transient failures only (`mode: 'normal'` with
335
+ * an explicit 1000-attempt cap opencode-style persistence without the
336
+ * unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
337
+ * `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
338
+ * and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
339
+ * recovers in-session instead of failing after two tries. Permanent
340
+ * failures (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`,
341
+ * plan rejections) are absent from the whitelist and surface immediately
342
+ * instead of looping. Waits the pool/adapter attach as
343
+ * `providerRetryAfterMs` are honored verbatim at or below the 15-minute
344
+ * cap and never attached above it (in normal mode a longer attached wait
345
+ * makes the executor abandon the retry outright — see RETRY_MAX_DELAY_MS).
346
+ *
347
+ * Captured once at route registration (dsh-llm snapshots this value), so a
348
+ * future config knob for it would apply on profile restart, not per request.
340
349
  */
341
350
  providerRetryPolicy(_provider: string): ResolvedRetryPolicy;
342
351
  /** Refresh the catalog (live fetch, cache fallback) and return it. */
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`, "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)", "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.1):
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.1) 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.1 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",
@@ -369,7 +569,7 @@ function compareByPlan(a, b) {
369
569
  }
370
570
  /**
371
571
  * Subscription plan table, synced from the official CLI bundle's plan maps
372
- * (`Nn`/`$n` in command-code@1.28.1 `dist/cli.mjs`): subscription `planId`
572
+ * (`Nn`/`$n` in command-code@1.31.0 `dist/cli.mjs`): subscription `planId`
373
573
  * prefix → display name and the plan's monthly credit total. This is the
374
574
  * account's own subscription (from `/alpha/billing/subscriptions`) — distinct
375
575
  * from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
@@ -461,6 +661,10 @@ const KNOWN_DEALS = {
461
661
  "poolside/laguna-s-2.1-free": {
462
662
  label: "FREE",
463
663
  free: true
664
+ },
665
+ "stealth/ox-alpha": {
666
+ label: "FREE",
667
+ free: true
464
668
  }
465
669
  };
466
670
  /**
@@ -499,7 +703,7 @@ function peakPricingLabel(modelId, now = Date.now()) {
499
703
  if (state === void 0) return void 0;
500
704
  return state === "peak" ? "Peak" : "Half";
501
705
  }
502
- const COMMAND_CODE_CLI_VERSION = "1.28.1";
706
+ const COMMAND_CODE_CLI_VERSION = "1.31.0";
503
707
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
504
708
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
505
709
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -819,15 +1023,39 @@ var CommandCodeAdapter = class extends LlmAdapter {
819
1023
  this.resolveAttachments = deps.resolveAttachments;
820
1024
  }
821
1025
  /**
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).
1026
+ * Near-unbounded retry for transient failures only (`mode: 'normal'` with
1027
+ * an explicit 1000-attempt cap opencode-style persistence without the
1028
+ * unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
1029
+ * `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
1030
+ * and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
1031
+ * recovers in-session instead of failing after two tries. Permanent
1032
+ * failures (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`,
1033
+ * plan rejections) are absent from the whitelist and surface immediately
1034
+ * instead of looping. Waits the pool/adapter attach as
1035
+ * `providerRetryAfterMs` are honored verbatim at or below the 15-minute
1036
+ * cap and never attached above it (in normal mode a longer attached wait
1037
+ * makes the executor abandon the retry outright — see RETRY_MAX_DELAY_MS).
1038
+ *
1039
+ * Captured once at route registration (dsh-llm snapshots this value), so a
1040
+ * future config knob for it would apply on profile restart, not per request.
828
1041
  */
829
1042
  providerRetryPolicy(_provider) {
830
- return resolveRetryPolicy(void 0, "llm-commandcode: retryPolicy");
1043
+ return resolveRetryPolicy({
1044
+ mode: "normal",
1045
+ maxRetries: 1e3,
1046
+ retryableCodes: [
1047
+ "EMPTY_RESPONSE",
1048
+ "RATE_LIMIT",
1049
+ "SERVER",
1050
+ "TIMEOUT",
1051
+ "TRANSPORT"
1052
+ ],
1053
+ backoff: {
1054
+ initialDelayMs: 500,
1055
+ maxDelayMs: RETRY_MAX_DELAY_MS,
1056
+ jitterRatio: .1
1057
+ }
1058
+ }, "llm-commandcode: retryPolicy");
831
1059
  }
832
1060
  /** Refresh the catalog (live fetch, cache fallback) and return it. */
833
1061
  async loadCatalog(signal) {
@@ -1178,9 +1406,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
1178
1406
  if (!response.ok) {
1179
1407
  const errText = await response.text().catch(() => "");
1180
1408
  cleanup();
1181
- return {
1409
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
1410
+ return retryAfterMs === void 0 ? {
1182
1411
  status: response.status,
1183
1412
  errText
1413
+ } : {
1414
+ status: response.status,
1415
+ errText,
1416
+ retryAfterMs
1184
1417
  };
1185
1418
  }
1186
1419
  return {
@@ -1205,7 +1438,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
1205
1438
  continue;
1206
1439
  }
1207
1440
  }
1208
- throw generateHttpError(attempt.status, attempt.errText);
1441
+ throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs);
1209
1442
  }
1210
1443
  const { response, cleanup } = connected;
1211
1444
  if (!response.body) {
@@ -1432,9 +1665,13 @@ const MAX_ACCOUNT_ROTATIONS = 16;
1432
1665
  * Map a pre-stream generate HTTP failure onto a stable LlmError. Command
1433
1666
  * Code folds several business rejections into 403 (plan limits, CLI version,
1434
1667
  * model access): prefer the machine-readable `error.code` when present; the
1435
- * status alone cannot distinguish them.
1668
+ * status alone cannot distinguish them. A 429's `Retry-After` rides along as
1669
+ * `providerRetryAfterMs` so dsh-llm-retry can wait exactly that long instead
1670
+ * of guessing at the backoff cadence — capped at RETRY_MAX_DELAY_MS, because
1671
+ * in normal mode a longer attached wait makes the executor abandon the retry
1672
+ * outright instead of falling back to local backoff.
1436
1673
  */
1437
- function generateHttpError(status, errText) {
1674
+ function generateHttpError(status, errText, retryAfterMs) {
1438
1675
  let providerCode;
1439
1676
  try {
1440
1677
  const parsed = JSON.parse(errText);
@@ -1442,7 +1679,30 @@ function generateHttpError(status, errText) {
1442
1679
  } catch {}
1443
1680
  const detail = providerCode ?? `HTTP ${status}`;
1444
1681
  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 });
1682
+ return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
1683
+ status,
1684
+ ...retryAfterMs !== void 0 && retryAfterMs > 0 && retryAfterMs <= 9e5 ? { providerRetryAfterMs: retryAfterMs } : {}
1685
+ });
1686
+ }
1687
+ /**
1688
+ * Parse an HTTP `Retry-After` value (delay-seconds or an HTTP-date) into
1689
+ * milliseconds; undefined when absent or unparseable. An HTTP-date in the
1690
+ * past yields 0, which the caller drops (LlmError wants a positive delay).
1691
+ * A delay-seconds value whose millisecond product is not finite (e.g. `1e308`)
1692
+ * also yields undefined: LlmError validates its options and would otherwise
1693
+ * replace the provider failure with an internal construction error.
1694
+ */
1695
+ function parseRetryAfterMs(value, now = Date.now()) {
1696
+ if (value === void 0 || value === null) return void 0;
1697
+ const trimmed = value.trim();
1698
+ if (trimmed === "") return void 0;
1699
+ const seconds = Number(trimmed);
1700
+ if (Number.isFinite(seconds) && seconds >= 0) {
1701
+ const ms = seconds * 1e3;
1702
+ return Number.isFinite(ms) ? Math.round(ms) : void 0;
1703
+ }
1704
+ const date = Date.parse(trimmed);
1705
+ if (!Number.isNaN(date)) return Math.max(0, date - now);
1446
1706
  }
1447
1707
  function mapFinishReason(reason) {
1448
1708
  if (reason === "tool-calls") return { kind: "tool-calls" };
@@ -1450,190 +1710,6 @@ function mapFinishReason(reason) {
1450
1710
  return { kind: "stop" };
1451
1711
  }
1452
1712
  //#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
1713
  //#region src/commands.ts
1638
1714
  /** Format a dollar amount. */
1639
1715
  function money(value) {