@mars-sea/dsh-commandcode-provider 0.12.0 → 0.12.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
@@ -6,7 +6,7 @@ import { CONTEXT_WINDOW_EXCEEDED_CODE, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter,
6
6
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
7
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
8
8
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
9
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
10
  import { createHash, randomBytes, randomUUID } from "node:crypto";
11
11
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
12
12
  import { createServer } from "node:http";
@@ -103,7 +103,7 @@ const PLAN_ORDER = Object.fromEntries(PLAN_TIER_ORDER.map((tier, index) => [tier
103
103
  * subscription-plan labels, deals, and hourly (peak/off-peak) pricing.
104
104
  *
105
105
  * Everything here is synced from official sources — the command-code CLI
106
- * bundle's model table (`dist/cli.mjs`, re-verified at command-code@1.68.0) and
106
+ * bundle's model table (`dist/cli.mjs`, re-verified at command-code@1.72.1) and
107
107
  * the official plan/pricing/model docs; see the dsh-commandcode-upstream skill
108
108
  * for the extraction procedures. Keeping the snapshot in its own module
109
109
  * confines those frequent sync diffs here: src/adapter.ts holds only the stable
@@ -408,6 +408,18 @@ const KNOWN_EFFORTS = {
408
408
  "low",
409
409
  "medium",
410
410
  "xhigh"
411
+ ],
412
+ "gpt-6.1-sol": [
413
+ "low",
414
+ "medium",
415
+ "high",
416
+ "xhigh",
417
+ "max"
418
+ ],
419
+ "inclusionai/ling-3.1-flash:free": [
420
+ "low",
421
+ "medium",
422
+ "high"
411
423
  ]
412
424
  };
413
425
  /**
@@ -464,6 +476,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
464
476
  "gpt-6-astra",
465
477
  "gpt-6-luna",
466
478
  "gpt-6-sol",
479
+ "gpt-6.1-sol",
467
480
  "meta/muse-spark-1.1",
468
481
  "meta/muse-spark-1.2",
469
482
  "meta/muse-spark-1.2-contributor",
@@ -493,7 +506,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
493
506
  ]);
494
507
  /**
495
508
  * Models WITHOUT a zero-data-retention upstream, per the official CLI's own
496
- * registry (`command-code@1.68.0` `dist/cli.mjs`): `modelSupportsZdr(id)` is
509
+ * registry (`command-code@1.72.1` `dist/cli.mjs`): `modelSupportsZdr(id)` is
497
510
  * exactly `!nonZdrSet.has(canonicalize(id))`, and `knownModelSupportsZdr`
498
511
  * carries the same membership in the sibling route table — the UNION of both
499
512
  * is this set. Reading only the sibling route table would drop `meituan/
@@ -594,16 +607,22 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
594
607
  /**
595
608
  * Catalog models the Provider API serves ONLY through `/provider/v1/messages`
596
609
  * (Anthropic Messages shape). Every other model in the catalog answers on
597
- * `/provider/v1/chat/completions`; these reject it outright with HTTP 400
598
- * `Model "<id>" must be called via /provider/v1/messages (Anthropic Messages
599
- * shape)`.
610
+ * `/provider/v1/chat/completions`.
611
+ *
612
+ * Measured 2026-09-16 (command-code@1.54.0, 69 models posted to
613
+ * `/provider/v1/chat/completions`): exactly the then-entire Claude family
614
+ * refused with HTTP 400 `Model "<id>" must be called via
615
+ * /provider/v1/messages (Anthropic Messages shape)`, and every one of them is
616
+ * routed normally by `/alpha/generate`.
600
617
  *
601
- * Measured 2026-09-16 against the live catalog (command-code@1.54.0): all 69
602
- * models were posted to `/provider/v1/chat/completions`; exactly these eight
603
- * — the whole Claude family then — refused, and every one of them is routed
604
- * normally by `/alpha/generate` (a lower-plan key gets the ordinary
605
- * `MODEL_NOT_IN_PLAN` 403 there, never a routing error). So the CLI transport
606
- * is a complete fallback and the adapter does not need a Messages transport.
618
+ * Re-measured 2026-09-29 against the 84-model catalog: the same ten
619
+ * `claude-*` ids are the only ones carrying `supported_endpoints:
620
+ * ["/messages"]`, and posting a non-Claude model to the endpoint answers 400
621
+ * `Model "<id>" is not supported on this endpoint. Use
622
+ * /provider/v1/chat/completions for OpenAI and OSS models.` The adapter routes
623
+ * on that field when the catalog supplies it, so this set is the FALLBACK for
624
+ * a cached or hand-built catalog, not the primary source — see
625
+ * `requiresMessagesEndpoint()`.
607
626
  *
608
627
  * Note what this list is NOT: it is not a plan gate. `claude-sonnet-5` is
609
628
  * Pro-tier and `claude-opus-4-8` Provider-tier, so the accounts entitled to
@@ -612,20 +631,21 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
612
631
  * request before this snapshot existed.
613
632
  *
614
633
  * `requiresMessagesEndpoint()` additionally treats any `claude-*` id as
615
- * Messages-only, so a Claude model added upstream (`claude-opus-5-5` in
616
- * command-code@1.64.0 was the first this snapshot missed) is still routed
617
- * correctly by an un-updated plugin instead of hard-failing with the 400
618
- * above. The worst case of that rule going stale the other way (upstream
619
- * teaching `/provider/v1/chat/completions` to serve Claude) is one model
620
- * riding the CLI transport it already works on.
634
+ * Messages-only, so a Claude model added upstream is still routed correctly
635
+ * by an un-updated plugin instead of hard-failing with the 400 above. The
636
+ * worst case of that rule going stale the other way (upstream teaching
637
+ * `/provider/v1/chat/completions` to serve Claude) is one model riding a
638
+ * transport it already works on.
621
639
  *
622
640
  * Keep in sync when models ship (see the dsh-commandcode-upstream skill).
623
641
  */
624
642
  const MESSAGES_ONLY_MODELS = /* @__PURE__ */ new Set([
643
+ "claude-sonnet-5-5",
625
644
  "claude-sonnet-5",
626
645
  "claude-sonnet-4-6",
627
646
  "claude-fable-5-1",
628
647
  "claude-fable-5",
648
+ "claude-opus-5-5",
629
649
  "claude-opus-5",
630
650
  "claude-opus-4-8",
631
651
  "claude-opus-4-7",
@@ -636,12 +656,139 @@ function requiresMessagesEndpoint(modelId) {
636
656
  return MESSAGES_ONLY_MODELS.has(modelId) || modelId.startsWith("claude-");
637
657
  }
638
658
  /**
659
+ * Per-model output ceilings, which `/provider/v1/models` does NOT publish (it
660
+ * carries `context_length` and `supported_endpoints` only) while every endpoint
661
+ * refuses a larger `max_tokens` per model. Sending the context window as the
662
+ * output budget is what produced issue #71: `claude-sonnet-5-5` has a
663
+ * 1 000 000-token window and a 128 000-token ceiling, the adapter derived
664
+ * 131072, and the endpoint refused every request of a fresh process.
665
+ *
666
+ * **Generated — do not hand-edit.** `scripts/sync-output-limits.mjs` rewrites
667
+ * this literal from models.dev; run it after a catalog sync, and use its
668
+ * `--check` in CI. Command Code resells these models through their VENDOR's own
669
+ * API, so the vendor's published ceiling is the value recorded: 76 of the 86
670
+ * catalog ids as of 2026-09-30. Each row carries a comment naming the entry it
671
+ * came from.
672
+ *
673
+ * Two of those 76 rest on more than a vendor lookup. `gpt-6.1-sol` (128000) comes
674
+ * from OpenAI's own row and is corroborated by every other provider carrying it.
675
+ * `inclusionai/ling-3.1-flash:free` (32768) is hand-recorded in the script's
676
+ * `MANUAL_CEILINGS`: models.dev has no `ling-3.1` entry yet, so it takes the Ling
677
+ * family's uniform ceiling. Both are still correctable — a stricter gateway costs
678
+ * one refused request, and `streamRequest()` remembers what the endpoint said.
679
+ *
680
+ * The 10 unlisted models are ones no vendor publishes and the aggregators
681
+ * disagree about. They are NOT a gap: a refusal states the ceiling in full, and
682
+ * `streamRequest()` reads it off, retries once and remembers it for the model
683
+ * (issue #71). A vendor figure the gateway turns out to be stricter than costs
684
+ * one round trip; a conservative guess costs half of every answer, forever —
685
+ * which is why the unlisted path defers to the endpoint rather than to a
686
+ * fallback here.
687
+ *
688
+ * An unlisted Messages-route model still falls back to
689
+ * {@link DEFAULT_MESSAGES_MAX_TOKENS} before any request is made, so a ceiling
690
+ * is never absent when the body is built.
691
+ */
692
+ const MODEL_OUTPUT_TOKEN_LIMITS = /* @__PURE__ */ new Map([
693
+ ["claude-fable-5", 128e3],
694
+ ["claude-fable-5-1", 128e3],
695
+ ["claude-haiku-4-5-20251001", 64e3],
696
+ ["claude-opus-4-7", 128e3],
697
+ ["claude-opus-4-8", 128e3],
698
+ ["claude-opus-5", 128e3],
699
+ ["claude-opus-5-5", 128e3],
700
+ ["claude-sonnet-4-6", 128e3],
701
+ ["claude-sonnet-5", 128e3],
702
+ ["claude-sonnet-5-5", 128e3],
703
+ ["deepseek/deepseek-v4-flash", 393216],
704
+ ["deepseek/deepseek-v4-flash-vision-exp", 393216],
705
+ ["deepseek/deepseek-v4-pro", 393216],
706
+ ["google/gemini-3.1-flash-lite", 65536],
707
+ ["google/gemini-3.5-flash", 65536],
708
+ ["google/gemini-3.5-flash-lite", 65536],
709
+ ["google/gemini-3.6-flash", 65536],
710
+ ["google/gemini-3.7-flash", 65536],
711
+ ["google/gemini-3.8-flash", 65536],
712
+ ["gpt-5.3-codex", 128e3],
713
+ ["gpt-5.4", 128e3],
714
+ ["gpt-5.4-mini", 128e3],
715
+ ["gpt-5.5", 128e3],
716
+ ["gpt-5.6-luna", 128e3],
717
+ ["gpt-5.6-sol", 128e3],
718
+ ["gpt-5.6-terra", 128e3],
719
+ ["gpt-6-astra", 128e3],
720
+ ["gpt-6-luna", 128e3],
721
+ ["gpt-6-sol", 128e3],
722
+ ["gpt-6.1-sol", 128e3],
723
+ ["inclusionai/ling-3.0-flash-sante:free", 32768],
724
+ ["inclusionai/ling-3.1-flash:free", 32768],
725
+ ["meituan/LongCat-2.0", 131072],
726
+ ["meta/muse-spark-1.1", 131072],
727
+ ["meta/muse-spark-1.2", 131072],
728
+ ["meta/muse-spark-1.2-contributor", 131072],
729
+ ["meta/muse-spark-1.3", 131072],
730
+ ["meta/muse-spark-1.3-contributor", 131072],
731
+ ["MiniMaxAI/MiniMax-M2.5", 131072],
732
+ ["MiniMaxAI/MiniMax-M2.7", 131072],
733
+ ["MiniMaxAI/MiniMax-M3", 512e3],
734
+ ["moonshotai/Kimi-K2.6", 262144],
735
+ ["moonshotai/Kimi-K2.7-Code", 262144],
736
+ ["moonshotai/Kimi-K2.7-Code-Highspeed", 262144],
737
+ ["moonshotai/Kimi-K3", 1048576],
738
+ ["nvidia/nemotron-3-ultra-550b-a55b", 65536],
739
+ ["poolside/laguna-s-2.1-free", 32768],
740
+ ["Qwen/Qwen3.6-Max-Preview", 65536],
741
+ ["Qwen/Qwen3.6-Plus", 65536],
742
+ ["Qwen/Qwen3.7-Flash", 131072],
743
+ ["Qwen/Qwen3.7-Max", 131072],
744
+ ["Qwen/Qwen3.7-Plus", 131072],
745
+ ["Qwen/Qwen3.8-Flash", 131072],
746
+ ["Qwen/Qwen3.8-Max", 131072],
747
+ ["Qwen/Qwen3.8-Omni-Flash", 131072],
748
+ ["sakana/fugu-ultra", 1e6],
749
+ ["stealth/pixel-canary", 131072],
750
+ ["stealth/space-bunny-alpha", 524288],
751
+ ["stepfun/Step-3.5-Flash", 256e3],
752
+ ["stepfun/Step-3.7-Flash", 256e3],
753
+ ["stepfun/Step-5-Preview", 65536],
754
+ ["thinkingmachines/inkling", 65536],
755
+ ["xai/grok-4.5", 5e5],
756
+ ["xai/grok-4.6", 5e5],
757
+ ["xai/grok-4.7", 5e5],
758
+ ["xiaomi/mimo-v2.5", 131072],
759
+ ["xiaomi/mimo-v2.5-pro", 131072],
760
+ ["xiaomi/mimo-v2.6-flash", 131072],
761
+ ["xiaomi/mimo-v2.6-pro", 131072],
762
+ ["xiaomi/mimo-v2.6-pro-ultraspeed", 131072],
763
+ ["z-ai/glm-5.3-flash", 131072],
764
+ ["z-ai/glm-5.3-flashx", 131072],
765
+ ["zai-org/GLM-5", 131072],
766
+ ["zai-org/GLM-5.1", 131072],
767
+ ["zai-org/GLM-5.2", 131072],
768
+ ["zai-org/GLM-5.3", 131072]
769
+ ]);
770
+ /**
771
+ * Output ceiling for a Messages-route model that {@link MODEL_OUTPUT_TOKEN_LIMITS}
772
+ * does not carry — today only models no vendor publishes yet. Deliberately
773
+ * conservative: a 400 on `max_tokens` is a wasted round trip on every request,
774
+ * whereas a lower ceiling only shortens an answer, and the endpoint states the
775
+ * real one the first time it refuses, at which point the runtime overrides this
776
+ * anyway (issue #71).
777
+ *
778
+ * 64 000 is not arbitrary: it is `claude-haiku-4-5-20251001`'s actual ceiling,
779
+ * the lowest in the Claude family. A Claude model added to the catalog before
780
+ * its row exists here therefore answers at a size the family really supports,
781
+ * and is corrected upward on its first rejection rather than clamped here
782
+ * forever.
783
+ */
784
+ const DEFAULT_MESSAGES_MAX_TOKENS = 64e3;
785
+ /**
639
786
  * The minimum subscription plan a model is included in, per the official plan
640
787
  * pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`,
641
788
  * `/docs/plans/max` and `/docs/resources/pricing-limits`). Each plan's model
642
789
  * list is a superset of the one below it: Go ⊂ GOAT ⊂ Pro ⊂ Provider/Max.
643
790
  * Models absent from every plan list (Claude Opus/Fable, Fugu Ultra) are
644
- * Provider-tier. Re-verified at command-code@1.68.0 (2026-09-29): 84 catalog
791
+ * Provider-tier. Re-verified at command-code@1.72.1 (2026-09-30): 86 catalog
645
792
  * ids at 53/62/76/84 cumulative, a strict superset chain — every release since
646
793
  * 1.49.0 has been additive with no tier move, and per-entry tags below name the
647
794
  * release that added each row.
@@ -678,6 +825,7 @@ const KNOWN_PLANS = {
678
825
  "gpt-6-luna": "go",
679
826
  "meituan/LongCat-2.0": "go",
680
827
  "inclusionai/ling-3.0-flash-sante:free": "go",
828
+ "inclusionai/ling-3.1-flash:free": "go",
681
829
  "meta/muse-spark-1.2-contributor": "go",
682
830
  "meta/muse-spark-1.3-contributor": "go",
683
831
  "moonshotai/Kimi-K2.5": "go",
@@ -738,7 +886,8 @@ const KNOWN_PLANS = {
738
886
  "claude-opus-5-5": "provider",
739
887
  "claude-opus-5": "provider",
740
888
  "gpt-6-astra": "provider",
741
- "sakana/fugu-ultra": "provider"
889
+ "sakana/fugu-ultra": "provider",
890
+ "gpt-6.1-sol": "max"
742
891
  };
743
892
  /**
744
893
  * Whether a model is free (requests cost no credits), per the pricing page's
@@ -767,7 +916,7 @@ function compareByPlan(a, b) {
767
916
  /**
768
917
  * Subscription plan table, synced from the official CLI bundle's plan maps
769
918
  * (located by the `"individual-go"` key in `dist/cli.mjs`, re-verified unchanged
770
- * through command-code@1.68.0): subscription `planId` prefix → display name and
919
+ * through command-code@1.72.1): subscription `planId` prefix → display name and
771
920
  * the plan's monthly credit total. This is the account's own subscription
772
921
  * (from `/alpha/billing/subscriptions`) — distinct from {@link KNOWN_PLANS},
773
922
  * which maps catalog models to their minimum tier.
@@ -782,6 +931,11 @@ const KNOWN_SUBSCRIPTION_PLANS = {
782
931
  monthlyCredits: 10,
783
932
  tierWeight: 0
784
933
  },
934
+ "individual-go-v1": {
935
+ name: "Go",
936
+ monthlyCredits: 10,
937
+ tierWeight: 0
938
+ },
785
939
  "individual-goat": {
786
940
  name: "GOAT",
787
941
  monthlyCredits: 70,
@@ -870,10 +1024,6 @@ const KNOWN_DEALS = {
870
1024
  "MiniMaxAI/MiniMax-M3": { label: "50% off" },
871
1025
  "xiaomi/mimo-v2.5-pro": { label: "99% off" },
872
1026
  "xiaomi/mimo-v2.5": { label: "98% off" },
873
- "xai/grok-4.7": {
874
- label: "40% off",
875
- expiresAt: "2026-09-27T23:59:59.999Z"
876
- },
877
1027
  "poolside/laguna-s-2.1-free": {
878
1028
  label: "FREE",
879
1029
  free: true
@@ -882,6 +1032,10 @@ const KNOWN_DEALS = {
882
1032
  label: "FREE",
883
1033
  free: true
884
1034
  },
1035
+ "inclusionai/ling-3.1-flash:free": {
1036
+ label: "FREE",
1037
+ free: true
1038
+ },
885
1039
  "stealth/space-bunny-alpha": {
886
1040
  label: "FREE",
887
1041
  free: true
@@ -1629,6 +1783,33 @@ const MODEL_PRICE_ROWS = [
1629
1783
  25
1630
1784
  ] }]
1631
1785
  },
1786
+ {
1787
+ id: "gpt-6.1-sol",
1788
+ rates: [
1789
+ 2,
1790
+ 10,
1791
+ .1,
1792
+ 2.5
1793
+ ],
1794
+ allowance: {
1795
+ goat: 20,
1796
+ pro: 20
1797
+ },
1798
+ contextTiers: [{
1799
+ "maxContext": 272e3,
1800
+ "rates": [
1801
+ 2,
1802
+ 10,
1803
+ .1,
1804
+ 2.5
1805
+ ]
1806
+ }, { "rates": [
1807
+ 4,
1808
+ 15,
1809
+ .2,
1810
+ 5
1811
+ ] }]
1812
+ },
1632
1813
  {
1633
1814
  id: "gpt-6-luna",
1634
1815
  rates: [
@@ -1722,11 +1903,6 @@ const MODEL_PRICE_ROWS = [
1722
1903
  {
1723
1904
  id: "grok-4.7",
1724
1905
  rates: [
1725
- 1.2,
1726
- 3.6,
1727
- .3
1728
- ],
1729
- listRates: [
1730
1906
  2,
1731
1907
  6,
1732
1908
  .5
@@ -1738,27 +1914,15 @@ const MODEL_PRICE_ROWS = [
1738
1914
  contextTiers: [{
1739
1915
  "maxContext": 2e5,
1740
1916
  "rates": [
1741
- 1.2,
1742
- 3.6,
1743
- .3
1744
- ],
1745
- "listRates": [
1746
1917
  2,
1747
1918
  6,
1748
1919
  .5
1749
1920
  ]
1750
- }, {
1751
- "rates": [
1752
- 2.4,
1753
- 7.2,
1754
- .6
1755
- ],
1756
- "listRates": [
1757
- 4,
1758
- 12,
1759
- 1
1760
- ]
1761
- }]
1921
+ }, { "rates": [
1922
+ 4,
1923
+ 12,
1924
+ 1
1925
+ ] }]
1762
1926
  },
1763
1927
  {
1764
1928
  id: "inkling",
@@ -2596,6 +2760,27 @@ function installCostProjection(ctx) {
2596
2760
  * JSON, SSE, and pre-stream error bodies undecoded.
2597
2761
  */
2598
2762
  const IDENTITY_ENCODING_HEADER = { "accept-encoding": "identity" };
2763
+ const THROTTLED_CODE = "THROTTLED";
2764
+ const boundedCodes = /* @__PURE__ */ new Set([
2765
+ "EMPTY_RESPONSE",
2766
+ "SERVER",
2767
+ "TIMEOUT",
2768
+ THROTTLED_CODE
2769
+ ]);
2770
+ const counts$1 = /* @__PURE__ */ new WeakMap();
2771
+ function absorbTransientFailure(agent, code) {
2772
+ if (!boundedCodes.has(code)) return "ignored";
2773
+ const used = counts$1.get(agent) ?? 0;
2774
+ if (used >= 3) return "exhausted";
2775
+ counts$1.set(agent, used + 1);
2776
+ return "retry";
2777
+ }
2778
+ function resetTransientFailures(agent) {
2779
+ counts$1.delete(agent);
2780
+ }
2781
+ function transientBudgetMessage(code, message) {
2782
+ return `Command Code 请求在 3 次自动重试后仍失败,已停止重试(${code})。请稍后重新发送;若反复出现,请检查服务状态或请求耗时诊断。 原因:${message}`;
2783
+ }
2599
2784
  //#endregion
2600
2785
  //#region src/accounts.ts
2601
2786
  /**
@@ -2973,7 +3158,7 @@ var CommandCodeAccountPool = class {
2973
3158
  function allAccountsUnusable(accounts) {
2974
3159
  if (accounts.filter((account) => account.state?.kind === "disabled").length === accounts.length) return new LlmError(`llm-commandcode: every configured Command Code account (${accounts.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file;已配置的 ${accounts.length} 个 Command Code 账户密钥均被拒绝(401)——请在设置页检查存储的 API 密钥,或重新运行 command-code login`, "INVALID_CREDENTIAL");
2975
3160
  const windowMarked = accounts.map((account) => account.state).filter((state) => state !== void 0).filter((state) => state.cause === "window");
2976
- if (windowMarked.length === 0) return new LlmError(`llm-commandcode: all ${accounts.length} Command Code account(s) are rate limited (429) — the provider did not report an exhausted usage window; retrying;全部 ${accounts.length} 个 Command Code 账户被限流(429)——服务商未报告用量窗口用尽,正在重试`, "RATE_LIMIT");
3161
+ if (windowMarked.length === 0) return new LlmError(`llm-commandcode: all ${accounts.length} Command Code account(s) are rate limited (429) — the provider did not report an exhausted usage window; retrying;全部 ${accounts.length} 个 Command Code 账户被限流(429)——服务商未报告用量窗口用尽,正在重试`, THROTTLED_CODE);
2977
3162
  const resets = windowMarked.filter((state) => state.kind === "cooldown" && state.until > 0).map((state) => state.until);
2978
3163
  const earliest = resets.length > 0 ? Math.min(...resets) : 0;
2979
3164
  const wait = earliest > 0 ? Math.max(1e3, earliest - Date.now()) : 0;
@@ -3394,6 +3579,95 @@ function openStreamTrace(scope, options = {}) {
3394
3579
  };
3395
3580
  }
3396
3581
  //#endregion
3582
+ //#region src/request-timing.ts
3583
+ /** 请求耗时只记录阶段、计数和状态;不记录消息、凭据、请求头或错误正文。 */
3584
+ const REQUEST_TIMING_ENV = "DSH_COMMANDCODE_TIMING";
3585
+ /** 每次生成独立计时;可注入时钟验证顺序,不依赖真实等待。 */
3586
+ var RequestTiming = class {
3587
+ sink;
3588
+ now;
3589
+ started;
3590
+ summary;
3591
+ constructor(model, sink, now = () => performance.now()) {
3592
+ this.sink = sink;
3593
+ this.now = now;
3594
+ this.started = now();
3595
+ this.summary = {
3596
+ version: 1,
3597
+ startedAt: Date.now(),
3598
+ model,
3599
+ outcome: "cancelled",
3600
+ totalMs: 0,
3601
+ phasesMs: {
3602
+ credentials: 0,
3603
+ images: 0,
3604
+ body: 0,
3605
+ serialization: 0,
3606
+ headers: 0
3607
+ },
3608
+ attempts: []
3609
+ };
3610
+ }
3611
+ /** 返回结束函数,确保成功和异常都计入同一阶段。阶段可嵌套,不能直接相加。 */
3612
+ phase(name) {
3613
+ if (!this.sink) return () => {};
3614
+ const start = this.now();
3615
+ return () => {
3616
+ this.summary.phasesMs[name] += this.now() - start;
3617
+ };
3618
+ }
3619
+ attempt(protocol, bodyBytes) {
3620
+ if (!this.sink) return () => {};
3621
+ const start = this.now();
3622
+ const attempt = {
3623
+ protocol,
3624
+ bodyBytes,
3625
+ headersMs: 0
3626
+ };
3627
+ this.summary.attempts.push(attempt);
3628
+ return (status) => {
3629
+ Object.assign(attempt, {
3630
+ headersMs: this.now() - start,
3631
+ ...status === void 0 ? {} : { status }
3632
+ });
3633
+ this.summary.phasesMs.headers += attempt.headersMs;
3634
+ };
3635
+ }
3636
+ first(kind) {
3637
+ if (this.sink && this.summary[kind] === void 0) this.summary[kind] = this.now() - this.started;
3638
+ }
3639
+ async close(outcome, errorCode) {
3640
+ if (!this.sink) return;
3641
+ this.summary.outcome = outcome;
3642
+ this.summary.totalMs = this.now() - this.started;
3643
+ if (errorCode !== void 0) this.summary.errorCode = errorCode;
3644
+ try {
3645
+ await this.sink(this.summary);
3646
+ } catch {}
3647
+ }
3648
+ };
3649
+ /** 与原始流诊断独立开关,便于仅收集数值耗时。 */
3650
+ function openRequestTiming(model, sink) {
3651
+ if (sink) return new RequestTiming(model, sink);
3652
+ const raw = process.env[REQUEST_TIMING_ENV]?.trim();
3653
+ if (!raw || [
3654
+ "0",
3655
+ "false",
3656
+ "no",
3657
+ "off"
3658
+ ].includes(raw.toLowerCase())) return new RequestTiming(model);
3659
+ const path = [
3660
+ "1",
3661
+ "true",
3662
+ "yes",
3663
+ "on"
3664
+ ].includes(raw.toLowerCase()) ? join(tmpdir(), "dsh-commandcode-timing.jsonl") : raw;
3665
+ return new RequestTiming(model, async (summary) => {
3666
+ await mkdir(dirname(path), { recursive: true });
3667
+ await appendFile(path, `${JSON.stringify(summary)}\n`, { mode: 384 });
3668
+ });
3669
+ }
3670
+ //#endregion
3397
3671
  //#region src/adapter.ts
3398
3672
  /**
3399
3673
  * DeepSeek Harness LLM adapter for the Command Code Provider API.
@@ -3402,21 +3676,113 @@ function openStreamTrace(scope, options = {}) {
3402
3676
  * community-maintained integration; you need your own Command Code account
3403
3677
  * and API key or subscription, and Command Code's terms apply.
3404
3678
  *
3405
- * Owns the two chat transports (`/alpha/generate`, `/provider/v1/chat/completions`),
3679
+ * Owns the three chat transports (`/alpha/generate`, `/provider/v1/chat/completions`,
3680
+ * `/provider/v1/messages`),
3406
3681
  * message conversion, SSE/JSONL parsing, the catalog + its on-disk cache, and
3407
3682
  * the pre-stream account rotation loop. The wire protocol is
3408
- * reverse-engineered (command-code@1.28.4, re-verified through 1.68.0);
3409
- * AGENTS.md holds the full protocol record.
3683
+ * reverse-engineered (command-code@1.28.4, re-verified through 1.72.1);
3684
+ * docs/agent-reference/adapter-protocol.md holds the full protocol record.
3410
3685
  *
3411
3686
  * The adapter is deliberately free of cordis/schemastery: it receives a
3412
3687
  * per-request options thunk and an API-key resolver from the plugin entry
3413
3688
  * (src/index.ts), so a settings change reaches the very next request.
3414
3689
  */
3690
+ /**
3691
+ * Output ceilings the endpoint has named in a live rejection, per model.
3692
+ *
3693
+ * The snapshot in ./capabilities.ts carries the vendor-published ceiling for
3694
+ * 74 of the 84 catalog models (see scripts/sync-output-limits.mjs). The other
3695
+ * 10 are models no vendor publishes yet, and a gateway is free to sit below
3696
+ * the vendor figure even where one exists — so this map is the correction
3697
+ * channel, not a fallback of last resort: the endpoint states the model's own
3698
+ * ceiling in its rejection, so one wasted round trip is enough to know it for
3699
+ * the rest of the process.
3700
+ *
3701
+ * Shared by adapters for the same gateway, but not across apiBase values: a
3702
+ * proxy can enforce a smaller ceiling for the same model id. Only ever tightens
3703
+ * within that gateway, so a stale catalog row cannot undo a live refusal.
3704
+ */
3705
+ const learnedOutputLimits = /* @__PURE__ */ new Map();
3706
+ /**
3707
+ * Output ceilings the CATALOG published, per model.
3708
+ *
3709
+ * `/provider/v1/models` carries `max_output_tokens` in its schema and the
3710
+ * official pi provider already prefers it, but the endpoint does not send it
3711
+ * yet — so this is empty today and fills in on its own the day it does. It sits
3712
+ * above the models.dev snapshot and below what a live rejection proved, because
3713
+ * that ordering is also the order of trust: the gateway knows its own ceiling,
3714
+ * a rejection proves it, and models.dev is a third party describing the same
3715
+ * model. Restored from the on-disk catalog cache so a published ceiling
3716
+ * survives a restart without waiting for the next catalog fetch.
3717
+ */
3718
+ const publishedOutputLimits = /* @__PURE__ */ new Map();
3719
+ /** Keep model facts from one gateway from constraining another gateway. */
3720
+ function outputLimitsFor(store, apiBase) {
3721
+ let limits = store.get(apiBase);
3722
+ if (limits === void 0) {
3723
+ limits = /* @__PURE__ */ new Map();
3724
+ store.set(apiBase, limits);
3725
+ }
3726
+ return limits;
3727
+ }
3728
+ /**
3729
+ * The output ceiling this gateway route sends for `modelId`.
3730
+ *
3731
+ * `/provider/v1/models` publishes `context_length` and `supported_endpoints`
3732
+ * but no output cap today, while each endpoint refuses a larger `max_tokens` per
3733
+ * model — the Messages endpoint names its own in the rejection (`max_tokens:
3734
+ * 200000 > 128000, which is the maximum allowed number of output tokens for
3735
+ * claude-sonnet-5-5`). What the endpoint has already named wins, then the
3736
+ * catalog's own figure once it publishes one, then the snapshot, then — for
3737
+ * the Messages-route models only — the conservative value, because the ceilings
3738
+ * actually observed are below the global default and assuming otherwise would
3739
+ * fail every request.
3740
+ */
3741
+ function modelOutputTokenLimit(apiBase, modelId) {
3742
+ const learned = outputLimitsFor(learnedOutputLimits, apiBase).get(modelId);
3743
+ const known = outputLimitsFor(publishedOutputLimits, apiBase).get(modelId) ?? MODEL_OUTPUT_TOKEN_LIMITS.get(modelId);
3744
+ if (learned !== void 0 && (known === void 0 || learned < known)) return learned;
3745
+ if (known !== void 0) return known;
3746
+ return requiresMessagesEndpoint(modelId) ? DEFAULT_MESSAGES_MAX_TOKENS : DEFAULT_MAX_OUTPUT_TOKENS;
3747
+ }
3748
+ /**
3749
+ * Record a ceiling the endpoint just stated, if it is one this route can act
3750
+ * on: a real number, and one below what the next request would otherwise send.
3751
+ * A ceiling at or above the current budget describes a request that was not
3752
+ * refused for being too large, and acting on it would be a guess.
3753
+ */
3754
+ function noteOutputCeiling(apiBase, modelId, ceiling) {
3755
+ if (!Number.isSafeInteger(ceiling) || ceiling <= 0) return false;
3756
+ if (ceiling >= modelOutputTokenLimit(apiBase, modelId)) return false;
3757
+ outputLimitsFor(learnedOutputLimits, apiBase).set(modelId, ceiling);
3758
+ return true;
3759
+ }
3760
+ /**
3761
+ * The ceiling to retry this turn with, when `error` is a request-side output
3762
+ * refusal the route can act on — else undefined, meaning surface it.
3763
+ *
3764
+ * Read from the message, so it covers both shapes the same refusal arrives in:
3765
+ * an HTTP 400 (`PROVIDER_HTTP_ERROR`) and an in-stream error frame
3766
+ * (`PROVIDER_STREAM_ERROR`). Both carry the endpoint's wording, and both are
3767
+ * already classified as output refusals rather than context overflows by the
3768
+ * time they reach here.
3769
+ *
3770
+ * Recording is what makes the retry pay off: the ceiling is kept for this
3771
+ * gateway and model, so a refused turn leaves its next request correct.
3772
+ */
3773
+ function outputCeilingRefusal(apiBase, modelId, error) {
3774
+ if (!(error instanceof LlmError)) return void 0;
3775
+ const detail = error.message;
3776
+ if (!OUTPUT_LIMIT_REJECTION.test(detail)) return void 0;
3777
+ const stated = parseOutputCeiling(detail);
3778
+ if (stated === void 0) return void 0;
3779
+ return noteOutputCeiling(apiBase, modelId, stated) ? stated : void 0;
3780
+ }
3415
3781
  /** Keep the two user-facing languages and error metadata together. */
3416
3782
  function bilingual(code, en, zh, options) {
3417
3783
  return new LlmError(`${en};${zh}`, code, options);
3418
3784
  }
3419
- const COMMAND_CODE_CLI_VERSION = "1.68.0";
3785
+ const COMMAND_CODE_CLI_VERSION = "1.72.1";
3420
3786
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
3421
3787
  /**
3422
3788
  * The output budget this bundle asks for, and the ceiling it will never exceed.
@@ -3452,6 +3818,9 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 131072;
3452
3818
  /** Preserve a small usable answer budget when the heuristic overshoots. */
3453
3819
  const MIN_CONTEXT_OUTPUT_TOKENS = 1024;
3454
3820
  const MODELS_TIMEOUT_MS = 1e4;
3821
+ /** 目录短期复用;刷新失败只短暂退避,避免反复阻塞模型选择器。 */
3822
+ const MODEL_CATALOG_TTL_MS = 3e5;
3823
+ const MODEL_CATALOG_RETRY_MS = 1e4;
3455
3824
  /** How long the picker's plan-filter billing facts stay cached before refetching. */
3456
3825
  const BILLING_ACCESS_TTL_MS = 3e5;
3457
3826
  /** The entry-plan tier weight (`individual-go` in KNOWN_SUBSCRIPTION_PLANS). */
@@ -3489,7 +3858,49 @@ const ACTIVE_SUBSCRIPTION_STATUSES = /* @__PURE__ */ new Set([
3489
3858
  const DEFAULT_REQUEST_TIMEOUT_MS = 3e5;
3490
3859
  /** Stream idle timeout: a generation that stalls this long is a dead connection. */
3491
3860
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
3492
- const MODEL_CACHE_VERSION = 1;
3861
+ /** Version 3 binds cached catalog facts to their gateway; older files lack a safe source. */
3862
+ const MODEL_CACHE_VERSION = 3;
3863
+ /**
3864
+ * A lone UTF-16 surrogate half: a high surrogate not followed by a low one, or
3865
+ * a low surrogate not preceded by a high one. Such a half is not a character —
3866
+ * it only arises from a truncated slice, a lossy decode, or a lone `\uD83D`
3867
+ * escape — and it survives `JSON.stringify` as an escape (`"\ud83d"`) rather
3868
+ * than failing locally, so the request leaves the process carrying a string no
3869
+ * UTF-8 consumer can round-trip. Providers running strict decoders reject it.
3870
+ *
3871
+ * This is the same expression `@earendil-works/pi-ai` applies to every outgoing
3872
+ * string on this endpoint. Valid astral characters (emoji and anything else
3873
+ * outside the BMP) are PAIRED surrogates and pass through untouched.
3874
+ */
3875
+ const LONE_SURROGATE_PATTERN = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
3876
+ /**
3877
+ * A cheap pre-test for "this string contains any surrogate code unit at all",
3878
+ * paired or not. The exact pattern above needs lookaround, which V8 cannot
3879
+ * optimize into a fast scan; this one is a plain range test. Real prompts are
3880
+ * overwhelmingly ASCII-plus-CJK with no surrogates, so the common case pays one
3881
+ * linear scan instead of the lookaround pass. Measured on a 412 KB request
3882
+ * body: 1.40 ms with the exact pattern alone against 0.77 ms with this gate,
3883
+ * where a bare `JSON.stringify` is 0.59 ms.
3884
+ */
3885
+ const ANY_SURROGATE_PATTERN = /[\uD800-\uDFFF]/;
3886
+ /**
3887
+ * Drop lone surrogate halves from one outgoing string.
3888
+ *
3889
+ * Applied at the single point a request body is serialized, so every field of
3890
+ * every transport is covered — system text, message content, tool schemas and
3891
+ * tool results alike — instead of enumerating the fields that can carry text
3892
+ * and missing one. The value is returned unchanged whenever it holds no
3893
+ * surrogate at all, which is the ordinary case.
3894
+ *
3895
+ * The `test()` call deliberately uses the flagless {@link ANY_SURROGATE_PATTERN}:
3896
+ * a global regex is stateful, so testing with one would advance `lastIndex` and
3897
+ * make consecutive calls on the same string alternate between true and false.
3898
+ * `replace()` resets `lastIndex` itself, so the global pattern is safe there.
3899
+ */
3900
+ function sanitizeSurrogates(value) {
3901
+ if (typeof value !== "string") return value;
3902
+ return ANY_SURROGATE_PATTERN.test(value) ? value.replace(LONE_SURROGATE_PATTERN, "") : value;
3903
+ }
3493
3904
  /** Hash one request value without retaining or logging its contents. */
3494
3905
  function fingerprintValue(value) {
3495
3906
  let serialized;
@@ -3665,6 +4076,35 @@ function isContextOverflowDetail(detail) {
3665
4076
  /** A gateway refusal observed for an over-reserved request, but not unique to that cause. */
3666
4077
  const AMBIGUOUS_SIZE_REJECTION = /\bmodel could not complete the request\b/i;
3667
4078
  /**
4079
+ * A request-side `max_tokens` refusal, told apart from a context overflow that
4080
+ * mentions the same field. Kept narrow on purpose: the overflow pattern also
4081
+ * matches a bare `max_tokens`, so this must name the specific shapes the
4082
+ * endpoint uses — a ceiling comparison, its prose about output tokens, or a
4083
+ * request-validation error on the field.
4084
+ */
4085
+ const OUTPUT_LIMIT_REJECTION = new RegExp([
4086
+ "max_tokens:\\s*\\d+\\s*>\\s*\\d+",
4087
+ "maximum allowed number of output tokens",
4088
+ "invalid input[^\\n]*max_tokens",
4089
+ "expected number[^\\n]*at max_tokens"
4090
+ ].join("|"), "i");
4091
+ /**
4092
+ * The output ceiling the endpoint states in a `max_tokens` refusal, read off
4093
+ * its own comparison: `max_tokens: 131072 > 128000` yields 128000.
4094
+ *
4095
+ * Only the comparison form is parsed. The other shapes
4096
+ * {@link OUTPUT_LIMIT_REJECTION} matches — a bare validation error, or the
4097
+ * prose without the numbers — name no ceiling, and guessing one would be worse
4098
+ * than failing: a fabricated value is indistinguishable from a real one at the
4099
+ * point where it decides the next request's budget.
4100
+ */
4101
+ function parseOutputCeiling(detail) {
4102
+ const stated = /max_tokens:\s*\d+\s*>\s*(\d+)/i.exec(detail)?.[1];
4103
+ if (stated === void 0) return void 0;
4104
+ const ceiling = Number(stated);
4105
+ return Number.isSafeInteger(ceiling) && ceiling > 0 ? ceiling : void 0;
4106
+ }
4107
+ /**
3668
4108
  * The advice floor for a Provider-API timeout: doubling a 60 s budget is still
3669
4109
  * short for a slow upstream first token, so the suggestion never goes below 5
3670
4110
  * minutes. Capped by `RETRY_MAX_DELAY_MS` rather than the schema's timer
@@ -3711,6 +4151,8 @@ function headersTimeoutAdvice(protocol, timeoutMs) {
3711
4151
  * answer either way.
3712
4152
  */
3713
4153
  const MAX_CONSECUTIVE_HEADER_TIMEOUTS = 3;
4154
+ /** Streak key for a session-less call, so those keep the old single-slot semantics instead of never escalating. */
4155
+ const GLOBAL_HEADER_TIMEOUT_KEY = "\0global";
3714
4156
  /** UTF-8 byte length, computed only on the timeout path so a large body is encoded once, not per attempt. */
3715
4157
  function utf8ByteLength(text) {
3716
4158
  return new TextEncoder().encode(text).length;
@@ -3777,9 +4219,21 @@ function streamErrorToLlmError(value, fallbackMessage) {
3777
4219
  message
3778
4220
  ].filter((part) => part !== void 0 && part !== "").join(" ");
3779
4221
  const statusOption = statusCode !== void 0 ? { status: statusCode } : void 0;
4222
+ if (OUTPUT_LIMIT_REJECTION.test(detail)) {
4223
+ const stated = /max_tokens:\s*\d+\s*>\s*\d+/i.exec(detail)?.[0];
4224
+ return bilingual("PROVIDER_STREAM_ERROR", `Command Code refused the request's max_tokens for this model` + (stated !== void 0 ? ` (${stated})` : "") + " — the session is not oversized, so compacting it will not help; lower \"Max output tokens\" or pick a model with a larger output ceiling", `Command Code 拒绝了本次请求的 max_tokens(该模型的输出上限更低)` + (stated !== void 0 ? `(${stated})` : "") + "——会话内容并未超长,压缩上下文无效;请调低「最大输出 token」或改用输出上限更大的模型", statusOption);
4225
+ }
3780
4226
  if (isContextOverflowDetail(detail)) return bilingual(CONTEXT_WINDOW_EXCEEDED_CODE, `Command Code stream error: ${message}`, "Command Code API 拒绝了这次请求:内容超出模型上下文窗口。正在压缩上下文后重试;如仍失败,请新建会话或减少上下文", statusOption);
3781
4227
  const terminal = hasTerminalStreamMarker(detail);
3782
4228
  if (!(isRetryable === true || (isRetryable === false || terminal ? false : statusCode !== void 0 ? statusCode !== void 0 && (statusCode === 429 || statusCode >= 500) : true))) return new LlmError(`Command Code stream error: ${message}`, "PROVIDER_STREAM_ERROR", statusOption);
4229
+ const rejection = classifyAccountRejection(statusCode ?? 0, JSON.stringify(value) ?? message);
4230
+ if (rejection?.reason === "rate-limit" || rejection?.reason === "throttled") {
4231
+ const wait = rejection.resetAtMs === void 0 ? 0 : Math.min(RETRY_MAX_DELAY_MS, Math.max(1e3, rejection.resetAtMs - Date.now()));
4232
+ return bilingual(rejection.reason === "rate-limit" ? "RATE_LIMIT" : THROTTLED_CODE, `Command Code stream error: ${message}`, rejection.reason === "rate-limit" ? "Command Code 用量窗口已限流,等待恢复" : "Command Code 请求暂时被限流,正在重试", {
4233
+ ...statusOption,
4234
+ ...wait > 0 ? { providerRetryAfterMs: wait } : {}
4235
+ });
4236
+ }
3783
4237
  return new LlmError(`Command Code stream error: ${message}`, "SERVER", statusOption);
3784
4238
  }
3785
4239
  function recordOrEmpty(value) {
@@ -3944,8 +4398,9 @@ function resolveAuthFileApiKey() {
3944
4398
  return apiKeyFromCredentialRecord(parsed.commandcode) ?? apiKeyFromCredentialRecord(parsed["command-code"]);
3945
4399
  } catch {}
3946
4400
  }
3947
- function parseCatalogResponse(value) {
4401
+ function parseCatalogResponse(value, apiBase) {
3948
4402
  if (!isRecord(value) || value.object !== "list" || !Array.isArray(value.data)) throw new LlmError("Unexpected Command Code models response shape", "PROVIDER_PROTOCOL_ERROR");
4403
+ publishedOutputLimits.set(apiBase, /* @__PURE__ */ new Map());
3949
4404
  const models = [];
3950
4405
  for (const entry of value.data) {
3951
4406
  if (!isRecord(entry)) continue;
@@ -3953,33 +4408,52 @@ function parseCatalogResponse(value) {
3953
4408
  const name = stringValue(entry.name);
3954
4409
  const contextLength = numberValue(entry.context_length);
3955
4410
  if (!id || !name || !contextLength || contextLength <= 0) continue;
4411
+ const endpoints = Array.isArray(entry.supported_endpoints) ? entry.supported_endpoints.filter((item) => typeof item === "string") : [];
4412
+ const published = numberValue(entry.max_output_tokens);
4413
+ if (published !== void 0 && published > 0) outputLimitsFor(publishedOutputLimits, apiBase).set(id, published);
3956
4414
  models.push({
3957
4415
  id,
3958
4416
  name,
3959
4417
  contextWindow: contextLength,
3960
- maxTokens: Math.min(contextLength, DEFAULT_MAX_OUTPUT_TOKENS)
4418
+ maxTokens: Math.min(contextLength, modelOutputTokenLimit(apiBase, id)),
4419
+ ...published !== void 0 && published > 0 ? { publishedMaxTokens: published } : {},
4420
+ supportedEndpoints: endpoints
3961
4421
  });
3962
4422
  }
3963
4423
  if (models.length === 0) throw new LlmError("Command Code returned an empty model catalog", "PROVIDER_PROTOCOL_ERROR");
3964
4424
  return models;
3965
4425
  }
3966
- async function readModelsCache(cachePath) {
4426
+ async function readModelsCache(cachePath, apiBase) {
3967
4427
  const parsed = JSON.parse(await readFile(cachePath, "utf-8"));
3968
- if (!isRecord(parsed) || parsed.version !== MODEL_CACHE_VERSION || !Array.isArray(parsed.models)) throw new Error(`Invalid model cache at ${cachePath}`);
3969
- return parsed.models;
4428
+ if (!isRecord(parsed) || parsed.version !== MODEL_CACHE_VERSION || parsed.apiBase !== apiBase || !Array.isArray(parsed.models)) throw new Error(`Invalid model cache at ${cachePath}`);
4429
+ publishedOutputLimits.set(apiBase, /* @__PURE__ */ new Map());
4430
+ return parsed.models.map((entry) => {
4431
+ const model = isRecord(entry) ? entry : {};
4432
+ const endpoints = Array.isArray(model.supportedEndpoints) ? model.supportedEndpoints.filter((item) => typeof item === "string") : [];
4433
+ const id = stringValue(model.id);
4434
+ const published = numberValue(model.publishedMaxTokens);
4435
+ if (id !== void 0 && published !== void 0 && published > 0) outputLimitsFor(publishedOutputLimits, apiBase).set(id, published);
4436
+ const contextWindow = numberValue(model.contextWindow);
4437
+ return {
4438
+ ...model,
4439
+ supportedEndpoints: endpoints,
4440
+ maxTokens: id === void 0 || contextWindow === void 0 ? numberValue(model.maxTokens) ?? 131072 : Math.min(contextWindow, modelOutputTokenLimit(apiBase, id))
4441
+ };
4442
+ });
3970
4443
  }
3971
- async function writeModelsCache(cachePath, models) {
4444
+ async function writeModelsCache(cachePath, apiBase, models, mayCommit) {
3972
4445
  await mkdir(dirname(cachePath), { recursive: true });
3973
- const tmp = `${cachePath}.${process.pid}.tmp`;
4446
+ const tmp = `${cachePath}.${process.pid}.${randomUUID()}.tmp`;
3974
4447
  try {
3975
4448
  await writeFile(tmp, `${JSON.stringify({
3976
4449
  version: MODEL_CACHE_VERSION,
4450
+ apiBase,
3977
4451
  models
3978
4452
  }, null, 2)}\n`, {
3979
4453
  encoding: "utf-8",
3980
4454
  mode: 384
3981
4455
  });
3982
- await rename(tmp, cachePath);
4456
+ if (mayCommit()) await rename(tmp, cachePath);
3983
4457
  } finally {
3984
4458
  await rm(tmp, { force: true }).catch(() => void 0);
3985
4459
  }
@@ -4229,6 +4703,38 @@ function seenImageOffloadCount(messages, model) {
4229
4703
  async function readRequestImage(attachments, ref) {
4230
4704
  return attachments.readImageRequest(ref, requestImageTarget(ref));
4231
4705
  }
4706
+ /** 同一次生成及其协议切换共享图片读取,最多同时准备四张,避免内存峰值失控。 */
4707
+ function requestImages(attachments, signal, timing) {
4708
+ const cache = /* @__PURE__ */ new Map();
4709
+ const read = (ref) => {
4710
+ signal?.throwIfAborted();
4711
+ const key = JSON.stringify([ref.attachmentId, requestImageTarget(ref)]);
4712
+ let pending = cache.get(key);
4713
+ if (!pending) {
4714
+ pending = readRequestImage(attachments, ref);
4715
+ cache.set(key, pending);
4716
+ }
4717
+ return pending;
4718
+ };
4719
+ read.prepare = async (refs) => {
4720
+ if (refs.length === 0) return;
4721
+ const finishImages = timing.phase("images");
4722
+ let cursor = 0;
4723
+ try {
4724
+ const workers = Array.from({ length: Math.min(4, refs.length) }, async () => {
4725
+ while (cursor < refs.length) {
4726
+ signal?.throwIfAborted();
4727
+ await read(refs[cursor++]);
4728
+ }
4729
+ });
4730
+ const failure = (await Promise.allSettled(workers)).find((result) => result.status === "rejected");
4731
+ if (failure?.status === "rejected") throw failure.reason;
4732
+ } finally {
4733
+ finishImages();
4734
+ }
4735
+ };
4736
+ return read;
4737
+ }
4232
4738
  /**
4233
4739
  * Price one request occurrence.
4234
4740
  *
@@ -4272,6 +4778,17 @@ async function imageToCommandCode(ref, readImage) {
4272
4778
  };
4273
4779
  }
4274
4780
  /**
4781
+ * The signature recorded for the reasoning block at `position` of a
4782
+ * historical assistant message, or undefined when the message was produced
4783
+ * by another route or an older plugin version.
4784
+ */
4785
+ function messagesSignatureAt(message, position) {
4786
+ const replay = message.source?.replayState;
4787
+ if (!isRecord(replay) || !Array.isArray(replay.blocks)) return void 0;
4788
+ const entry = replay.blocks[position];
4789
+ return isRecord(entry) ? stringValue(entry.signature) : void 0;
4790
+ }
4791
+ /**
4275
4792
  * The common pairing and image-carrier ordering for both transports. Each
4276
4793
  * codec owns its wire shape: CLI content blocks and Chat Completions
4277
4794
  * reasoning/tool calls differ beyond the image envelope.
@@ -4279,6 +4796,16 @@ async function imageToCommandCode(ref, readImage) {
4279
4796
  async function convertMessages(messages, readImage, codec) {
4280
4797
  const out = [];
4281
4798
  const { ids: paired, names: toolNames } = pairedToolCalls(messages);
4799
+ if (readImage?.prepare) {
4800
+ const refs = [];
4801
+ for (const message of messages) {
4802
+ const result = toolResultOf(message);
4803
+ if (message.role === "user" && !result) {
4804
+ for (const block of message.content) if (block.type === "image") refs.push(block.attachment);
4805
+ } else if (result && paired.has(result.toolCallId)) refs.push(...toolResultMedia(result).images);
4806
+ }
4807
+ await readImage.prepare(refs);
4808
+ }
4282
4809
  const wireIds = wireToolCallIds(paired);
4283
4810
  const pendingImages = [];
4284
4811
  const flushPendingImages = () => {
@@ -4314,8 +4841,8 @@ async function convertMessages(messages, readImage, codec) {
4314
4841
  if (!result || !paired.has(result.toolCallId)) continue;
4315
4842
  const media = toolResultMedia(result);
4316
4843
  const wireId = wireIds.get(result.toolCallId) ?? result.toolCallId;
4317
- out.push(codec.encodeTool(result, media, wireId, toolNames.get(result.toolCallId) || "unknown"));
4318
- if (media.images.length > 0) {
4844
+ out.push(await codec.encodeTool(result, media, wireId, toolNames.get(result.toolCallId) || "unknown", readImage));
4845
+ if (!codec.carriesToolResultMedia && media.images.length > 0) {
4319
4846
  const carried = [{
4320
4847
  type: "text",
4321
4848
  text: toolResultImageNote(media, wireId)
@@ -4328,6 +4855,7 @@ async function convertMessages(messages, readImage, codec) {
4328
4855
  return out;
4329
4856
  }
4330
4857
  const ccMessageCodec = {
4858
+ carriesToolResultMedia: false,
4331
4859
  encodeImage: imageToCommandCode,
4332
4860
  encodeUser: (parts) => ({
4333
4861
  role: "user",
@@ -4395,6 +4923,7 @@ async function imageToOpenAI(ref, readImage) {
4395
4923
  * replayed (same policy as the CLI path).
4396
4924
  */
4397
4925
  const openAiMessageCodec = {
4926
+ carriesToolResultMedia: false,
4398
4927
  encodeImage: imageToOpenAI,
4399
4928
  encodeUser: (parts) => {
4400
4929
  return !parts.some((part) => part.type === "image_url") && parts.length === 1 ? {
@@ -4434,6 +4963,119 @@ const openAiMessageCodec = {
4434
4963
  async function messagesToOpenAI(messages, readImage) {
4435
4964
  return convertMessages(messages, readImage, openAiMessageCodec);
4436
4965
  }
4966
+ /**
4967
+ * Convert one image reference to the Messages wire shape. Unlike the other two
4968
+ * transports this one keeps the native media type rather than a data URL, and
4969
+ * it may be used INSIDE a tool result — `tool_result.content` accepts image
4970
+ * blocks, which is why this codec sets `carriesToolResultMedia`.
4971
+ */
4972
+ async function imageToMessages(ref, readImage) {
4973
+ const version = await readImage(ref);
4974
+ return {
4975
+ type: "image",
4976
+ source: {
4977
+ type: "base64",
4978
+ media_type: version.mediaType,
4979
+ data: Buffer.from(version.data).toString("base64")
4980
+ }
4981
+ };
4982
+ }
4983
+ /** A tool call's `arguments` is a raw JSON string in the harness, an object here. */
4984
+ function parseToolArguments(raw) {
4985
+ try {
4986
+ const parsed = JSON.parse(raw);
4987
+ return isRecord(parsed) ? parsed : {};
4988
+ } catch {
4989
+ return {};
4990
+ }
4991
+ }
4992
+ /**
4993
+ * Convert harness messages to the Anthropic Messages request shape.
4994
+ *
4995
+ * Differences from the other two transports, all measured against the live
4996
+ * endpoint on 2026-09-29:
4997
+ *
4998
+ * - Content is always a block array; there is no string shorthand, so a
4999
+ * single-text user turn is still `{ role: 'user', content: [{type:'text'…}] }`.
5000
+ * - Tool results live in a `user` turn as `tool_result` blocks (not a
5001
+ * `role: 'tool'` message) and may carry images natively, so no image is
5002
+ * split into a separate user message here.
5003
+ * - A replayed `thinking` block is only valid WITH its `signature`: the
5004
+ * endpoint answers `thinking.signature: Field required` without it, and
5005
+ * `each thinking block must contain thinking` when the text is empty and
5006
+ * the signature is blank. A reasoning block with no recorded signature is
5007
+ * therefore dropped rather than sent — replaying it would fail the request,
5008
+ * while omitting it is accepted (verified 200 both ways).
5009
+ * - The conversation must end with a user turn; the endpoint refuses an
5010
+ * assistant prefill (`This model does not support assistant message
5011
+ * prefill`), which `convertMessages` already guarantees for a tool loop.
5012
+ */
5013
+ const messagesMessageCodec = {
5014
+ carriesToolResultMedia: true,
5015
+ encodeImage: imageToMessages,
5016
+ encodeUser: (parts) => ({
5017
+ role: "user",
5018
+ content: parts
5019
+ }),
5020
+ encodeAssistant: (message, paired, wireIds) => {
5021
+ const parts = [];
5022
+ let position = 0;
5023
+ for (const block of message.content) {
5024
+ if (block.type === "text") {
5025
+ parts.push({
5026
+ type: "text",
5027
+ text: block.text
5028
+ });
5029
+ position++;
5030
+ continue;
5031
+ }
5032
+ if (block.type === "reasoning") {
5033
+ const signature = messagesSignatureAt(message, position);
5034
+ if (signature !== void 0) parts.push({
5035
+ type: "thinking",
5036
+ thinking: block.text,
5037
+ signature
5038
+ });
5039
+ position++;
5040
+ continue;
5041
+ }
5042
+ if (block.type === "tool-call" && paired.has(block.id)) {
5043
+ parts.push({
5044
+ type: "tool_use",
5045
+ id: wireIds.get(block.id) ?? block.id,
5046
+ name: block.name,
5047
+ input: parseToolArguments(block.arguments)
5048
+ });
5049
+ position++;
5050
+ }
5051
+ }
5052
+ return parts.length > 0 ? {
5053
+ role: "assistant",
5054
+ content: parts
5055
+ } : void 0;
5056
+ },
5057
+ encodeTool: async (result, media, wireId, _toolName, readImage) => {
5058
+ const content = [];
5059
+ if (readImage) for (const attachment of media.images) content.push(await imageToMessages(attachment, readImage));
5060
+ content.push({
5061
+ type: "text",
5062
+ text: toolResultTextForWire(media)
5063
+ });
5064
+ const toolResult = {
5065
+ type: "tool_result",
5066
+ tool_use_id: wireId,
5067
+ content
5068
+ };
5069
+ if (result.isError) toolResult.is_error = true;
5070
+ return {
5071
+ role: "user",
5072
+ content: [toolResult]
5073
+ };
5074
+ }
5075
+ };
5076
+ async function messagesToMessages(messages, readImage) {
5077
+ return convertMessages(messages, readImage, messagesMessageCodec);
5078
+ }
4437
5079
  /** Account endpoints fetched by one `getUsage()` run (see the classification there). */
4438
5080
  const USAGE_ENDPOINT_COUNT = 4;
4439
5081
  /**
@@ -4561,6 +5203,50 @@ function cliSystem(systemText) {
4561
5203
  cache_control: { type: "ephemeral" }
4562
5204
  }];
4563
5205
  }
5206
+ /**
5207
+ * The Anthropic ephemeral cache marker, shared by every breakpoint this route
5208
+ * sets. One object is reused across positions because it is only ever
5209
+ * serialized, never mutated.
5210
+ *
5211
+ * No `ttl` is sent: the default 5-minute entry covers a continuously advancing
5212
+ * session, and `ttl: "1h"` is a separate billable tier whose read/write prices
5213
+ * are identical — it would only lengthen survival, not reduce cost.
5214
+ */
5215
+ const EPHEMERAL_CACHE_CONTROL = { type: "ephemeral" };
5216
+ /**
5217
+ * Content-block types that may carry the rolling breakpoint. Matches what the
5218
+ * official Command Code provider for pi marks through
5219
+ * `@earendil-works/pi-ai`'s `anthropic-messages` transport (its own list also
5220
+ * holds pi-internal `tool_addition` / `tool_removal`, which this route never
5221
+ * emits). Assistant-side blocks are deliberately excluded: a breakpoint on a
5222
+ * `thinking` or `tool_use` block caches a prefix the next turn invalidates.
5223
+ */
5224
+ const CACHEABLE_BLOCK_TYPES = /* @__PURE__ */ new Set([
5225
+ "text",
5226
+ "image",
5227
+ "tool_result"
5228
+ ]);
5229
+ /**
5230
+ * Mark the last user turn as the rolling cache breakpoint.
5231
+ *
5232
+ * Anthropic caching is explicit — a prefix is stored only when a
5233
+ * `cache_control` marker names it — so an unmarked route re-prefills the whole
5234
+ * replayed history at the full input price on every turn. Measured cost of that
5235
+ * omission on a 10-turn session: $0.5648, all of it at the undiscounted
5236
+ * $2/M input rate, against $0.1869 with the three breakpoints this route sets.
5237
+ *
5238
+ * The final user message is the rolling boundary: DSH resends the whole
5239
+ * history each turn, so a marker here grows with the conversation, whereas a
5240
+ * fixed `system`-only marker would leave every later turn's history
5241
+ * uncached. {@link messagesMessageCodec} renders both user input and tool
5242
+ * results as `role: 'user'`, so this covers a tool loop's trailing turn too.
5243
+ */
5244
+ function markLastUserCacheControl(messages) {
5245
+ const last = messages[messages.length - 1];
5246
+ if (!isRecord(last) || last.role !== "user" || !Array.isArray(last.content)) return;
5247
+ const lastBlock = last.content[last.content.length - 1];
5248
+ if (isRecord(lastBlock) && typeof lastBlock.type === "string" && CACHEABLE_BLOCK_TYPES.has(lastBlock.type)) lastBlock.cache_control = EPHEMERAL_CACHE_CONTROL;
5249
+ }
4564
5250
  /** Build the legacy CLI (`/alpha/generate`) request body for one call. */
4565
5251
  async function buildCliBody(options, connection, facts) {
4566
5252
  return {
@@ -4621,6 +5307,62 @@ async function buildOpenAIBody(options, facts) {
4621
5307
  };
4622
5308
  }
4623
5309
  /**
5310
+ * Build the Anthropic Messages request body for one call.
5311
+ *
5312
+ * Measured deviations from DeepSeek's own Messages API (the shape
5313
+ * `dsh-llm-deepseek` implements), all confirmed on 2026-09-29:
5314
+ *
5315
+ * - `thinking` accepts ONLY `{ type: 'adaptive' }` here. `enabled` and
5316
+ * `disabled` are refused with `"thinking.type.enabled" is not supported for
5317
+ * this model. Use "thinking.type.adaptive" and "output_config.effort" to
5318
+ * control thinking behavior.`, and a `between_tools` value fails validation
5319
+ * outright. The model reasons on its own; this switch only admits it.
5320
+ * - Reasoning strength travels in `output_config.effort`, whose accepted
5321
+ * values are `low | medium | high | xhigh | max` — there is no `none`, and
5322
+ * `xhigh` is absent from DeepSeek's own type.
5323
+ * - `stop_sequences` is a real field here, but this adapter still rejects
5324
+ * `GenerateOptions.stop` in `stream()` because Command Code documents no
5325
+ * equivalent for the other two transports.
5326
+ * - **`temperature` is never sent.** Adaptive thinking constrains it to 1:
5327
+ * `` `temperature` may only be set to 1 when thinking is enabled or in
5328
+ * adaptive mode `` (measured 2026-09-29 — every request carrying the other
5329
+ * transports' 0.3 default was refused with HTTP 400). Omitting the field
5330
+ * is the only way to honour both the thinking switch and a caller that
5331
+ * asked for a temperature, so Claude loses sampling control on this route
5332
+ * because the endpoint, not this adapter, removed it.
5333
+ * - `system` travels as a one-block ARRAY carrying `cache_control`, not as the
5334
+ * bare string the other two transports send. Anthropic's caching is opt-in
5335
+ * per block, and this route is the only Claude path, so the marker is what
5336
+ * makes a session's replayed history bill at the cache-read rate instead of
5337
+ * the full input rate. See {@link EPHEMERAL_CACHE_CONTROL}.
5338
+ */
5339
+ async function buildMessagesBody(options, facts) {
5340
+ const body = {
5341
+ model: options.model,
5342
+ max_tokens: facts.maxTokens,
5343
+ stream: true,
5344
+ messages: await messagesToMessages(options.messages, facts.readImage)
5345
+ };
5346
+ if (facts.selectableEffort) body.thinking = { type: "adaptive" };
5347
+ if (facts.systemText) body.system = [{
5348
+ type: "text",
5349
+ text: facts.systemText,
5350
+ cache_control: EPHEMERAL_CACHE_CONTROL
5351
+ }];
5352
+ if (facts.reasoningEffort) body.output_config = { effort: facts.reasoningEffort };
5353
+ const tools = (options.tools ?? []).map((tool) => ({
5354
+ name: tool.name,
5355
+ description: tool.description,
5356
+ input_schema: toolParametersSchema(tool.parameters)
5357
+ }));
5358
+ if (tools.length > 0) {
5359
+ tools[tools.length - 1].cache_control = EPHEMERAL_CACHE_CONTROL;
5360
+ body.tools = tools;
5361
+ }
5362
+ markLastUserCacheControl(body.messages);
5363
+ return body;
5364
+ }
5365
+ /**
4624
5366
  * Estimate the text the selected transport actually sends. JSON framing and
4625
5367
  * punctuation each get a token; ordinary ASCII words get one, with an extra
4626
5368
  * allowance for long identifiers. UTF-8 bytes / 4 tracks the measured CJK
@@ -4656,6 +5398,17 @@ function requestContextBudget(protocol, body, messages, model, contextWindow, re
4656
5398
  };
4657
5399
  }
4658
5400
  /**
5401
+ * One connect attempt's inputs: everything `connectGenerate` needs beyond
5402
+ * the per-attempt key and protocol. Passed explicitly (not closed over) so
5403
+ * the rotation loop's mutation of `protocol`/`body` stays visible at the
5404
+ * call site.
5405
+ */
5406
+ /** The generate endpoint one protocol posts to. */
5407
+ function protocolEndpoint(protocol, apiBase) {
5408
+ if (protocol === "cli") return `${apiBase}/alpha/generate`;
5409
+ return protocol === "messages" ? `${apiBase}/provider/v1/messages` : `${apiBase}/provider/v1/chat/completions`;
5410
+ }
5411
+ /**
4659
5412
  * One pre-stream connect attempt: POST the body and wait for response
4660
5413
  * headers only (`requestTimeoutMs` must never bound the body stream). Returns
4661
5414
  * the live response plus its cleanup, or the rejection facts for the rotation
@@ -4663,11 +5416,11 @@ function requestContextBudget(protocol, body, messages, model, contextWindow, re
4663
5416
  * on success the caller-abort listener outlives the connect phase (it aborts a
4664
5417
  * stalled body read), so the streaming tail calls cleanup.
4665
5418
  */
4666
- async function connectGenerate(deps, key, protocol, body) {
5419
+ async function connectGenerate(deps, key, protocol, serializedBody) {
4667
5420
  const { options, connection, fetchImpl } = deps;
4668
5421
  const connectAbort = new AbortController();
4669
5422
  let connectTimedOut = false;
4670
- const endpoint = protocol === "cli" ? `${connection.apiBase}/alpha/generate` : `${connection.apiBase}/provider/v1/chat/completions`;
5423
+ const endpoint = protocolEndpoint(protocol, connection.apiBase);
4671
5424
  const connectTimer = setTimeout(() => {
4672
5425
  connectTimedOut = true;
4673
5426
  connectAbort.abort(new DOMException(`Command Code API request to ${endpoint} did not respond within ${connection.requestTimeoutMs}ms`, "TimeoutError"));
@@ -4685,26 +5438,25 @@ async function connectGenerate(deps, key, protocol, body) {
4685
5438
  };
4686
5439
  let response;
4687
5440
  const zdr = connection.zdr === true;
4688
- const headers = protocol === "cli" ? {
5441
+ const sharedHeaders = {
4689
5442
  "Content-Type": "application/json",
4690
5443
  ...IDENTITY_ENCODING_HEADER,
4691
5444
  Authorization: `Bearer ${key}`,
5445
+ ...zdr ? { "x-cmd-zdr": "1" } : {},
5446
+ ...attributionHeaders()
5447
+ };
5448
+ const headers = protocol === "cli" ? {
5449
+ ...sharedHeaders,
4692
5450
  "x-command-code-version": COMMAND_CODE_CLI_VERSION,
4693
5451
  "x-cli-environment": "production",
4694
5452
  "x-project-slug": projectSlugFromPath(connection.workingDir),
4695
5453
  "x-taste-learning": "true",
4696
- "x-co-flag": "false",
4697
- ...zdr ? { "x-cmd-zdr": "1" } : {},
4698
- ...attributionHeaders()
5454
+ "x-co-flag": "false"
4699
5455
  } : {
4700
- "Content-Type": "application/json",
4701
- ...IDENTITY_ENCODING_HEADER,
4702
- Authorization: `Bearer ${key}`,
4703
- Accept: "text/event-stream",
4704
- ...zdr ? { "x-cmd-zdr": "1" } : {},
4705
- ...attributionHeaders()
5456
+ ...sharedHeaders,
5457
+ Accept: "text/event-stream"
4706
5458
  };
4707
- const serializedBody = JSON.stringify(body);
5459
+ const finishHeaders = deps.timing.attempt(protocol, Buffer.byteLength(serializedBody));
4708
5460
  try {
4709
5461
  response = await fetchImpl(endpoint, {
4710
5462
  method: "POST",
@@ -4712,8 +5464,10 @@ async function connectGenerate(deps, key, protocol, body) {
4712
5464
  body: serializedBody,
4713
5465
  signal: connectAbort.signal
4714
5466
  });
5467
+ finishHeaders(response.status);
4715
5468
  clearTimeout(connectTimer);
4716
5469
  } catch (error) {
5470
+ finishHeaders();
4717
5471
  cleanup();
4718
5472
  if (options.signal?.aborted) throw error;
4719
5473
  if (connectTimedOut || error instanceof DOMException && error.name === "TimeoutError") throw headersTimeoutError({
@@ -4755,7 +5509,9 @@ function createBlockAssembler() {
4755
5509
  sawContent: false,
4756
5510
  finishReason: void 0,
4757
5511
  cliCacheWriteTokens: void 0,
4758
- openAiToolCalls: []
5512
+ openAiToolCalls: [],
5513
+ messagesBlocks: /* @__PURE__ */ new Map(),
5514
+ replayBlocks: []
4759
5515
  };
4760
5516
  }
4761
5517
  function* closeText(asm) {
@@ -4816,9 +5572,10 @@ function* emitOpenAiToolCalls(asm) {
4816
5572
  }
4817
5573
  asm.openAiToolCalls.length = 0;
4818
5574
  }
4819
- var CommandCodeAdapter = class extends LlmAdapter {
5575
+ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter {
4820
5576
  deps;
4821
5577
  catalog = [];
5578
+ catalogState;
4822
5579
  fetchImpl;
4823
5580
  resolveAttachments;
4824
5581
  /**
@@ -4829,13 +5586,25 @@ var CommandCodeAdapter = class extends LlmAdapter {
4829
5586
  billingAccess = /* @__PURE__ */ new Map();
4830
5587
  billingAccessInflight = /* @__PURE__ */ new Map();
4831
5588
  protocolCache = /* @__PURE__ */ new Map();
5589
+ /** Namespace account-derived facts by gateway without exposing the key. */
5590
+ accountCacheKey(apiBase, apiKey) {
5591
+ return JSON.stringify([apiBase, apiKey]);
5592
+ }
4832
5593
  /**
4833
5594
  * Consecutive header timeouts for the last request shape, counted ACROSS
4834
5595
  * `stream()` calls: dsh-llm-retry re-invokes this same instance, so this is
4835
- * the only place a streak can live. One slot rather than a map is the point —
4836
- * the claim is "consecutive", and a single record cannot grow.
5596
+ * the only place a streak can live. Keyed by `sessionId` (not by `agent`:
5597
+ * `GenerateOptions` carries no agent reference, only `sessionId`) so
5598
+ * concurrent sessions on this one shared adapter instance cannot clear or
5599
+ * inflate each other's count. A call with no `sessionId` (a one-shot,
5600
+ * session-less caller) falls back to {@link GLOBAL_HEADER_TIMEOUT_KEY},
5601
+ * matching the old single-slot behavior for exactly that case. Bounded by
5602
+ * {@link MAX_HEADER_TIMEOUT_STREAK_ENTRIES} with FIFO eviction (`Map`
5603
+ * preserves insertion order) since the key space is per-session rather than
5604
+ * per-account/model, so it is not naturally small like `protocolCache`.
4837
5605
  */
4838
- headerTimeoutStreak;
5606
+ headerTimeoutStreaks = /* @__PURE__ */ new Map();
5607
+ static MAX_HEADER_TIMEOUT_STREAK_ENTRIES = 200;
4839
5608
  constructor(deps) {
4840
5609
  super();
4841
5610
  this.deps = deps;
@@ -4848,25 +5617,31 @@ var CommandCodeAdapter = class extends LlmAdapter {
4848
5617
  * payload eventually leaves the retryable whitelist.
4849
5618
  *
4850
5619
  * `providerRetryPolicy()` cannot express this itself — dsh-llm's
4851
- * `NormalRetryPolicyConfig` carries a single scalar `maxRetries` next to the
4852
- * code list, so there is no per-code limit to lower. Escalating from here is
4853
- * the only way to keep `RATE_LIMIT` / `SERVER` / `TRANSPORT` retryable (each
4854
- * carries a real "try again later" signal) while stopping the one code that,
4855
- * repeated unchanged, only burns the user's session.
5620
+ * `NormalRetryPolicyConfig` carries one `maxRetries` for every listed code.
5621
+ * Escalating here stops repeated identical header timeouts with a specific
5622
+ * diagnosis; the agent listener separately caps ordinary transient errors.
4856
5623
  *
4857
5624
  * @returns the error to surface: unchanged for any other failure, the
4858
5625
  * original timeout below the threshold, and a terminal one at it.
4859
5626
  */
4860
- gradeHeaderTimeout(error, protocol, body, timeoutMs) {
5627
+ gradeHeaderTimeout(error, protocol, body, timeoutMs, sessionId) {
4861
5628
  if (!(error instanceof LlmError && error.code === "TIMEOUT")) return error;
5629
+ const key = sessionId ?? GLOBAL_HEADER_TIMEOUT_KEY;
4862
5630
  const fingerprint = `${protocol}:${fingerprintValue(body).sha256}`;
4863
- const count = this.headerTimeoutStreak?.fingerprint === fingerprint ? this.headerTimeoutStreak.count + 1 : 1;
4864
- this.headerTimeoutStreak = {
5631
+ const previous = this.headerTimeoutStreaks.get(key);
5632
+ const count = previous?.fingerprint === fingerprint ? previous.count + 1 : 1;
5633
+ if (previous === void 0) {
5634
+ if (this.headerTimeoutStreaks.size >= CommandCodeAdapter.MAX_HEADER_TIMEOUT_STREAK_ENTRIES) {
5635
+ const oldest = this.headerTimeoutStreaks.keys().next().value;
5636
+ if (oldest !== void 0) this.headerTimeoutStreaks.delete(oldest);
5637
+ }
5638
+ } else this.headerTimeoutStreaks.delete(key);
5639
+ this.headerTimeoutStreaks.set(key, {
4865
5640
  fingerprint,
4866
5641
  count
4867
- };
5642
+ });
4868
5643
  if (count < MAX_CONSECUTIVE_HEADER_TIMEOUTS) return error;
4869
- this.headerTimeoutStreak = void 0;
5644
+ this.headerTimeoutStreaks.delete(key);
4870
5645
  return bilingual("PROVIDER_HTTP_ERROR", `Command Code API stopped retrying: the gateway returned no response headers on ${count} consecutive attempts at the same request, each after the full ${timeoutMs}ms budget, so this is not a network blip — it is provider queueing, a Go-plan capacity limit, or a network/proxy path problem. Resending the identical payload again is unlikely to help; wait for the provider to recover, or switch model or account.`, `Command Code API 已停止重试:同一请求连续 ${count} 次在 ${timeoutMs} 毫秒内都没有收到任何响应头,这不是网络抖动,而是服务商排队、Go 套餐容量限制或网络/代理链路问题。继续原样重发同一个请求通常无济于事——可以等服务商恢复,或换模型/换账号。`);
4871
5646
  }
4872
5647
  /**
@@ -4883,12 +5658,11 @@ var CommandCodeAdapter = class extends LlmAdapter {
4883
5658
  };
4884
5659
  }
4885
5660
  /**
4886
- * Near-unbounded retry for transient failures only (`mode: 'normal'` with
4887
- * an explicit 1000-attempt cap — opencode-style persistence without the
4888
- * unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
4889
- * `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
4890
- * and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
4891
- * recovers in-session instead of failing after two tries. Permanent failures
5661
+ * The official executor owns backoff and provider reset waits. Its high
5662
+ * route-wide cap lets a confirmed `RATE_LIMIT` window recover in-session;
5663
+ * our request-error listener limits `SERVER`/`TIMEOUT`/`EMPTY_RESPONSE`/
5664
+ * `THROTTLED` to three retries and `TRANSPORT` to its separate budget.
5665
+ * Waits double from 500 ms and cap at 15 minutes (±10% jitter). Permanent failures
4892
5666
  * (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`, plan
4893
5667
  * rejections) are absent from the whitelist and surface immediately instead
4894
5668
  * of looping. Waits the pool/adapter attach as `providerRetryAfterMs` are
@@ -4906,6 +5680,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
4906
5680
  retryableCodes: [
4907
5681
  "EMPTY_RESPONSE",
4908
5682
  "RATE_LIMIT",
5683
+ THROTTLED_CODE,
4909
5684
  "SERVER",
4910
5685
  "TIMEOUT",
4911
5686
  "TRANSPORT"
@@ -4932,23 +5707,61 @@ var CommandCodeAdapter = class extends LlmAdapter {
4932
5707
  const imageCapable = KNOWN_IMAGE_MODELS.has(model);
4933
5708
  return { priceImages: (images) => images.map((image) => priceRequestImage(image, model, imageCapable)) };
4934
5709
  }
4935
- /** Refresh the catalog (live fetch, cache fallback) and return it. */
5710
+ /** 地址或缓存文件变化时丢弃旧目录,防止旧网关的路由声明影响新请求。 */
5711
+ currentCatalogState(connection = this.deps.options()) {
5712
+ const { apiBase, modelsCachePath } = connection;
5713
+ const source = JSON.stringify([apiBase, modelsCachePath]);
5714
+ if (this.catalogState?.source !== source) {
5715
+ this.catalog = [];
5716
+ this.catalogState = {
5717
+ source,
5718
+ expiresAt: 0
5719
+ };
5720
+ }
5721
+ return this.catalogState;
5722
+ }
5723
+ /** 共享一次刷新;调用者取消只结束自己的等待,不取消其他调用者的请求。 */
4936
5724
  async loadCatalog(signal) {
4937
- const { apiBase, modelsCachePath } = this.deps.options();
4938
- try {
4939
- const { status, record } = await this.fetchJson(`${apiBase}/provider/v1/models`, {
4940
- accept: "application/json",
4941
- ...IDENTITY_ENCODING_HEADER,
4942
- ...attributionHeaders()
4943
- }, MODELS_TIMEOUT_MS, signal);
4944
- if (status < 200 || status >= 300) throw new Error(`models endpoint returned ${status}`);
4945
- this.catalog = parseCatalogResponse(record);
4946
- await writeModelsCache(modelsCachePath, this.catalog).catch(() => void 0);
4947
- } catch (error) {
4948
- if (signal?.aborted) throw error;
4949
- this.catalog = await readModelsCache(modelsCachePath).catch(() => this.catalog);
5725
+ signal?.throwIfAborted();
5726
+ const connection = this.deps.options();
5727
+ const state = this.currentCatalogState(connection);
5728
+ if (Date.now() < state.expiresAt) return this.catalog;
5729
+ const { apiBase, modelsCachePath } = connection;
5730
+ if (!state.inflight) {
5731
+ const fallback = this.catalog;
5732
+ state.inflight = (async () => {
5733
+ let models;
5734
+ let ttl = MODEL_CATALOG_TTL_MS;
5735
+ try {
5736
+ const { status, record } = await this.fetchJson(`${apiBase}/provider/v1/models`, {
5737
+ accept: "application/json",
5738
+ ...IDENTITY_ENCODING_HEADER,
5739
+ ...attributionHeaders()
5740
+ }, MODELS_TIMEOUT_MS);
5741
+ if (status < 200 || status >= 300) throw new Error(`models endpoint returned ${status}`);
5742
+ models = parseCatalogResponse(record, apiBase);
5743
+ if (this.catalogState === state) await writeModelsCache(modelsCachePath, apiBase, models, () => this.catalogState === state).catch(() => void 0);
5744
+ } catch {
5745
+ ttl = MODEL_CATALOG_RETRY_MS;
5746
+ models = await readModelsCache(modelsCachePath, apiBase).catch(() => fallback);
5747
+ }
5748
+ if (this.catalogState === state) {
5749
+ this.catalog = models;
5750
+ state.expiresAt = Date.now() + ttl;
5751
+ }
5752
+ return models;
5753
+ })().finally(() => {
5754
+ delete state.inflight;
5755
+ });
4950
5756
  }
4951
- return this.catalog;
5757
+ if (!signal) return state.inflight;
5758
+ const inflight = state.inflight;
5759
+ return new Promise((resolve, reject) => {
5760
+ const abort = () => reject(signal.reason);
5761
+ signal.addEventListener("abort", abort, { once: true });
5762
+ inflight.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
5763
+ if (signal.aborted) abort();
5764
+ });
4952
5765
  }
4953
5766
  async listModels(provider, opts) {
4954
5767
  const catalog = await this.loadCatalog();
@@ -4999,6 +5812,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
4999
5812
  return allowanceTierForWeight(highest);
5000
5813
  }
5001
5814
  async resolveModel(provider, model, signal) {
5815
+ this.currentCatalogState();
5002
5816
  const entry = this.catalog.find((m) => m.id === model) ?? (await this.loadCatalog(signal)).find((m) => m.id === model);
5003
5817
  const efforts = KNOWN_EFFORTS[model];
5004
5818
  const vision = KNOWN_IMAGE_MODELS.has(model);
@@ -5076,7 +5890,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
5076
5890
  async loadPoolBillingAccess() {
5077
5891
  const keys = await this.poolAccountKeys();
5078
5892
  if (keys.length === 0) return void 0;
5079
- return Promise.all(keys.map((key) => this.loadBillingAccessForKey(key)));
5893
+ const apiBase = this.deps.options().apiBase;
5894
+ return Promise.all(keys.map((key) => this.loadBillingAccessForKey(apiBase, key)));
5080
5895
  }
5081
5896
  /**
5082
5897
  * The API keys the picker's plan filter must consult: every account the host
@@ -5100,21 +5915,22 @@ var CommandCodeAdapter = class extends LlmAdapter {
5100
5915
  * shared across concurrent callers. `undefined` means "unknown — show
5101
5916
  * everything" (fail-open).
5102
5917
  */
5103
- async loadBillingAccessForKey(apiKey) {
5104
- const cached = this.billingAccess.get(apiKey);
5918
+ async loadBillingAccessForKey(apiBase, apiKey) {
5919
+ const cacheKey = this.accountCacheKey(apiBase, apiKey);
5920
+ const cached = this.billingAccess.get(cacheKey);
5105
5921
  if (cached !== void 0 && Date.now() - cached.at < 3e5) return cached.value;
5106
- const existing = this.billingAccessInflight.get(apiKey);
5922
+ const existing = this.billingAccessInflight.get(cacheKey);
5107
5923
  if (existing !== void 0) return existing;
5108
- const inflight = this.fetchBillingAccess(apiKey).then((value) => {
5109
- this.billingAccess.set(apiKey, {
5924
+ const inflight = this.fetchBillingAccess(apiBase, apiKey).then((value) => {
5925
+ this.billingAccess.set(cacheKey, {
5110
5926
  value,
5111
5927
  at: Date.now()
5112
5928
  });
5113
5929
  return value;
5114
5930
  }).finally(() => {
5115
- this.billingAccessInflight.delete(apiKey);
5931
+ this.billingAccessInflight.delete(cacheKey);
5116
5932
  });
5117
- this.billingAccessInflight.set(apiKey, inflight);
5933
+ this.billingAccessInflight.set(cacheKey, inflight);
5118
5934
  return inflight;
5119
5935
  }
5120
5936
  /**
@@ -5126,12 +5942,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
5126
5942
  * fallback (the CLI stamps plan identity from it too). Any failure resolves
5127
5943
  * to `undefined` (fail-open) rather than breaking the picker.
5128
5944
  */
5129
- async fetchBillingAccess(apiKey) {
5945
+ async fetchBillingAccess(apiBase, apiKey) {
5130
5946
  try {
5131
- const connection = this.deps.options();
5132
5947
  const headers = await this.accountHeaders(apiKey);
5133
- const base = connection.apiBase;
5134
- const getJson = async (path) => (await this.fetchJson(`${base}${path}`, headers)).record;
5948
+ const getJson = async (path) => (await this.fetchJson(`${apiBase}${path}`, headers)).record;
5135
5949
  const whoami = await getJson("/alpha/whoami");
5136
5950
  const orgData = whoami && isRecord(whoami.org) ? whoami.org : void 0;
5137
5951
  const orgId = orgData === void 0 ? void 0 : stringValue(orgData.id);
@@ -5153,50 +5967,65 @@ var CommandCodeAdapter = class extends LlmAdapter {
5153
5967
  }
5154
5968
  }
5155
5969
  /** Fresh cached billing tier weight for a key, or undefined when not known. */
5156
- cachedBillingTierWeight(apiKey) {
5157
- const hit = this.billingAccess.get(apiKey);
5970
+ cachedBillingTierWeight(apiBase, apiKey) {
5971
+ const hit = this.billingAccess.get(this.accountCacheKey(apiBase, apiKey));
5158
5972
  if (hit === void 0 || Date.now() - hit.at >= 3e5) return void 0;
5159
5973
  return hit.value?.tierWeight;
5160
5974
  }
5161
5975
  /** Cached protocol decision for a key, or undefined when expired/unknown. */
5162
- cachedProtocolUseCli(apiKey) {
5163
- const hit = this.protocolCache.get(apiKey);
5976
+ cachedProtocol(apiBase, apiKey) {
5977
+ const hit = this.protocolCache.get(this.accountCacheKey(apiBase, apiKey));
5164
5978
  if (hit === void 0 || Date.now() - hit.at >= 9e5) return void 0;
5165
- return hit.useCli;
5979
+ return hit.protocol;
5166
5980
  }
5167
- rememberProtocol(apiKey, useCli) {
5168
- this.protocolCache.set(apiKey, {
5169
- useCli,
5981
+ rememberProtocol(apiBase, apiKey, protocol) {
5982
+ this.protocolCache.set(this.accountCacheKey(apiBase, apiKey), {
5983
+ protocol,
5170
5984
  at: Date.now()
5171
5985
  });
5172
5986
  }
5173
5987
  /**
5174
- * Choose the initial protocol for one request. A fresh protocol cache entry
5175
- * wins; otherwise a cached (not network-fetched) billing tier of Go is
5176
- * treated as CLI-only. Unknown accounts default to Provider API and fall
5177
- * back only after an `upgrade_required` rejection.
5988
+ * True when the catalog routes `model` to `/provider/v1/messages`.
5989
+ *
5990
+ * The catalog's own `supported_endpoints` is authoritative (it is what
5991
+ * upstream publishes per model); `requiresMessagesEndpoint()` — the
5992
+ * `claude-*` prefix rule — is the fallback for a catalog entry that carries
5993
+ * no route list, which is the shape every pre-1.55 cache file has.
5994
+ */
5995
+ routesToMessages(model) {
5996
+ const entry = this.catalog.find((candidate) => candidate.id === model);
5997
+ if (entry !== void 0 && entry.supportedEndpoints.length > 0) return entry.supportedEndpoints.includes("/messages");
5998
+ return requiresMessagesEndpoint(model);
5999
+ }
6000
+ /**
6001
+ * Choose the initial protocol for one request.
5178
6002
  *
5179
- * Models the Provider API serves ONLY through `/provider/v1/messages` (the
5180
- * Claude family — `requiresMessagesEndpoint()`) always take the CLI
5181
- * transport, on any tier and even under a forced `'openai'` preference:
5182
- * that option is documented as "prefer the Provider API, still fall back",
5183
- * and the 400 these models answer with is not a preference to be honoured
5184
- * but a hard refusal. `/alpha/generate` routes every one of them, so this
5185
- * costs nothing.
6003
+ * Models the Provider API serves only through `/provider/v1/messages` (the
6004
+ * Claude family) take the Messages transport on any tier and even under a
6005
+ * forced `'openai'` preference: that option is documented as "prefer the
6006
+ * Provider API, still fall back", and the 400 these models answer with on
6007
+ * Chat Completions (`Model "<id>" must be called via /provider/v1/messages`)
6008
+ * is a hard refusal, not a preference to honour.
5186
6009
  *
5187
6010
  * That decision is deliberately NOT written to `protocolCache`, which is
5188
- * keyed by API key alone: remembering it would pin the whole ACCOUNT to the
5189
- * CLI transport and drag every other model off it until the entry expired.
6011
+ * keyed by gateway and API key: it depends on the model, not just the account, so
6012
+ * remembering it would pin the whole ACCOUNT to one transport and drag
6013
+ * every other model along (issue #46).
6014
+ *
6015
+ * Otherwise a fresh protocol cache entry wins, then a cached (not
6016
+ * network-fetched) billing tier of Go — the one plan without Provider API
6017
+ * access. Unknown accounts default to Chat Completions and fall back to the
6018
+ * CLI transport only after an `upgrade_required` rejection.
5190
6019
  */
5191
- resolveProtocol(apiKey, model) {
6020
+ resolveProtocol(apiBase, apiKey, model) {
5192
6021
  const forced = this.deps.options().protocol;
5193
6022
  if (forced === "cli") return forced;
5194
- if (requiresMessagesEndpoint(model)) return "cli";
6023
+ if (this.routesToMessages(model)) return "messages";
5195
6024
  if (forced === "openai") return forced;
5196
- const cached = this.cachedProtocolUseCli(apiKey);
5197
- if (cached !== void 0) return cached ? "cli" : "openai";
5198
- if (this.cachedBillingTierWeight(apiKey) === GO_TIER_WEIGHT) {
5199
- this.rememberProtocol(apiKey, true);
6025
+ const cached = this.cachedProtocol(apiBase, apiKey);
6026
+ if (cached !== void 0) return cached;
6027
+ if (this.cachedBillingTierWeight(apiBase, apiKey) === GO_TIER_WEIGHT) {
6028
+ this.rememberProtocol(apiBase, apiKey, "cli");
5200
6029
  return "cli";
5201
6030
  }
5202
6031
  return "openai";
@@ -5336,6 +6165,59 @@ var CommandCodeAdapter = class extends LlmAdapter {
5336
6165
  }
5337
6166
  }
5338
6167
  async *stream(options) {
6168
+ const timing = openRequestTiming(options.model, this.deps.onRequestTiming);
6169
+ let outcome = "cancelled";
6170
+ let errorCode;
6171
+ try {
6172
+ for await (const chunk of this.streamRequest(options, timing)) {
6173
+ if (chunk.type === "text-delta" || chunk.type === "reasoning-delta" || chunk.type === "tool-call-delta") timing.first("firstContentMs");
6174
+ if (chunk.type === "finish") outcome = "finished";
6175
+ yield chunk;
6176
+ }
6177
+ } catch (error) {
6178
+ outcome = options.signal?.aborted ? "aborted" : "error";
6179
+ errorCode = error instanceof LlmError ? error.code : "unknown";
6180
+ throw error;
6181
+ } finally {
6182
+ await timing.close(outcome, errorCode);
6183
+ }
6184
+ }
6185
+ /**
6186
+ * One turn, with at most one output-ceiling downshift.
6187
+ *
6188
+ * The ceiling a model accepts is not published anywhere the request can be
6189
+ * built from (see `modelOutputTokenLimit`), and the endpoint states it only
6190
+ * by refusing. That refusal is worth one retry rather than a failed turn: the
6191
+ * harness's own recovery for it — compact the context — cannot help, because
6192
+ * nothing about the context is wrong, and resending the identical request
6193
+ * would be refused identically (issue #71). Reading the stated ceiling off
6194
+ * the rejection turns one wasted turn into one, for good: it is recorded
6195
+ * process-wide, so the next request for this model is built correctly
6196
+ * without spending the round trip again.
6197
+ *
6198
+ * Only BEFORE the first chunk reaches the host. Once a delta has been
6199
+ * delivered the answer is already partly on screen, and a second request
6200
+ * would duplicate it — so from that point the error stands and says what is
6201
+ * actually wrong. One downshift per turn, and a second refusal surfaces.
6202
+ */
6203
+ async *streamRequest(options, timing) {
6204
+ for (let attempt = 0;; attempt++) {
6205
+ const connection = this.deps.options();
6206
+ let delivered = false;
6207
+ try {
6208
+ for await (const chunk of this.streamAttempt(options, timing, connection)) {
6209
+ delivered = true;
6210
+ yield chunk;
6211
+ }
6212
+ return;
6213
+ } catch (error) {
6214
+ const ceiling = delivered ? void 0 : outputCeilingRefusal(connection.apiBase, options.model, error);
6215
+ if (attempt > 0 || ceiling === void 0) throw error;
6216
+ }
6217
+ }
6218
+ }
6219
+ async *streamAttempt(options, timing, connection) {
6220
+ this.currentCatalogState(connection);
5339
6221
  if (options.stop?.length) throw new LlmError("Command Code adapter does not support stop sequences", "UNSUPPORTED_OPTION");
5340
6222
  const hasImages = options.messages.some(hasImageContent);
5341
6223
  let readImage;
@@ -5343,12 +6225,17 @@ var CommandCodeAdapter = class extends LlmAdapter {
5343
6225
  if (!KNOWN_IMAGE_MODELS.has(options.model)) throw new LlmError(`Command Code model "${options.model}" does not support image input; switch to a Vision-capable model (see the model registry)`, "UNSUPPORTED_CONTENT");
5344
6226
  const attachments = this.resolveAttachments?.();
5345
6227
  if (attachments === void 0) throw new LlmError("Command Code image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
5346
- readImage = (ref) => readRequestImage(attachments, ref);
6228
+ readImage = requestImages(attachments, options.signal, timing);
6229
+ }
6230
+ const finishCredentials = timing.phase("credentials");
6231
+ let apiKey;
6232
+ try {
6233
+ apiKey = await this.deps.resolveApiKey(connection, options.model);
6234
+ } finally {
6235
+ finishCredentials();
5347
6236
  }
5348
- const connection = this.deps.options();
5349
- let apiKey = await this.deps.resolveApiKey(connection, options.model);
5350
6237
  const modelEntry = this.catalog.find((m) => m.id === options.model);
5351
- const modelMax = modelEntry?.maxTokens ?? 131072;
6238
+ const modelMax = Math.min(modelEntry?.maxTokens ?? Infinity, modelOutputTokenLimit(connection.apiBase, options.model));
5352
6239
  const requestedMaxTokens = Math.min(options.maxTokens ?? modelMax, modelMax, DEFAULT_GENERATE_MAX_TOKENS);
5353
6240
  let maxTokens = requestedMaxTokens;
5354
6241
  let contextBudget = {
@@ -5359,23 +6246,30 @@ var CommandCodeAdapter = class extends LlmAdapter {
5359
6246
  const supported = KNOWN_EFFORTS[options.model];
5360
6247
  const reasoningEffort = effort && effort !== "off" && supported?.includes(effort) ? effort : void 0;
5361
6248
  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");
5362
- let protocol = this.resolveProtocol(apiKey, options.model);
6249
+ let protocol = this.resolveProtocol(connection.apiBase, apiKey, options.model);
5363
6250
  let requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0], connection.offloadSeenImagesForCache === true && protocol === "cli" ? options.model : void 0);
5364
6251
  const buildBody = async (target) => {
5365
- const facts = {
5366
- maxTokens: requestedMaxTokens,
5367
- reasoningEffort,
5368
- systemText,
5369
- readImage
5370
- };
5371
- const built = target === "cli" ? await buildCliBody(requestOptions, connection, facts) : await buildOpenAIBody(requestOptions, facts);
5372
- contextBudget = requestContextBudget(target, built, requestOptions.messages, options.model, modelEntry?.contextWindow, requestedMaxTokens);
5373
- maxTokens = contextBudget.maxTokens;
5374
- if (target === "cli") recordOrEmpty(built.params).max_tokens = maxTokens;
5375
- else built.max_tokens = maxTokens;
5376
- return built;
6252
+ const finishBody = timing.phase("body");
6253
+ try {
6254
+ const facts = {
6255
+ maxTokens: requestedMaxTokens,
6256
+ reasoningEffort,
6257
+ selectableEffort: supported !== void 0 && supported.length > 0,
6258
+ systemText,
6259
+ readImage
6260
+ };
6261
+ const built = target === "cli" ? await buildCliBody(requestOptions, connection, facts) : target === "messages" ? await buildMessagesBody(requestOptions, facts) : await buildOpenAIBody(requestOptions, facts);
6262
+ contextBudget = requestContextBudget(target, built, requestOptions.messages, options.model, modelEntry?.contextWindow, requestedMaxTokens);
6263
+ maxTokens = contextBudget.maxTokens;
6264
+ if (target === "cli") recordOrEmpty(built.params).max_tokens = maxTokens;
6265
+ else built.max_tokens = maxTokens;
6266
+ return built;
6267
+ } finally {
6268
+ finishBody();
6269
+ }
5377
6270
  };
5378
6271
  let body = await buildBody(protocol);
6272
+ const serializedBodies = /* @__PURE__ */ new WeakMap();
5379
6273
  const trace = openStreamTrace(`${options.provider}/${options.model}`);
5380
6274
  const tried = /* @__PURE__ */ new Set();
5381
6275
  let attemptNumber = 0;
@@ -5387,15 +6281,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
5387
6281
  options,
5388
6282
  connection,
5389
6283
  fetchImpl: this.fetchImpl,
6284
+ timing,
5390
6285
  onResponse: (response) => {
5391
- this.headerTimeoutStreak = void 0;
6286
+ this.headerTimeoutStreaks.delete(options.sessionId ?? GLOBAL_HEADER_TIMEOUT_KEY);
5392
6287
  responseMetadata = {
5393
6288
  protocol,
5394
6289
  headers: responseIdentifiers(response.headers)
5395
6290
  };
5396
6291
  trace.record("response", {
5397
6292
  protocol,
5398
- endpoint: protocol === "cli" ? `${connection.apiBase}/alpha/generate` : `${connection.apiBase}/provider/v1/chat/completions`,
6293
+ endpoint: protocolEndpoint(protocol, connection.apiBase),
5399
6294
  status: response.status,
5400
6295
  attempt: attemptNumber,
5401
6296
  accountsTried: tried.size,
@@ -5419,15 +6314,26 @@ var CommandCodeAdapter = class extends LlmAdapter {
5419
6314
  sessionId: options.sessionId,
5420
6315
  ...requestFingerprint(protocol, body)
5421
6316
  });
5422
- const attempt = await connectGenerate(connectDeps, apiKey, protocol, body);
6317
+ let serialized = serializedBodies.get(body);
6318
+ if (serialized === void 0) {
6319
+ const finishSerialization = timing.phase("serialization");
6320
+ try {
6321
+ serialized = JSON.stringify(body, (_key, value) => sanitizeSurrogates(value));
6322
+ } finally {
6323
+ finishSerialization();
6324
+ }
6325
+ serializedBodies.set(body, serialized);
6326
+ }
6327
+ const attempt = await connectGenerate(connectDeps, apiKey, protocol, serialized);
5423
6328
  if ("response" in attempt) {
5424
6329
  connected = attempt;
5425
6330
  break;
5426
6331
  }
5427
6332
  const providerError = parseProviderError(attempt.errText);
5428
- if (protocol === "openai" && isUpgradeRequiredError(attempt.status, attempt.errText, providerError)) {
6333
+ if (protocol !== "cli" && isUpgradeRequiredError(attempt.status, attempt.errText, providerError)) {
6334
+ const wasMessages = protocol === "messages";
5429
6335
  protocol = "cli";
5430
- this.rememberProtocol(apiKey, true);
6336
+ if (!wasMessages) this.rememberProtocol(connection.apiBase, apiKey, "cli");
5431
6337
  requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0], connection.offloadSeenImagesForCache === true ? options.model : void 0);
5432
6338
  body = await buildBody("cli");
5433
6339
  continue;
@@ -5445,15 +6351,24 @@ var CommandCodeAdapter = class extends LlmAdapter {
5445
6351
  });
5446
6352
  if (next !== void 0 && !tried.has(next)) {
5447
6353
  apiKey = next;
6354
+ const nextProtocol = this.resolveProtocol(connection.apiBase, apiKey, options.model);
6355
+ if (nextProtocol !== protocol) {
6356
+ protocol = nextProtocol;
6357
+ requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0], connection.offloadSeenImagesForCache === true && protocol === "cli" ? options.model : void 0);
6358
+ body = await buildBody(protocol);
6359
+ }
5448
6360
  continue;
5449
6361
  }
5450
6362
  }
5451
- if (rejection !== void 0 && (rejection.reason === "rate-limit" || rejection.reason === "throttled") && (rejection.resetAtMs !== void 0 || attempt.status !== 429)) {
6363
+ if (rejection !== void 0 && (rejection.reason === "rate-limit" || rejection.reason === "throttled")) {
5452
6364
  const reset = rejection.resetAtMs;
5453
- const wait = reset === void 0 ? 0 : Math.min(Math.max(1e3, reset - Date.now()), RETRY_MAX_DELAY_MS);
6365
+ const wait = reset === void 0 ? attempt.retryAfterMs ?? 0 : Math.min(Math.max(1e3, reset - Date.now()), RETRY_MAX_DELAY_MS);
5454
6366
  const when = reset === void 0 ? void 0 : new Date(reset).toISOString();
5455
6367
  const window = rejection.reason === "rate-limit";
5456
- throw bilingual("RATE_LIMIT", (window ? "llm-commandcode: the Command Code account is rate limited" : "llm-commandcode: the Command Code account is rate limited (429) — the provider did not report an exhausted usage window") + (when === void 0 ? "" : ` — the provider reports it resets at ${when}`), (window ? "当前 Command Code 账户已被限流" : "当前 Command Code 账户被限流(429),服务商未报告用量窗口用尽") + (when === void 0 ? "" : `,服务商给出的重置时间为 ${when}`), wait > 0 ? { providerRetryAfterMs: wait } : void 0);
6368
+ throw bilingual(window ? "RATE_LIMIT" : THROTTLED_CODE, (window ? "llm-commandcode: the Command Code account is rate limited" : "llm-commandcode: the Command Code account is rate limited (429) — the provider did not report an exhausted usage window") + (when === void 0 ? "" : ` — the provider reports it resets at ${when}`), (window ? "当前 Command Code 账户已被限流" : "当前 Command Code 账户被限流(429),服务商未报告用量窗口用尽") + (when === void 0 ? "" : `,服务商给出的重置时间为 ${when}`), {
6369
+ status: attempt.status,
6370
+ ...wait > 0 && wait <= 9e5 ? { providerRetryAfterMs: wait } : {}
6371
+ });
5457
6372
  }
5458
6373
  throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs, providerError, contextBudget.sizePressure);
5459
6374
  }
@@ -5464,7 +6379,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
5464
6379
  });
5465
6380
  trace.close();
5466
6381
  if (options.signal?.aborted) throw error;
5467
- throw failureWithRequestId(this.gradeHeaderTimeout(error, protocol, body, connection.requestTimeoutMs), responseMetadata);
6382
+ throw failureWithRequestId(this.gradeHeaderTimeout(error, protocol, body, connection.requestTimeoutMs, options.sessionId), responseMetadata);
5468
6383
  }
5469
6384
  if (connected === void 0) throw new LlmError("Command Code API connection failed without a response", "TRANSPORT");
5470
6385
  const { response, cleanup } = connected;
@@ -5544,6 +6459,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
5544
6459
  break;
5545
6460
  }
5546
6461
  const text = decoder.decode(value, { stream: true });
6462
+ if (value.byteLength > 0) timing.first("firstByteMs");
5547
6463
  buffer += text;
5548
6464
  chunkCount += 1;
5549
6465
  totalBytes += value.byteLength;
@@ -5581,9 +6497,13 @@ var CommandCodeAdapter = class extends LlmAdapter {
5581
6497
  });
5582
6498
  endRecorded = true;
5583
6499
  if (failure !== void 0) throw failure;
5584
- yield hasResponseIdentifiers(responseMetadata) ? {
6500
+ const replayBlocks = asm.replayBlocks;
6501
+ yield hasResponseIdentifiers(responseMetadata) || replayBlocks.length > 0 ? {
5585
6502
  ...finish,
5586
- replayState: { response: responseMetadata }
6503
+ replayState: {
6504
+ response: responseMetadata,
6505
+ ...replayBlocks.length > 0 ? { blocks: replayBlocks } : {}
6506
+ }
5587
6507
  } : finish;
5588
6508
  } else {
5589
6509
  const elapsed = Date.now() - streamStartedAt;
@@ -5627,23 +6547,47 @@ var CommandCodeAdapter = class extends LlmAdapter {
5627
6547
  }
5628
6548
  }
5629
6549
  };
6550
+ /**
6551
+ * Unwrap one level of a double-encoded provider message.
6552
+ *
6553
+ * The Messages endpoint sometimes reports a rejection with `error.message`
6554
+ * holding a whole JSON document rather than the sentence
6555
+ * (`{"type":"error","error":{"type":"invalid_request_error","message":"{\"type\":
6556
+ * \"error\",…}"},"request_id":"req_…"}`, measured 2026-09-29). Showing that blob
6557
+ * to the user hides the actual refusal, so one nesting level is peeled; the
6558
+ * outer text is returned unchanged when it is not JSON or carries no message.
6559
+ */
6560
+ function unwrapProviderMessage(raw) {
6561
+ if (raw === void 0) return void 0;
6562
+ const trimmed = raw.trim();
6563
+ if (!trimmed.startsWith("{")) return raw;
6564
+ try {
6565
+ const inner = JSON.parse(trimmed);
6566
+ if (!isRecord(inner)) return raw;
6567
+ return stringValue(((isRecord(inner.error) ? inner.error : void 0) ?? inner).message) ?? raw;
6568
+ } catch {
6569
+ return raw;
6570
+ }
6571
+ }
5630
6572
  /** Parse a rejected body once, retaining the nested/root distinction. */
5631
6573
  function parseProviderError(errText) {
5632
6574
  try {
5633
6575
  const parsed = JSON.parse(errText);
5634
- if (!isRecord(parsed)) return {};
6576
+ if (!isRecord(parsed)) return { message: errText };
5635
6577
  const nested = isRecord(parsed.error) ? parsed.error : void 0;
5636
6578
  const body = nested ?? parsed;
6579
+ const rawMessage = stringValue(body.message);
5637
6580
  return {
5638
6581
  root: parsed,
5639
6582
  nested,
5640
6583
  code: stringValue(body.code),
5641
6584
  type: stringValue(body.type),
5642
- message: stringValue(body.message),
6585
+ rawMessage,
6586
+ message: unwrapProviderMessage(rawMessage) ?? errText,
5643
6587
  rateLimit: isRecord(body.rateLimit) ? body.rateLimit : void 0
5644
6588
  };
5645
6589
  } catch {
5646
- return {};
6590
+ return { message: errText };
5647
6591
  }
5648
6592
  }
5649
6593
  function generateHttpError(status, errText, retryAfterMs, parsed = parseProviderError(errText), sizePressure = false) {
@@ -5651,10 +6595,14 @@ function generateHttpError(status, errText, retryAfterMs, parsed = parseProvider
5651
6595
  const providerDetail = parsed.nested === void 0 ? "" : [
5652
6596
  parsed.code,
5653
6597
  parsed.type,
5654
- parsed.message
6598
+ parsed.rawMessage ?? parsed.message
5655
6599
  ].filter((part) => part !== void 0 && part !== "").join(" ");
5656
6600
  const detail = providerCode ?? `HTTP ${status}`;
5657
6601
  const overflowDetail = providerDetail !== "" ? providerDetail : errText.slice(0, 500);
6602
+ if (status < 500 && OUTPUT_LIMIT_REJECTION.test(overflowDetail)) {
6603
+ const stated = /max_tokens:\s*\d+\s*>\s*\d+/.exec(overflowDetail)?.[0] ?? (parsed.message !== void 0 && parsed.message.length <= 200 ? parsed.message : void 0);
6604
+ return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error ${status}: this request's max_tokens is not valid for this model` + (stated ? ` (${stated})` : "") + " — the session is not oversized, so compacting it will not help; lower \"Max output tokens\" or pick a model with a larger output ceiling", `Command Code API 返回 ${status}:本次请求的 max_tokens 对该模型不合法` + (stated ? `(${stated})` : "") + "——会话内容并未超长,压缩上下文无效;请调低「最大输出 token」或改用输出上限更大的模型", { status });
6605
+ }
5658
6606
  if (status < 500 && isContextOverflowDetail(overflowDetail)) return bilingual(CONTEXT_WINDOW_EXCEEDED_CODE, `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}:请求内容超出模型上下文窗口——正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, { status });
5659
6607
  if (status === 400 && sizePressure && AMBIGUOUS_SIZE_REJECTION.test(providerDetail) && /\bprovider_error\b/i.test(providerDetail)) return bilingual(CONTEXT_WINDOW_EXCEEDED_CODE, "Command Code API error 400: the provider could not complete a request near the model context limit — compacting the session and retrying", "Command Code API 返回 400:请求接近模型上下文上限,服务商无法完成——正在压缩上下文后重试", { status });
5660
6608
  if (status === 422 && isZdrNoProviders(providerCode, providerDetail, errText)) return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error 422 (${detail}): zero data retention is enforced but no ZDR-capable upstream is available for this model — turn the ZDR setting off (or use another model)`, "Command Code API 返回 422:已开启零数据保留(ZDR),但该模型当前没有可用的 ZDR 上游——请关闭 ZDR 开关,或改用其他模型", { status: 422 });
@@ -5678,7 +6626,7 @@ function generateHttpError(status, errText, retryAfterMs, parsed = parseProvider
5678
6626
  * rejection repeats identically on every attempt.
5679
6627
  */
5680
6628
  function httpErrorCode(status) {
5681
- if (status === 429) return "RATE_LIMIT";
6629
+ if (status === 429) return THROTTLED_CODE;
5682
6630
  if (status === 408) return "TIMEOUT";
5683
6631
  if (status >= 500) return "SERVER";
5684
6632
  return "PROVIDER_HTTP_ERROR";
@@ -5898,8 +6846,195 @@ function handleOpenAIEvent(asm, event) {
5898
6846
  return chunks;
5899
6847
  }
5900
6848
  /** Dispatch one parsed stream event to the active transport's handler. */
6849
+ /**
6850
+ * Messages `usage` → the harness meter.
6851
+ *
6852
+ * The field names are the Anthropic ones, and unlike Chat Completions
6853
+ * `input_tokens` already excludes cache reads and writes, so it is mapped
6854
+ * as-is rather than reduced (the shape `dsh-llm-deepseek` implements).
6855
+ * Measured 2026-09-29: `{ input_tokens, cache_creation_input_tokens,
6856
+ * cache_read_input_tokens, output_tokens, output_tokens_details:
6857
+ * { thinking_tokens } }`, plus gateway additions the meter ignores.
6858
+ */
6859
+ function mapMessagesUsage(raw) {
6860
+ if (!isRecord(raw)) return void 0;
6861
+ const inputTokens = numberValue(raw.input_tokens) ?? 0;
6862
+ const outputTokens = numberValue(raw.output_tokens) ?? 0;
6863
+ const cacheRead = numberValue(raw.cache_read_input_tokens) ?? 0;
6864
+ const cacheWrite = numberValue(raw.cache_creation_input_tokens) ?? 0;
6865
+ const reasoningTokens = numberValue((isRecord(raw.output_tokens_details) ? raw.output_tokens_details : void 0)?.thinking_tokens);
6866
+ const usage = {
6867
+ inputTokens: Math.max(0, inputTokens),
6868
+ outputTokens,
6869
+ cacheReadTokens: cacheRead
6870
+ };
6871
+ if (cacheWrite > 0) usage.cacheWriteTokens = cacheWrite;
6872
+ if (reasoningTokens !== void 0) usage.reasoningTokens = reasoningTokens;
6873
+ return usage;
6874
+ }
6875
+ /** The harness block type one Messages content block maps to. */
6876
+ function messagesBlockType(native) {
6877
+ if (native.type === "text") return "text";
6878
+ if (native.type === "thinking") return "reasoning";
6879
+ if (native.type === "tool_use") return "tool-call";
6880
+ }
6881
+ /**
6882
+ * Handle one Messages-transport stream event, appending harness StreamChunks.
6883
+ *
6884
+ * The event sequence is the Anthropic one measured on 2026-09-29:
6885
+ * `message_start`, then per content block `content_block_start` /
6886
+ * `content_block_delta`* / `content_block_stop`, then `message_delta`
6887
+ * (carrying `stop_reason` and the final usage) and `message_stop`. A `ping`
6888
+ * keepalive interleaves freely and carries no content. The terminal chunks are
6889
+ * NOT emitted here: `stream()` holds the success finish until it has validated
6890
+ * the assembled answer, and it attaches the replay envelope.
6891
+ */
6892
+ function handleMessagesEvent(asm, event) {
6893
+ const chunks = [];
6894
+ if (!isRecord(event)) return chunks;
6895
+ switch (event.type) {
6896
+ case "message_start": {
6897
+ const usage = mapMessagesUsage((isRecord(event.message) ? event.message : void 0)?.usage);
6898
+ if (usage) chunks.push({
6899
+ type: "usage",
6900
+ usage
6901
+ });
6902
+ break;
6903
+ }
6904
+ case "content_block_start": {
6905
+ const wireIndex = numberValue(event.index);
6906
+ const native = isRecord(event.content_block) ? event.content_block : void 0;
6907
+ const type = native ? messagesBlockType(native) : void 0;
6908
+ if (wireIndex === void 0 || type === void 0) break;
6909
+ const chunkIndex = asm.nextIndex++;
6910
+ const state = {
6911
+ type,
6912
+ text: stringValue(native?.text) ?? "",
6913
+ signature: stringValue(native?.signature) ?? "",
6914
+ json: "",
6915
+ id: stringValue(native?.id) ?? "",
6916
+ name: stringValue(native?.name) ?? "",
6917
+ chunkIndex
6918
+ };
6919
+ asm.messagesBlocks.set(wireIndex, state);
6920
+ asm.replayBlocks[chunkIndex] = { type };
6921
+ chunks.push({
6922
+ type: "block-start",
6923
+ index: chunkIndex,
6924
+ blockType: type
6925
+ });
6926
+ if (type === "tool-call" && state.id !== "") chunks.push({
6927
+ type: "tool-call-delta",
6928
+ index: chunkIndex,
6929
+ id: ToolCallId(state.id),
6930
+ name: state.name,
6931
+ argumentsDelta: ""
6932
+ });
6933
+ break;
6934
+ }
6935
+ case "content_block_delta": {
6936
+ const wireIndex = numberValue(event.index);
6937
+ const state = wireIndex === void 0 ? void 0 : asm.messagesBlocks.get(wireIndex);
6938
+ const delta = isRecord(event.delta) ? event.delta : void 0;
6939
+ if (!state || !delta) break;
6940
+ const index = state.chunkIndex;
6941
+ if (delta.type === "text_delta" && state.type === "text") {
6942
+ const text = stringValue(delta.text) ?? "";
6943
+ state.text += text;
6944
+ if (text.trim() !== "") asm.sawContent = true;
6945
+ chunks.push({
6946
+ type: "text-delta",
6947
+ index,
6948
+ text
6949
+ });
6950
+ } else if (delta.type === "thinking_delta" && state.type === "reasoning") {
6951
+ const text = stringValue(delta.thinking) ?? "";
6952
+ state.text += text;
6953
+ chunks.push({
6954
+ type: "reasoning-delta",
6955
+ index,
6956
+ text
6957
+ });
6958
+ } else if (delta.type === "signature_delta" && state.type === "reasoning") {
6959
+ state.signature += stringValue(delta.signature) ?? "";
6960
+ const entry = asm.replayBlocks[index];
6961
+ if (entry) entry.signature = state.signature;
6962
+ } else if (delta.type === "input_json_delta" && state.type === "tool-call") {
6963
+ const fragment = stringValue(delta.partial_json) ?? "";
6964
+ state.json += fragment;
6965
+ chunks.push({
6966
+ type: "tool-call-delta",
6967
+ index,
6968
+ id: ToolCallId(state.id),
6969
+ name: state.name,
6970
+ argumentsDelta: fragment
6971
+ });
6972
+ }
6973
+ break;
6974
+ }
6975
+ case "content_block_stop": {
6976
+ const wireIndex = numberValue(event.index);
6977
+ if (wireIndex === void 0) break;
6978
+ const state = asm.messagesBlocks.get(wireIndex);
6979
+ if (!state) break;
6980
+ asm.messagesBlocks.delete(wireIndex);
6981
+ const index = state.chunkIndex;
6982
+ if (state.type === "text") {
6983
+ if (state.text.trim() !== "") asm.sawContent = true;
6984
+ chunks.push({
6985
+ type: "block-end",
6986
+ index,
6987
+ block: {
6988
+ type: "text",
6989
+ text: state.text
6990
+ }
6991
+ });
6992
+ } else if (state.type === "reasoning") chunks.push({
6993
+ type: "block-end",
6994
+ index,
6995
+ block: {
6996
+ type: "reasoning",
6997
+ text: state.text
6998
+ }
6999
+ });
7000
+ else {
7001
+ if (state.id !== "") asm.sawContent = true;
7002
+ chunks.push({
7003
+ type: "block-end",
7004
+ index,
7005
+ block: {
7006
+ type: "tool-call",
7007
+ id: ToolCallId(state.id),
7008
+ name: state.name,
7009
+ arguments: state.json || "{}"
7010
+ }
7011
+ });
7012
+ }
7013
+ break;
7014
+ }
7015
+ case "message_delta": {
7016
+ const reason = stringValue((isRecord(event.delta) ? event.delta : void 0)?.stop_reason);
7017
+ if (reason !== void 0) asm.finishReason = reason;
7018
+ const usage = mapMessagesUsage(event.usage);
7019
+ if (usage) chunks.push({
7020
+ type: "usage",
7021
+ usage
7022
+ });
7023
+ chunks.push({
7024
+ type: "finish",
7025
+ reason: mapFinishReason(reason)
7026
+ });
7027
+ break;
7028
+ }
7029
+ case "message_stop":
7030
+ case "ping": break;
7031
+ case "error": throw streamErrorToLlmError(event.error ?? event, stringValue(event.message));
7032
+ }
7033
+ return chunks;
7034
+ }
5901
7035
  function handleEvent(asm, protocol, event) {
5902
- return protocol === "openai" ? handleOpenAIEvent(asm, event) : handleCliEvent(asm, event);
7036
+ if (protocol === "cli") return handleCliEvent(asm, event);
7037
+ return protocol === "messages" ? handleMessagesEvent(asm, event) : handleOpenAIEvent(asm, event);
5903
7038
  }
5904
7039
  /**
5905
7040
  * Parse an HTTP `Retry-After` value (delay-seconds or an HTTP-date) into
@@ -5928,7 +7063,7 @@ function parseRetryAfterMs(value, now = Date.now()) {
5928
7063
  * one function serves both — a new reason cannot drift between them.
5929
7064
  */
5930
7065
  function mapFinishReason(reason) {
5931
- if (reason === "tool-calls" || reason === "tool_calls") return { kind: "tool-calls" };
7066
+ if (reason === "tool-calls" || reason === "tool_calls" || reason === "tool_use") return { kind: "tool-calls" };
5932
7067
  if (reason === "length" || reason === "max_tokens" || reason === "max-tokens" || reason === "max_output_tokens") return { kind: "max-tokens" };
5933
7068
  return { kind: "stop" };
5934
7069
  }
@@ -7886,7 +9021,7 @@ function transportBudgetMessage(failureMessage, maxRetries) {
7886
9021
  * Mark one schema field volatile.
7887
9022
  *
7888
9023
  * `.volatile()` is unconditional: it is a schemastery 3.18.3 feature and the
7889
- * only supported engine (`dsh 0.2.0-rc.1`) pins `~3.18.4`, so the method is
9024
+ * only supported engine (`dsh 0.2.0-rc.2`) pins `~3.18.4`, so the method is
7890
9025
  * always present. It is reached through a structural member so the caller's
7891
9026
  * declared field type survives — the mark changes what the schema PARSES to (a
7892
9027
  * live reference), not the plugin-facing `Config` shape.
@@ -8120,10 +9255,13 @@ function apply(ctx, config) {
8120
9255
  return Math.min(Math.trunc(value), 50);
8121
9256
  };
8122
9257
  const sessionAgents = /* @__PURE__ */ new WeakMap();
8123
- ctx.on("agent/request-error", async ({ agent, failure, signal }, next) => {
8124
- if (signal.aborted) return next();
9258
+ ctx.on("agent/request-error", async ({ agent, provider, failure, signal }, next) => {
9259
+ if (signal.aborted || provider !== "commandcode") return next();
8125
9260
  const session = Reflect.get(agent, "session");
8126
9261
  if (typeof session === "object" && session !== null) sessionAgents.set(session, agent);
9262
+ const transient = absorbTransientFailure(agent, failure.code);
9263
+ if (transient === "exhausted") throw new Error(transientBudgetMessage(failure.code, failure.message));
9264
+ if (transient === "retry") return next();
8127
9265
  const decision = absorbTransportFailure(agent, failure.code, transportMaxRetries());
8128
9266
  if (decision === "ignored") return next();
8129
9267
  if (decision === "retry") return { kind: "retry" };
@@ -8139,10 +9277,16 @@ function apply(ctx, config) {
8139
9277
  }
8140
9278
  if (action !== "reset") return;
8141
9279
  const agent = sessionAgents.get(session);
8142
- if (agent !== void 0) resetTransportFailures(agent);
9280
+ if (agent !== void 0) {
9281
+ resetTransportFailures(agent);
9282
+ resetTransientFailures(agent);
9283
+ }
8143
9284
  });
8144
9285
  ctx.on("agent/status", ({ agent, status }) => {
8145
- if (status === "idle") resetTransportFailures(agent);
9286
+ if (status === "idle") {
9287
+ resetTransportFailures(agent);
9288
+ resetTransientFailures(agent);
9289
+ }
8146
9290
  });
8147
9291
  const usageReports = async () => {
8148
9292
  const described = await pool.describeAccounts();