@mars-sea/dsh-commandcode-provider 0.10.6 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.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,267 +12,6 @@ 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
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:
23
- *
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.
39
- *
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.
42
- *
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
15
  //#region src/capabilities.ts
277
16
  /**
278
17
  * Static capability snapshot for the Command Code provider: model →
@@ -751,268 +490,1586 @@ const PLAN_LABELS = {
751
490
  max: "Max"
752
491
  };
753
492
  /**
754
- * Plan-tier sort weights, low to high. Models outside the snapshot (unknown
755
- * plans) sort after every known tier, keeping known models predictable.
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.
756
715
  */
757
- const PLAN_ORDER = {
758
- go: 0,
759
- goat: 1,
760
- pro: 2,
761
- provider: 3,
762
- max: 4
763
- };
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
+ }
764
721
  /**
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.
722
+ * Official display label for a model's minimum plan, or undefined for models
723
+ * outside the snapshot (e.g. future catalog additions).
769
724
  */
770
- function isFreeModel(modelId) {
771
- return KNOWN_DEALS[modelId]?.free === true;
725
+ function planLabel(modelId) {
726
+ const plan = KNOWN_PLANS[modelId];
727
+ return plan === void 0 ? void 0 : PLAN_LABELS[plan];
772
728
  }
773
729
  /**
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.
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.
777
735
  */
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);
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;
787
741
  }
788
742
  /**
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.
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.
795
781
  *
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.
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.
799
790
  */
800
- const KNOWN_SUBSCRIPTION_PLANS = {
801
- "individual-go": {
802
- name: "Go",
803
- monthlyCredits: 10,
804
- tierWeight: 0
791
+ const MODEL_PRICE_ROWS = [
792
+ {
793
+ id: "claude-fable-5",
794
+ rates: [
795
+ 10,
796
+ 50,
797
+ 1,
798
+ 12.5
799
+ ]
805
800
  },
806
- "individual-goat": {
807
- name: "GOAT",
808
- monthlyCredits: 70,
809
- tierWeight: 1
801
+ {
802
+ id: "claude-fable-5-1",
803
+ rates: [
804
+ 10,
805
+ 50,
806
+ .25,
807
+ 12.5
808
+ ]
810
809
  },
811
- "individual-pro": {
812
- name: "Pro",
813
- monthlyCredits: 30,
814
- tierWeight: 2
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
+ ]
815
1458
  },
816
- "individual-pro-v1": {
817
- name: "Pro",
818
- monthlyCredits: 80,
819
- tierWeight: 2
1459
+ {
1460
+ id: "qwen-3.8-max-0902",
1461
+ rates: [
1462
+ 2,
1463
+ 6,
1464
+ .25
1465
+ ]
820
1466
  },
821
- "individual-provider": {
822
- name: "Provider",
823
- monthlyCredits: 15,
824
- tierWeight: 3
1467
+ {
1468
+ id: "step-3.5-flash",
1469
+ rates: [
1470
+ .1,
1471
+ .3,
1472
+ .02
1473
+ ]
825
1474
  },
826
- "individual-max": {
827
- name: "Max",
828
- monthlyCredits: 150,
829
- tierWeight: 4
1475
+ {
1476
+ id: "step-3.7-flash",
1477
+ rates: [
1478
+ .2,
1479
+ 1.15,
1480
+ .04
1481
+ ]
830
1482
  },
831
- "individual-ultra": {
832
- name: "Ultra",
833
- monthlyCredits: 300,
834
- tierWeight: 4
1483
+ {
1484
+ id: "tencent/hy3-paid",
1485
+ rates: [
1486
+ .14,
1487
+ .58,
1488
+ .035
1489
+ ]
835
1490
  },
836
- "teams-pro": {
837
- name: "Teams Pro",
838
- monthlyCredits: 40,
839
- tierWeight: 2
1491
+ {
1492
+ id: "tencent/hy4-preview",
1493
+ rates: [
1494
+ .834,
1495
+ 2.501,
1496
+ .042
1497
+ ]
840
1498
  }
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);
1499
+ ];
844
1500
  /**
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`.
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.
850
1505
  */
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];
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.
1509
+ *
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.
1516
+ */
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;
855
1563
  }
856
1564
  /**
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`).
1565
+ * Build the table the composer prices a session with.
1566
+ *
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.
1574
+ *
1575
+ * The peak windows travel with the table, so the browser applies the very
1576
+ * schedule this snapshot knows instead of restating it.
862
1577
  */
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;
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
+ };
872
1610
  }
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
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
+ }
1629
+ /**
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.
1637
+ */
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;
888
1697
  }
889
- };
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
890
1815
  /**
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.
1816
+ * Multi-account pool for the Command Code provider (host side).
903
1817
  *
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).
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:
911
1822
  *
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.
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.
919
1838
  *
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).
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
922
1843
  */
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
1844
  /**
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.
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".
932
1852
  */
933
- const PEAK_HOUR_RANGES = [[1, 4], [6, 10]];
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();
1857
+ }
934
1858
  /**
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.
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.
940
1863
  */
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";
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;
948
1868
  }
949
1869
  /**
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.
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.
955
1874
  */
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";
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;
1880
+ }
1881
+ return usable[0];
960
1882
  }
961
1883
  /**
962
- * Official display label for a model's minimum plan, or undefined for models
963
- * outside the snapshot (e.g. future catalog additions).
1884
+ * The first routing rule whose model list contains the request's model id.
1885
+ * Undefined when no rule matches.
964
1886
  */
965
- function planLabel(modelId) {
966
- const plan = KNOWN_PLANS[modelId];
967
- return plan === void 0 ? void 0 : PLAN_LABELS[plan];
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;
968
1890
  }
969
1891
  /**
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.
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).
975
1896
  */
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;
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));
981
1901
  }
982
1902
  /**
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.
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.
986
1906
  */
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`;
1907
+ var CommandCodeAccountPool = class {
1908
+ deps;
1909
+ /** Rotation state by API key. */
1910
+ states = /* @__PURE__ */ new Map();
1911
+ constructor(deps) {
1912
+ this.deps = deps;
993
1913
  }
994
- if (contextWindow < 1e3) return String(Math.floor(contextWindow));
995
- return `${Math.floor(contextWindow / 1e3)}K`;
996
- }
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
+ });
2021
+ }
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
+ }
2055
+ }
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.1 — 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.1";
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 {
@@ -1431,6 +2543,52 @@ function hasImageContent(message) {
1431
2543
  const check = (blocks) => blocks.some((b) => b.type === "image" || b.type === "tool-result" && check(b.content));
1432
2544
  return check(message.content);
1433
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
+ }
1434
2592
  /**
1435
2593
  * Convert one image reference to the Command Code wire format, as the official
1436
2594
  * CLI does: `{ type: 'image', source: { type: 'base64', media_type, data } }`.
@@ -1665,13 +2823,22 @@ function parseUsageTotals(usage) {
1665
2823
  };
1666
2824
  }
1667
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
+ */
1668
2834
  function parseWindowLimit(value) {
1669
2835
  const block = isRecord(value) ? value : void 0;
2836
+ if (block === void 0) return void 0;
1670
2837
  return {
1671
- used: numberValue(block?.used) ?? 0,
1672
- cap: numberValue(block?.cap) ?? 0,
1673
- exceeded: block?.exceeded === true,
1674
- 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
1675
2842
  };
1676
2843
  }
1677
2844
  /**
@@ -1685,13 +2852,19 @@ function parseCreditLimits(credits) {
1685
2852
  const fiveHour = windowLimits !== void 0 && isRecord(windowLimits.fiveHour) ? windowLimits.fiveHour : void 0;
1686
2853
  const weekly = windowLimits !== void 0 && isRecord(windowLimits.weekly) ? windowLimits.weekly : void 0;
1687
2854
  if (creditsData === void 0 && fiveHour === void 0 && weekly === void 0) return void 0;
1688
- return {
2855
+ const parsed = {
1689
2856
  monthlyCredits: numberValue(creditsData?.monthlyCredits) ?? 0,
1690
2857
  purchasedCredits: numberValue(creditsData?.purchasedCredits) ?? 0,
1691
2858
  freeCredits: numberValue(creditsData?.freeCredits) ?? 0,
1692
- fiveHour: parseWindowLimit(fiveHour),
1693
- weekly: parseWindowLimit(weekly)
2859
+ monthlyReported: numberValue(creditsData?.monthlyCredits) !== void 0,
2860
+ purchasedReported: numberValue(creditsData?.purchasedCredits) !== void 0,
2861
+ freeReported: numberValue(creditsData?.freeCredits) !== void 0
1694
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;
1695
2868
  }
1696
2869
  /**
1697
2870
  * Parse the account identity from whoami plus the plan identity from the
@@ -2287,12 +3460,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
2287
3460
  const reasoningEffort = effort && effort !== "off" && supported?.includes(effort) ? effort : void 0;
2288
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");
2289
3462
  let protocol = this.resolveProtocol(apiKey);
2290
- 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, {
2291
3466
  maxTokens,
2292
3467
  reasoningEffort,
2293
3468
  systemText,
2294
3469
  readImage
2295
- }) : buildOpenAIBody(options, {
3470
+ }) : buildOpenAIBody(requestOptions, {
2296
3471
  maxTokens,
2297
3472
  reasoningEffort,
2298
3473
  systemText,
@@ -2319,6 +3494,15 @@ var CommandCodeAdapter = class extends LlmAdapter {
2319
3494
  body = await buildBody("cli");
2320
3495
  continue;
2321
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
+ }
2322
3506
  const rotate = this.deps.rotateApiKey;
2323
3507
  if ((attempt.status === 429 || attempt.status === 401) && rotate !== void 0 && options.signal?.aborted !== true && tried.size < MAX_ACCOUNT_ROTATIONS) {
2324
3508
  const next = await rotate(apiKey, attempt.status === 429 ? "rate-limit" : "invalid-credential", connection, options.model);
@@ -2411,26 +3595,68 @@ var CommandCodeAdapter = class extends LlmAdapter {
2411
3595
  * Map a pre-stream generate HTTP failure onto a stable LlmError. Command
2412
3596
  * Code folds several business rejections into 403 (plan limits, CLI version,
2413
3597
  * model access): prefer the machine-readable `error.code` when present; the
2414
- * 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
2415
3602
  * `providerRetryAfterMs` so dsh-llm-retry can wait exactly that long instead
2416
3603
  * of guessing at the backoff cadence — capped at RETRY_MAX_DELAY_MS, because
2417
3604
  * in normal mode a longer attached wait makes the executor abandon the retry
2418
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.
2419
3611
  */
2420
3612
  function generateHttpError(status, errText, retryAfterMs) {
2421
3613
  let providerCode;
3614
+ let providerDetail = "";
2422
3615
  try {
2423
3616
  const parsed = JSON.parse(errText);
2424
- 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
+ }
2425
3625
  } catch {}
2426
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 });
2427
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 });
2428
- 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), {
2429
3632
  status,
2430
3633
  ...retryAfterMs !== void 0 && retryAfterMs > 0 && retryAfterMs <= 9e5 ? { providerRetryAfterMs: retryAfterMs } : {}
2431
3634
  });
2432
3635
  }
2433
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
+ /**
2434
3660
  * Handle one CLI-transport (`/alpha/generate`) stream event, appending
2435
3661
  * harness StreamChunks. Pure over the passed assembler — no stream() locals.
2436
3662
  */
@@ -2537,16 +3763,7 @@ function handleCliEvent(asm, event) {
2537
3763
  });
2538
3764
  break;
2539
3765
  }
2540
- case "error": {
2541
- const err = isRecord(event.error) ? event.error : void 0;
2542
- const detail = isRecord(event.error) ? stringValue(event.error.message) ?? JSON.stringify(event.error) : stringValue(event.error) ?? stringValue(event.message) ?? "Stream error";
2543
- const statusCode = err ? numberValue(err.statusCode) : void 0;
2544
- const isRetryable = err ? booleanValue(err.isRetryable) : void 0;
2545
- const retryableStatus = statusCode !== void 0 && (statusCode === 429 || statusCode >= 500);
2546
- const terminal = hasTerminalStreamMarker(detail);
2547
- 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);
2548
- throw new LlmError(`Command Code stream error: ${detail}`, "SERVER", statusCode !== void 0 ? { status: statusCode } : void 0);
2549
- }
3766
+ case "error": throw streamErrorToLlmError(event.error, stringValue(event.message));
2550
3767
  }
2551
3768
  return chunks;
2552
3769
  }
@@ -2558,6 +3775,7 @@ function handleCliEvent(asm, event) {
2558
3775
  function handleOpenAIEvent(asm, event) {
2559
3776
  const chunks = [];
2560
3777
  if (!isRecord(event)) return chunks;
3778
+ if (event.error !== void 0) throw streamErrorToLlmError(event.error, stringValue(event.message));
2561
3779
  const choices = event.choices;
2562
3780
  if (!Array.isArray(choices) || choices.length === 0) {
2563
3781
  if (event.usage !== void 0) chunks.push({
@@ -2880,8 +4098,13 @@ function renderReport(report, locale, title) {
2880
4098
  }
2881
4099
  if (report.credits) {
2882
4100
  const c = report.credits;
2883
- const monthlyPct = c.monthlyCredits > 0 ? `${(c.monthlyCredits / (c.monthlyCredits + c.purchasedCredits) * 100).toFixed(0)}%` : "—";
2884
- 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, "");
2885
4108
  }
2886
4109
  if (report.failures.length > 0) lines.push(commandCopy(locale, "partialFailures").replace("{list}", report.failures.join("; ")), "");
2887
4110
  if (!report.account && !report.usage && !report.credits) lines.push(commandCopy(locale, "noData"), "");
@@ -2997,7 +4220,16 @@ const USAGE_REPORT_ENDPOINT = "commandcode/report";
2997
4220
  */
2998
4221
  const { reject: reject$1, record: record$1, stringField: stringField$1, numberField, booleanField } = makeBoundaryValidator("commandcode/report result:");
2999
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
+ */
3000
4231
  function windowLimit(value, field) {
4232
+ if (value === void 0) return void 0;
3001
4233
  const source = record$1(value, field);
3002
4234
  return {
3003
4235
  used: numberField(source, "used", `${field}.used`),
@@ -3045,13 +4277,18 @@ function parseUsageReport(value) {
3045
4277
  }
3046
4278
  if (source.credits !== void 0) {
3047
4279
  const credits = record$1(source.credits, "credits");
3048
- report.credits = {
4280
+ const parsed = {
3049
4281
  monthlyCredits: numberField(credits, "monthlyCredits", "credits.monthlyCredits"),
3050
4282
  purchasedCredits: numberField(credits, "purchasedCredits", "credits.purchasedCredits"),
3051
- freeCredits: numberField(credits, "freeCredits", "credits.freeCredits"),
3052
- fiveHour: windowLimit(credits.fiveHour, "credits.fiveHour"),
3053
- weekly: windowLimit(credits.weekly, "credits.weekly")
4283
+ freeCredits: numberField(credits, "freeCredits", "credits.freeCredits")
3054
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;
3055
4292
  }
3056
4293
  if (source.plan !== void 0) {
3057
4294
  const plan = record$1(source.plan, "plan");
@@ -3133,6 +4370,72 @@ function parseCatalog(value) {
3133
4370
  * service and `commandcode` namespace as the usage report.
3134
4371
  */
3135
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 });
3136
4439
  //#endregion
3137
4440
  //#region src/login-wire.ts
3138
4441
  /** The canonical endpoint paths of the three login Remotes. */
@@ -3232,6 +4535,15 @@ var CommandCodeUsageService = class extends TypertRemoteService {
3232
4535
  return this.deps.listModels?.() ?? { models: [] };
3233
4536
  }
3234
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
+ /**
3235
4547
  * Start (or rejoin) a browser-login attempt and return its fresh status —
3236
4548
  * `waiting` carrying the Studio URL. Rejects when the flow cannot start
3237
4549
  * (no free loopback port, disposed plugin); the Gateway folds the throw
@@ -3268,6 +4580,7 @@ function applyUsageRemote(ctx, deps) {
3268
4580
  invocations: [
3269
4581
  ...USAGE_HOST_CONTRIBUTION.invocations,
3270
4582
  MODELS_DESCRIPTOR,
4583
+ PRICES_DESCRIPTOR,
3271
4584
  ...LOGIN_DESCRIPTORS
3272
4585
  ]
3273
4586
  });
@@ -4325,6 +5638,7 @@ const Config = z.object({
4325
5638
  */
4326
5639
  modelVisibility: z.dict(z.boolean()),
4327
5640
  webSearch: z.boolean().default(true),
5641
+ showSidebarQuota: z.boolean().default(false),
4328
5642
  accounts: z.array(z.object({
4329
5643
  label: z.string(),
4330
5644
  apiKeyEnv: z.string().role("credential-ref"),
@@ -4370,6 +5684,7 @@ function readModelVisibility(raw) {
4370
5684
  return entries.length === 0 ? void 0 : Object.fromEntries(entries);
4371
5685
  }
4372
5686
  function apply(ctx, config) {
5687
+ installCostProjection(ctx);
4373
5688
  let current = () => config;
4374
5689
  let lastRaw;
4375
5690
  let lastGood;