@mars-sea/dsh-commandcode-provider 0.10.5 → 0.11.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
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
5
- import { LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, isContextWindowExceededError, offloadRequestImagesWithPolicy, offloadedImageText, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
6
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
7
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
8
8
  import { existsSync, readFileSync } from "node:fs";
@@ -12,288 +12,27 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
12
12
  import { createServer } from "node:http";
13
13
  import { createServer as createServer$1 } from "node:net";
14
14
  import { WebError } from "@deepseek-ai/dsh-web";
15
- //#region src/accounts.ts
15
+ //#region src/capabilities.ts
16
16
  /**
17
- * Multi-account pool for the Command Code provider (host side).
18
- *
19
- * One Command Code subscription (e.g. the Go plan's 5-hour window) is
20
- * metered; a user with several subscriptions wants a request that hits one
21
- * account's limit to continue on the next account without a visible failure.
22
- * This module owns that rotation:
17
+ * Static capability snapshot for the Command Code provider: model →
18
+ * reasoning-effort levels, vision/thinking flags, model → minimum plan tier,
19
+ * subscription-plan labels, deals, and hourly (peak/off-peak) pricing.
23
20
  *
24
- * - {@link CommandCodeAccountPool.resolveKey} hands out the first account
25
- * whose key is not currently marked exhausted — or, when the request's
26
- * model matches a {@link CommandCodeModelAccountRule} and that account is
27
- * usable, the routed account — resolving each slot's key lazily (literal
28
- * config key → credential seam → launch environment → the official CLI
29
- * auth file for the default slot only).
30
- * - {@link CommandCodeAccountPool.markRejected} records a 429 (rate limit,
31
- * window unknown) or 401 (invalid key, disabled until the config changes)
32
- * against the exact API key, so several slots sharing one key share one
33
- * state.
34
- * - When every account is marked, the pool probes each key's
35
- * `/alpha/billing/credits` window limits (through the injected
36
- * {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
37
- * no longer reports `exceeded` is revived, otherwise the pool throws a
38
- * `RATE_LIMIT` error naming the earliest reset time.
21
+ * Everything in this module is synced from official sources (the command-code
22
+ * CLI bundle's model table and the official plan/pricing/model docs — see the
23
+ * dsh-commandcode-upstream skill for the exact extraction procedures), and
24
+ * changes whenever an upstream CLI release reshuffles models/plans/prices.
25
+ * Keeping the snapshot in its own module confines those frequent sync diffs
26
+ * here: src/adapter.ts holds only the stable wire/runtime logic and imports
27
+ * these tables + read helpers.
39
28
  *
40
- * The pool is deliberately cordis-free (like the adapter): every host fact
41
- * arrives through injected thunks, so node tests can drive it directly.
29
+ * Snapshot read helpers (planLabel, dealLabel, formatContext,
30
+ * capabilityDescription, peakPricing*, compareByPlan, modelVisibleInPlan,
31
+ * subscriptionPlanInfo, isFreeModel) live here too — they exist only to read
32
+ * the tables, so a sync never has to touch src/adapter.ts.
42
33
  *
43
- * @module dsh-commandcode-provider/accounts
44
- */
45
- /**
46
- * Upper bound on the retry wait this pool attaches to the all-exhausted
47
- * `RATE_LIMIT` error. Must equal the `backoff.maxDelayMs` in the adapter's
48
- * `providerRetryPolicy` (which imports it from here): dsh-llm-retry honors a
49
- * provider-specified wait verbatim only at or below that cap — in normal mode
50
- * a LONGER attached wait makes the executor abandon the retry entirely
51
- * instead of falling back to local backoff, which would turn "poll until the
52
- * window opens" into "fail now".
53
- */
54
- const RETRY_MAX_DELAY_MS = 9e5;
55
- /** A labeled, human-readable clock reading for error messages. */
56
- function clockLabel(ms) {
57
- return new Date(ms).toLocaleString();
58
- }
59
- /**
60
- * Whether an account with this rotation state can serve a request right now.
61
- * `undefined` (never rejected) is usable; a cooldown becomes usable again
62
- * once its reset time passes; `unknown` (429, reset unprobed) and
63
- * `disabled` (401) are not.
64
- */
65
- function accountUsable(state) {
66
- if (state === void 0) return true;
67
- if (state.kind === "cooldown") return state.until > 0 && Date.now() >= state.until;
68
- return false;
69
- }
70
- /**
71
- * Pick the account that should serve now: the manually preferred slot when it
72
- * is usable, otherwise the first usable account in rotation order; undefined
73
- * when no account is usable. Shared by the pool (request path) and the plugin
74
- * entry (the usage view's active badge) so both always agree.
75
- */
76
- function selectActiveAccount(accounts, preferredId) {
77
- const usable = accounts.filter((account) => accountUsable(account.state));
78
- if (preferredId !== void 0) {
79
- const preferred = usable.find((account) => account.slot.id === preferredId);
80
- if (preferred !== void 0) return preferred;
81
- }
82
- return usable[0];
83
- }
84
- /**
85
- * The first routing rule whose model list contains the request's model id.
86
- * Undefined when no rule matches.
87
- */
88
- function matchModelRule(model, rules) {
89
- if (model === "" || rules === void 0 || rules.length === 0) return void 0;
90
- for (const rule of rules) if (rule.models.includes(model)) return rule;
91
- }
92
- /**
93
- * The routed account for a request's model: the first usable account whose
94
- * slot id matches the first matching rule's target. Undefined when no rule
95
- * matches or the routed account is not usable (the caller then falls back to
96
- * the normal preferred/rotation selection).
97
- */
98
- function selectAccountForModel(accounts, model, rules) {
99
- const rule = matchModelRule(model, rules);
100
- if (rule === void 0) return void 0;
101
- return accounts.find((account) => account.slot.id === rule.account && accountUsable(account.state));
102
- }
103
- /**
104
- * The account pool. Rotation state is keyed by API key (never logged), so two
105
- * slots resolving to the same credential share one mark, and a key changed in
106
- * the credentials service starts with a clean slate.
107
- */
108
- var CommandCodeAccountPool = class {
109
- deps;
110
- /** Rotation state by API key. */
111
- states = /* @__PURE__ */ new Map();
112
- constructor(deps) {
113
- this.deps = deps;
114
- }
115
- /**
116
- * Resolve every slot's key, deduplicated by key (first slot wins). Slots
117
- * without any resolvable key are omitted — they still appear in the
118
- * settings page as unconfigured, they just cannot serve requests.
119
- */
120
- async resolvedAccounts() {
121
- const out = [];
122
- const seen = /* @__PURE__ */ new Set();
123
- for (const slot of this.deps.slots()) {
124
- const key = await this.resolveSlotKey(slot);
125
- if (key === void 0 || seen.has(key)) continue;
126
- seen.add(key);
127
- out.push({
128
- slot,
129
- key,
130
- state: this.states.get(key)
131
- });
132
- }
133
- return out;
134
- }
135
- /**
136
- * Every slot paired with its resolved key and rotation state — NOT
137
- * deduplicated: two slots sharing one credential both appear (the usage
138
- * view reports them individually), while slots without any resolvable key
139
- * are omitted. The serving path uses {@link resolvedAccounts} instead.
140
- */
141
- async describeAccounts() {
142
- const out = [];
143
- for (const slot of this.deps.slots()) {
144
- const key = await this.resolveSlotKey(slot);
145
- if (key === void 0) continue;
146
- out.push({
147
- slot,
148
- key,
149
- state: this.states.get(key)
150
- });
151
- }
152
- return out;
153
- }
154
- /**
155
- * Hand out the key for a request: the model-routed account when the
156
- * request's model matches a rule (and that account is usable), else the
157
- * manually preferred account when usable, else the first usable account in
158
- * rotation order. Returns `undefined` when no account resolves any key at
159
- * all (the caller then reports the missing credential). Throws
160
- * `RATE_LIMIT` — naming the earliest window reset — or
161
- * `INVALID_CREDENTIAL` when accounts exist but none can serve.
162
- *
163
- * `options.model` is the request's model id; routing rules re-read per
164
- * resolution, so a settings change applies live.
165
- *
166
- * `options.exclude` skips one key during the probe-revival pass: the
167
- * rotation hook excludes the just-rejected key so a probe that clears its
168
- * window cannot re-offer the same key within the same request (the adapter
169
- * refuses already-tried keys; the next request picks the revived key up).
170
- */
171
- async resolveKey(options) {
172
- const accounts = await this.resolvedAccounts();
173
- if (accounts.length === 0) return;
174
- const routed = selectAccountForModel(accounts, options?.model ?? "", this.deps.modelAccountRules?.());
175
- if (routed !== void 0) return this.pick(routed);
176
- const preferred = this.deps.preferredId?.();
177
- const chosen = selectActiveAccount(accounts, preferred);
178
- if (chosen !== void 0) return this.pick(chosen);
179
- await Promise.all(accounts.map(async (account) => {
180
- if (account.state?.kind === "disabled") return;
181
- if (options?.exclude !== void 0 && account.key === options.exclude) return;
182
- let probe;
183
- try {
184
- probe = await this.deps.probeWindow(account.key);
185
- } catch {
186
- return;
187
- }
188
- if (probe === void 0) return;
189
- if (!probe.exceeded) this.states.delete(account.key);
190
- else this.states.set(account.key, {
191
- kind: "cooldown",
192
- reason: account.state?.reason ?? "rate limited (429)",
193
- until: probe.resetAt
194
- });
195
- }));
196
- const latest = await this.resolvedAccounts();
197
- const revived = selectActiveAccount(latest, preferred);
198
- if (revived !== void 0) return this.pick(revived);
199
- if (latest.filter((account) => account.state?.kind === "disabled").length === latest.length) throw new LlmError(`llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file;已配置的 ${latest.length} 个 Command Code 账户密钥均被拒绝(401)——请在设置页检查存储的 API 密钥,或重新运行 command-code login`, "INVALID_CREDENTIAL");
200
- const resets = latest.map((account) => account.state).filter((state) => state !== void 0 && state.kind === "cooldown" && state.until > 0).map((state) => state.until);
201
- const earliest = resets.length > 0 ? Math.min(...resets) : 0;
202
- const wait = earliest > 0 ? Math.max(1e3, earliest - Date.now()) : 0;
203
- throw new LlmError(`llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : "") + ` — requests will succeed again after the reset (or add another account);已用尽全部 ${latest.length} 个 Command Code 账户的用量窗口` + (earliest > 0 ? `,最早的重置时间为 ${clockLabel(earliest)}` : "") + "——窗口重置后请求会自动恢复(也可以添加更多账户)", "RATE_LIMIT", wait > 0 && wait <= 9e5 ? { providerRetryAfterMs: wait } : void 0);
204
- }
205
- /**
206
- * Record a rejection against one key. `rate-limit` (429) marks the key
207
- * exhausted with an unknown reset (probed lazily at the next resolution
208
- * once every account is marked); `invalid-credential` (401) disables the
209
- * key until the stored credential changes.
210
- */
211
- markRejected(apiKey, rejection) {
212
- if (rejection === "invalid-credential") this.states.set(apiKey, {
213
- kind: "disabled",
214
- reason: "invalid API key (401)",
215
- until: 0
216
- });
217
- else this.states.set(apiKey, {
218
- kind: "unknown",
219
- reason: "rate limited (429)",
220
- until: 0
221
- });
222
- }
223
- /**
224
- * One account's key: literal → credential seam → auth file (default slot).
225
- *
226
- * Every source is normalized here, at the single point where a slot's key
227
- * enters the pool. The adapter sends the key through the harness's
228
- * `assertUsableApiKey()`, which trims it — a stored key from the credentials
229
- * seam, a `.env` line, or a shell export all pick up surrounding whitespace
230
- * — and reports that trimmed form back to `markRejected()`. Returning the
231
- * raw value would file every 429/401 mark under a key no later lookup can
232
- * find: rotation would re-offer the same account, the account card would show
233
- * no mark, and the usage endpoints would 401 while chat kept working.
234
- * Normalizing once makes resolution, probing, marking, and the request path
235
- * agree on one string.
236
- */
237
- async resolveSlotKey(slot) {
238
- if (slot.literal !== void 0) {
239
- const literal = normalizeResolvedKey(slot.literal);
240
- if (literal !== void 0) return literal;
241
- }
242
- if (slot.ref !== void 0) {
243
- const hit = await this.deps.resolveRef(slot.ref);
244
- if (hit !== void 0) {
245
- const resolved = normalizeResolvedKey(hit);
246
- if (resolved !== void 0) return resolved;
247
- }
248
- }
249
- if (slot.allowAuthFile) {
250
- const fromFile = this.deps.authFileKey();
251
- if (fromFile !== void 0) {
252
- const fileKey = normalizeResolvedKey(fromFile);
253
- if (fileKey !== void 0) return fileKey;
254
- }
255
- }
256
- }
257
- /** Hand out the chosen account's key. */
258
- pick(account) {
259
- return {
260
- key: account.key,
261
- slot: account.slot
262
- };
263
- }
264
- };
265
- /**
266
- * Normalize one resolved credential to the form every consumer sees. Trim
267
- * only: a blank-after-trim value means "no key" (the slot is omitted and the
268
- * caller reports `MISSING_CREDENTIAL`), while characters an HTTP header cannot
269
- * carry are left for `assertUsableApiKey()` to reject with its own message.
270
- */
271
- function normalizeResolvedKey(value) {
272
- const trimmed = value.trim();
273
- return trimmed === "" ? void 0 : trimmed;
274
- }
275
- //#endregion
276
- //#region src/capabilities.ts
277
- /**
278
- * Static capability snapshot for the Command Code provider: model →
279
- * reasoning-effort levels, vision/thinking flags, model → minimum plan tier,
280
- * subscription-plan labels, deals, and hourly (peak/off-peak) pricing.
281
- *
282
- * Everything in this module is synced from official sources (the command-code
283
- * CLI bundle's model table and the official plan/pricing/model docs — see the
284
- * dsh-commandcode-upstream skill for the exact extraction procedures), and
285
- * changes whenever an upstream CLI release reshuffles models/plans/prices.
286
- * Keeping the snapshot in its own module confines those frequent sync diffs
287
- * here: src/adapter.ts holds only the stable wire/runtime logic and imports
288
- * these tables + read helpers.
289
- *
290
- * Snapshot read helpers (planLabel, dealLabel, formatContext,
291
- * capabilityDescription, peakPricing*, compareByPlan, modelVisibleInPlan,
292
- * subscriptionPlanInfo, isFreeModel) live here too — they exist only to read
293
- * the tables, so a sync never has to touch src/adapter.ts.
294
- *
295
- * Ported from pi-commandcode-provider (MIT); originally part of src/adapter.ts
296
- * and split out so upstream syncs stay reviewable.
34
+ * Ported from pi-commandcode-provider (MIT); originally part of src/adapter.ts
35
+ * and split out so upstream syncs stay reviewable.
297
36
  */
298
37
  const KNOWN_EFFORTS = {
299
38
  "Qwen/Qwen3.8-Max": [
@@ -597,422 +336,1740 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
597
336
  "z-ai/glm-5.3-flash"
598
337
  ]);
599
338
  /**
600
- * Models the official CLI's model table (command-code@1.53.0) marks
601
- * `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
602
- * think automatically, with Command Code driving the depth. This is the
603
- * authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
604
- * mirrors the CLI's effort map exactly) stays the sole source for selectable
605
- * effort levels, and this snapshot is not surfaced in the picker's compact
606
- * description — it exists for programmatic consumers.
339
+ * Models the official CLI's model table (command-code@1.53.0) marks
340
+ * `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
341
+ * think automatically, with Command Code driving the depth. This is the
342
+ * authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
343
+ * mirrors the CLI's effort map exactly) stays the sole source for selectable
344
+ * effort levels, and this snapshot is not surfaced in the picker's compact
345
+ * description — it exists for programmatic consumers.
346
+ *
347
+ * Source: the command-code@1.53.0 bundled model table (dist/cli.mjs),
348
+ * cross-checked with https://commandcode.ai/docs/reference/cli/models.
349
+ * (`stealth/ox-alpha` left this set in command-code@1.32.1, which gave it
350
+ * selectable `['low', 'high', 'max']` efforts; the preview then ended in
351
+ * 1.34.0, removing the model from the catalog entirely. `tencent/hy4-preview`
352
+ * joined this set in command-code@1.37.0 — reasoning:!0, no efforts, 1M
353
+ * context, routed through OpenRouter — then gained selectable
354
+ * `['low', 'medium', 'high']` efforts in command-code@1.38.0 and moved to
355
+ * `KNOWN_EFFORTS`. `moonshotai/Kimi-K3` followed the same path in
356
+ * command-code@1.39.3 — it gained `['low', 'high', 'max']` efforts and moved
357
+ * to `KNOWN_EFFORTS`. command-code@1.42.0 added `meituan/LongCat-2.0:free`
358
+ * (reasoning:!0, no efforts). command-code@1.45.0 gave the Muse Spark family
359
+ * (1.1, 1.2, 1.2-contributor, 1.3, 1.3-contributor) selectable
360
+ * `['low', 'medium', 'high', 'xhigh']` efforts — they moved to `KNOWN_EFFORTS`.
361
+ * command-code@1.51.3 gave `MiniMaxAI/MiniMax-M3` selectable
362
+ * `['low', 'medium', 'high']` efforts — it moved to `KNOWN_EFFORTS` too.
363
+ * command-code@1.52.0 added `inclusionai/ling-3.0-flash-sante:free`
364
+ * (reasoning:!0, no efforts).)
365
+ * command-code@1.53.0 added `deepseek/deepseek-v4.1-flash` with selectable
366
+ * ['low', 'high', 'max'] efforts, so it lives in `KNOWN_EFFORTS`, not here.)
367
+ * Keep in sync via the dsh-commandcode-upstream skill.
368
+ */
369
+ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
370
+ "Qwen/Qwen3.6-Max-Preview",
371
+ "Qwen/Qwen3.6-Plus",
372
+ "Qwen/Qwen3.7-Flash",
373
+ "Qwen/Qwen3.7-Max",
374
+ "Qwen/Qwen3.7-Plus",
375
+ "moonshotai/Kimi-K2.7-Code",
376
+ "moonshotai/Kimi-K2.7-Code-Highspeed",
377
+ "stepfun/Step-3.5-Flash",
378
+ "stepfun/Step-3.7-Flash",
379
+ "tencent/hy3-paid",
380
+ "nvidia/nemotron-3-ultra-550b-a55b",
381
+ "thinkingmachines/inkling",
382
+ "thinkingmachines/inkling-small",
383
+ "poolside/laguna-s-2.1-free",
384
+ "meituan/LongCat-2.0:free",
385
+ "inclusionai/ling-3.0-flash-sante:free"
386
+ ]);
387
+ /**
388
+ * The minimum subscription plan a model is included in, per the official plan
389
+ * pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`, `/docs/plans/max`
390
+ * and `/docs/resources/pricing-limits`). Each plan's model list is a superset of
391
+ * the one below it: Go ⊂ GOAT ⊂ Pro ⊂ Provider/Max. Models absent from every
392
+ * plan list (Claude Opus/Fable, Fugu Ultra) are Provider-tier.
393
+ * `claude-fable-5-1` (Claude Fable 5.1, added in command-code@1.40.0) is
394
+ * Provider/Max-tier exactly like `claude-fable-5` — its availability matrix on
395
+ * the official plan/pricing pages grants individual-provider/max/ultra and
396
+ * teams-pro only, and the CLI's plan-access map blocks it on Go/GOAT/Pro.
397
+ * command-code@1.41.0 added `Qwen/Qwen3.8-Max-0902` (Go) and 1.42.0 added
398
+ * `meituan/LongCat-2.0:free` (Go, free promo); command-code@1.43.0 added
399
+ * `google/gemini-3.8-flash` (GOAT) and 1.44.0 added `meta/muse-spark-1.3`
400
+ * (GOAT) plus its Contributor sibling (Go); command-code@1.52.0 added the
401
+ * free `inclusionai/ling-3.0-flash-sante:free` (Go); command-code@1.53.0
402
+ * added `deepseek/deepseek-v4.1-flash` (Go).
403
+ *
404
+ * The Provider API exposes no plan metadata, so this snapshot is the source of
405
+ * truth for the picker's plan annotation — it answers "which plan do I need to
406
+ * actually use this model?" at a glance. Plan labels use the official tier
407
+ * names (`Go`, `GOAT`, `Pro`, `Provider`), with `Max` implying Provider.
408
+ *
409
+ * Keep in sync with the official plan pages when they change (see the
410
+ * dsh-commandcode-upstream skill).
411
+ */
412
+ const KNOWN_PLANS = {
413
+ "MiniMaxAI/MiniMax-M2.5": "go",
414
+ "MiniMaxAI/MiniMax-M2.7": "go",
415
+ "MiniMaxAI/MiniMax-M3": "go",
416
+ "Qwen/Qwen3.6-Max-Preview": "go",
417
+ "Qwen/Qwen3.6-Plus": "go",
418
+ "Qwen/Qwen3.7-Flash": "go",
419
+ "Qwen/Qwen3.7-Max": "go",
420
+ "Qwen/Qwen3.7-Plus": "go",
421
+ "Qwen/Qwen3.8-27B": "go",
422
+ "Qwen/Qwen3.8-Flash": "go",
423
+ "Qwen/Qwen3.8-Max": "go",
424
+ "Qwen/Qwen3.8-Max-0902": "go",
425
+ "deepseek/deepseek-v4-flash-fast": "go",
426
+ "deepseek/deepseek-v4.1-flash": "go",
427
+ "deepseek/deepseek-v4-flash": "go",
428
+ "deepseek/deepseek-v4-flash-vision-exp": "go",
429
+ "deepseek/deepseek-v4-pro": "go",
430
+ "gpt-5.6-luna": "go",
431
+ "meituan/LongCat-2.0:free": "go",
432
+ "inclusionai/ling-3.0-flash-sante:free": "go",
433
+ "meta/muse-spark-1.2-contributor": "go",
434
+ "meta/muse-spark-1.3-contributor": "go",
435
+ "moonshotai/Kimi-K2.5": "go",
436
+ "moonshotai/Kimi-K2.6": "go",
437
+ "moonshotai/Kimi-K2.7-Code": "go",
438
+ "moonshotai/Kimi-K2.7-Code-Highspeed": "go",
439
+ "moonshotai/Kimi-K3": "go",
440
+ "nvidia/nemotron-3-ultra-550b-a55b": "go",
441
+ "poolside/laguna-s-2.1-free": "go",
442
+ "stepfun/Step-3.5-Flash": "go",
443
+ "stepfun/Step-3.7-Flash": "go",
444
+ "tencent/hy3-paid": "go",
445
+ "tencent/hy4-preview": "go",
446
+ "thinkingmachines/inkling": "go",
447
+ "thinkingmachines/inkling-small": "go",
448
+ "xai/grok-4.5": "go",
449
+ "xiaomi/mimo-v2.5": "go",
450
+ "xiaomi/mimo-v2.5-pro": "go",
451
+ "z-ai/glm-5.3-flash": "go",
452
+ "zai-org/GLM-5": "go",
453
+ "zai-org/GLM-5.1": "go",
454
+ "zai-org/GLM-5.2": "go",
455
+ "zai-org/GLM-5.2-Fast": "go",
456
+ "zai-org/GLM-5.3": "go",
457
+ "google/gemini-3.7-flash": "goat",
458
+ "google/gemini-3.8-flash": "goat",
459
+ "gpt-5.6-sol": "goat",
460
+ "meta/muse-spark-1.2": "goat",
461
+ "meta/muse-spark-1.3": "goat",
462
+ "xai/grok-4.6": "goat",
463
+ "claude-haiku-4-5-20251001": "pro",
464
+ "claude-sonnet-4-6": "pro",
465
+ "claude-sonnet-5": "pro",
466
+ "google/gemini-3.1-flash-lite": "pro",
467
+ "google/gemini-3.5-flash": "pro",
468
+ "google/gemini-3.5-flash-lite": "pro",
469
+ "google/gemini-3.6-flash": "pro",
470
+ "gpt-5.3-codex": "pro",
471
+ "gpt-5.4": "pro",
472
+ "gpt-5.4-mini": "pro",
473
+ "gpt-5.5": "pro",
474
+ "gpt-5.6-terra": "pro",
475
+ "meta/muse-spark-1.1": "pro",
476
+ "claude-fable-5-1": "provider",
477
+ "claude-fable-5": "provider",
478
+ "claude-opus-4-7": "provider",
479
+ "claude-opus-4-8": "provider",
480
+ "claude-opus-5": "provider",
481
+ "gpt-6-astra": "provider",
482
+ "sakana/fugu-ultra": "provider"
483
+ };
484
+ /** Official display labels for each plan tier. */
485
+ const PLAN_LABELS = {
486
+ go: "Go",
487
+ goat: "GOAT",
488
+ pro: "Pro",
489
+ provider: "Provider",
490
+ max: "Max"
491
+ };
492
+ /**
493
+ * Plan-tier sort weights, low to high. Models outside the snapshot (unknown
494
+ * plans) sort after every known tier, keeping known models predictable.
495
+ */
496
+ const PLAN_ORDER = {
497
+ go: 0,
498
+ goat: 1,
499
+ pro: 2,
500
+ provider: 3,
501
+ max: 4
502
+ };
503
+ /**
504
+ * Whether a model is free (requests cost no credits), per the pricing page's
505
+ * deals (`KNOWN_DEALS` `free: true`). Free models lead the picker regardless
506
+ * of tier — they are usable by every account, so they are the best default
507
+ * candidates.
508
+ */
509
+ function isFreeModel(modelId) {
510
+ return KNOWN_DEALS[modelId]?.free === true;
511
+ }
512
+ /**
513
+ * Comparator for the model picker: free models first (zero credit cost, usable
514
+ * by every account), then by plan tier (lowest first), then by model name,
515
+ * then by id as a tiebreak. Models with no known plan sort last.
516
+ */
517
+ function compareByPlan(a, b) {
518
+ const freeDelta = Number(isFreeModel(b.id)) - Number(isFreeModel(a.id));
519
+ if (freeDelta !== 0) return freeDelta;
520
+ const pa = PLAN_ORDER[KNOWN_PLANS[a.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
521
+ const pb = PLAN_ORDER[KNOWN_PLANS[b.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
522
+ if (pa !== pb) return pa - pb;
523
+ const nameDiff = a.name.localeCompare(b.name);
524
+ if (nameDiff !== 0) return nameDiff;
525
+ return a.id.localeCompare(b.id);
526
+ }
527
+ /**
528
+ * Subscription plan table, synced from the official CLI bundle's plan maps
529
+ * (located by the `"individual-go"` key in command-code@1.53.0 `dist/cli.mjs`,
530
+ * re-verified unchanged through 1.53.0): subscription `planId`
531
+ * prefix → display name and the plan's monthly credit total. This is the
532
+ * account's own subscription (from `/alpha/billing/subscriptions`) — distinct
533
+ * from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
534
+ *
535
+ * `tierWeight` is plugin-added (not from the CLI maps): the plan's rank on
536
+ * the {@link PLAN_ORDER} scale, used by the picker's plan filter
537
+ * ({@link modelVisibleInPlan}) to hide models above the account's tier.
538
+ */
539
+ const KNOWN_SUBSCRIPTION_PLANS = {
540
+ "individual-go": {
541
+ name: "Go",
542
+ monthlyCredits: 10,
543
+ tierWeight: 0
544
+ },
545
+ "individual-goat": {
546
+ name: "GOAT",
547
+ monthlyCredits: 70,
548
+ tierWeight: 1
549
+ },
550
+ "individual-pro": {
551
+ name: "Pro",
552
+ monthlyCredits: 30,
553
+ tierWeight: 2
554
+ },
555
+ "individual-pro-v1": {
556
+ name: "Pro",
557
+ monthlyCredits: 80,
558
+ tierWeight: 2
559
+ },
560
+ "individual-provider": {
561
+ name: "Provider",
562
+ monthlyCredits: 15,
563
+ tierWeight: 3
564
+ },
565
+ "individual-max": {
566
+ name: "Max",
567
+ monthlyCredits: 150,
568
+ tierWeight: 4
569
+ },
570
+ "individual-ultra": {
571
+ name: "Ultra",
572
+ monthlyCredits: 300,
573
+ tierWeight: 4
574
+ },
575
+ "teams-pro": {
576
+ name: "Teams Pro",
577
+ monthlyCredits: 40,
578
+ tierWeight: 2
579
+ }
580
+ };
581
+ /** Plan-id prefixes, longest first — the CLI's prefix-match order. */
582
+ const SUBSCRIPTION_PLAN_PREFIXES = Object.keys(KNOWN_SUBSCRIPTION_PLANS).sort((a, b) => b.length - a.length);
583
+ /**
584
+ * Resolve a subscription `planId` (e.g. `individual-pro-v1`) to its display
585
+ * name and monthly credit total, mirroring the CLI's `getPlanInfo`:
586
+ * normalize (lowercase, `_` → `-`), then longest-prefix match so
587
+ * `individual-pro-v1` wins over `individual-pro`. Unknown ids return
588
+ * `undefined`.
589
+ */
590
+ function subscriptionPlanInfo(planId) {
591
+ const normalized = planId.toLowerCase().replace(/_/g, "-");
592
+ const prefix = SUBSCRIPTION_PLAN_PREFIXES.find((candidate) => normalized.startsWith(candidate));
593
+ return prefix === void 0 ? void 0 : KNOWN_SUBSCRIPTION_PLANS[prefix];
594
+ }
595
+ /**
596
+ * Whether the picker lists `modelId` for an account with the given billing
597
+ * access. Fails open at every uncertainty: no billing data, an unknown plan,
598
+ * a non-finite weight (corrupt billing fact), or a model outside
599
+ * {@link KNOWN_PLANS} all keep the model visible — the server remains the
600
+ * final gate (`403 MODEL_NOT_IN_PLAN`).
601
+ */
602
+ function modelVisibleInPlan(modelId, access) {
603
+ if (access === void 0) return true;
604
+ if (access.onDemandCredits > 0) return true;
605
+ if (access.tierWeight === void 0 || !Number.isFinite(access.tierWeight)) return true;
606
+ const tier = KNOWN_PLANS[modelId];
607
+ if (tier === void 0) return true;
608
+ const weight = PLAN_ORDER[tier];
609
+ if (weight === void 0) return true;
610
+ return weight <= access.tierWeight;
611
+ }
612
+ const KNOWN_DEALS = {
613
+ "MiniMaxAI/MiniMax-M3": { label: "50% off" },
614
+ "xiaomi/mimo-v2.5-pro": { label: "99% off" },
615
+ "xiaomi/mimo-v2.5": { label: "98% off" },
616
+ "poolside/laguna-s-2.1-free": {
617
+ label: "FREE",
618
+ free: true
619
+ },
620
+ "meituan/LongCat-2.0:free": {
621
+ label: "FREE",
622
+ free: true
623
+ },
624
+ "inclusionai/ling-3.0-flash-sante:free": {
625
+ label: "FREE",
626
+ free: true
627
+ }
628
+ };
629
+ /**
630
+ * Models with time-of-day (peak/off-peak) pricing, per the official pricing
631
+ * page (`/docs/resources/pricing-limits`). Since 2026-08-16 16:00 UTC, DeepSeek
632
+ * charges by the hour: peak hours are 01:00–04:00 and 06:00–10:00 UTC (7h per
633
+ * weekday, full price) **Monday to Friday only**; the other 17 hours of a
634
+ * weekday and every hour of Saturday/Sunday (UTC) are off-peak at half price.
635
+ * The V4 Flash Vision (exp) variant (command-code@1.32.0) shares the V4 Flash
636
+ * rates exactly — $0.15/$0.60 off-peak and $0.30/$1.20 peak, per the page's own
637
+ * `timeOfDay` block, not merely 2× its own off-peak figures: a rate that is
638
+ * internally consistent can still be the wrong row, which is why the vendored
639
+ * price table (`./model-prices.ts`) is synced from the page and not hand-kept.
640
+ * The picker shows the
641
+ * *current* state as a compact
642
+ * label (`Peak`/`Half`) matching the English noun style of the other markers
643
+ * (`Image`, `FREE`), so a developer can tell at a glance whether calling the
644
+ * model right now is cheap or expensive.
645
+ *
646
+ * Authoritative extraction: the pricing page embeds a model JSON array whose
647
+ * hourly-priced entries carry a `timeOfDay` block
648
+ * (`{ windows: "01–04 & 06–10 UTC, Mon–Fri", peakHoursPerDay: 7,
649
+ * offPeakHoursPerDay: 17, peak: {...}, offPeak: {...} }`). Exactly four models
650
+ * carry it: V4 Pro, V4 Flash, V4 Flash Vision (exp), and V4.1 Flash (added in
651
+ * command-code@1.53.0 at $0.15/$0.60 off-peak, $0.30/$1.20 peak — the same
652
+ * schedule as the other three).
653
+ *
654
+ * Extraction caution: the rendered HTML rows are a trap. Each annotation div
655
+ * sits inside its OWN row's container, immediately before the NEXT row starts,
656
+ * so flattening the page to text makes every annotation look like it belongs
657
+ * to the model printed after it — that is how `deepseek/deepseek-v4-flash-fast`
658
+ * was wrongly added here (its row is flat-priced at $0.28/$0.56/$0.07 and has
659
+ * no `timeOfDay` block). Trust the embedded JSON's `timeOfDay` membership and
660
+ * the 2× price relation, never the flat-text neighbor.
661
+ *
662
+ * Keep in sync with the official pricing page when the model set, the peak
663
+ * windows, or the weekday rule change (see the dsh-commandcode-upstream skill).
664
+ */
665
+ const KNOWN_PEAK_PRICING = /* @__PURE__ */ new Set([
666
+ "deepseek/deepseek-v4-pro",
667
+ "deepseek/deepseek-v4-flash",
668
+ "deepseek/deepseek-v4-flash-vision-exp",
669
+ "deepseek/deepseek-v4.1-flash"
670
+ ]);
671
+ /**
672
+ * Peak hours (UTC, hour-of-day range end-exclusive): 01–03 and 06–09.
673
+ * Weekday-only — see `peakPricingState()`; weekends are fully off-peak.
674
+ *
675
+ * Exported because the vendored price table (`./model-prices.ts`) ships these
676
+ * windows to the browser with the table itself: the composer prices a session
677
+ * against the very schedule this snapshot knows rather than restating it, so
678
+ * there is one place to update when the windows move.
679
+ */
680
+ const PEAK_HOUR_RANGES = [[1, 4], [6, 10]];
681
+ /**
682
+ * Whether `now` (defaults to `Date.now()`) falls inside a peak-pricing window,
683
+ * ignoring which model is asking. Peak rates apply Monday–Friday (UTC) only,
684
+ * so a weekend timestamp is off-peak even inside {@link PEAK_HOUR_RANGES}.
685
+ *
686
+ * Model-independent on purpose: `peakPricingState()` adds the membership test
687
+ * on top for the picker's label, while the price table selects peak rates from
688
+ * a row's own `peak` block — so a model whose catalog id spelling differs from
689
+ * the one in {@link KNOWN_PEAK_PRICING} still gets the right half of the day.
690
+ */
691
+ function isPeakPricingHour(now = Date.now()) {
692
+ const at = new Date(now);
693
+ const day = at.getUTCDay();
694
+ if (day === 0 || day === 6) return false;
695
+ const hour = at.getUTCHours();
696
+ return PEAK_HOUR_RANGES.some(([start, end]) => hour >= start && hour < end);
697
+ }
698
+ /**
699
+ * Whether `now` (defaults to `Date.now()`) falls in a peak-pricing window for
700
+ * time-of-day-priced models. Peak rates apply Monday–Friday (UTC) only: the
701
+ * official rule charges Saturday and Sunday completely off-peak for all 24
702
+ * hours, so a weekend timestamp is off-peak even inside `PEAK_HOUR_RANGES`.
703
+ * `undefined` for models outside the snapshot.
704
+ */
705
+ function peakPricingState(modelId, now = Date.now()) {
706
+ if (!KNOWN_PEAK_PRICING.has(modelId)) return void 0;
707
+ return isPeakPricingHour(now) ? "peak" : "off-peak";
708
+ }
709
+ /**
710
+ * Compact label for the current peak/off-peak state: `Peak` (full price) or
711
+ * `Half` (off-peak, half price). These English nouns match the picker's other
712
+ * markers (`Go`, `Image`, `FREE`), and since they appear only on time-of-day
713
+ * priced models they double as a "priced by the hour" signal. Returns undefined
714
+ * for models without time-of-day pricing.
715
+ */
716
+ function peakPricingLabel(modelId, now = Date.now()) {
717
+ const state = peakPricingState(modelId, now);
718
+ if (state === void 0) return void 0;
719
+ return state === "peak" ? "Peak" : "Half";
720
+ }
721
+ /**
722
+ * Official display label for a model's minimum plan, or undefined for models
723
+ * outside the snapshot (e.g. future catalog additions).
724
+ */
725
+ function planLabel(modelId) {
726
+ const plan = KNOWN_PLANS[modelId];
727
+ return plan === void 0 ? void 0 : PLAN_LABELS[plan];
728
+ }
729
+ /**
730
+ * The active deal label for a model, or undefined when the model has no deal
731
+ * or the deal has expired. Expiry is judged against `now` (defaults to
732
+ * `Date.now()`), so a snapshot that has gone stale stops showing its discount
733
+ * the moment the official end date passes — the user never believes a lapsed
734
+ * deal is still live. Permanent deals (no `expiresAt`) never lapse.
735
+ */
736
+ function dealLabel(modelId, now = Date.now()) {
737
+ const deal = KNOWN_DEALS[modelId];
738
+ if (deal === void 0) return void 0;
739
+ if (deal.expiresAt !== void 0 && now >= Date.parse(deal.expiresAt)) return void 0;
740
+ return deal.label;
741
+ }
742
+ /**
743
+ * Compact human-readable context window, e.g. `1_000_000 -> "1M"`,
744
+ * `256_000 -> "256K"`, `262_144 -> "262K"` (floor to the nearest K).
745
+ * Returns undefined for unknown/absent sizes; values under 1K render raw.
746
+ */
747
+ function formatContext(contextWindow) {
748
+ if (contextWindow === void 0 || !Number.isFinite(contextWindow) || contextWindow <= 0) return;
749
+ if (contextWindow >= 1e6) {
750
+ const m = contextWindow / 1e6;
751
+ const rounded = Math.round(m * 10) / 10;
752
+ return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`;
753
+ }
754
+ if (contextWindow < 1e3) return String(Math.floor(contextWindow));
755
+ return `${Math.floor(contextWindow / 1e3)}K`;
756
+ }
757
+ /**
758
+ * Compact one-line summary for the model picker: plan tier, then any active
759
+ * deal (discount or FREE), then the current peak/off-peak state (`Peak`/`Half`)
760
+ * for time-of-day-priced models, then `Image` for Vision-capable models, then
761
+ * the context window. Text-only models simply omit the Image marker — "Text
762
+ * only" adds nothing the picker needs to show.
763
+ */
764
+ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
765
+ const parts = [];
766
+ const plan = planLabel(modelId);
767
+ if (plan !== void 0) parts.push(plan);
768
+ const deal = dealLabel(modelId, now);
769
+ if (deal !== void 0) parts.push(deal);
770
+ const peak = peakPricingLabel(modelId, now);
771
+ if (peak !== void 0) parts.push(peak);
772
+ if (KNOWN_IMAGE_MODELS.has(modelId)) parts.push("Image");
773
+ const ctx = formatContext(contextWindow);
774
+ if (ctx !== void 0) parts.push(ctx);
775
+ return parts.join(" · ");
776
+ }
777
+ //#endregion
778
+ //#region src/model-prices.ts
779
+ /**
780
+ * Every price row the page publishes, ordered by its own slug.
781
+ *
782
+ * Note on the GPT rows: `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini` and
783
+ * `gpt-5.5` carry a LITERAL `cacheWriteCost: 0` — a different fact from a
784
+ * missing key, because `ratesOf()` keeps it and the readout then charges
785
+ * cache writes at zero instead of reporting them unpriced. That is what the
786
+ * page's JSON says; its rendered column shows `—` for exactly these four while
787
+ * rendering a genuine zero as `$0.00` elsewhere, so the page contradicts
788
+ * itself. The table stays faithful to the machine-readable source and the
789
+ * discrepancy is recorded in AGENTS.md rather than papered over here.
790
+ */
791
+ const MODEL_PRICE_ROWS = [
792
+ {
793
+ id: "claude-fable-5",
794
+ rates: [
795
+ 10,
796
+ 50,
797
+ 1,
798
+ 12.5
799
+ ]
800
+ },
801
+ {
802
+ id: "claude-fable-5-1",
803
+ rates: [
804
+ 10,
805
+ 50,
806
+ .25,
807
+ 12.5
808
+ ]
809
+ },
810
+ {
811
+ id: "claude-haiku-4-5",
812
+ rates: [
813
+ 1,
814
+ 5,
815
+ .1,
816
+ 1.25
817
+ ]
818
+ },
819
+ {
820
+ id: "claude-opus-4-6",
821
+ rates: [
822
+ 5,
823
+ 25,
824
+ .5,
825
+ 6.25
826
+ ]
827
+ },
828
+ {
829
+ id: "claude-opus-4-7",
830
+ rates: [
831
+ 5,
832
+ 25,
833
+ .5,
834
+ 6.25
835
+ ]
836
+ },
837
+ {
838
+ id: "claude-opus-4-8",
839
+ rates: [
840
+ 5,
841
+ 25,
842
+ .5,
843
+ 6.25
844
+ ]
845
+ },
846
+ {
847
+ id: "claude-opus-5",
848
+ rates: [
849
+ 5,
850
+ 25,
851
+ .5,
852
+ 6.25
853
+ ]
854
+ },
855
+ {
856
+ id: "claude-sonnet-4-6",
857
+ rates: [
858
+ 3,
859
+ 15,
860
+ .3,
861
+ 3.75
862
+ ]
863
+ },
864
+ {
865
+ id: "claude-sonnet-5",
866
+ rates: [
867
+ 2,
868
+ 10,
869
+ .2,
870
+ 2.5
871
+ ]
872
+ },
873
+ {
874
+ id: "deepseek-v4-flash",
875
+ rates: [
876
+ .15,
877
+ .6,
878
+ .003
879
+ ],
880
+ peak: [
881
+ .3,
882
+ 1.2,
883
+ .006
884
+ ]
885
+ },
886
+ {
887
+ id: "deepseek-v4-flash-fast",
888
+ rates: [
889
+ .28,
890
+ .56,
891
+ .07
892
+ ]
893
+ },
894
+ {
895
+ id: "deepseek-v4-flash-vision-exp",
896
+ rates: [
897
+ .15,
898
+ .6,
899
+ .003
900
+ ],
901
+ peak: [
902
+ .3,
903
+ 1.2,
904
+ .006
905
+ ]
906
+ },
907
+ {
908
+ id: "deepseek-v4-pro",
909
+ rates: [
910
+ .66,
911
+ 1.98,
912
+ .022
913
+ ],
914
+ peak: [
915
+ 1.32,
916
+ 3.96,
917
+ .044
918
+ ]
919
+ },
920
+ {
921
+ id: "deepseek-v4.1-flash",
922
+ rates: [
923
+ .15,
924
+ .6,
925
+ .003
926
+ ],
927
+ peak: [
928
+ .3,
929
+ 1.2,
930
+ .006
931
+ ]
932
+ },
933
+ {
934
+ id: "fugu-ultra",
935
+ rates: [
936
+ 5,
937
+ 30,
938
+ .5
939
+ ]
940
+ },
941
+ {
942
+ id: "gemini-3.1-flash-lite",
943
+ rates: [
944
+ .25,
945
+ 1.5,
946
+ .03
947
+ ]
948
+ },
949
+ {
950
+ id: "gemini-3.5-flash",
951
+ rates: [
952
+ 1.5,
953
+ 9,
954
+ .15
955
+ ]
956
+ },
957
+ {
958
+ id: "gemini-3.5-flash-lite",
959
+ rates: [
960
+ .3,
961
+ 2.5,
962
+ .03
963
+ ]
964
+ },
965
+ {
966
+ id: "gemini-3.6-flash",
967
+ rates: [
968
+ 1.5,
969
+ 7.5,
970
+ .15
971
+ ]
972
+ },
973
+ {
974
+ id: "gemini-3.7-flash",
975
+ rates: [
976
+ 1.5,
977
+ 7.5,
978
+ .15,
979
+ .08334
980
+ ]
981
+ },
982
+ {
983
+ id: "gemini-3.8-flash",
984
+ rates: [
985
+ 1.5,
986
+ 7.5,
987
+ .15
988
+ ]
989
+ },
990
+ {
991
+ id: "glm-5",
992
+ rates: [
993
+ 1,
994
+ 3.2,
995
+ .2
996
+ ]
997
+ },
998
+ {
999
+ id: "glm-5.1",
1000
+ rates: [
1001
+ 1.4,
1002
+ 4.4,
1003
+ .26
1004
+ ]
1005
+ },
1006
+ {
1007
+ id: "glm-5.2",
1008
+ rates: [
1009
+ 1.4,
1010
+ 4.4,
1011
+ .26
1012
+ ]
1013
+ },
1014
+ {
1015
+ id: "glm-5.2-fast",
1016
+ rates: [
1017
+ 3,
1018
+ 10.25,
1019
+ .5
1020
+ ]
1021
+ },
1022
+ {
1023
+ id: "glm-5.3",
1024
+ rates: [
1025
+ 1.4,
1026
+ 4.4,
1027
+ .26
1028
+ ]
1029
+ },
1030
+ {
1031
+ id: "glm-5.3-flash",
1032
+ rates: [
1033
+ .15,
1034
+ .5,
1035
+ .03
1036
+ ]
1037
+ },
1038
+ {
1039
+ id: "gpt-5.3-codex",
1040
+ rates: [
1041
+ 2,
1042
+ 8,
1043
+ .5,
1044
+ 0
1045
+ ]
1046
+ },
1047
+ {
1048
+ id: "gpt-5.4",
1049
+ rates: [
1050
+ 2.5,
1051
+ 15,
1052
+ .25,
1053
+ 0
1054
+ ]
1055
+ },
1056
+ {
1057
+ id: "gpt-5.4-mini",
1058
+ rates: [
1059
+ .75,
1060
+ 4.5,
1061
+ .075,
1062
+ 0
1063
+ ]
1064
+ },
1065
+ {
1066
+ id: "gpt-5.5",
1067
+ rates: [
1068
+ 5,
1069
+ 30,
1070
+ .5,
1071
+ 0
1072
+ ]
1073
+ },
1074
+ {
1075
+ id: "gpt-5.6-luna",
1076
+ rates: [
1077
+ .2,
1078
+ 1.2,
1079
+ .02,
1080
+ .25
1081
+ ],
1082
+ contextTiers: [{
1083
+ "maxContext": 272e3,
1084
+ "rates": [
1085
+ .2,
1086
+ 1.2,
1087
+ .02,
1088
+ .25
1089
+ ]
1090
+ }, { "rates": [
1091
+ .4,
1092
+ 1.8,
1093
+ .04,
1094
+ .5
1095
+ ] }]
1096
+ },
1097
+ {
1098
+ id: "gpt-5.6-sol",
1099
+ rates: [
1100
+ 5,
1101
+ 30,
1102
+ .5,
1103
+ 6.25
1104
+ ],
1105
+ contextTiers: [{
1106
+ "maxContext": 272e3,
1107
+ "rates": [
1108
+ 5,
1109
+ 30,
1110
+ .5,
1111
+ 6.25
1112
+ ]
1113
+ }, { "rates": [
1114
+ 10,
1115
+ 45,
1116
+ 1,
1117
+ 12.5
1118
+ ] }]
1119
+ },
1120
+ {
1121
+ id: "gpt-5.6-terra",
1122
+ rates: [
1123
+ 2,
1124
+ 12,
1125
+ .2,
1126
+ 2.5
1127
+ ],
1128
+ contextTiers: [{
1129
+ "maxContext": 272e3,
1130
+ "rates": [
1131
+ 2,
1132
+ 12,
1133
+ .2,
1134
+ 2.5
1135
+ ]
1136
+ }, { "rates": [
1137
+ 4,
1138
+ 18,
1139
+ .4,
1140
+ 5
1141
+ ] }]
1142
+ },
1143
+ {
1144
+ id: "gpt-6-astra",
1145
+ rates: [
1146
+ 10,
1147
+ 50,
1148
+ 1,
1149
+ 12.5
1150
+ ],
1151
+ contextTiers: [{
1152
+ "maxContext": 272e3,
1153
+ "rates": [
1154
+ 10,
1155
+ 50,
1156
+ 1,
1157
+ 12.5
1158
+ ]
1159
+ }, { "rates": [
1160
+ 20,
1161
+ 75,
1162
+ 2,
1163
+ 25
1164
+ ] }]
1165
+ },
1166
+ {
1167
+ id: "grok-4.5",
1168
+ rates: [
1169
+ 2,
1170
+ 6,
1171
+ .5
1172
+ ]
1173
+ },
1174
+ {
1175
+ id: "grok-4.6",
1176
+ rates: [
1177
+ 2,
1178
+ 6,
1179
+ .5
1180
+ ],
1181
+ contextTiers: [{
1182
+ "maxContext": 2e5,
1183
+ "rates": [
1184
+ 2,
1185
+ 6,
1186
+ .5
1187
+ ]
1188
+ }, { "rates": [
1189
+ 4,
1190
+ 12,
1191
+ 1
1192
+ ] }]
1193
+ },
1194
+ {
1195
+ id: "inkling",
1196
+ rates: [
1197
+ 1,
1198
+ 4.05,
1199
+ .17
1200
+ ]
1201
+ },
1202
+ {
1203
+ id: "inkling-small",
1204
+ rates: [
1205
+ .5,
1206
+ 1.2,
1207
+ .1
1208
+ ]
1209
+ },
1210
+ {
1211
+ id: "kimi-k2.5",
1212
+ rates: [
1213
+ .6,
1214
+ 3,
1215
+ .1
1216
+ ]
1217
+ },
1218
+ {
1219
+ id: "kimi-k2.6",
1220
+ rates: [
1221
+ .95,
1222
+ 4,
1223
+ .16
1224
+ ]
1225
+ },
1226
+ {
1227
+ id: "kimi-k2.7-code",
1228
+ rates: [
1229
+ .95,
1230
+ 4,
1231
+ .19
1232
+ ]
1233
+ },
1234
+ {
1235
+ id: "kimi-k2.7-code-highspeed",
1236
+ rates: [
1237
+ 1.9,
1238
+ 8,
1239
+ .38
1240
+ ]
1241
+ },
1242
+ {
1243
+ id: "kimi-k3",
1244
+ rates: [
1245
+ 3,
1246
+ 15,
1247
+ .3
1248
+ ]
1249
+ },
1250
+ {
1251
+ id: "mimo-v2.5",
1252
+ rates: [
1253
+ .14,
1254
+ .28,
1255
+ .0028
1256
+ ]
1257
+ },
1258
+ {
1259
+ id: "mimo-v2.5-pro",
1260
+ rates: [
1261
+ .435,
1262
+ .87,
1263
+ .0036
1264
+ ]
1265
+ },
1266
+ {
1267
+ id: "minimax-m2.5",
1268
+ rates: [
1269
+ .3,
1270
+ 1.2,
1271
+ .03
1272
+ ]
1273
+ },
1274
+ {
1275
+ id: "minimax-m2.7",
1276
+ rates: [
1277
+ .3,
1278
+ 1.2,
1279
+ .06
1280
+ ]
1281
+ },
1282
+ {
1283
+ id: "minimax-m3",
1284
+ rates: [
1285
+ .3,
1286
+ 1.2,
1287
+ .06
1288
+ ]
1289
+ },
1290
+ {
1291
+ id: "muse-spark-1.1",
1292
+ rates: [
1293
+ 1.25,
1294
+ 4.25,
1295
+ .15
1296
+ ]
1297
+ },
1298
+ {
1299
+ id: "muse-spark-1.2",
1300
+ rates: [
1301
+ 1.25,
1302
+ 4.25,
1303
+ .15
1304
+ ]
1305
+ },
1306
+ {
1307
+ id: "muse-spark-1.2-contributor",
1308
+ rates: [
1309
+ .1,
1310
+ .2,
1311
+ .002
1312
+ ]
1313
+ },
1314
+ {
1315
+ id: "muse-spark-1.3",
1316
+ rates: [
1317
+ 1.25,
1318
+ 4.25,
1319
+ .15
1320
+ ]
1321
+ },
1322
+ {
1323
+ id: "muse-spark-1.3-contributor",
1324
+ rates: [
1325
+ .1,
1326
+ .2,
1327
+ .002
1328
+ ]
1329
+ },
1330
+ {
1331
+ id: "nemotron-3-ultra",
1332
+ rates: [
1333
+ .6,
1334
+ 2.4,
1335
+ .12
1336
+ ]
1337
+ },
1338
+ {
1339
+ id: "qwen-3.6-max",
1340
+ rates: [
1341
+ 1.3,
1342
+ 7.8,
1343
+ .26,
1344
+ 1.63
1345
+ ]
1346
+ },
1347
+ {
1348
+ id: "qwen-3.6-plus",
1349
+ rates: [
1350
+ .5,
1351
+ 3,
1352
+ .1
1353
+ ],
1354
+ contextTiers: [{
1355
+ "maxContext": 256e3,
1356
+ "rates": [
1357
+ .5,
1358
+ 3,
1359
+ .1
1360
+ ]
1361
+ }, { "rates": [
1362
+ 2,
1363
+ 6,
1364
+ .2
1365
+ ] }]
1366
+ },
1367
+ {
1368
+ id: "qwen-3.7-flash",
1369
+ rates: [
1370
+ .03,
1371
+ .13,
1372
+ .006,
1373
+ .038
1374
+ ],
1375
+ contextTiers: [
1376
+ {
1377
+ "maxContext": 32e3,
1378
+ "rates": [
1379
+ .03,
1380
+ .13,
1381
+ .006,
1382
+ .038
1383
+ ]
1384
+ },
1385
+ {
1386
+ "maxContext": 256e3,
1387
+ "rates": [
1388
+ .1,
1389
+ .4,
1390
+ .02,
1391
+ .125
1392
+ ]
1393
+ },
1394
+ { "rates": [
1395
+ .2,
1396
+ .8,
1397
+ .04,
1398
+ .25
1399
+ ] }
1400
+ ]
1401
+ },
1402
+ {
1403
+ id: "qwen-3.7-max",
1404
+ rates: [
1405
+ 2.5,
1406
+ 7.5,
1407
+ .5,
1408
+ 3.13
1409
+ ]
1410
+ },
1411
+ {
1412
+ id: "qwen-3.7-plus",
1413
+ rates: [
1414
+ .4,
1415
+ 1.6,
1416
+ .08,
1417
+ .5
1418
+ ],
1419
+ contextTiers: [{
1420
+ "maxContext": 256e3,
1421
+ "rates": [
1422
+ .4,
1423
+ 1.6,
1424
+ .08,
1425
+ .5
1426
+ ]
1427
+ }, { "rates": [
1428
+ 1.2,
1429
+ 4.8,
1430
+ .24,
1431
+ 1.5
1432
+ ] }]
1433
+ },
1434
+ {
1435
+ id: "qwen-3.8-27b",
1436
+ rates: [
1437
+ .4,
1438
+ 3,
1439
+ .04
1440
+ ]
1441
+ },
1442
+ {
1443
+ id: "qwen-3.8-flash",
1444
+ rates: [
1445
+ .16,
1446
+ .47,
1447
+ .016
1448
+ ]
1449
+ },
1450
+ {
1451
+ id: "qwen-3.8-max",
1452
+ rates: [
1453
+ 2,
1454
+ 6,
1455
+ .25,
1456
+ 2.5
1457
+ ]
1458
+ },
1459
+ {
1460
+ id: "qwen-3.8-max-0902",
1461
+ rates: [
1462
+ 2,
1463
+ 6,
1464
+ .25
1465
+ ]
1466
+ },
1467
+ {
1468
+ id: "step-3.5-flash",
1469
+ rates: [
1470
+ .1,
1471
+ .3,
1472
+ .02
1473
+ ]
1474
+ },
1475
+ {
1476
+ id: "step-3.7-flash",
1477
+ rates: [
1478
+ .2,
1479
+ 1.15,
1480
+ .04
1481
+ ]
1482
+ },
1483
+ {
1484
+ id: "tencent/hy3-paid",
1485
+ rates: [
1486
+ .14,
1487
+ .58,
1488
+ .035
1489
+ ]
1490
+ },
1491
+ {
1492
+ id: "tencent/hy4-preview",
1493
+ rates: [
1494
+ .834,
1495
+ 2.501,
1496
+ .042
1497
+ ]
1498
+ }
1499
+ ];
1500
+ /**
1501
+ * Catalog ids no candidate rule can reach, because the page names the model
1502
+ * differently from the catalog. `tests/model-prices.test.ts` fails whenever a
1503
+ * catalog model has no price, so a new miss lands here as a visible decision
1504
+ * rather than a silently unpriced model.
1505
+ */
1506
+ const PRICE_SLUG_OVERRIDES = { "nvidia/nemotron-3-ultra-550b-a55b": "nemotron-3-ultra" };
1507
+ /**
1508
+ * Plausible price slugs for one catalog model id, most specific first.
607
1509
  *
608
- * Source: the command-code@1.53.0 bundled model table (dist/cli.mjs),
609
- * cross-checked with https://commandcode.ai/docs/reference/cli/models.
610
- * (`stealth/ox-alpha` left this set in command-code@1.32.1, which gave it
611
- * selectable `['low', 'high', 'max']` efforts; the preview then ended in
612
- * 1.34.0, removing the model from the catalog entirely. `tencent/hy4-preview`
613
- * joined this set in command-code@1.37.0 — reasoning:!0, no efforts, 1M
614
- * context, routed through OpenRouter — then gained selectable
615
- * `['low', 'medium', 'high']` efforts in command-code@1.38.0 and moved to
616
- * `KNOWN_EFFORTS`. `moonshotai/Kimi-K3` followed the same path in
617
- * command-code@1.39.3 — it gained `['low', 'high', 'max']` efforts and moved
618
- * to `KNOWN_EFFORTS`. command-code@1.42.0 added `meituan/LongCat-2.0:free`
619
- * (reasoning:!0, no efforts). command-code@1.45.0 gave the Muse Spark family
620
- * (1.1, 1.2, 1.2-contributor, 1.3, 1.3-contributor) selectable
621
- * `['low', 'medium', 'high', 'xhigh']` efforts — they moved to `KNOWN_EFFORTS`.
622
- * command-code@1.51.3 gave `MiniMaxAI/MiniMax-M3` selectable
623
- * `['low', 'medium', 'high']` efforts — it moved to `KNOWN_EFFORTS` too.
624
- * command-code@1.52.0 added `inclusionai/ling-3.0-flash-sante:free`
625
- * (reasoning:!0, no efforts).)
626
- * command-code@1.53.0 added `deepseek/deepseek-v4.1-flash` with selectable
627
- * ['low', 'high', 'max'] efforts, so it lives in `KNOWN_EFFORTS`, not here.)
628
- * Keep in sync via the dsh-commandcode-upstream skill.
1510
+ * The page normalizes to lowercase and usually drops the vendor segment, but
1511
+ * not consistently: `tencent/hy4-preview` keeps its prefix while
1512
+ * `Qwen/Qwen3.8-Max-0902` becomes `qwen-3.8-max-0902`, with a hyphen the
1513
+ * catalog id does not have. Generating candidates and taking the first that
1514
+ * exists in the vendored table absorbs that drift without a hand-maintained
1515
+ * map of seventy ids.
629
1516
  */
630
- const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
631
- "Qwen/Qwen3.6-Max-Preview",
632
- "Qwen/Qwen3.6-Plus",
633
- "Qwen/Qwen3.7-Flash",
634
- "Qwen/Qwen3.7-Max",
635
- "Qwen/Qwen3.7-Plus",
636
- "moonshotai/Kimi-K2.7-Code",
637
- "moonshotai/Kimi-K2.7-Code-Highspeed",
638
- "stepfun/Step-3.5-Flash",
639
- "stepfun/Step-3.7-Flash",
640
- "tencent/hy3-paid",
641
- "nvidia/nemotron-3-ultra-550b-a55b",
642
- "thinkingmachines/inkling",
643
- "thinkingmachines/inkling-small",
644
- "poolside/laguna-s-2.1-free",
645
- "meituan/LongCat-2.0:free",
646
- "inclusionai/ling-3.0-flash-sante:free"
647
- ]);
1517
+ function priceSlugCandidates(modelId) {
1518
+ const lower = modelId.toLowerCase();
1519
+ const bare = lower.includes("/") ? lower.slice(lower.indexOf("/") + 1) : lower;
1520
+ const out = /* @__PURE__ */ new Set();
1521
+ const add = (slug) => {
1522
+ const hyphenated = slug.replace(/^([a-z]+)(\d)/, "$1-$2");
1523
+ out.add(slug);
1524
+ out.add(hyphenated);
1525
+ out.add(slug.replace(/-\d{8}$/, ""));
1526
+ out.add(slug.replace(/-(preview|latest)$/, ""));
1527
+ out.add(hyphenated.replace(/-\d{8}$/, ""));
1528
+ out.add(hyphenated.replace(/-(preview|latest)$/, ""));
1529
+ };
1530
+ add(lower);
1531
+ add(bare);
1532
+ return [...out];
1533
+ }
1534
+ /** The pricing-page slug for one catalog model id, or undefined when unpriced. */
1535
+ function priceSlugFor(modelId, known) {
1536
+ const override = PRICE_SLUG_OVERRIDES[modelId];
1537
+ if (override !== void 0) return known.has(override) ? override : void 0;
1538
+ return priceSlugCandidates(modelId).find((slug) => known.has(slug));
1539
+ }
1540
+ /** Split a stored triplet/quadruplet into the wire rate shape. */
1541
+ function ratesOf(values) {
1542
+ const rates = {
1543
+ inputCost: values[0],
1544
+ outputCost: values[1],
1545
+ cacheReadCost: values[2]
1546
+ };
1547
+ if (values[3] !== void 0) rates.cacheWriteCost = values[3];
1548
+ return rates;
1549
+ }
1550
+ /** Build the full price row the browser prices a session with. */
1551
+ function wireRow(id, slug, row) {
1552
+ const price = {
1553
+ id,
1554
+ slug,
1555
+ ...ratesOf(row.rates)
1556
+ };
1557
+ if (row.peak !== void 0) price.peak = ratesOf(row.peak);
1558
+ if (row.contextTiers !== void 0) price.contextTiers = row.contextTiers.map((tier) => ({
1559
+ ...ratesOf(tier.rates),
1560
+ ...tier.maxContext === void 0 ? {} : { maxContext: tier.maxContext }
1561
+ }));
1562
+ return price;
1563
+ }
648
1564
  /**
649
- * The minimum subscription plan a model is included in, per the official plan
650
- * pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`, `/docs/plans/max`
651
- * and `/docs/resources/pricing-limits`). Each plan's model list is a superset of
652
- * the one below it: Go ⊂ GOAT ⊂ Pro ⊂ Provider/Max. Models absent from every
653
- * plan list (Claude Opus/Fable, Fugu Ultra) are Provider-tier.
654
- * `claude-fable-5-1` (Claude Fable 5.1, added in command-code@1.40.0) is
655
- * Provider/Max-tier exactly like `claude-fable-5` — its availability matrix on
656
- * the official plan/pricing pages grants individual-provider/max/ultra and
657
- * teams-pro only, and the CLI's plan-access map blocks it on Go/GOAT/Pro.
658
- * command-code@1.41.0 added `Qwen/Qwen3.8-Max-0902` (Go) and 1.42.0 added
659
- * `meituan/LongCat-2.0:free` (Go, free promo); command-code@1.43.0 added
660
- * `google/gemini-3.8-flash` (GOAT) and 1.44.0 added `meta/muse-spark-1.3`
661
- * (GOAT) plus its Contributor sibling (Go); command-code@1.52.0 added the
662
- * free `inclusionai/ling-3.0-flash-sante:free` (Go); command-code@1.53.0
663
- * added `deepseek/deepseek-v4.1-flash` (Go).
1565
+ * Build the table the composer prices a session with.
664
1566
  *
665
- * The Provider API exposes no plan metadata, so this snapshot is the source of
666
- * truth for the picker's plan annotation — it answers "which plan do I need to
667
- * actually use this model?" at a glance. Plan labels use the official tier
668
- * names (`Go`, `GOAT`, `Pro`, `Provider`), with `Max` implying Provider.
1567
+ * Rows are keyed by CATALOG id wherever the two namespaces reconcile, because
1568
+ * that is what a session reports, and every row also carries its page slug as a
1569
+ * second lookup key. Price rows no catalog model claims are served under the
1570
+ * slug alone, so drift in either direction still prices: a page rename the
1571
+ * catalog has not followed, or a model the catalog snapshot has not learned
1572
+ * yet. Free models are served explicitly at zero so the composer can say so
1573
+ * instead of showing nothing.
669
1574
  *
670
- * Keep in sync with the official plan pages when they change (see the
671
- * dsh-commandcode-upstream skill).
1575
+ * The peak windows travel with the table, so the browser applies the very
1576
+ * schedule this snapshot knows instead of restating it.
672
1577
  */
673
- const KNOWN_PLANS = {
674
- "MiniMaxAI/MiniMax-M2.5": "go",
675
- "MiniMaxAI/MiniMax-M2.7": "go",
676
- "MiniMaxAI/MiniMax-M3": "go",
677
- "Qwen/Qwen3.6-Max-Preview": "go",
678
- "Qwen/Qwen3.6-Plus": "go",
679
- "Qwen/Qwen3.7-Flash": "go",
680
- "Qwen/Qwen3.7-Max": "go",
681
- "Qwen/Qwen3.7-Plus": "go",
682
- "Qwen/Qwen3.8-27B": "go",
683
- "Qwen/Qwen3.8-Flash": "go",
684
- "Qwen/Qwen3.8-Max": "go",
685
- "Qwen/Qwen3.8-Max-0902": "go",
686
- "deepseek/deepseek-v4-flash-fast": "go",
687
- "deepseek/deepseek-v4.1-flash": "go",
688
- "deepseek/deepseek-v4-flash": "go",
689
- "deepseek/deepseek-v4-flash-vision-exp": "go",
690
- "deepseek/deepseek-v4-pro": "go",
691
- "gpt-5.6-luna": "go",
692
- "meituan/LongCat-2.0:free": "go",
693
- "inclusionai/ling-3.0-flash-sante:free": "go",
694
- "meta/muse-spark-1.2-contributor": "go",
695
- "meta/muse-spark-1.3-contributor": "go",
696
- "moonshotai/Kimi-K2.5": "go",
697
- "moonshotai/Kimi-K2.6": "go",
698
- "moonshotai/Kimi-K2.7-Code": "go",
699
- "moonshotai/Kimi-K2.7-Code-Highspeed": "go",
700
- "moonshotai/Kimi-K3": "go",
701
- "nvidia/nemotron-3-ultra-550b-a55b": "go",
702
- "poolside/laguna-s-2.1-free": "go",
703
- "stepfun/Step-3.5-Flash": "go",
704
- "stepfun/Step-3.7-Flash": "go",
705
- "tencent/hy3-paid": "go",
706
- "tencent/hy4-preview": "go",
707
- "thinkingmachines/inkling": "go",
708
- "thinkingmachines/inkling-small": "go",
709
- "xai/grok-4.5": "go",
710
- "xiaomi/mimo-v2.5": "go",
711
- "xiaomi/mimo-v2.5-pro": "go",
712
- "z-ai/glm-5.3-flash": "go",
713
- "zai-org/GLM-5": "go",
714
- "zai-org/GLM-5.1": "go",
715
- "zai-org/GLM-5.2": "go",
716
- "zai-org/GLM-5.2-Fast": "go",
717
- "zai-org/GLM-5.3": "go",
718
- "google/gemini-3.7-flash": "goat",
719
- "google/gemini-3.8-flash": "goat",
720
- "gpt-5.6-sol": "goat",
721
- "meta/muse-spark-1.2": "goat",
722
- "meta/muse-spark-1.3": "goat",
723
- "xai/grok-4.6": "goat",
724
- "claude-haiku-4-5-20251001": "pro",
725
- "claude-sonnet-4-6": "pro",
726
- "claude-sonnet-5": "pro",
727
- "google/gemini-3.1-flash-lite": "pro",
728
- "google/gemini-3.5-flash": "pro",
729
- "google/gemini-3.5-flash-lite": "pro",
730
- "google/gemini-3.6-flash": "pro",
731
- "gpt-5.3-codex": "pro",
732
- "gpt-5.4": "pro",
733
- "gpt-5.4-mini": "pro",
734
- "gpt-5.5": "pro",
735
- "gpt-5.6-terra": "pro",
736
- "meta/muse-spark-1.1": "pro",
737
- "claude-fable-5-1": "provider",
738
- "claude-fable-5": "provider",
739
- "claude-opus-4-7": "provider",
740
- "claude-opus-4-8": "provider",
741
- "claude-opus-5": "provider",
742
- "gpt-6-astra": "provider",
743
- "sakana/fugu-ultra": "provider"
744
- };
745
- /** Official display labels for each plan tier. */
746
- const PLAN_LABELS = {
747
- go: "Go",
748
- goat: "GOAT",
749
- pro: "Pro",
750
- provider: "Provider",
751
- max: "Max"
752
- };
1578
+ function modelPriceTable() {
1579
+ const bySlug = new Map(MODEL_PRICE_ROWS.map((row) => [row.id, row]));
1580
+ const known = new Set(bySlug.keys());
1581
+ const models = [];
1582
+ const claimed = /* @__PURE__ */ new Set();
1583
+ for (const catalogId of Object.keys(KNOWN_PLANS)) {
1584
+ if (isFreeModel(catalogId) || catalogId.endsWith(":free")) {
1585
+ models.push({
1586
+ id: catalogId,
1587
+ slug: catalogId,
1588
+ inputCost: 0,
1589
+ outputCost: 0,
1590
+ cacheReadCost: 0,
1591
+ free: true
1592
+ });
1593
+ continue;
1594
+ }
1595
+ const slug = priceSlugFor(catalogId, known);
1596
+ if (slug === void 0) continue;
1597
+ const row = bySlug.get(slug);
1598
+ if (row === void 0) continue;
1599
+ claimed.add(slug);
1600
+ models.push(wireRow(catalogId, slug, row));
1601
+ }
1602
+ for (const row of MODEL_PRICE_ROWS) {
1603
+ if (claimed.has(row.id)) continue;
1604
+ models.push(wireRow(row.id, row.id, row));
1605
+ }
1606
+ return {
1607
+ models,
1608
+ peakHours: PEAK_HOUR_RANGES.map(([start, end]) => [start, end])
1609
+ };
1610
+ }
1611
+ //#endregion
1612
+ //#region src/cost-facts.ts
1613
+ const zeroCostTokens = () => ({
1614
+ uncachedInputTokens: 0,
1615
+ outputTokens: 0,
1616
+ cacheReadTokens: 0,
1617
+ cacheWriteTokens: 0
1618
+ });
1619
+ const COST_TOKEN_KEYS = [
1620
+ "uncachedInputTokens",
1621
+ "outputTokens",
1622
+ "cacheReadTokens",
1623
+ "cacheWriteTokens"
1624
+ ];
1625
+ function peakHour(at, windows) {
1626
+ const time = new Date(at);
1627
+ return time.getUTCDay() !== 0 && time.getUTCDay() !== 6 && windows.some(([start, end]) => time.getUTCHours() >= start && time.getUTCHours() < end);
1628
+ }
753
1629
  /**
754
- * Plan-tier sort weights, low to high. Models outside the snapshot (unknown
755
- * plans) sort after every known tier, keeping known models predictable.
1630
+ * All prompt billing buckets determine a request's band, never cumulative session input.
1631
+ *
1632
+ * A tiered model returns the matching band and does NOT then apply `peak`: the
1633
+ * page publishes those two dimensions independently, no row carries both today,
1634
+ * and the generator would report a tiered row on every sync — so the combination
1635
+ * becoming real is visible before it reaches a user. This ordering is a latent
1636
+ * choice, not a verified upstream rule.
756
1637
  */
757
- const PLAN_ORDER = {
758
- go: 0,
759
- goat: 1,
760
- pro: 2,
761
- provider: 3,
762
- max: 4
763
- };
1638
+ function requestRates(price, at, contextTokens, table) {
1639
+ const tier = price.contextTiers?.find((tier) => tier.maxContext === void 0 || contextTokens <= tier.maxContext);
1640
+ if (tier !== void 0) return tier;
1641
+ return price.peak !== void 0 && at !== null && peakHour(at, table.peakHours) ? price.peak : price;
1642
+ }
1643
+ /** Cache version and wire guard: changing bands/rates must refold historical groups. */
1644
+ function pricingKey(table) {
1645
+ const rates = (r) => [
1646
+ r.inputCost,
1647
+ r.outputCost,
1648
+ r.cacheReadCost,
1649
+ r.cacheWriteCost ?? null
1650
+ ];
1651
+ const canonical = JSON.stringify([
1652
+ "commandCodeCost-v1",
1653
+ table.peakHours,
1654
+ table.models.map((p) => [
1655
+ p.id,
1656
+ p.slug,
1657
+ p.free === true,
1658
+ rates(p),
1659
+ p.peak ? rates(p.peak) : null,
1660
+ p.contextTiers?.map((t) => [t.maxContext ?? null, rates(t)]) ?? null
1661
+ ])
1662
+ ]);
1663
+ let hash = 2166136261;
1664
+ for (const c of canonical) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
1665
+ return hash;
1666
+ }
1667
+ /** Only safe to merge requests whose billing classification is identical. */
1668
+ function costGroupKey(group, table) {
1669
+ const price = table.models.find((p) => p.id === group.model || p.slug === group.model);
1670
+ return JSON.stringify([
1671
+ group.provider,
1672
+ group.model,
1673
+ price === void 0 ? null : requestRates(price, group.at, group.contextTokens, table),
1674
+ group.at === null
1675
+ ]);
1676
+ }
1677
+ //#endregion
1678
+ //#region src/cost-projection.ts
1679
+ const record$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
1680
+ const finite = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
1681
+ function tokensOf(value) {
1682
+ const u = record$2(value);
1683
+ if (!u || !finite(u.inputTokens) || !finite(u.outputTokens)) return void 0;
1684
+ if (u.cacheReadTokens !== void 0 && !finite(u.cacheReadTokens) || u.cacheWriteTokens !== void 0 && !finite(u.cacheWriteTokens)) return void 0;
1685
+ return {
1686
+ uncachedInputTokens: u.inputTokens,
1687
+ outputTokens: u.outputTokens,
1688
+ cacheReadTokens: u.cacheReadTokens ?? 0,
1689
+ cacheWriteTokens: u.cacheWriteTokens ?? 0
1690
+ };
1691
+ }
1692
+ /** Current v2 settlements store the last usage in their stream; v1 stores chunk events. */
1693
+ function usageOf(type, data) {
1694
+ if (type === "assistant/chunk") {
1695
+ const chunk = record$2(data.chunk);
1696
+ return chunk?.type === "usage" ? tokensOf(chunk.usage) : void 0;
1697
+ }
1698
+ if (type !== "assistant/message" && type !== "assistant/attempt") return void 0;
1699
+ const direct = tokensOf(data.usage);
1700
+ if (direct !== void 0) return direct;
1701
+ if (!Array.isArray(data.stream)) return void 0;
1702
+ for (let i = data.stream.length - 1; i >= 0; i--) {
1703
+ const item = record$2(data.stream[i]);
1704
+ const chunk = item?.type === "chunk" ? record$2(item.chunk) : void 0;
1705
+ if (chunk?.type === "usage") return tokensOf(chunk.usage);
1706
+ }
1707
+ }
1708
+ function parseState(value) {
1709
+ const s = record$2(value), facts = record$2(s?.facts);
1710
+ if (!s || !facts || !finite(facts.pricingKey) || !Array.isArray(facts.groups)) throw new TypeError("invalid commandCodeCost state");
1711
+ const group = (v) => {
1712
+ const g = record$2(v), t = record$2(g?.tokens);
1713
+ return !!g && typeof g.provider === "string" && typeof g.model === "string" && (g.at === null || finite(g.at)) && finite(g.contextTokens) && !!t && COST_TOKEN_KEYS.every((k) => finite(t[k]));
1714
+ };
1715
+ const sel = record$2(s.selection), last = record$2(s.last);
1716
+ if (!(s.selection === null || sel && typeof sel.provider === "string" && typeof sel.model === "string") || !(s.at === null || finite(s.at)) || !facts.groups.every(group) || !(s.last === null || last && finite(last.turn) && finite(last.step) && group(last.group))) throw new TypeError("invalid commandCodeCost state");
1717
+ return value;
1718
+ }
1719
+ /** Optional registry uses only synchronous parse/view; no new runtime module is required. */
1720
+ function createCostProjection(table = modelPriceTable()) {
1721
+ const version = pricingKey(table);
1722
+ return {
1723
+ key: "commandCodeCost",
1724
+ stateVersion: version,
1725
+ stateSchema: { parse: parseState },
1726
+ init: () => ({
1727
+ selection: null,
1728
+ at: null,
1729
+ last: null,
1730
+ facts: {
1731
+ pricingKey: version,
1732
+ groups: []
1733
+ }
1734
+ }),
1735
+ apply(state, event) {
1736
+ const data = record$2(event.data);
1737
+ if (!data) return state;
1738
+ if (event.type === "request/header") {
1739
+ const config = record$2(record$2(data.header)?.config);
1740
+ return {
1741
+ ...state,
1742
+ selection: config && typeof config.provider === "string" && typeof config.model === "string" ? {
1743
+ provider: config.provider,
1744
+ model: config.model
1745
+ } : null
1746
+ };
1747
+ }
1748
+ if (event.type === "step/start" || event.type === "llm/retry-started") return {
1749
+ ...state,
1750
+ at: finite(event.time) ? event.time : null,
1751
+ last: null
1752
+ };
1753
+ const tokens = usageOf(event.type, data);
1754
+ if (tokens === void 0 || !finite(data.turn) || !finite(data.step)) return state;
1755
+ const previous = state.last?.turn === data.turn && state.last.step === data.step ? state.last.group : void 0;
1756
+ const group = {
1757
+ provider: state.selection?.provider ?? "",
1758
+ model: state.selection?.model ?? "",
1759
+ at: state.at,
1760
+ contextTokens: tokens.uncachedInputTokens + tokens.cacheReadTokens + tokens.cacheWriteTokens,
1761
+ tokens
1762
+ };
1763
+ if (previous && JSON.stringify(previous) === JSON.stringify(group)) return state;
1764
+ const groups = state.facts.groups.map((g) => ({
1765
+ ...g,
1766
+ tokens: { ...g.tokens }
1767
+ }));
1768
+ if (previous) {
1769
+ const old = groups.find((g) => costGroupKey(g, table) === costGroupKey(previous, table));
1770
+ if (old) for (const k of COST_TOKEN_KEYS) old.tokens[k] -= previous.tokens[k];
1771
+ }
1772
+ let target = groups.find((g) => costGroupKey(g, table) === costGroupKey(group, table));
1773
+ if (!target) {
1774
+ target = {
1775
+ ...group,
1776
+ tokens: zeroCostTokens()
1777
+ };
1778
+ groups.push(target);
1779
+ }
1780
+ for (const k of COST_TOKEN_KEYS) target.tokens[k] += tokens[k];
1781
+ return {
1782
+ ...state,
1783
+ last: {
1784
+ turn: data.turn,
1785
+ step: data.step,
1786
+ group
1787
+ },
1788
+ facts: {
1789
+ pricingKey: version,
1790
+ groups: groups.filter((g) => COST_TOKEN_KEYS.some((k) => g.tokens[k] > 0))
1791
+ }
1792
+ };
1793
+ },
1794
+ wire: {
1795
+ view: (state) => state.facts,
1796
+ viewSchema: { parse(value) {
1797
+ return parseState({
1798
+ selection: null,
1799
+ at: null,
1800
+ last: null,
1801
+ facts: value
1802
+ }).facts;
1803
+ } }
1804
+ }
1805
+ };
1806
+ }
1807
+ function installCostProjection(ctx) {
1808
+ ctx.inject(["sessionProjections"], (c) => {
1809
+ const registry = c.get("sessionProjections");
1810
+ if (typeof registry?.register === "function") c.effect(() => registry.register(createCostProjection()), "commandcode cost projection");
1811
+ });
1812
+ }
1813
+ //#endregion
1814
+ //#region src/accounts.ts
764
1815
  /**
765
- * Whether a model is free (requests cost no credits), per the pricing page's
766
- * deals (`KNOWN_DEALS` `free: true`). Free models lead the picker regardless
767
- * of tier — they are usable by every account, so they are the best default
768
- * candidates.
1816
+ * Multi-account pool for the Command Code provider (host side).
1817
+ *
1818
+ * One Command Code subscription (e.g. the Go plan's 5-hour window) is
1819
+ * metered; a user with several subscriptions wants a request that hits one
1820
+ * account's limit to continue on the next account without a visible failure.
1821
+ * This module owns that rotation:
1822
+ *
1823
+ * - {@link CommandCodeAccountPool.resolveKey} hands out the first account
1824
+ * whose key is not currently marked exhausted — or, when the request's
1825
+ * model matches a {@link CommandCodeModelAccountRule} and that account is
1826
+ * usable, the routed account — resolving each slot's key lazily (literal
1827
+ * config key → credential seam → launch environment → the official CLI
1828
+ * auth file for the default slot only).
1829
+ * - {@link CommandCodeAccountPool.markRejected} records a 429 (rate limit,
1830
+ * window unknown) or 401 (invalid key, disabled until the config changes)
1831
+ * against the exact API key, so several slots sharing one key share one
1832
+ * state.
1833
+ * - When every account is marked, the pool probes each key's
1834
+ * `/alpha/billing/credits` window limits (through the injected
1835
+ * {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
1836
+ * no longer reports `exceeded` is revived, otherwise the pool throws a
1837
+ * `RATE_LIMIT` error naming the earliest reset time.
1838
+ *
1839
+ * The pool is deliberately cordis-free (like the adapter): every host fact
1840
+ * arrives through injected thunks, so node tests can drive it directly.
1841
+ *
1842
+ * @module dsh-commandcode-provider/accounts
1843
+ */
1844
+ /**
1845
+ * Upper bound on the retry wait this pool attaches to the all-exhausted
1846
+ * `RATE_LIMIT` error. Must equal the `backoff.maxDelayMs` in the adapter's
1847
+ * `providerRetryPolicy` (which imports it from here): dsh-llm-retry honors a
1848
+ * provider-specified wait verbatim only at or below that cap — in normal mode
1849
+ * a LONGER attached wait makes the executor abandon the retry entirely
1850
+ * instead of falling back to local backoff, which would turn "poll until the
1851
+ * window opens" into "fail now".
769
1852
  */
770
- function isFreeModel(modelId) {
771
- return KNOWN_DEALS[modelId]?.free === true;
1853
+ const RETRY_MAX_DELAY_MS = 9e5;
1854
+ /** A labeled, human-readable clock reading for error messages. */
1855
+ function clockLabel(ms) {
1856
+ return new Date(ms).toLocaleString();
772
1857
  }
773
1858
  /**
774
- * Comparator for the model picker: free models first (zero credit cost, usable
775
- * by every account), then by plan tier (lowest first), then by model name,
776
- * then by id as a tiebreak. Models with no known plan sort last.
1859
+ * Whether an account with this rotation state can serve a request right now.
1860
+ * `undefined` (never rejected) is usable; a cooldown becomes usable again
1861
+ * once its reset time passes; `unknown` (429, reset unprobed) and
1862
+ * `disabled` (401) are not.
777
1863
  */
778
- function compareByPlan(a, b) {
779
- const freeDelta = Number(isFreeModel(b.id)) - Number(isFreeModel(a.id));
780
- if (freeDelta !== 0) return freeDelta;
781
- const pa = PLAN_ORDER[KNOWN_PLANS[a.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
782
- const pb = PLAN_ORDER[KNOWN_PLANS[b.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
783
- if (pa !== pb) return pa - pb;
784
- const nameDiff = a.name.localeCompare(b.name);
785
- if (nameDiff !== 0) return nameDiff;
786
- return a.id.localeCompare(b.id);
1864
+ function accountUsable(state) {
1865
+ if (state === void 0) return true;
1866
+ if (state.kind === "cooldown") return state.until > 0 && Date.now() >= state.until;
1867
+ return false;
787
1868
  }
788
1869
  /**
789
- * Subscription plan table, synced from the official CLI bundle's plan maps
790
- * (located by the `"individual-go"` key in command-code@1.53.0 `dist/cli.mjs`,
791
- * re-verified unchanged through 1.53.0): subscription `planId`
792
- * prefix → display name and the plan's monthly credit total. This is the
793
- * account's own subscription (from `/alpha/billing/subscriptions`) — distinct
794
- * from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
795
- *
796
- * `tierWeight` is plugin-added (not from the CLI maps): the plan's rank on
797
- * the {@link PLAN_ORDER} scale, used by the picker's plan filter
798
- * ({@link modelVisibleInPlan}) to hide models above the account's tier.
1870
+ * Pick the account that should serve now: the manually preferred slot when it
1871
+ * is usable, otherwise the first usable account in rotation order; undefined
1872
+ * when no account is usable. Shared by the pool (request path) and the plugin
1873
+ * entry (the usage view's active badge) so both always agree.
799
1874
  */
800
- const KNOWN_SUBSCRIPTION_PLANS = {
801
- "individual-go": {
802
- name: "Go",
803
- monthlyCredits: 10,
804
- tierWeight: 0
805
- },
806
- "individual-goat": {
807
- name: "GOAT",
808
- monthlyCredits: 70,
809
- tierWeight: 1
810
- },
811
- "individual-pro": {
812
- name: "Pro",
813
- monthlyCredits: 30,
814
- tierWeight: 2
815
- },
816
- "individual-pro-v1": {
817
- name: "Pro",
818
- monthlyCredits: 80,
819
- tierWeight: 2
820
- },
821
- "individual-provider": {
822
- name: "Provider",
823
- monthlyCredits: 15,
824
- tierWeight: 3
825
- },
826
- "individual-max": {
827
- name: "Max",
828
- monthlyCredits: 150,
829
- tierWeight: 4
830
- },
831
- "individual-ultra": {
832
- name: "Ultra",
833
- monthlyCredits: 300,
834
- tierWeight: 4
835
- },
836
- "teams-pro": {
837
- name: "Teams Pro",
838
- monthlyCredits: 40,
839
- tierWeight: 2
1875
+ function selectActiveAccount(accounts, preferredId) {
1876
+ const usable = accounts.filter((account) => accountUsable(account.state));
1877
+ if (preferredId !== void 0) {
1878
+ const preferred = usable.find((account) => account.slot.id === preferredId);
1879
+ if (preferred !== void 0) return preferred;
840
1880
  }
841
- };
842
- /** Plan-id prefixes, longest first — the CLI's prefix-match order. */
843
- const SUBSCRIPTION_PLAN_PREFIXES = Object.keys(KNOWN_SUBSCRIPTION_PLANS).sort((a, b) => b.length - a.length);
1881
+ return usable[0];
1882
+ }
844
1883
  /**
845
- * Resolve a subscription `planId` (e.g. `individual-pro-v1`) to its display
846
- * name and monthly credit total, mirroring the CLI's `getPlanInfo`:
847
- * normalize (lowercase, `_` → `-`), then longest-prefix match so
848
- * `individual-pro-v1` wins over `individual-pro`. Unknown ids return
849
- * `undefined`.
1884
+ * The first routing rule whose model list contains the request's model id.
1885
+ * Undefined when no rule matches.
850
1886
  */
851
- function subscriptionPlanInfo(planId) {
852
- const normalized = planId.toLowerCase().replace(/_/g, "-");
853
- const prefix = SUBSCRIPTION_PLAN_PREFIXES.find((candidate) => normalized.startsWith(candidate));
854
- return prefix === void 0 ? void 0 : KNOWN_SUBSCRIPTION_PLANS[prefix];
1887
+ function matchModelRule(model, rules) {
1888
+ if (model === "" || rules === void 0 || rules.length === 0) return void 0;
1889
+ for (const rule of rules) if (rule.models.includes(model)) return rule;
855
1890
  }
856
1891
  /**
857
- * Whether the picker lists `modelId` for an account with the given billing
858
- * access. Fails open at every uncertainty: no billing data, an unknown plan,
859
- * a non-finite weight (corrupt billing fact), or a model outside
860
- * {@link KNOWN_PLANS} all keep the model visible — the server remains the
861
- * final gate (`403 MODEL_NOT_IN_PLAN`).
1892
+ * The routed account for a request's model: the first usable account whose
1893
+ * slot id matches the first matching rule's target. Undefined when no rule
1894
+ * matches or the routed account is not usable (the caller then falls back to
1895
+ * the normal preferred/rotation selection).
862
1896
  */
863
- function modelVisibleInPlan(modelId, access) {
864
- if (access === void 0) return true;
865
- if (access.onDemandCredits > 0) return true;
866
- if (access.tierWeight === void 0 || !Number.isFinite(access.tierWeight)) return true;
867
- const tier = KNOWN_PLANS[modelId];
868
- if (tier === void 0) return true;
869
- const weight = PLAN_ORDER[tier];
870
- if (weight === void 0) return true;
871
- return weight <= access.tierWeight;
1897
+ function selectAccountForModel(accounts, model, rules) {
1898
+ const rule = matchModelRule(model, rules);
1899
+ if (rule === void 0) return void 0;
1900
+ return accounts.find((account) => account.slot.id === rule.account && accountUsable(account.state));
872
1901
  }
873
- const KNOWN_DEALS = {
874
- "MiniMaxAI/MiniMax-M3": { label: "50% off" },
875
- "xiaomi/mimo-v2.5-pro": { label: "99% off" },
876
- "xiaomi/mimo-v2.5": { label: "98% off" },
877
- "poolside/laguna-s-2.1-free": {
878
- label: "FREE",
879
- free: true
880
- },
881
- "meituan/LongCat-2.0:free": {
882
- label: "FREE",
883
- free: true
884
- },
885
- "inclusionai/ling-3.0-flash-sante:free": {
886
- label: "FREE",
887
- free: true
1902
+ /**
1903
+ * The account pool. Rotation state is keyed by API key (never logged), so two
1904
+ * slots resolving to the same credential share one mark, and a key changed in
1905
+ * the credentials service starts with a clean slate.
1906
+ */
1907
+ var CommandCodeAccountPool = class {
1908
+ deps;
1909
+ /** Rotation state by API key. */
1910
+ states = /* @__PURE__ */ new Map();
1911
+ constructor(deps) {
1912
+ this.deps = deps;
1913
+ }
1914
+ /**
1915
+ * Resolve every slot's key, deduplicated by key (first slot wins). Slots
1916
+ * without any resolvable key are omitted — they still appear in the
1917
+ * settings page as unconfigured, they just cannot serve requests.
1918
+ */
1919
+ async resolvedAccounts() {
1920
+ const out = [];
1921
+ const seen = /* @__PURE__ */ new Set();
1922
+ for (const slot of this.deps.slots()) {
1923
+ const key = await this.resolveSlotKey(slot);
1924
+ if (key === void 0 || seen.has(key)) continue;
1925
+ seen.add(key);
1926
+ out.push({
1927
+ slot,
1928
+ key,
1929
+ state: this.states.get(key)
1930
+ });
1931
+ }
1932
+ return out;
1933
+ }
1934
+ /**
1935
+ * Every slot paired with its resolved key and rotation state — NOT
1936
+ * deduplicated: two slots sharing one credential both appear (the usage
1937
+ * view reports them individually), while slots without any resolvable key
1938
+ * are omitted. The serving path uses {@link resolvedAccounts} instead.
1939
+ */
1940
+ async describeAccounts() {
1941
+ const out = [];
1942
+ for (const slot of this.deps.slots()) {
1943
+ const key = await this.resolveSlotKey(slot);
1944
+ if (key === void 0) continue;
1945
+ out.push({
1946
+ slot,
1947
+ key,
1948
+ state: this.states.get(key)
1949
+ });
1950
+ }
1951
+ return out;
1952
+ }
1953
+ /**
1954
+ * Hand out the key for a request: the model-routed account when the
1955
+ * request's model matches a rule (and that account is usable), else the
1956
+ * manually preferred account when usable, else the first usable account in
1957
+ * rotation order. Returns `undefined` when no account resolves any key at
1958
+ * all (the caller then reports the missing credential). Throws
1959
+ * `RATE_LIMIT` — naming the earliest window reset — or
1960
+ * `INVALID_CREDENTIAL` when accounts exist but none can serve.
1961
+ *
1962
+ * `options.model` is the request's model id; routing rules re-read per
1963
+ * resolution, so a settings change applies live.
1964
+ *
1965
+ * `options.exclude` skips one key during the probe-revival pass: the
1966
+ * rotation hook excludes the just-rejected key so a probe that clears its
1967
+ * window cannot re-offer the same key within the same request (the adapter
1968
+ * refuses already-tried keys; the next request picks the revived key up).
1969
+ */
1970
+ async resolveKey(options) {
1971
+ const accounts = await this.resolvedAccounts();
1972
+ if (accounts.length === 0) return;
1973
+ const routed = selectAccountForModel(accounts, options?.model ?? "", this.deps.modelAccountRules?.());
1974
+ if (routed !== void 0) return this.pick(routed);
1975
+ const preferred = this.deps.preferredId?.();
1976
+ const chosen = selectActiveAccount(accounts, preferred);
1977
+ if (chosen !== void 0) return this.pick(chosen);
1978
+ await Promise.all(accounts.map(async (account) => {
1979
+ if (account.state?.kind === "disabled") return;
1980
+ if (options?.exclude !== void 0 && account.key === options.exclude) return;
1981
+ let probe;
1982
+ try {
1983
+ probe = await this.deps.probeWindow(account.key);
1984
+ } catch {
1985
+ return;
1986
+ }
1987
+ if (probe === void 0) return;
1988
+ if (!probe.exceeded) this.states.delete(account.key);
1989
+ else this.states.set(account.key, {
1990
+ kind: "cooldown",
1991
+ reason: account.state?.reason ?? "rate limited (429)",
1992
+ until: probe.resetAt
1993
+ });
1994
+ }));
1995
+ const latest = await this.resolvedAccounts();
1996
+ const revived = selectActiveAccount(latest, preferred);
1997
+ if (revived !== void 0) return this.pick(revived);
1998
+ if (latest.filter((account) => account.state?.kind === "disabled").length === latest.length) throw new LlmError(`llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file;已配置的 ${latest.length} 个 Command Code 账户密钥均被拒绝(401)——请在设置页检查存储的 API 密钥,或重新运行 command-code login`, "INVALID_CREDENTIAL");
1999
+ const resets = latest.map((account) => account.state).filter((state) => state !== void 0 && state.kind === "cooldown" && state.until > 0).map((state) => state.until);
2000
+ const earliest = resets.length > 0 ? Math.min(...resets) : 0;
2001
+ const wait = earliest > 0 ? Math.max(1e3, earliest - Date.now()) : 0;
2002
+ throw new LlmError(`llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : "") + ` — requests will succeed again after the reset (or add another account);已用尽全部 ${latest.length} 个 Command Code 账户的用量窗口` + (earliest > 0 ? `,最早的重置时间为 ${clockLabel(earliest)}` : "") + "——窗口重置后请求会自动恢复(也可以添加更多账户)", "RATE_LIMIT", wait > 0 && wait <= 9e5 ? { providerRetryAfterMs: wait } : void 0);
2003
+ }
2004
+ /**
2005
+ * Record a rejection against one key. `rate-limit` (429) marks the key
2006
+ * exhausted with an unknown reset (probed lazily at the next resolution
2007
+ * once every account is marked); `invalid-credential` (401) disables the
2008
+ * key until the stored credential changes.
2009
+ */
2010
+ markRejected(apiKey, rejection) {
2011
+ if (rejection === "invalid-credential") this.states.set(apiKey, {
2012
+ kind: "disabled",
2013
+ reason: "invalid API key (401)",
2014
+ until: 0
2015
+ });
2016
+ else this.states.set(apiKey, {
2017
+ kind: "unknown",
2018
+ reason: "rate limited (429)",
2019
+ until: 0
2020
+ });
888
2021
  }
889
- };
890
- /**
891
- * Models with time-of-day (peak/off-peak) pricing, per the official pricing
892
- * page (`/docs/resources/pricing-limits`). Since 2026-08-16 16:00 UTC, DeepSeek
893
- * charges by the hour: peak hours are 01:00–04:00 and 06:00–10:00 UTC (7h per
894
- * weekday, full price) **Monday to Friday only**; the other 17 hours of a
895
- * weekday and every hour of Saturday/Sunday (UTC) are off-peak at half price.
896
- * The V4 Flash Vision (exp) variant (command-code@1.32.0) shares the V4 Flash
897
- * windows and peak prices ($0.44/$1.32) — each row's hover annotation states
898
- * exactly 2× that row's displayed off-peak prices. The picker shows the
899
- * *current* state as a compact
900
- * label (`Peak`/`Half`) matching the English noun style of the other markers
901
- * (`Image`, `FREE`), so a developer can tell at a glance whether calling the
902
- * model right now is cheap or expensive.
903
- *
904
- * Authoritative extraction: the pricing page embeds a model JSON array whose
905
- * hourly-priced entries carry a `timeOfDay` block
906
- * (`{ windows: "01–04 & 06–10 UTC, Mon–Fri", peakHoursPerDay: 7,
907
- * offPeakHoursPerDay: 17, peak: {...}, offPeak: {...} }`). Exactly four models
908
- * carry it: V4 Pro, V4 Flash, V4 Flash Vision (exp), and V4.1 Flash (added in
909
- * command-code@1.53.0 at $0.15/$0.60 off-peak, $0.30/$1.20 peak — the same
910
- * schedule as the other three).
911
- *
912
- * Extraction caution: the rendered HTML rows are a trap. Each annotation div
913
- * sits inside its OWN row's container, immediately before the NEXT row starts,
914
- * so flattening the page to text makes every annotation look like it belongs
915
- * to the model printed after it — that is how `deepseek/deepseek-v4-flash-fast`
916
- * was wrongly added here (its row is flat-priced at $0.28/$0.56/$0.07 and has
917
- * no `timeOfDay` block). Trust the embedded JSON's `timeOfDay` membership and
918
- * the 2× price relation, never the flat-text neighbor.
919
- *
920
- * Keep in sync with the official pricing page when the model set, the peak
921
- * windows, or the weekday rule change (see the dsh-commandcode-upstream skill).
922
- */
923
- const KNOWN_PEAK_PRICING = /* @__PURE__ */ new Set([
924
- "deepseek/deepseek-v4-pro",
925
- "deepseek/deepseek-v4-flash",
926
- "deepseek/deepseek-v4-flash-vision-exp",
927
- "deepseek/deepseek-v4.1-flash"
928
- ]);
929
- /**
930
- * Peak hours (UTC, hour-of-day range end-exclusive): 01–03 and 06–09.
931
- * Weekday-only — see `peakPricingState()`; weekends are fully off-peak.
932
- */
933
- const PEAK_HOUR_RANGES = [[1, 4], [6, 10]];
934
- /**
935
- * Whether `now` (defaults to `Date.now()`) falls in a peak-pricing window for
936
- * time-of-day-priced models. Peak rates apply Monday–Friday (UTC) only: the
937
- * official rule charges Saturday and Sunday completely off-peak for all 24
938
- * hours, so a weekend timestamp is off-peak even inside `PEAK_HOUR_RANGES`.
939
- * `undefined` for models outside the snapshot.
940
- */
941
- function peakPricingState(modelId, now = Date.now()) {
942
- if (!KNOWN_PEAK_PRICING.has(modelId)) return void 0;
943
- const at = new Date(now);
944
- const day = at.getUTCDay();
945
- if (day === 0 || day === 6) return "off-peak";
946
- const hour = at.getUTCHours();
947
- return PEAK_HOUR_RANGES.some(([start, end]) => hour >= start && hour < end) ? "peak" : "off-peak";
948
- }
949
- /**
950
- * Compact label for the current peak/off-peak state: `Peak` (full price) or
951
- * `Half` (off-peak, half price). These English nouns match the picker's other
952
- * markers (`Go`, `Image`, `FREE`), and since they appear only on time-of-day
953
- * priced models they double as a "priced by the hour" signal. Returns undefined
954
- * for models without time-of-day pricing.
955
- */
956
- function peakPricingLabel(modelId, now = Date.now()) {
957
- const state = peakPricingState(modelId, now);
958
- if (state === void 0) return void 0;
959
- return state === "peak" ? "Peak" : "Half";
960
- }
961
- /**
962
- * Official display label for a model's minimum plan, or undefined for models
963
- * outside the snapshot (e.g. future catalog additions).
964
- */
965
- function planLabel(modelId) {
966
- const plan = KNOWN_PLANS[modelId];
967
- return plan === void 0 ? void 0 : PLAN_LABELS[plan];
968
- }
969
- /**
970
- * The active deal label for a model, or undefined when the model has no deal
971
- * or the deal has expired. Expiry is judged against `now` (defaults to
972
- * `Date.now()`), so a snapshot that has gone stale stops showing its discount
973
- * the moment the official end date passes — the user never believes a lapsed
974
- * deal is still live. Permanent deals (no `expiresAt`) never lapse.
975
- */
976
- function dealLabel(modelId, now = Date.now()) {
977
- const deal = KNOWN_DEALS[modelId];
978
- if (deal === void 0) return void 0;
979
- if (deal.expiresAt !== void 0 && now >= Date.parse(deal.expiresAt)) return void 0;
980
- return deal.label;
981
- }
982
- /**
983
- * Compact human-readable context window, e.g. `1_000_000 -> "1M"`,
984
- * `256_000 -> "256K"`, `262_144 -> "262K"` (floor to the nearest K).
985
- * Returns undefined for unknown/absent sizes; values under 1K render raw.
986
- */
987
- function formatContext(contextWindow) {
988
- if (contextWindow === void 0 || !Number.isFinite(contextWindow) || contextWindow <= 0) return;
989
- if (contextWindow >= 1e6) {
990
- const m = contextWindow / 1e6;
991
- const rounded = Math.round(m * 10) / 10;
992
- return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`;
2022
+ /**
2023
+ * One account's key: literal → credential seam → auth file (default slot).
2024
+ *
2025
+ * Every source is normalized here, at the single point where a slot's key
2026
+ * enters the pool. The adapter sends the key through the harness's
2027
+ * `assertUsableApiKey()`, which trims it — a stored key from the credentials
2028
+ * seam, a `.env` line, or a shell export all pick up surrounding whitespace
2029
+ * — and reports that trimmed form back to `markRejected()`. Returning the
2030
+ * raw value would file every 429/401 mark under a key no later lookup can
2031
+ * find: rotation would re-offer the same account, the account card would show
2032
+ * no mark, and the usage endpoints would 401 while chat kept working.
2033
+ * Normalizing once makes resolution, probing, marking, and the request path
2034
+ * agree on one string.
2035
+ */
2036
+ async resolveSlotKey(slot) {
2037
+ if (slot.literal !== void 0) {
2038
+ const literal = normalizeResolvedKey(slot.literal);
2039
+ if (literal !== void 0) return literal;
2040
+ }
2041
+ if (slot.ref !== void 0) {
2042
+ const hit = await this.deps.resolveRef(slot.ref);
2043
+ if (hit !== void 0) {
2044
+ const resolved = normalizeResolvedKey(hit);
2045
+ if (resolved !== void 0) return resolved;
2046
+ }
2047
+ }
2048
+ if (slot.allowAuthFile) {
2049
+ const fromFile = this.deps.authFileKey();
2050
+ if (fromFile !== void 0) {
2051
+ const fileKey = normalizeResolvedKey(fromFile);
2052
+ if (fileKey !== void 0) return fileKey;
2053
+ }
2054
+ }
993
2055
  }
994
- if (contextWindow < 1e3) return String(Math.floor(contextWindow));
995
- return `${Math.floor(contextWindow / 1e3)}K`;
996
- }
2056
+ /** Hand out the chosen account's key. */
2057
+ pick(account) {
2058
+ return {
2059
+ key: account.key,
2060
+ slot: account.slot
2061
+ };
2062
+ }
2063
+ };
997
2064
  /**
998
- * Compact one-line summary for the model picker: plan tier, then any active
999
- * deal (discount or FREE), then the current peak/off-peak state (`Peak`/`Half`)
1000
- * for time-of-day-priced models, then `Image` for Vision-capable models, then
1001
- * the context window. Text-only models simply omit the Image marker — "Text
1002
- * only" adds nothing the picker needs to show.
2065
+ * Normalize one resolved credential to the form every consumer sees. Trim
2066
+ * only: a blank-after-trim value means "no key" (the slot is omitted and the
2067
+ * caller reports `MISSING_CREDENTIAL`), while characters an HTTP header cannot
2068
+ * carry are left for `assertUsableApiKey()` to reject with its own message.
1003
2069
  */
1004
- function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1005
- const parts = [];
1006
- const plan = planLabel(modelId);
1007
- if (plan !== void 0) parts.push(plan);
1008
- const deal = dealLabel(modelId, now);
1009
- if (deal !== void 0) parts.push(deal);
1010
- const peak = peakPricingLabel(modelId, now);
1011
- if (peak !== void 0) parts.push(peak);
1012
- if (KNOWN_IMAGE_MODELS.has(modelId)) parts.push("Image");
1013
- const ctx = formatContext(contextWindow);
1014
- if (ctx !== void 0) parts.push(ctx);
1015
- return parts.join(" · ");
2070
+ function normalizeResolvedKey(value) {
2071
+ const trimmed = value.trim();
2072
+ return trimmed === "" ? void 0 : trimmed;
1016
2073
  }
1017
2074
  //#endregion
1018
2075
  //#region src/adapter.ts
@@ -1024,7 +2081,7 @@ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1024
2081
  * and API key or subscription, and Command Code's terms apply.
1025
2082
  *
1026
2083
  * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4;
1027
- * re-verified against command-code@1.53.0 — endpoints, request shape, and
2084
+ * re-verified against command-code@1.54.0 — endpoints, request shape, and
1028
2085
  * stream events unchanged):
1029
2086
  * POST {apiBase}/alpha/generate
1030
2087
  * body: { config, memory, taste, skills, params: { model, messages, tools,
@@ -1037,7 +2094,7 @@ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1037
2094
  * per-request options thunk and an API-key resolver from the plugin entry
1038
2095
  * (src/index.ts), so a settings change reaches the very next request.
1039
2096
  */
1040
- const COMMAND_CODE_CLI_VERSION = "1.53.0";
2097
+ const COMMAND_CODE_CLI_VERSION = "1.54.0";
1041
2098
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
1042
2099
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
1043
2100
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -1097,6 +2154,61 @@ function hasTerminalStreamMarker(message) {
1097
2154
  const lower = message.toLowerCase();
1098
2155
  return TERMINAL_STREAM_ERROR_MARKERS.some((marker) => lower.includes(marker));
1099
2156
  }
2157
+ /**
2158
+ * Context-window wording the provider uses to reject a request that outgrew
2159
+ * the model's window. `isContextWindowExceededError` is the harness's own
2160
+ * provider-neutral classifier — and the contract `CONTEXT_WINDOW_EXCEEDED`
2161
+ * exists for — so it decides; the official CLI's `truncated` pattern adds the
2162
+ * phrasings Command Code's upstreams emit that the harness helper does not
2163
+ * cover (a bare `prompt is too long`, or a complaint about `max_tokens`).
2164
+ * The CLI matches these first and answers them by compacting and retrying —
2165
+ * never by resending the request unchanged.
2166
+ */
2167
+ const CLI_CONTEXT_OVERFLOW_PATTERN = /prompt is too long|context.*(length|window)|max_tokens|maximum.*tokens/i;
2168
+ function isContextOverflowDetail(detail) {
2169
+ return isContextWindowExceededError(detail) || CLI_CONTEXT_OVERFLOW_PATTERN.test(detail);
2170
+ }
2171
+ /**
2172
+ * Classify one in-band stream `error` payload into the failure the caller
2173
+ * throws. Shared by both transports — the CLI transport delivers it as an
2174
+ * `error` event (`{ type: 'error', error }`), the Provider API transport as a
2175
+ * top-level `error` member of an SSE chunk — so the two cannot drift apart.
2176
+ *
2177
+ * The wording decides before the status does, mirroring the official CLI's
2178
+ * `classifyKind` (which reads the message first) and the harness helpers
2179
+ * (which exist so thrown and in-band delivery share one classifier). Order is
2180
+ * load-bearing:
2181
+ *
2182
+ * - A context-window rejection is terminal for THIS request but recoverable by
2183
+ * the harness: `dsh-compaction-basic` listens on `agent/request-error` for
2184
+ * `CONTEXT_WINDOW_EXCEEDED` and answers it with a compaction + retry of the
2185
+ * reduced surface. That is what lets a long session survive a request that
2186
+ * outgrew the model's window, and it is what the official CLI does (its
2187
+ * non-retryable `truncated` kind). Reported as retryable `SERVER` — where
2188
+ * this wording landed before — the adapter resent the byte-identical
2189
+ * oversized request up to `maxRetries` times and the session could never
2190
+ * recover, which is exactly the endless-retry-at-long-context report
2191
+ * (issue #39).
2192
+ * - Credits/plan wording is terminal, so an exhausted balance or a model
2193
+ * outside the plan is not retried as if it were a transient rate limit.
2194
+ * - Only then does the status/`isRetryable` pair decide, as before.
2195
+ */
2196
+ function streamErrorToLlmError(value, fallbackMessage) {
2197
+ const record = isRecord(value) ? value : void 0;
2198
+ const message = record ? stringValue(record.message) ?? JSON.stringify(record) : stringValue(value) ?? fallbackMessage ?? "Stream error";
2199
+ const statusCode = record === void 0 ? void 0 : numberValue(record.statusCode) ?? numberValue(record.status);
2200
+ const isRetryable = record === void 0 ? void 0 : booleanValue(record.isRetryable);
2201
+ const detail = [
2202
+ stringValue(record?.code),
2203
+ stringValue(record?.type),
2204
+ message
2205
+ ].filter((part) => part !== void 0 && part !== "").join(" ");
2206
+ const statusOption = statusCode !== void 0 ? { status: statusCode } : void 0;
2207
+ if (isContextOverflowDetail(detail)) return new LlmError(`Command Code stream error: ${message};Command Code API 拒绝了这次请求:内容超出模型上下文窗口。正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, CONTEXT_WINDOW_EXCEEDED_CODE, statusOption);
2208
+ const terminal = hasTerminalStreamMarker(detail);
2209
+ if (!(isRetryable === true || (statusCode !== void 0 ? statusCode !== void 0 && (statusCode === 429 || statusCode >= 500) : isRetryable !== false && !terminal))) return new LlmError(`Command Code stream error: ${message}`, "PROVIDER_STREAM_ERROR", statusOption);
2210
+ return new LlmError(`Command Code stream error: ${message}`, "SERVER", statusOption);
2211
+ }
1100
2212
  function recordOrEmpty(value) {
1101
2213
  if (isRecord(value)) return value;
1102
2214
  if (typeof value === "string") try {
@@ -1105,6 +2217,124 @@ function recordOrEmpty(value) {
1105
2217
  } catch {}
1106
2218
  return {};
1107
2219
  }
2220
+ /** How deep a root `$ref` / combinator chain is followed while normalizing. */
2221
+ const SCHEMA_NORMALIZE_MAX_DEPTH = 4;
2222
+ /**
2223
+ * Whether a type-less node is object-shaped enough to be one with the type
2224
+ * declared: `properties`/`required`/`additionalProperties` only make sense on
2225
+ * an object, and a schema carrying them was meant to be one.
2226
+ */
2227
+ function isObjectShaped(node) {
2228
+ return isRecord(node.properties) || isRecord(node.patternProperties) || Array.isArray(node.required) || node.additionalProperties !== void 0;
2229
+ }
2230
+ /** The `required` names of one schema node, ignoring malformed entries. */
2231
+ function requiredNames(node) {
2232
+ return Array.isArray(node.required) ? node.required.filter((name) => typeof name === "string") : [];
2233
+ }
2234
+ /** Resolve a local `#/...` pointer inside the schema that carried it. */
2235
+ function resolveLocalRef(root, ref) {
2236
+ if (!ref.startsWith("#/")) return void 0;
2237
+ let node = root;
2238
+ for (const rawSegment of ref.slice(2).split("/")) {
2239
+ const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
2240
+ if (!isRecord(node)) return void 0;
2241
+ node = node[segment];
2242
+ }
2243
+ return isRecord(node) ? node : void 0;
2244
+ }
2245
+ /**
2246
+ * Flatten a type-less combinator node into one object schema, so the model
2247
+ * still sees the branch fields instead of an argument-free tool. `allOf`
2248
+ * branches must all hold, so their `required` entries are all kept; `anyOf` /
2249
+ * `oneOf` branches are alternatives, so only a name required by every branch
2250
+ * survives. Returns undefined when no branch describes an object.
2251
+ */
2252
+ function mergeCombinatorBranches(node, depth) {
2253
+ if (depth >= SCHEMA_NORMALIZE_MAX_DEPTH) return void 0;
2254
+ const properties = {};
2255
+ const required = /* @__PURE__ */ new Set();
2256
+ const alternatives = [];
2257
+ let sawBranch = false;
2258
+ for (const key of [
2259
+ "allOf",
2260
+ "anyOf",
2261
+ "oneOf"
2262
+ ]) {
2263
+ const branches = node[key];
2264
+ if (!Array.isArray(branches)) continue;
2265
+ const objectBranches = [];
2266
+ for (const branch of branches) {
2267
+ if (!isRecord(branch)) continue;
2268
+ objectBranches.push(toolParametersSchema(branch, depth + 1));
2269
+ }
2270
+ if (objectBranches.length === 0) continue;
2271
+ sawBranch = true;
2272
+ const names = objectBranches.map(requiredNames);
2273
+ if (key === "allOf") for (const name of names.flat()) required.add(name);
2274
+ else if (names.length > 0) alternatives.push(names[0].filter((name) => names.every((list) => list.includes(name))));
2275
+ for (const branch of objectBranches) {
2276
+ const branchProperties = isRecord(branch.properties) ? branch.properties : {};
2277
+ for (const [name, schema] of Object.entries(branchProperties)) if (!(name in properties)) properties[name] = schema;
2278
+ }
2279
+ }
2280
+ if (!sawBranch) return void 0;
2281
+ for (const group of alternatives) for (const name of group) required.add(name);
2282
+ const merged = {
2283
+ type: "object",
2284
+ properties
2285
+ };
2286
+ if (required.size > 0) merged.required = [...required];
2287
+ if (node.additionalProperties !== void 0) merged.additionalProperties = node.additionalProperties;
2288
+ for (const key of ["description", "title"]) if (node[key] !== void 0) merged[key] = node[key];
2289
+ return merged;
2290
+ }
2291
+ /**
2292
+ * Normalize one tool's parameters schema to an object-rooted JSON Schema.
2293
+ * A schema that already declares an object root is passed through untouched;
2294
+ * a type-less object-shaped one gains the type; a root `$ref` or combinator is
2295
+ * resolved/merged; anything else (an array/scalar root, or no schema at all)
2296
+ * degrades to a permissive free-form object, because a request the provider
2297
+ * refuses helps no one and the tool's own description is still in the prompt.
2298
+ */
2299
+ function toolParametersSchema(parameters, depth = 0) {
2300
+ if (!isRecord(parameters)) return {
2301
+ type: "object",
2302
+ properties: {},
2303
+ additionalProperties: true
2304
+ };
2305
+ if (parameters.type === "object") return parameters;
2306
+ if (Array.isArray(parameters.type) && parameters.type.includes("object")) return {
2307
+ ...parameters,
2308
+ type: "object"
2309
+ };
2310
+ if (parameters.type === void 0 || parameters.type === null) {
2311
+ if (typeof parameters.$ref === "string" && depth < SCHEMA_NORMALIZE_MAX_DEPTH) {
2312
+ const target = resolveLocalRef(parameters, parameters.$ref);
2313
+ if (target !== void 0) {
2314
+ const { $ref: _ref, ...rest } = parameters;
2315
+ return toolParametersSchema({
2316
+ ...target,
2317
+ ...rest
2318
+ }, depth + 1);
2319
+ }
2320
+ }
2321
+ if (isObjectShaped(parameters)) return {
2322
+ ...parameters,
2323
+ type: "object"
2324
+ };
2325
+ const merged = mergeCombinatorBranches(parameters, depth);
2326
+ if (merged !== void 0) return merged;
2327
+ if (typeof parameters.$ref === "string") return {
2328
+ ...parameters,
2329
+ type: "object"
2330
+ };
2331
+ }
2332
+ return {
2333
+ type: "object",
2334
+ properties: {},
2335
+ additionalProperties: true
2336
+ };
2337
+ }
1108
2338
  function projectSlugFromPath(pathName) {
1109
2339
  return pathName.toLowerCase().replace(/^[a-z]:/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|(?<!-)-+$/g, "") || "project";
1110
2340
  }
@@ -1281,28 +2511,84 @@ function toolResultTextForWire(media) {
1281
2511
  return media.text || (media.images.length > 0 ? "(image returned; see the attached image)" : "");
1282
2512
  }
1283
2513
  /** Leading line of the user message that carries a tool result's images. */
1284
- const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result:";
2514
+ const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result";
1285
2515
  /**
1286
2516
  * The model-visible note introducing the images carried out of one tool
1287
2517
  * result. Neither transport merges them back into the tool result, so this
1288
2518
  * line is what tells the model the image it is about to see belongs to the
1289
2519
  * tool it just ran rather than to the user; it also leads the part list,
1290
- * because an image-first content array is what some gateways reject. Count and
1291
- * pixel dimensions are appended when the tool's own text does not already
1292
- * state them (a result that returns the image and nothing else).
2520
+ * because an image-first content array is what some gateways reject.
2521
+ *
2522
+ * The note names the tool call the image came out of, because a turn's
2523
+ * carriers are emitted as a group after the whole tool group (issue #33):
2524
+ * position alone would have to carry the association, and every `read_image`
2525
+ * result renders the same envelope text, so two parallel calls would produce
2526
+ * two identical notes. The id is the WIRE id — the one the model saw on the
2527
+ * `tool-call` it issued and on the tool message just above — so an overlong
2528
+ * cross-provider id is named by the alias that replaced it, not by the
2529
+ * harness-side id the model never saw.
2530
+ *
2531
+ * Count and pixel dimensions are appended when the tool's own text does not
2532
+ * already state them (a result that returns the image and nothing else).
1293
2533
  */
1294
- function toolResultImageNote(media) {
2534
+ function toolResultImageNote(media, toolCallId) {
2535
+ const lead = `${TOOL_RESULT_IMAGE_TEXT} (${toolCallId}):`;
1295
2536
  const first = media.images[0];
1296
- if (first === void 0) return TOOL_RESULT_IMAGE_TEXT;
2537
+ if (first === void 0) return lead;
1297
2538
  const dimensions = `${first.width}x${first.height} px`;
1298
- if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) return TOOL_RESULT_IMAGE_TEXT;
1299
- const count = media.images.length > 1 ? `${media.images.length} images, ` : "";
1300
- return `${TOOL_RESULT_IMAGE_TEXT} ${count}${dimensions}`;
2539
+ if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) return lead;
2540
+ return `${lead} ${media.images.length > 1 ? `${media.images.length} images, ` : ""}${dimensions}`;
1301
2541
  }
1302
2542
  function hasImageContent(message) {
1303
2543
  const check = (blocks) => blocks.some((b) => b.type === "image" || b.type === "tool-result" && check(b.content));
1304
2544
  return check(message.content);
1305
2545
  }
2546
+ /** The budget ladder: the primary rung first, the 413 eviction rung second. */
2547
+ const REQUEST_IMAGE_BUDGETS = [{
2548
+ maxBytes: 33554432,
2549
+ maxImages: 60,
2550
+ byteQuantum: 16777216,
2551
+ countQuantum: 30
2552
+ }, {
2553
+ maxBytes: 8388608,
2554
+ maxImages: 12,
2555
+ byteQuantum: 8388608,
2556
+ countQuantum: 12
2557
+ }];
2558
+ /**
2559
+ * Project request history under one image budget: the oldest images past it
2560
+ * become placeholder text in place, the newest keep their bytes, and a history
2561
+ * already inside the budget is returned as the SAME array — the caller uses
2562
+ * that identity to tell "nothing evicted" from "evicted".
2563
+ *
2564
+ * Nested tool-result images ride the same core walk, which is exactly right
2565
+ * for this adapter: an evicted `read_image` result surfaces its placeholder as
2566
+ * the tool message's own text, so no carrier user message is emitted for it.
2567
+ */
2568
+ function projectRequestImages(messages, budget) {
2569
+ return offloadRequestImagesWithPolicy(messages, {
2570
+ representation: "base64",
2571
+ maxBytes: budget.maxBytes,
2572
+ maxImages: budget.maxImages,
2573
+ byteQuantum: budget.byteQuantum,
2574
+ countQuantum: budget.countQuantum,
2575
+ placeholder: (ref) => offloadedImageText(ref)
2576
+ });
2577
+ }
2578
+ /**
2579
+ * The request options one budget produced: the caller's own object when the
2580
+ * history was already inside the budget, else a copy carrying the projection.
2581
+ * The next rung of the ladder is applied to the CURRENT projection, never to
2582
+ * the raw history — otherwise a retry could reintroduce images the first rung
2583
+ * had already evicted.
2584
+ */
2585
+ function withImageBudget(options, budget) {
2586
+ const projected = projectRequestImages(options.messages, budget);
2587
+ return projected === options.messages ? options : {
2588
+ ...options,
2589
+ messages: [...projected]
2590
+ };
2591
+ }
1306
2592
  /**
1307
2593
  * Convert one image reference to the Command Code wire format, as the official
1308
2594
  * CLI does: `{ type: 'image', source: { type: 'base64', media_type, data } }`.
@@ -1324,9 +2610,15 @@ async function messagesToCC(messages, readImage) {
1324
2610
  const out = [];
1325
2611
  const { ids: paired, names: toolNames } = pairedToolCalls(messages);
1326
2612
  const wireIds = wireToolCallIds(paired);
2613
+ const pendingImages = [];
2614
+ const flushPendingImages = () => {
2615
+ for (const message of pendingImages) out.push(message);
2616
+ pendingImages.length = 0;
2617
+ };
1327
2618
  for (const message of messages) {
1328
2619
  if (message.role === "system") continue;
1329
2620
  if (message.role === "user" && message.source.kind !== "tool") {
2621
+ flushPendingImages();
1330
2622
  const parts = [];
1331
2623
  for (const block of message.content) {
1332
2624
  if (block.type === "text") parts.push({
@@ -1338,6 +2630,7 @@ async function messagesToCC(messages, readImage) {
1338
2630
  parts.push(await imageToCommandCode(block.attachment, readImage));
1339
2631
  }
1340
2632
  }
2633
+ if (parts.length === 0) continue;
1341
2634
  out.push({
1342
2635
  role: "user",
1343
2636
  content: parts
@@ -1345,11 +2638,16 @@ async function messagesToCC(messages, readImage) {
1345
2638
  continue;
1346
2639
  }
1347
2640
  if (message.role === "assistant") {
2641
+ flushPendingImages();
1348
2642
  const parts = [];
1349
2643
  for (const block of message.content) if (block.type === "text") parts.push({
1350
2644
  type: "text",
1351
2645
  text: block.text
1352
2646
  });
2647
+ else if (block.type === "reasoning") parts.push({
2648
+ type: "reasoning",
2649
+ text: block.text
2650
+ });
1353
2651
  else if (block.type === "tool-call" && paired.has(block.id)) parts.push({
1354
2652
  type: "tool-call",
1355
2653
  toolCallId: wireIds.get(block.id) ?? block.id,
@@ -1366,11 +2664,12 @@ async function messagesToCC(messages, readImage) {
1366
2664
  const block = message.content[0];
1367
2665
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1368
2666
  const media = toolResultMedia(block);
2667
+ const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
1369
2668
  out.push({
1370
2669
  role: "tool",
1371
2670
  content: [{
1372
2671
  type: "tool-result",
1373
- toolCallId: wireIds.get(block.toolCallId) ?? block.toolCallId,
2672
+ toolCallId: wireToolCallId,
1374
2673
  toolName: toolNames.get(block.toolCallId) || "unknown",
1375
2674
  output: block.isError ? {
1376
2675
  type: "error-text",
@@ -1385,16 +2684,17 @@ async function messagesToCC(messages, readImage) {
1385
2684
  if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1386
2685
  const carried = [{
1387
2686
  type: "text",
1388
- text: toolResultImageNote(media)
2687
+ text: toolResultImageNote(media, wireToolCallId)
1389
2688
  }];
1390
2689
  for (const attachment of media.images) carried.push(await imageToCommandCode(attachment, readImage));
1391
- out.push({
2690
+ pendingImages.push({
1392
2691
  role: "user",
1393
2692
  content: carried
1394
2693
  });
1395
2694
  }
1396
2695
  }
1397
2696
  }
2697
+ flushPendingImages();
1398
2698
  return out;
1399
2699
  }
1400
2700
  /**
@@ -1422,9 +2722,15 @@ async function messagesToOpenAI(messages, readImage) {
1422
2722
  const out = [];
1423
2723
  const { ids: paired } = pairedToolCalls(messages);
1424
2724
  const wireIds = wireToolCallIds(paired);
2725
+ const pendingImages = [];
2726
+ const flushPendingImages = () => {
2727
+ for (const message of pendingImages) out.push(message);
2728
+ pendingImages.length = 0;
2729
+ };
1425
2730
  for (const message of messages) {
1426
2731
  if (message.role === "system") continue;
1427
2732
  if (message.role === "user" && message.source.kind !== "tool") {
2733
+ flushPendingImages();
1428
2734
  const parts = [];
1429
2735
  for (const block of message.content) {
1430
2736
  if (block.type === "text") parts.push({
@@ -1448,6 +2754,7 @@ async function messagesToOpenAI(messages, readImage) {
1448
2754
  continue;
1449
2755
  }
1450
2756
  if (message.role === "assistant") {
2757
+ flushPendingImages();
1451
2758
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1452
2759
  const reasoning = message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
1453
2760
  const toolCalls = message.content.filter((block) => block.type === "tool-call" && paired.has(block.id)).map((block) => ({
@@ -1472,25 +2779,27 @@ async function messagesToOpenAI(messages, readImage) {
1472
2779
  const block = message.content[0];
1473
2780
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1474
2781
  const media = toolResultMedia(block);
2782
+ const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
1475
2783
  out.push({
1476
2784
  role: "tool",
1477
- tool_call_id: wireIds.get(block.toolCallId) ?? block.toolCallId,
2785
+ tool_call_id: wireToolCallId,
1478
2786
  content: toolResultTextForWire(media)
1479
2787
  });
1480
2788
  if (media.images.length > 0) {
1481
2789
  if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1482
2790
  const carried = [{
1483
2791
  type: "text",
1484
- text: toolResultImageNote(media)
2792
+ text: toolResultImageNote(media, wireToolCallId)
1485
2793
  }];
1486
2794
  for (const attachment of media.images) carried.push(await imageToOpenAI(attachment, readImage));
1487
- out.push({
2795
+ pendingImages.push({
1488
2796
  role: "user",
1489
2797
  content: carried
1490
2798
  });
1491
2799
  }
1492
2800
  }
1493
2801
  }
2802
+ flushPendingImages();
1494
2803
  return out;
1495
2804
  }
1496
2805
  /** Account endpoints fetched by one `getUsage()` run (see the classification there). */
@@ -1514,13 +2823,22 @@ function parseUsageTotals(usage) {
1514
2823
  };
1515
2824
  }
1516
2825
  /** Parse one window-limit block (`fiveHour` / `weekly`). */
2826
+ /**
2827
+ * Parse one window block into a window limit, or undefined when the endpoint
2828
+ * reported no such window.
2829
+ *
2830
+ * Returning undefined (rather than a zeroed window) is load-bearing: the panel
2831
+ * must not draw a quota row for a window the account never reported, and a
2832
+ * zeroed window is indistinguishable from a genuinely uncapped one.
2833
+ */
1517
2834
  function parseWindowLimit(value) {
1518
2835
  const block = isRecord(value) ? value : void 0;
2836
+ if (block === void 0) return void 0;
1519
2837
  return {
1520
- used: numberValue(block?.used) ?? 0,
1521
- cap: numberValue(block?.cap) ?? 0,
1522
- exceeded: block?.exceeded === true,
1523
- resetAt: numberValue(block?.resetAt) ?? 0
2838
+ used: numberValue(block.used) ?? 0,
2839
+ cap: numberValue(block.cap) ?? 0,
2840
+ exceeded: block.exceeded === true,
2841
+ resetAt: numberValue(block.resetAt) ?? 0
1524
2842
  };
1525
2843
  }
1526
2844
  /**
@@ -1534,13 +2852,19 @@ function parseCreditLimits(credits) {
1534
2852
  const fiveHour = windowLimits !== void 0 && isRecord(windowLimits.fiveHour) ? windowLimits.fiveHour : void 0;
1535
2853
  const weekly = windowLimits !== void 0 && isRecord(windowLimits.weekly) ? windowLimits.weekly : void 0;
1536
2854
  if (creditsData === void 0 && fiveHour === void 0 && weekly === void 0) return void 0;
1537
- return {
2855
+ const parsed = {
1538
2856
  monthlyCredits: numberValue(creditsData?.monthlyCredits) ?? 0,
1539
2857
  purchasedCredits: numberValue(creditsData?.purchasedCredits) ?? 0,
1540
2858
  freeCredits: numberValue(creditsData?.freeCredits) ?? 0,
1541
- fiveHour: parseWindowLimit(fiveHour),
1542
- weekly: parseWindowLimit(weekly)
2859
+ monthlyReported: numberValue(creditsData?.monthlyCredits) !== void 0,
2860
+ purchasedReported: numberValue(creditsData?.purchasedCredits) !== void 0,
2861
+ freeReported: numberValue(creditsData?.freeCredits) !== void 0
1543
2862
  };
2863
+ const fiveHourLimit = parseWindowLimit(fiveHour);
2864
+ const weeklyLimit = parseWindowLimit(weekly);
2865
+ if (fiveHourLimit !== void 0) parsed.fiveHour = fiveHourLimit;
2866
+ if (weeklyLimit !== void 0) parsed.weekly = weeklyLimit;
2867
+ return parsed;
1544
2868
  }
1545
2869
  /**
1546
2870
  * Parse the account identity from whoami plus the plan identity from the
@@ -1595,7 +2919,7 @@ async function buildCliBody(options, connection, facts) {
1595
2919
  type: "function",
1596
2920
  name: tool.name,
1597
2921
  description: tool.description,
1598
- input_schema: tool.parameters
2922
+ input_schema: toolParametersSchema(tool.parameters)
1599
2923
  })),
1600
2924
  system: facts.systemText,
1601
2925
  max_tokens: facts.maxTokens,
@@ -1617,7 +2941,7 @@ async function buildOpenAIBody(options, facts) {
1617
2941
  function: {
1618
2942
  name: tool.name,
1619
2943
  description: tool.description,
1620
- parameters: tool.parameters
2944
+ parameters: toolParametersSchema(tool.parameters)
1621
2945
  }
1622
2946
  }));
1623
2947
  return {
@@ -2136,12 +3460,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
2136
3460
  const reasoningEffort = effort && effort !== "off" && supported?.includes(effort) ? effort : void 0;
2137
3461
  const systemText = [options.system ?? "", ...options.messages.filter((m) => m.role === "system").map((m) => m.content.map(blockText).filter(Boolean).join("\n"))].filter(Boolean).join("\n\n");
2138
3462
  let protocol = this.resolveProtocol(apiKey);
2139
- const buildBody = async (target) => target === "cli" ? buildCliBody(options, connection, {
3463
+ let requestOptions = withImageBudget(options, REQUEST_IMAGE_BUDGETS[0]);
3464
+ let imageBudgetIndex = 0;
3465
+ const buildBody = async (target) => target === "cli" ? buildCliBody(requestOptions, connection, {
2140
3466
  maxTokens,
2141
3467
  reasoningEffort,
2142
3468
  systemText,
2143
3469
  readImage
2144
- }) : buildOpenAIBody(options, {
3470
+ }) : buildOpenAIBody(requestOptions, {
2145
3471
  maxTokens,
2146
3472
  reasoningEffort,
2147
3473
  systemText,
@@ -2168,6 +3494,15 @@ var CommandCodeAdapter = class extends LlmAdapter {
2168
3494
  body = await buildBody("cli");
2169
3495
  continue;
2170
3496
  }
3497
+ if (attempt.status === 413 && imageBudgetIndex + 1 < REQUEST_IMAGE_BUDGETS.length) {
3498
+ const tightened = withImageBudget(requestOptions, REQUEST_IMAGE_BUDGETS[imageBudgetIndex + 1]);
3499
+ if (tightened !== requestOptions) {
3500
+ imageBudgetIndex += 1;
3501
+ requestOptions = tightened;
3502
+ body = await buildBody(protocol);
3503
+ continue;
3504
+ }
3505
+ }
2171
3506
  const rotate = this.deps.rotateApiKey;
2172
3507
  if ((attempt.status === 429 || attempt.status === 401) && rotate !== void 0 && options.signal?.aborted !== true && tried.size < MAX_ACCOUNT_ROTATIONS) {
2173
3508
  const next = await rotate(apiKey, attempt.status === 429 ? "rate-limit" : "invalid-credential", connection, options.model);
@@ -2260,26 +3595,68 @@ var CommandCodeAdapter = class extends LlmAdapter {
2260
3595
  * Map a pre-stream generate HTTP failure onto a stable LlmError. Command
2261
3596
  * Code folds several business rejections into 403 (plan limits, CLI version,
2262
3597
  * model access): prefer the machine-readable `error.code` when present; the
2263
- * status alone cannot distinguish them. A 429's `Retry-After` rides along as
3598
+ * status alone cannot distinguish them. The status also decides the failure
3599
+ * CODE via {@link httpErrorCode}, which is what the retry policy routes on —
3600
+ * a transient 5xx must not be reported as a permanent provider rejection. A
3601
+ * 429's `Retry-After` header rides along as
2264
3602
  * `providerRetryAfterMs` so dsh-llm-retry can wait exactly that long instead
2265
3603
  * of guessing at the backoff cadence — capped at RETRY_MAX_DELAY_MS, because
2266
3604
  * in normal mode a longer attached wait makes the executor abandon the retry
2267
3605
  * outright instead of falling back to local backoff.
3606
+ *
3607
+ * One body is read, not just its status: a client-side rejection whose
3608
+ * `error.code`/`type`/`message` names the model context window is reported as
3609
+ * `CONTEXT_WINDOW_EXCEEDED` (see the branch below), because that failure has a
3610
+ * recovery the harness performs and a generic provider error does not.
2268
3611
  */
2269
3612
  function generateHttpError(status, errText, retryAfterMs) {
2270
3613
  let providerCode;
3614
+ let providerDetail = "";
2271
3615
  try {
2272
3616
  const parsed = JSON.parse(errText);
2273
- if (isRecord(parsed) && isRecord(parsed.error)) providerCode = stringValue(parsed.error.code);
3617
+ if (isRecord(parsed) && isRecord(parsed.error)) {
3618
+ providerCode = stringValue(parsed.error.code);
3619
+ providerDetail = [
3620
+ stringValue(parsed.error.code),
3621
+ stringValue(parsed.error.type),
3622
+ stringValue(parsed.error.message)
3623
+ ].filter((part) => part !== void 0 && part !== "").join(" ");
3624
+ }
2274
3625
  } catch {}
2275
3626
  const detail = providerCode ?? `HTTP ${status}`;
3627
+ const overflowDetail = providerDetail !== "" ? providerDetail : errText.slice(0, 500);
3628
+ if (status < 500 && isContextOverflowDetail(overflowDetail)) return new LlmError(`Command Code API error ${status}: the request exceeds the model's context window — this session is being compacted and the request retried;Command Code API 返回 ${status}:请求内容超出模型上下文窗口——正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, CONTEXT_WINDOW_EXCEEDED_CODE, { status });
2276
3629
  if (status === 401) return new LlmError(`Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file;Command Code API 返回 401:API 密钥缺失或无效——请在设置页检查 COMMANDCODE_API_KEY 存储的密钥,或检查 auth 文件`, "INVALID_CREDENTIAL", { status: 401 });
2277
- return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
3630
+ if (status === 413) return new LlmError("Command Code API error 413: the request body exceeds the provider's size limit (the cap is undocumented, measured at about 50 MB) — the session history is too large to send, usually because of accumulated image attachments. Start a new session, or drop the image-heavy part of this one, and retry;Command Code API 返回 413:请求体超过服务端的体积上限(官方未公开,实测约 50 MB)——会话历史过大,通常是历史图片累积所致。请新建会话,或移除本会话中图片较多的部分后重试", "PROVIDER_HTTP_ERROR", { status: 413 });
3631
+ return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, httpErrorCode(status), {
2278
3632
  status,
2279
3633
  ...retryAfterMs !== void 0 && retryAfterMs > 0 && retryAfterMs <= 9e5 ? { providerRetryAfterMs: retryAfterMs } : {}
2280
3634
  });
2281
3635
  }
2282
3636
  /**
3637
+ * Classify a pre-stream HTTP status into the failure code the route's retry
3638
+ * policy keys on. dsh-llm-retry matches `retryableCodes` only — never the
3639
+ * status, never the provider's `error.type` — so a status that arrives here as
3640
+ * a permanent code is never retried, whatever it says about itself.
3641
+ *
3642
+ * 5xx is the provider's own "we are temporarily unavailable" class (Cloudflare's
3643
+ * 520-527 included; a 520 body says exactly that in words) and the only sane
3644
+ * answer to it is the byte-identical resend the retry policy exists to make.
3645
+ * Keeping it on `PROVIDER_HTTP_ERROR` — absent from the whitelist by design, so
3646
+ * that a 403 plan rejection or a 400 shape error fails fast — made the one
3647
+ * failure that asks to be retried the only one that never was, while the same
3648
+ * status arriving as an in-band stream `error` event was classified retryable
3649
+ * `SERVER` (see the `error` case of {@link handleCliEvent}). 408 is the same
3650
+ * story for the request itself. Everything else stays permanent: a 400/403/404/
3651
+ * 409/422 rejection repeats identically on every attempt.
3652
+ */
3653
+ function httpErrorCode(status) {
3654
+ if (status === 429) return "RATE_LIMIT";
3655
+ if (status === 408) return "TIMEOUT";
3656
+ if (status >= 500) return "SERVER";
3657
+ return "PROVIDER_HTTP_ERROR";
3658
+ }
3659
+ /**
2283
3660
  * Handle one CLI-transport (`/alpha/generate`) stream event, appending
2284
3661
  * harness StreamChunks. Pure over the passed assembler — no stream() locals.
2285
3662
  */
@@ -2386,16 +3763,7 @@ function handleCliEvent(asm, event) {
2386
3763
  });
2387
3764
  break;
2388
3765
  }
2389
- case "error": {
2390
- const err = isRecord(event.error) ? event.error : void 0;
2391
- const detail = isRecord(event.error) ? stringValue(event.error.message) ?? JSON.stringify(event.error) : stringValue(event.error) ?? stringValue(event.message) ?? "Stream error";
2392
- const statusCode = err ? numberValue(err.statusCode) : void 0;
2393
- const isRetryable = err ? booleanValue(err.isRetryable) : void 0;
2394
- const retryableStatus = statusCode !== void 0 && (statusCode === 429 || statusCode >= 500);
2395
- const terminal = hasTerminalStreamMarker(detail);
2396
- if (!(isRetryable === true || (statusCode !== void 0 ? retryableStatus : isRetryable !== false && !terminal))) throw new LlmError(`Command Code stream error: ${detail}`, "PROVIDER_STREAM_ERROR", statusCode !== void 0 ? { status: statusCode } : void 0);
2397
- throw new LlmError(`Command Code stream error: ${detail}`, "SERVER", statusCode !== void 0 ? { status: statusCode } : void 0);
2398
- }
3766
+ case "error": throw streamErrorToLlmError(event.error, stringValue(event.message));
2399
3767
  }
2400
3768
  return chunks;
2401
3769
  }
@@ -2407,6 +3775,7 @@ function handleCliEvent(asm, event) {
2407
3775
  function handleOpenAIEvent(asm, event) {
2408
3776
  const chunks = [];
2409
3777
  if (!isRecord(event)) return chunks;
3778
+ if (event.error !== void 0) throw streamErrorToLlmError(event.error, stringValue(event.message));
2410
3779
  const choices = event.choices;
2411
3780
  if (!Array.isArray(choices) || choices.length === 0) {
2412
3781
  if (event.usage !== void 0) chunks.push({
@@ -2729,8 +4098,13 @@ function renderReport(report, locale, title) {
2729
4098
  }
2730
4099
  if (report.credits) {
2731
4100
  const c = report.credits;
2732
- const monthlyPct = c.monthlyCredits > 0 ? `${(c.monthlyCredits / (c.monthlyCredits + c.purchasedCredits) * 100).toFixed(0)}%` : "—";
2733
- lines.push(commandCopy(locale, "creditsHeader"), commandCopy(locale, "monthlyLine").replace("{monthly}", moneyShort(c.monthlyCredits)).replace("{purchased}", moneyShort(c.purchasedCredits)).replace("{free}", moneyShort(c.freeCredits)), commandCopy(locale, "barLine").replace("{bar}", bar(c.monthlyCredits, c.monthlyCredits + c.purchasedCredits)).replace("{pct}", monthlyPct), "", commandCopy(locale, "windowsHeader"), commandCopy(locale, "fiveHourLine").replace("{used}", moneyShort(c.fiveHour.used)).replace("{cap}", moneyShort(c.fiveHour.cap)).replace("{warn}", c.fiveHour.exceeded ? commandCopy(locale, "exceededWarning") : ""), commandCopy(locale, "windowBarLine").replace("{bar}", bar(c.fiveHour.used, c.fiveHour.cap)).replace("{when}", resetLabel(c.fiveHour.resetAt)), commandCopy(locale, "weeklyLine").replace("{used}", moneyShort(c.weekly.used)).replace("{cap}", moneyShort(c.weekly.cap)).replace("{warn}", c.weekly.exceeded ? commandCopy(locale, "exceededWarning") : ""), commandCopy(locale, "windowBarLine").replace("{bar}", bar(c.weekly.used, c.weekly.cap)).replace("{when}", resetLabel(c.weekly.resetAt)), "");
4101
+ const balanceTotal = c.monthlyCredits + c.purchasedCredits;
4102
+ const ratioKnown = c.monthlyReported !== false && c.purchasedReported !== false && balanceTotal > 0;
4103
+ lines.push(commandCopy(locale, "creditsHeader"), commandCopy(locale, "monthlyLine").replace("{monthly}", c.monthlyReported === false ? "—" : moneyShort(c.monthlyCredits)).replace("{purchased}", c.purchasedReported === false ? "—" : moneyShort(c.purchasedCredits)).replace("{free}", c.freeReported === false ? "—" : moneyShort(c.freeCredits)), ...ratioKnown ? [commandCopy(locale, "barLine").replace("{bar}", bar(c.monthlyCredits, balanceTotal)).replace("{pct}", (c.monthlyCredits / balanceTotal * 100).toFixed(0))] : [], "");
4104
+ const windowLines = [];
4105
+ if (c.fiveHour !== void 0) windowLines.push(commandCopy(locale, "fiveHourLine").replace("{used}", moneyShort(c.fiveHour.used)).replace("{cap}", moneyShort(c.fiveHour.cap)).replace("{warn}", c.fiveHour.exceeded ? commandCopy(locale, "exceededWarning") : ""), commandCopy(locale, "windowBarLine").replace("{bar}", bar(c.fiveHour.used, c.fiveHour.cap)).replace("{when}", resetLabel(c.fiveHour.resetAt)));
4106
+ if (c.weekly !== void 0) windowLines.push(commandCopy(locale, "weeklyLine").replace("{used}", moneyShort(c.weekly.used)).replace("{cap}", moneyShort(c.weekly.cap)).replace("{warn}", c.weekly.exceeded ? commandCopy(locale, "exceededWarning") : ""), commandCopy(locale, "windowBarLine").replace("{bar}", bar(c.weekly.used, c.weekly.cap)).replace("{when}", resetLabel(c.weekly.resetAt)));
4107
+ if (windowLines.length > 0) lines.push(commandCopy(locale, "windowsHeader"), ...windowLines, "");
2734
4108
  }
2735
4109
  if (report.failures.length > 0) lines.push(commandCopy(locale, "partialFailures").replace("{list}", report.failures.join("; ")), "");
2736
4110
  if (!report.account && !report.usage && !report.credits) lines.push(commandCopy(locale, "noData"), "");
@@ -2846,7 +4220,16 @@ const USAGE_REPORT_ENDPOINT = "commandcode/report";
2846
4220
  */
2847
4221
  const { reject: reject$1, record: record$1, stringField: stringField$1, numberField, booleanField } = makeBoundaryValidator("commandcode/report result:");
2848
4222
  /** Validate one window-limit block (`fiveHour` / `weekly`). */
4223
+ /**
4224
+ * Parse one quota window, or undefined when the frame carries no such window.
4225
+ *
4226
+ * The distinction is load-bearing at this boundary: an ABSENT window means the
4227
+ * billing endpoint reported none (an unlimited plan), while a present block with
4228
+ * `cap: 0` means uncapped spend that was really reported. Collapsing the two
4229
+ * would draw a zeroed quota row for an account that has no such limit.
4230
+ */
2849
4231
  function windowLimit(value, field) {
4232
+ if (value === void 0) return void 0;
2850
4233
  const source = record$1(value, field);
2851
4234
  return {
2852
4235
  used: numberField(source, "used", `${field}.used`),
@@ -2894,13 +4277,18 @@ function parseUsageReport(value) {
2894
4277
  }
2895
4278
  if (source.credits !== void 0) {
2896
4279
  const credits = record$1(source.credits, "credits");
2897
- report.credits = {
4280
+ const parsed = {
2898
4281
  monthlyCredits: numberField(credits, "monthlyCredits", "credits.monthlyCredits"),
2899
4282
  purchasedCredits: numberField(credits, "purchasedCredits", "credits.purchasedCredits"),
2900
- freeCredits: numberField(credits, "freeCredits", "credits.freeCredits"),
2901
- fiveHour: windowLimit(credits.fiveHour, "credits.fiveHour"),
2902
- weekly: windowLimit(credits.weekly, "credits.weekly")
4283
+ freeCredits: numberField(credits, "freeCredits", "credits.freeCredits")
2903
4284
  };
4285
+ if (credits.monthlyReported !== void 0) parsed.monthlyReported = booleanField(credits, "monthlyReported", "credits.monthlyReported");
4286
+ for (const flag of ["purchasedReported", "freeReported"]) if (credits[flag] !== void 0) parsed[flag] = booleanField(credits, flag, `credits.${flag}`);
4287
+ const fiveHour = windowLimit(credits.fiveHour, "credits.fiveHour");
4288
+ const weekly = windowLimit(credits.weekly, "credits.weekly");
4289
+ if (fiveHour !== void 0) parsed.fiveHour = fiveHour;
4290
+ if (weekly !== void 0) parsed.weekly = weekly;
4291
+ report.credits = parsed;
2904
4292
  }
2905
4293
  if (source.plan !== void 0) {
2906
4294
  const plan = record$1(source.plan, "plan");
@@ -2982,6 +4370,72 @@ function parseCatalog(value) {
2982
4370
  * service and `commandcode` namespace as the usage report.
2983
4371
  */
2984
4372
  const MODELS_DESCRIPTOR = makeRemoteDescriptor(MODELS_ENDPOINT, "models", `${USAGE_REMOTE_PACKAGE}#CommandCodeCatalog`, { parse: parseCatalog });
4373
+ /** Canonical `<namespace>/<method>` endpoint of the price-table Remote. */
4374
+ const PRICES_ENDPOINT = "commandcode/prices";
4375
+ /**
4376
+ * The shared read/validate helpers for the price-table endpoint — its own
4377
+ * instance so price boundary errors name `commandcode/prices`.
4378
+ */
4379
+ const { reject: priceReject, record: priceRecord, stringField: priceString, numberField: priceNumber, booleanField: priceBoolean } = makeBoundaryValidator("commandcode/prices result:");
4380
+ /** Parse one rate block (`rates`, or a model's `peak` override). */
4381
+ function parseRates(source, field) {
4382
+ const rates = {
4383
+ inputCost: priceNumber(source, "inputCost", `${field}.inputCost`),
4384
+ outputCost: priceNumber(source, "outputCost", `${field}.outputCost`),
4385
+ cacheReadCost: priceNumber(source, "cacheReadCost", `${field}.cacheReadCost`)
4386
+ };
4387
+ if (source.cacheWriteCost !== void 0) rates.cacheWriteCost = priceNumber(source, "cacheWriteCost", `${field}.cacheWriteCost`);
4388
+ return rates;
4389
+ }
4390
+ /** Parse one untrusted boundary value into a {@link CommandCodeModelPrice}. */
4391
+ function parseModelPrice(value) {
4392
+ const source = priceRecord(value, "model");
4393
+ const price = {
4394
+ id: priceString(source, "id", "model.id"),
4395
+ slug: priceString(source, "slug", "model.slug"),
4396
+ ...parseRates(source, "model")
4397
+ };
4398
+ if (source.peak !== void 0) price.peak = parseRates(priceRecord(source.peak, "model.peak"), "model.peak");
4399
+ if (source.contextTiers !== void 0) {
4400
+ if (!Array.isArray(source.contextTiers) || source.contextTiers.length === 0) priceReject("contextTiers");
4401
+ let previous = 0;
4402
+ price.contextTiers = source.contextTiers.map((value, index, tiers) => {
4403
+ const tier = priceRecord(value, "contextTier");
4404
+ const out = parseRates(tier, "contextTier");
4405
+ if (tier.maxContext !== void 0) {
4406
+ const max = priceNumber(tier, "maxContext", "contextTier.maxContext");
4407
+ if (!Number.isSafeInteger(max) || max <= previous || index === tiers.length - 1) priceReject("contextTier.maxContext");
4408
+ previous = max;
4409
+ out.maxContext = max;
4410
+ } else if (index !== tiers.length - 1) priceReject("contextTier.maxContext");
4411
+ return out;
4412
+ });
4413
+ }
4414
+ if (source.free !== void 0) price.free = priceBoolean(source, "free", "model.free");
4415
+ return price;
4416
+ }
4417
+ /** Parse the wire result into a {@link CommandCodePriceTable}. */
4418
+ function parsePriceTable(value) {
4419
+ const source = priceRecord(value, "result");
4420
+ const models = source.models;
4421
+ if (!Array.isArray(models)) priceReject("models");
4422
+ const peakHours = source.peakHours;
4423
+ if (!Array.isArray(peakHours)) priceReject("peakHours");
4424
+ return {
4425
+ models: models.map(parseModelPrice),
4426
+ peakHours: peakHours.map((window) => {
4427
+ if (!Array.isArray(window) || window.length !== 2) priceReject("peakHours[]");
4428
+ const [start, end] = window;
4429
+ if (typeof start !== "number" || typeof end !== "number") priceReject("peakHours[]");
4430
+ return [start, end];
4431
+ })
4432
+ };
4433
+ }
4434
+ /**
4435
+ * The price-table invocation descriptor, sharing the same `commandcodeUsage`
4436
+ * service and `commandcode` namespace as the report and catalog endpoints.
4437
+ */
4438
+ const PRICES_DESCRIPTOR = makeRemoteDescriptor(PRICES_ENDPOINT, "prices", `${USAGE_REMOTE_PACKAGE}#CommandCodePriceTable`, { parse: parsePriceTable });
2985
4439
  //#endregion
2986
4440
  //#region src/login-wire.ts
2987
4441
  /** The canonical endpoint paths of the three login Remotes. */
@@ -3081,6 +4535,15 @@ var CommandCodeUsageService = class extends TypertRemoteService {
3081
4535
  return this.deps.listModels?.() ?? { models: [] };
3082
4536
  }
3083
4537
  /**
4538
+ * The model price table the composer prices an in-progress session with.
4539
+ * Static vendored data (the official pricing page's rates), served Host-side
4540
+ * so the browser bundle never carries a copy that could drift from the
4541
+ * snapshot, and so a price update reaches an open page without a rebuild.
4542
+ */
4543
+ async prices() {
4544
+ return (this.deps.prices ?? modelPriceTable)();
4545
+ }
4546
+ /**
3084
4547
  * Start (or rejoin) a browser-login attempt and return its fresh status —
3085
4548
  * `waiting` carrying the Studio URL. Rejects when the flow cannot start
3086
4549
  * (no free loopback port, disposed plugin); the Gateway folds the throw
@@ -3117,6 +4580,7 @@ function applyUsageRemote(ctx, deps) {
3117
4580
  invocations: [
3118
4581
  ...USAGE_HOST_CONTRIBUTION.invocations,
3119
4582
  MODELS_DESCRIPTOR,
4583
+ PRICES_DESCRIPTOR,
3120
4584
  ...LOGIN_DESCRIPTORS
3121
4585
  ]
3122
4586
  });
@@ -4219,6 +5683,7 @@ function readModelVisibility(raw) {
4219
5683
  return entries.length === 0 ? void 0 : Object.fromEntries(entries);
4220
5684
  }
4221
5685
  function apply(ctx, config) {
5686
+ installCostProjection(ctx);
4222
5687
  let current = () => config;
4223
5688
  let lastRaw;
4224
5689
  let lastGood;