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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
5
- import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, isContextWindowExceededError, offloadRequestImagesWithPolicy, offloadedImageText, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
+ import * as dshLlm from "@deepseek-ai/dsh-llm";
6
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, isContextWindowExceededError, offloadedImageText, resolveRetryPolicy, textOnlyImageText } from "@deepseek-ai/dsh-llm";
6
7
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
8
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
8
9
  import { existsSync, readFileSync } from "node:fs";
@@ -385,6 +386,48 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
385
386
  "inclusionai/ling-3.0-flash-sante:free"
386
387
  ]);
387
388
  /**
389
+ * Catalog models the Provider API serves ONLY through `/provider/v1/messages`
390
+ * (Anthropic Messages shape). Every other model in the catalog answers on
391
+ * `/provider/v1/chat/completions`; these reject it outright with HTTP 400
392
+ * `Model "<id>" must be called via /provider/v1/messages (Anthropic Messages
393
+ * shape)`.
394
+ *
395
+ * Measured 2026-09-16 against the live catalog (command-code@1.54.0): all 69
396
+ * models were posted to `/provider/v1/chat/completions`; exactly these eight
397
+ * — the whole Claude family — refused, and every one of them is routed
398
+ * normally by `/alpha/generate` (a lower-plan key gets the ordinary
399
+ * `MODEL_NOT_IN_PLAN` 403 there, never a routing error). So the CLI transport
400
+ * is a complete fallback and the adapter does not need a Messages transport.
401
+ *
402
+ * Note what this list is NOT: it is not a plan gate. `claude-sonnet-5` is
403
+ * Pro-tier and `claude-opus-4-8` Provider-tier, so the accounts entitled to
404
+ * these models are exactly the non-Go ones, which `resolveProtocol()` sends to
405
+ * the Provider API by default — picking any Claude model there failed every
406
+ * request before this snapshot existed.
407
+ *
408
+ * Keep in sync when models ship (see the dsh-commandcode-upstream skill).
409
+ * `requiresMessagesEndpoint()` additionally treats any `claude-*` id as
410
+ * Messages-only, so a Claude model added upstream is routed correctly by an
411
+ * un-updated plugin instead of hard-failing with the 400 above; the worst case
412
+ * of that rule going stale the other way (upstream teaching
413
+ * `/provider/v1/chat/completions` to serve Claude) is one model riding the CLI
414
+ * transport it already works on.
415
+ */
416
+ const MESSAGES_ONLY_MODELS = /* @__PURE__ */ new Set([
417
+ "claude-sonnet-5",
418
+ "claude-sonnet-4-6",
419
+ "claude-fable-5-1",
420
+ "claude-fable-5",
421
+ "claude-opus-5",
422
+ "claude-opus-4-8",
423
+ "claude-opus-4-7",
424
+ "claude-haiku-4-5-20251001"
425
+ ]);
426
+ /** True when the Provider API will only serve `modelId` via `/provider/v1/messages`. */
427
+ function requiresMessagesEndpoint(modelId) {
428
+ return MESSAGES_ONLY_MODELS.has(modelId) || modelId.startsWith("claude-");
429
+ }
430
+ /**
388
431
  * The minimum subscription plan a model is included in, per the official plan
389
432
  * pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`, `/docs/plans/max`
390
433
  * and `/docs/resources/pricing-limits`). Each plan's model list is a superset of
@@ -2072,6 +2115,235 @@ function normalizeResolvedKey(value) {
2072
2115
  return trimmed === "" ? void 0 : trimmed;
2073
2116
  }
2074
2117
  //#endregion
2118
+ //#region src/image-request.ts
2119
+ /**
2120
+ * Long edge one request image may keep, in pixels.
2121
+ *
2122
+ * 1568 is Anthropic's documented ceiling — their API downscales anything larger
2123
+ * before the model sees it — and it sits inside the high-detail band of the
2124
+ * OpenAI and Gemini tile schemes, so no upstream this gateway reaches reads
2125
+ * more detail than the target keeps. Screenshots from a Retina display are the
2126
+ * case this is for: 2880x1800 becomes 1568x980, a 3.4x pixel cut, and the
2127
+ * models lose nothing they would not have downscaled themselves.
2128
+ */
2129
+ const REQUEST_IMAGE_MAX_LONG_EDGE = 1568;
2130
+ /**
2131
+ * Encoded bytes one request image may keep, before base64 expansion. The same
2132
+ * 1 MiB budget the harness's own multi-provider adapter defaults to; the
2133
+ * attachment service keeps its smallest quality-ladder output when no quality
2134
+ * fits, so this bounds bytes without ever refusing an image.
2135
+ */
2136
+ const REQUEST_IMAGE_MAX_ENCODED_BYTES = 1048576;
2137
+ /**
2138
+ * Aspect-preserving projection onto a long-edge budget; never enlarges, and
2139
+ * never returns a zero dimension (a 1-px degenerate side is what the round of
2140
+ * an extreme aspect ratio can produce, and a 0 would make the encoder refuse).
2141
+ *
2142
+ * A local restatement of the helper 0.1.6 exports from `dsh-attachment`, which
2143
+ * 0.1.2–0.1.5 do not have; the math is the whole contract, so keeping the copy
2144
+ * here costs less than a version probe would.
2145
+ */
2146
+ function longEdgeDimensions(width, height, longEdge) {
2147
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return {
2148
+ width: Math.max(1, Math.round(width) || 1),
2149
+ height: Math.max(1, Math.round(height) || 1)
2150
+ };
2151
+ if (longEdge >= Math.max(width, height)) return {
2152
+ width,
2153
+ height
2154
+ };
2155
+ return width >= height ? {
2156
+ width: longEdge,
2157
+ height: Math.max(1, Math.round(longEdge * height / width))
2158
+ } : {
2159
+ width: Math.max(1, Math.round(longEdge * width / height)),
2160
+ height: longEdge
2161
+ };
2162
+ }
2163
+ /**
2164
+ * The request target for one normalized attachment: its aspect ratio under the
2165
+ * long-edge budget, plus the byte budget every generation honours.
2166
+ *
2167
+ * The area is computed from the PROJECTED dimensions rather than the source's,
2168
+ * so ≤0.1.5's own pixel projection lands on the same geometry instead of
2169
+ * re-deriving a slightly different one from the original.
2170
+ */
2171
+ function requestImageTarget(ref) {
2172
+ const projected = longEdgeDimensions(ref.width, ref.height, REQUEST_IMAGE_MAX_LONG_EDGE);
2173
+ return {
2174
+ maxPixels: projected.width * projected.height,
2175
+ width: projected.width,
2176
+ height: projected.height,
2177
+ maxBytes: REQUEST_IMAGE_MAX_ENCODED_BYTES
2178
+ };
2179
+ }
2180
+ //#endregion
2181
+ //#region src/image-tokens.ts
2182
+ /**
2183
+ * Vision-token accounting for the model families Command Code routes to.
2184
+ *
2185
+ * The harness's token meter prices request images through
2186
+ * `LlmAdapter.imageRequestPricing(provider, model).priceImages(blocks)` and,
2187
+ * when a route declares nothing, falls back to a structural estimate that
2188
+ * charges a handful of tokens for ANY image. On an image-heavy session that is
2189
+ * not a rounding error: a screenshot loop can carry dozens of full-size images
2190
+ * that the heuristic prices at ~0 tokens each, so context pressure is
2191
+ * under-reported and the session walks into its own context window.
2192
+ *
2193
+ * This route is a gateway in front of many upstreams — one catalog id per model,
2194
+ * but the visual-token rule belongs to whoever serves it. So the estimate is per
2195
+ * FAMILY, using each vendor's published rule, and the families are recognised by
2196
+ * the catalog id (which names the vendor). Every formula is stated here rather
2197
+ * than imported: the only in-tree implementations live inside the official
2198
+ * adapters, and this plugin deliberately does not depend on those protocols.
2199
+ *
2200
+ * These are ESTIMATES for context accounting, not invoices — the provider's own
2201
+ * usage remains authoritative for a completed request, exactly as the published
2202
+ * per-token rates the session-cost readout uses are.
2203
+ *
2204
+ * @module dsh-commandcode-provider/image-tokens
2205
+ */
2206
+ /**
2207
+ * Which family prices one catalog model id.
2208
+ *
2209
+ * `generic` is not "we do not know the model" but "we cannot tell its visual
2210
+ * rule apart from the known ones" — the Qwen, MiniMax, xAI, moonshot and
2211
+ * sakana families all publish patch- or tile-based accounting close to the
2212
+ * Anthropic rule, so the conservative fallback below covers them without
2213
+ * pretending to more precision than we have.
2214
+ */
2215
+ function imageTokenFamily(modelId) {
2216
+ const id = modelId.toLowerCase();
2217
+ const vendor = id.includes("/") ? id.slice(0, id.indexOf("/")) : "";
2218
+ if (id.startsWith("claude")) return "anthropic";
2219
+ if (id.startsWith("gpt") || /^o[1-9]/.test(id)) return "openai";
2220
+ if (vendor === "google" || id.startsWith("gemini")) return "google";
2221
+ if (vendor === "deepseek") return "deepseek";
2222
+ return "generic";
2223
+ }
2224
+ /**
2225
+ * Visual tokens one request image costs on the given family, at the dimensions
2226
+ * this route would actually send (see `./image-request.ts`).
2227
+ */
2228
+ function imageTokenCost(family, width, height) {
2229
+ switch (family) {
2230
+ case "anthropic": return anthropicImageTokens(width, height);
2231
+ case "openai": return openAiImageTokens(width, height);
2232
+ case "google": return googleImageTokens(width, height);
2233
+ case "deepseek": return deepSeekImageTokens(width, height);
2234
+ default: return genericImageTokens(width, height);
2235
+ }
2236
+ }
2237
+ /** Convenience for callers that have a model id: family lookup plus the rule. */
2238
+ function commandCodeImageTokens(modelId, width, height) {
2239
+ return imageTokenCost(imageTokenFamily(modelId), width, height);
2240
+ }
2241
+ /**
2242
+ * Anthropic: the image is first scaled so its long edge is at most 1568 px
2243
+ * (never enlarged), then charged roughly one token per 750 pixels.
2244
+ * Source: Anthropic vision documentation, "Image size and token usage".
2245
+ */
2246
+ function anthropicImageTokens(width, height) {
2247
+ const projected = longEdgeDimensions(width, height, 1568);
2248
+ return Math.max(1, Math.ceil(projected.width * projected.height / 750));
2249
+ }
2250
+ /**
2251
+ * OpenAI (high detail): scale to fit inside 2048x2048, then scale the shortest
2252
+ * side to 768 px, then charge an 85-token base plus 170 tokens per 512x512
2253
+ * tile. Source: OpenAI vision documentation, "Calculating costs".
2254
+ */
2255
+ function openAiImageTokens(width, height) {
2256
+ let scaled = {
2257
+ width: Math.max(1, width),
2258
+ height: Math.max(1, height)
2259
+ };
2260
+ const longest = Math.max(scaled.width, scaled.height);
2261
+ if (longest > 2048) scaled = scaleBy(scaled, 2048 / longest);
2262
+ const shortest = Math.min(scaled.width, scaled.height);
2263
+ if (shortest > 768) scaled = scaleBy(scaled, 768 / shortest);
2264
+ return 85 + 170 * (Math.ceil(scaled.width / 512) * Math.ceil(scaled.height / 512));
2265
+ }
2266
+ /**
2267
+ * Google Gemini: an image whose both sides are at most 384 px costs a flat 258
2268
+ * tokens; anything larger is cropped into 768x768 tiles of 258 tokens each.
2269
+ * Source: Gemini image-understanding documentation.
2270
+ */
2271
+ function googleImageTokens(width, height) {
2272
+ if (width <= 384 && height <= 384) return 258;
2273
+ return 258 * Math.ceil(width / 768) * Math.ceil(height / 768);
2274
+ }
2275
+ /** 14-px patches, merged 3x3 into one token cell. */
2276
+ const DEEPSEEK_PATCH_SIZE = 14;
2277
+ const DEEPSEEK_DOWNSAMPLE_RATIO = 3;
2278
+ /** The provider's cap on tokens for one request image. */
2279
+ const DEEPSEEK_MAX_IMAGE_TOKENS = 1024;
2280
+ /** Total-pixel floor; a smaller image is enlarged before grid projection. */
2281
+ const DEEPSEEK_MIN_PIXELS = 295936;
2282
+ /**
2283
+ * DeepSeek: pad to 14-px patches, merge each 3x3 patch block into one token
2284
+ * cell, charge one token per cell plus one separator per row plus two framing
2285
+ * tokens, and cap one image at 1024 tokens by downscaling it aspect-preserving
2286
+ * (images below 544x544 are enlarged first).
2287
+ *
2288
+ * Mirrors `deepSeekImageTokens` in the official `dsh-llm-deepseek` adapter
2289
+ * (its `PATCH_SIZE`, `DOWNSAMPLE_RATIO`, `MAX_IMAGE_TOKENS` and `MIN_PIXELS`).
2290
+ * The official solver picks the largest grid that fits the cap; this restates
2291
+ * that as a bounded search over the scale factor, which lands on the same grid
2292
+ * without vendoring the closed-form solver.
2293
+ */
2294
+ function deepSeekImageTokens(width, height) {
2295
+ let scaled = {
2296
+ width: Math.max(1, width),
2297
+ height: Math.max(1, height)
2298
+ };
2299
+ const pixels = scaled.width * scaled.height;
2300
+ if (pixels < DEEPSEEK_MIN_PIXELS) scaled = scaleBy(scaled, Math.sqrt(DEEPSEEK_MIN_PIXELS / pixels));
2301
+ const direct = deepSeekGridTokens(scaled.width, scaled.height);
2302
+ if (direct <= DEEPSEEK_MAX_IMAGE_TOKENS) return direct;
2303
+ let low = 0;
2304
+ let high = 1;
2305
+ let best = direct;
2306
+ for (let step = 0; step < 24; step += 1) {
2307
+ const mid = (low + high) / 2;
2308
+ const tokens = deepSeekGridTokens(Math.max(1, Math.round(scaled.width * mid)), Math.max(1, Math.round(scaled.height * mid)));
2309
+ if (tokens <= DEEPSEEK_MAX_IMAGE_TOKENS) {
2310
+ low = mid;
2311
+ best = tokens;
2312
+ } else high = mid;
2313
+ }
2314
+ return best;
2315
+ }
2316
+ /** One token grid's charge: every row carries a separator, plus two framing tokens. */
2317
+ function deepSeekGridTokens(width, height) {
2318
+ const cellsWide = cellCount(width);
2319
+ return cellCount(height) * (cellsWide + 1) + 2;
2320
+ }
2321
+ /** Token cells along one padded pixel axis. */
2322
+ function cellCount(length) {
2323
+ const patches = Math.ceil(length / DEEPSEEK_PATCH_SIZE);
2324
+ return Math.ceil(patches / DEEPSEEK_DOWNSAMPLE_RATIO);
2325
+ }
2326
+ /**
2327
+ * The conservative fallback for a family we cannot price exactly: the MOST
2328
+ * expensive of the three simple published rules at these dimensions.
2329
+ *
2330
+ * Chosen deliberately. Under-reporting context is what lets a session walk into
2331
+ * its own context window, while over-reporting only makes the meter cautious;
2332
+ * for the real families this covers (Qwen's 28-px patches, MiniMax, xAI,
2333
+ * moonshot, sakana) the Anthropic rule is the close one, and it is the maximum
2334
+ * at every size except the sub-384-px band, where Gemini's flat 258 wins.
2335
+ */
2336
+ function genericImageTokens(width, height) {
2337
+ return Math.max(anthropicImageTokens(width, height), openAiImageTokens(width, height), googleImageTokens(width, height));
2338
+ }
2339
+ /** Scale one dimension pair by a positive factor, rounded to whole pixels. */
2340
+ function scaleBy(size, factor) {
2341
+ return {
2342
+ width: Math.max(1, Math.round(size.width * factor)),
2343
+ height: Math.max(1, Math.round(size.height * factor))
2344
+ };
2345
+ }
2346
+ //#endregion
2075
2347
  //#region src/adapter.ts
2076
2348
  /**
2077
2349
  * DeepSeek Harness LLM adapter for the Command Code Provider API.
@@ -2131,6 +2403,28 @@ function numberValue(value) {
2131
2403
  function booleanValue(value) {
2132
2404
  return typeof value === "boolean" ? value : void 0;
2133
2405
  }
2406
+ /**
2407
+ * Thinking text carried by an OpenRouter-shaped `reasoning_details` array
2408
+ * (`[{ type: 'reasoning.text', text, format, index }]`), concatenated in the
2409
+ * order the gateway sent it.
2410
+ *
2411
+ * Entries are read by their `text` member alone rather than filtered on
2412
+ * `type === 'reasoning.text'`: the sibling shapes in that protocol carry
2413
+ * `summary` or an encrypted blob instead of `text`, so a plain read already
2414
+ * ignores them, while a type filter would silently drop a text-bearing variant
2415
+ * added later — and losing thinking is the failure this exists to prevent.
2416
+ * Returns undefined when the array yields nothing, so the caller's `??` chain
2417
+ * keeps falling through instead of treating "no text" as a reasoning delta.
2418
+ */
2419
+ function reasoningDetailsText(value) {
2420
+ if (!Array.isArray(value)) return void 0;
2421
+ let text = "";
2422
+ for (const entry of value) {
2423
+ if (!isRecord(entry)) continue;
2424
+ text += stringValue(entry.text) ?? "";
2425
+ }
2426
+ return text === "" ? void 0 : text;
2427
+ }
2134
2428
  /** Parse a billing-period timestamp (ISO string or millis) into millis; 0 when absent/invalid. */
2135
2429
  function periodEndValue(value) {
2136
2430
  const asNumber = numberValue(value);
@@ -2410,6 +2704,44 @@ async function writeModelsCache(cachePath, models) {
2410
2704
  }
2411
2705
  }
2412
2706
  /**
2707
+ * Whether an inbound message is a tool RESULT — a `user`-role message the
2708
+ * producer tagged with `source: { kind: 'tool' }`.
2709
+ *
2710
+ * The tag is read defensively because it is PRODUCER-supplied and nothing
2711
+ * validates it: dsh-llm's `Message` type declares `source` required, but the
2712
+ * runtime never checks it and every adapter shipped before this one ignored the
2713
+ * field outright (`dsh-llm-deepseek` never reads it). A third-party plugin that
2714
+ * assembles its own history can therefore omit it and run correctly everywhere
2715
+ * else — dsh-mneme's memory pipeline did — which made this adapter, the first
2716
+ * to actually read the field, the first to crash on such a message with
2717
+ * `TypeError: Cannot read properties of undefined (reading 'kind')` in the
2718
+ * serialization phase, 0–8 ms in, before any request left the machine and with
2719
+ * nothing in the error naming the message or the producer (issue #47).
2720
+ *
2721
+ * Two rules keep an untagged message from costing the turn:
2722
+ *
2723
+ * - A PRESENT tag is authoritative. `kind: 'user'`, `'model'`, `'plugin'` or
2724
+ * any producer-added kind means "not a tool result", whatever the content
2725
+ * looks like — the tag answers *who produced this*.
2726
+ * - An ABSENT tag falls back to the content shape. A `user` message whose
2727
+ * first block is a `tool-result` is a tool result by the harness's own
2728
+ * definition (`ToolResultMessage` is exactly that shape with a
2729
+ * `ToolMessageSource`), and the fallback is not cosmetic: `pairedToolCalls()`
2730
+ * counts a `tool-result` block from ANY message, so classifying such a
2731
+ * message as a plain user message would drop it at emission while its call
2732
+ * still counted as paired — leaving the assistant's tool call unanswered on
2733
+ * the wire, which the gateway rejects outright. FIRST block, not "any block":
2734
+ * the tool-result branch reads `content[0]` and skips anything else, so
2735
+ * claiming a mixed message as a tool result would discard its other blocks
2736
+ * instead of sending them.
2737
+ */
2738
+ function isToolResultMessage(message) {
2739
+ if (message.role !== "user") return false;
2740
+ const kind = message.source?.kind;
2741
+ if (kind !== void 0) return kind === "tool";
2742
+ return message.content?.[0]?.type === "tool-result";
2743
+ }
2744
+ /**
2413
2745
  * Collect the tool calls that have a paired tool result, plus each call's
2414
2746
  * name. The name map feeds the `toolName` of replayed tool results: some
2415
2747
  * backends (e.g. Google Gemini `functionResponse`) reject a result whose
@@ -2556,17 +2888,19 @@ const REQUEST_IMAGE_BUDGETS = [{
2556
2888
  countQuantum: 12
2557
2889
  }];
2558
2890
  /**
2559
- * Project request history under one image budget: the oldest images past it
2560
- * become placeholder text in place, the newest keep their bytes, and a history
2561
- * already inside the budget is returned as the SAME array — the caller uses
2562
- * that identity to tell "nothing evicted" from "evicted".
2891
+ * Project request history under one image budget (≤0.1.5 engines only): the
2892
+ * oldest images past it become placeholder text in place, the newest keep their
2893
+ * bytes, and a history already inside the budget is returned as the SAME array
2894
+ * — the caller uses that identity to tell "nothing evicted" from "evicted".
2563
2895
  *
2564
2896
  * Nested tool-result images ride the same core walk, which is exactly right
2565
2897
  * for this adapter: an evicted `read_image` result surfaces its placeholder as
2566
2898
  * the tool message's own text, so no carrier user message is emitted for it.
2567
2899
  */
2568
- function projectRequestImages(messages, budget) {
2569
- return offloadRequestImagesWithPolicy(messages, {
2900
+ function projectRequestImages(messages, budget, policy) {
2901
+ const offload = policy.offloadRequestImagesWithPolicy;
2902
+ if (typeof offload !== "function") return messages;
2903
+ return offload(messages, {
2570
2904
  representation: "base64",
2571
2905
  maxBytes: budget.maxBytes,
2572
2906
  maxImages: budget.maxImages,
@@ -2582,14 +2916,128 @@ function projectRequestImages(messages, budget) {
2582
2916
  * the raw history — otherwise a retry could reintroduce images the first rung
2583
2917
  * had already evicted.
2584
2918
  */
2585
- function withImageBudget(options, budget) {
2586
- const projected = projectRequestImages(options.messages, budget);
2919
+ function withImageBudget(options, budget, policy) {
2920
+ const projected = projectRequestImages(options.messages, budget, policy);
2587
2921
  return projected === options.messages ? options : {
2588
2922
  ...options,
2589
2923
  messages: [...projected]
2590
2924
  };
2591
2925
  }
2592
2926
  /**
2927
+ * Stable provider-neutral code for "a request still carries more images than
2928
+ * this route will send; offload N of them and retry". Declared locally rather
2929
+ * than imported: the constant does not exist on the 0.1.2–0.1.5 peers this
2930
+ * plugin still supports, and importing it statically is the link-time failure
2931
+ * described above the budget constants.
2932
+ */
2933
+ const IMAGE_OFFLOAD_REQUIRED_CODE = "IMAGE_OFFLOAD_REQUIRED";
2934
+ /** The installed engine's image policy; see {@link CoreImagePolicy}. */
2935
+ const CORE_IMAGE_POLICY = dshLlm;
2936
+ /**
2937
+ * The durable-offload contract this engine speaks, or undefined on ≤0.1.5.
2938
+ *
2939
+ * Both members are required together: a half-populated engine would either
2940
+ * lose the surface's marks (re-sending evicted images as pixels) or lose the
2941
+ * count (silently sending an over-budget body), and neither is worth a partial
2942
+ * opt-in. Absent, the adapter keeps its own transient projection instead.
2943
+ */
2944
+ function surfaceImagePolicy(api = CORE_IMAGE_POLICY) {
2945
+ return typeof api.projectOffloadedImages === "function" && typeof api.requiredImageOffload === "function" ? {
2946
+ projectOffloadedImages: api.projectOffloadedImages,
2947
+ requiredImageOffload: api.requiredImageOffload
2948
+ } : void 0;
2949
+ }
2950
+ /**
2951
+ * The failure a route raises instead of evicting on its own (≥0.1.6). The
2952
+ * count is the ONE thing the harness needs: `dsh-compaction-image-offload`
2953
+ * records it as an `image/offload` event, marks that many oldest retained
2954
+ * occurrences, and retries the step.
2955
+ *
2956
+ * Deliberately outside `providerRetryPolicy()`'s whitelist: a byte-identical
2957
+ * resend cannot succeed, so the retry belongs to the surface mutation, not to
2958
+ * dsh-llm-retry. On a profile that does not mount that plugin the failure is
2959
+ * terminal — every shipped profile reaches it through dsh-base.
2960
+ */
2961
+ function imageOffloadRequired(missing) {
2962
+ const options = { offloadImages: missing };
2963
+ return new LlmError(`commandcode request images exceed the route budget; ${missing} more oldest occurrence(s) must be offloaded.`, IMAGE_OFFLOAD_REQUIRED_CODE, options);
2964
+ }
2965
+ /** The budget in the shape the core counter takes (representation + the rung). */
2966
+ function coreBudget(budget) {
2967
+ return {
2968
+ representation: "base64",
2969
+ ...budget
2970
+ };
2971
+ }
2972
+ /**
2973
+ * Exact request-version bytes of one retained occurrence. The declared
2974
+ * normalized size IS what this adapter inlines (the body is built from
2975
+ * `attachments.readImage`, base64-encoded), so the accounting matches the wire
2976
+ * byte for byte, and the core helper expands it for `base64` on its own.
2977
+ */
2978
+ const imageVersionBytes = (block) => block.attachment.bytes;
2979
+ /**
2980
+ * ≥0.1.6 request options: the surface's durable marks become placeholder text,
2981
+ * and an over-budget history FAILS with the count to offload instead of being
2982
+ * evicted here. Throws rather than returning, so no caller can accidentally
2983
+ * send a body this route already knows is too large.
2984
+ */
2985
+ function withSurfaceOffload(options, policy, budget) {
2986
+ const marked = policy.projectOffloadedImages(options.messages, (ref) => offloadedImageText(ref));
2987
+ const messages = marked === options.messages ? options.messages : [...marked];
2988
+ const missing = policy.requiredImageOffload(messages, coreBudget(budget), imageVersionBytes);
2989
+ if (missing > 0) throw imageOffloadRequired(missing);
2990
+ return messages === options.messages ? options : {
2991
+ ...options,
2992
+ messages
2993
+ };
2994
+ }
2995
+ /**
2996
+ * One image's bytes for the wire: the attachment service's REQUEST VERSION when
2997
+ * it can produce one, else the normalized original.
2998
+ *
2999
+ * `readImage()` answers the stored original, which is what admission accepted —
3000
+ * not what a provider request should carry. `readImageRequest()` re-encodes to
3001
+ * the route target in `./image-request.ts` and caches the result under it, so
3002
+ * the same attachment costs one encode per target instead of one per occurrence,
3003
+ * and the base64 the wire expands is bounded (both the pixel budget and, more to
3004
+ * the point here, the encoded bytes). The fallback is for a backend that does
3005
+ * not implement the abstract method; every engine this plugin declares support
3006
+ * for does.
3007
+ */
3008
+ async function readRequestImage(attachments, ref) {
3009
+ if (typeof attachments.readImageRequest === "function") return (await attachments.readImageRequest(ref, requestImageTarget(ref))).data;
3010
+ return (await attachments.readImage(ref)).data;
3011
+ }
3012
+ /**
3013
+ * Price one request occurrence.
3014
+ *
3015
+ * Two cases cost no vision tokens at all, and both are priced as their
3016
+ * model-visible TEXT so the caller's estimator can charge that instead: a model
3017
+ * that accepts text only (the harness projects those images to placeholder text
3018
+ * before the request), and an occurrence the session surface has already
3019
+ * offloaded (≥0.1.6 renders the same placeholder text in every later request).
3020
+ * A retained occurrence on this route contributes no text at all — the pixels
3021
+ * are inlined — so its price is the family's visual-token count and nothing else.
3022
+ */
3023
+ function priceRequestImage(occurrence, model, imageCapable) {
3024
+ const block = occurrence;
3025
+ const ref = block.attachment ?? occurrence;
3026
+ if (!imageCapable) return {
3027
+ visualTokens: 0,
3028
+ text: textOnlyImageText(ref)
3029
+ };
3030
+ if (block.offloaded === true) return {
3031
+ visualTokens: 0,
3032
+ text: offloadedImageText(ref)
3033
+ };
3034
+ const target = requestImageTarget(ref);
3035
+ return {
3036
+ visualTokens: commandCodeImageTokens(model, target.width, target.height),
3037
+ text: ""
3038
+ };
3039
+ }
3040
+ /**
2593
3041
  * Convert one image reference to the Command Code wire format, as the official
2594
3042
  * CLI does: `{ type: 'image', source: { type: 'base64', media_type, data } }`.
2595
3043
  * Bytes come from the durable attachment service; the media type is the one
@@ -2617,7 +3065,7 @@ async function messagesToCC(messages, readImage) {
2617
3065
  };
2618
3066
  for (const message of messages) {
2619
3067
  if (message.role === "system") continue;
2620
- if (message.role === "user" && message.source.kind !== "tool") {
3068
+ if (message.role === "user" && !isToolResultMessage(message)) {
2621
3069
  flushPendingImages();
2622
3070
  const parts = [];
2623
3071
  for (const block of message.content) {
@@ -2660,7 +3108,7 @@ async function messagesToCC(messages, readImage) {
2660
3108
  });
2661
3109
  continue;
2662
3110
  }
2663
- if (message.role === "user" && message.source.kind === "tool") {
3111
+ if (isToolResultMessage(message)) {
2664
3112
  const block = message.content[0];
2665
3113
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
2666
3114
  const media = toolResultMedia(block);
@@ -2729,7 +3177,7 @@ async function messagesToOpenAI(messages, readImage) {
2729
3177
  };
2730
3178
  for (const message of messages) {
2731
3179
  if (message.role === "system") continue;
2732
- if (message.role === "user" && message.source.kind !== "tool") {
3180
+ if (message.role === "user" && !isToolResultMessage(message)) {
2733
3181
  flushPendingImages();
2734
3182
  const parts = [];
2735
3183
  for (const block of message.content) {
@@ -2775,7 +3223,7 @@ async function messagesToOpenAI(messages, readImage) {
2775
3223
  out.push(assistant);
2776
3224
  continue;
2777
3225
  }
2778
- if (message.role === "user" && message.source.kind === "tool") {
3226
+ if (isToolResultMessage(message)) {
2779
3227
  const block = message.content[0];
2780
3228
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
2781
3229
  const media = toolResultMedia(block);
@@ -3106,6 +3554,15 @@ var CommandCodeAdapter = class extends LlmAdapter {
3106
3554
  catalog = [];
3107
3555
  fetchImpl;
3108
3556
  resolveAttachments;
3557
+ /** The engine's request-image policy, resolved once (tests may inject one). */
3558
+ imagePolicy;
3559
+ /**
3560
+ * The ≥0.1.6 durable contract, or `undefined` on a ≤0.1.5 engine — where the
3561
+ * adapter must evict its own history. Resolved once: the contract a process
3562
+ * speaks cannot change while it runs, and resolving here keeps every request
3563
+ * on the same branch (see {@link surfaceImagePolicy}).
3564
+ */
3565
+ surfaceOffload;
3109
3566
  billingAccess = /* @__PURE__ */ new Map();
3110
3567
  billingAccessInflight = /* @__PURE__ */ new Map();
3111
3568
  protocolCache = /* @__PURE__ */ new Map();
@@ -3114,6 +3571,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
3114
3571
  this.deps = deps;
3115
3572
  this.fetchImpl = deps.fetchImpl ?? fetch;
3116
3573
  this.resolveAttachments = deps.resolveAttachments;
3574
+ this.imagePolicy = deps.imageOffload ?? CORE_IMAGE_POLICY;
3575
+ this.surfaceOffload = surfaceImagePolicy(this.imagePolicy);
3117
3576
  }
3118
3577
  /**
3119
3578
  * Display metadata for the picker's provider group header. The base class
@@ -3163,6 +3622,25 @@ var CommandCodeAdapter = class extends LlmAdapter {
3163
3622
  }
3164
3623
  }, "llm-commandcode: retryPolicy");
3165
3624
  }
3625
+ /**
3626
+ * Visual-token pricing for one exact model route (see `./image-tokens.ts`).
3627
+ *
3628
+ * Without this the token meter prices EVERY image with its structural
3629
+ * heuristic — a handful of tokens for a full screenshot — so an image-heavy
3630
+ * session reports far less context than it is actually carrying. The price is
3631
+ * computed at the dimensions this route would send (the same request target
3632
+ * `readImageRequest` encodes to), so the estimate tracks the wire rather than
3633
+ * the stored original.
3634
+ *
3635
+ * Both payload generations are handled, because they disagree: ≤0.1.5 hands
3636
+ * over bare `ImageAttachmentRef`s, ≥0.1.6 hands over `ImageBlock`s that also
3637
+ * carry the surface's `offloaded` mark. Returning a price per occurrence, in
3638
+ * order, is a hard requirement — the meter throws when the counts differ.
3639
+ */
3640
+ imageRequestPricing(_provider, model) {
3641
+ const imageCapable = KNOWN_IMAGE_MODELS.has(model);
3642
+ return { priceImages: (images) => images.map((image) => priceRequestImage(image, model, imageCapable)) };
3643
+ }
3166
3644
  /** Refresh the catalog (live fetch, cache fallback) and return it. */
3167
3645
  async loadCatalog(signal) {
3168
3646
  const { apiBase, modelsCachePath } = this.deps.options();
@@ -3341,10 +3819,25 @@ var CommandCodeAdapter = class extends LlmAdapter {
3341
3819
  * wins; otherwise a cached (not network-fetched) billing tier of Go is
3342
3820
  * treated as CLI-only. Unknown accounts default to Provider API and fall
3343
3821
  * back only after an `upgrade_required` rejection.
3822
+ *
3823
+ * Models the Provider API serves ONLY through `/provider/v1/messages` (the
3824
+ * Claude family — `requiresMessagesEndpoint()`) always take the CLI
3825
+ * transport, on any tier and even under a forced `'openai'` preference:
3826
+ * that option is documented as "prefer the Provider API, still fall back",
3827
+ * and the 400 these models answer with is not a preference to be honoured
3828
+ * but a hard refusal. `/alpha/generate` routes every one of them, so this
3829
+ * costs nothing.
3830
+ *
3831
+ * That decision is deliberately NOT written to `protocolCache`. The cache is
3832
+ * keyed by API key alone, so remembering it would pin the whole ACCOUNT to
3833
+ * the CLI transport and drag every other model — DeepSeek, GLM, Qwen, all of
3834
+ * which the Provider API serves correctly — off it until the entry expired.
3344
3835
  */
3345
- resolveProtocol(apiKey) {
3836
+ resolveProtocol(apiKey, model) {
3346
3837
  const forced = this.deps.options().protocol;
3347
- if (forced === "cli" || forced === "openai") return forced;
3838
+ if (forced === "cli") return forced;
3839
+ if (requiresMessagesEndpoint(model)) return "cli";
3840
+ if (forced === "openai") return forced;
3348
3841
  const cached = this.cachedProtocolUseCli(apiKey);
3349
3842
  if (cached !== void 0) return cached ? "cli" : "openai";
3350
3843
  if (this.cachedBillingTierWeight(apiKey) === GO_TIER_WEIGHT) {
@@ -3449,7 +3942,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
3449
3942
  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");
3450
3943
  const attachments = this.resolveAttachments?.();
3451
3944
  if (attachments === void 0) throw new LlmError("Command Code image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
3452
- readImage = (ref) => attachments.readImage(ref).then((stored) => stored.data);
3945
+ readImage = (ref) => readRequestImage(attachments, ref);
3453
3946
  }
3454
3947
  const connection = this.deps.options();
3455
3948
  let apiKey = await this.deps.resolveApiKey(connection, options.model);
@@ -3459,8 +3952,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
3459
3952
  const supported = KNOWN_EFFORTS[options.model];
3460
3953
  const reasoningEffort = effort && effort !== "off" && supported?.includes(effort) ? effort : void 0;
3461
3954
  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");
3462
- let protocol = this.resolveProtocol(apiKey);
3463
- let requestOptions = withImageBudget(options, REQUEST_IMAGE_BUDGETS[0]);
3955
+ let protocol = this.resolveProtocol(apiKey, options.model);
3956
+ let requestOptions = this.surfaceOffload === void 0 ? withImageBudget(options, REQUEST_IMAGE_BUDGETS[0], this.imagePolicy) : withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]);
3464
3957
  let imageBudgetIndex = 0;
3465
3958
  const buildBody = async (target) => target === "cli" ? buildCliBody(requestOptions, connection, {
3466
3959
  maxTokens,
@@ -3495,12 +3988,17 @@ var CommandCodeAdapter = class extends LlmAdapter {
3495
3988
  continue;
3496
3989
  }
3497
3990
  if (attempt.status === 413 && imageBudgetIndex + 1 < REQUEST_IMAGE_BUDGETS.length) {
3498
- const tightened = withImageBudget(requestOptions, REQUEST_IMAGE_BUDGETS[imageBudgetIndex + 1]);
3499
- if (tightened !== requestOptions) {
3500
- imageBudgetIndex += 1;
3501
- requestOptions = tightened;
3502
- body = await buildBody(protocol);
3503
- continue;
3991
+ if (this.surfaceOffload !== void 0) {
3992
+ const missing = this.surfaceOffload.requiredImageOffload(requestOptions.messages, coreBudget(REQUEST_IMAGE_BUDGETS[imageBudgetIndex + 1]), imageVersionBytes);
3993
+ if (missing > 0) throw imageOffloadRequired(missing);
3994
+ } else {
3995
+ const tightened = withImageBudget(requestOptions, REQUEST_IMAGE_BUDGETS[imageBudgetIndex + 1], this.imagePolicy);
3996
+ if (tightened !== requestOptions) {
3997
+ imageBudgetIndex += 1;
3998
+ requestOptions = tightened;
3999
+ body = await buildBody(protocol);
4000
+ continue;
4001
+ }
3504
4002
  }
3505
4003
  }
3506
4004
  const rotate = this.deps.rotateApiKey;
@@ -3786,7 +4284,7 @@ function handleOpenAIEvent(asm, event) {
3786
4284
  }
3787
4285
  const choice = isRecord(choices[0]) ? choices[0] : {};
3788
4286
  const delta = isRecord(choice.delta) ? choice.delta : {};
3789
- const reasoningDelta = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content) ?? "";
4287
+ const reasoningDelta = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content) ?? reasoningDetailsText(delta.reasoning_details) ?? "";
3790
4288
  if (reasoningDelta !== "") {
3791
4289
  chunks.push(...closeText(asm));
3792
4290
  if (asm.reasoningIndex < 0) {
@@ -3972,8 +4470,7 @@ const commandcodeCommand = {
3972
4470
  resetSuffix: "重置 {when}",
3973
4471
  partialFailures: "⚠️ 部分端点失败: {list}",
3974
4472
  noData: "(无数据 — 请检查 API 密钥)",
3975
- errorText: "获取 Command Code 用量失败:{message}",
3976
- imageGate: "当前会话已包含图片,而模型 {model} 不支持图片输入;请选择支持图片的模型,或先移除会话中的图片。"
4473
+ errorText: "获取 Command Code 用量失败:{message}"
3977
4474
  },
3978
4475
  en: {
3979
4476
  title: "📊 Command Code usage{account}",
@@ -4004,8 +4501,7 @@ const commandcodeCommand = {
4004
4501
  resetSuffix: "resets {when}",
4005
4502
  partialFailures: "⚠️ some endpoints failed: {list}",
4006
4503
  noData: "(no data — check your API key)",
4007
- errorText: "Could not fetch Command Code usage: {message}",
4008
- imageGate: "This session already contains images, and model {model} does not accept image input; please select an image-capable model, or remove the images from the session first."
4504
+ errorText: "Could not fetch Command Code usage: {message}"
4009
4505
  }
4010
4506
  };
4011
4507
  /**
@@ -5582,6 +6078,108 @@ function applyCommandCodeTuiSettings(ctx, deps) {
5582
6078
  return refresh;
5583
6079
  }
5584
6080
  //#endregion
6081
+ //#region src/transport-retry.ts
6082
+ /**
6083
+ * A bounded retry budget for `TRANSPORT` failures (issue #39, second report).
6084
+ *
6085
+ * The route's retry policy (`providerRetryPolicy` in `./adapter.ts`) is
6086
+ * deliberately near-unbounded: 1000 attempts with waits doubling to a 15-minute
6087
+ * cap. That shape exists for the failures a provider ASKS to have retried — an
6088
+ * exhausted 5-hour window, a Cloudflare 520 — where the wait is the point.
6089
+ *
6090
+ * A transport failure is not that. The reporter's second event at ~710k
6091
+ * context was `Connect Timeout Error ... timeout: 10000ms` (undici's own
6092
+ * connect timeout; this plugin's `requestTimeoutMs` is 60s) and, on another
6093
+ * attempt, `read ECONNRESET` against `/alpha/generate`. Because `TRANSPORT` is
6094
+ * in the whitelist, each of those entered the same 1000-attempt loop: with
6095
+ * `initialDelayMs: 500` doubling, the wait before attempt 11 alone is 512 s and
6096
+ * the cumulative wait before it is ~500 s — the "模型请求重试已取消(11/1000)·
6097
+ * 482s" the reporter photographed is that 512 s countdown, 30 s into it — and
6098
+ * those waits are counted BEFORE each retry attempt, not after it.
6099
+ *
6100
+ * A connection that cannot be established is either a sub-second blip (the
6101
+ * first few attempts recover it, invisibly) or an outage that no amount of
6102
+ * waiting inside one request will fix. So transport failures get their own
6103
+ * budget, and when it is exhausted the failure is surfaced with the diagnosis
6104
+ * instead of manufacturing a multi-minute stall. Waiting out a real outage is
6105
+ * the user's call — the next send starts a fresh budget — and
6106
+ * `dsh-llm-retry`'s window for rate limits is untouched.
6107
+ *
6108
+ * Deliberately NOT counted here: the pre-stream recovery the ADAPTER performs
6109
+ * inside one `stream()` call (account rotation on 429/401, the 413 image-budget
6110
+ * step-down, the Provider-API → CLI protocol switch). Those are invisible to
6111
+ * the caller and bounded on their own; this budget caps only the harness's
6112
+ * retry loop.
6113
+ *
6114
+ * Reset rule: the budget clears at each new STEP — one step is exactly one
6115
+ * model request (`step/start` is appended immediately before the call) — so the
6116
+ * cap is per logical request and the next step of the same turn gets its own
6117
+ * grace. `agent/status` → `idle` is NOT the reset signal even though it looks
6118
+ * like the natural one: the agent loop's `setPhase` emits it only on a status
6119
+ * CHANGE, and a turn's steps all run inside one `running` phase, so it never
6120
+ * fires between them — a budget reset only there would be per-turn, and an
6121
+ * outage would fail every later step of that turn immediately instead of
6122
+ * retrying each. `assistant/attempt` is wrong for the opposite reason: the loop
6123
+ * appends it for the FAILED attempt itself, *before* dispatching
6124
+ * `agent/request-error`, so resetting on it would clear the budget on every
6125
+ * failure and restore the very loop this module exists to stop.
6126
+ *
6127
+ * @module
6128
+ */
6129
+ /** Failure code this budget covers. */
6130
+ const TRANSPORT_FAILURE_CODE = "TRANSPORT";
6131
+ /** One agent's live transport-failure count, keyed weakly so a dropped agent cannot leak. */
6132
+ const counts = /* @__PURE__ */ new WeakMap();
6133
+ /**
6134
+ * Count one attempt's outcome against the transport budget.
6135
+ *
6136
+ * @param agent - the identity the budget is scoped to (the agent object).
6137
+ * @param failureCode - the failed attempt's `LlmFailure.code`.
6138
+ * @param maxRetries - transport retries to absorb before the failure surfaces.
6139
+ * @returns `'ignored'` for any non-transport code (the route policy decides
6140
+ * those, unchanged), `'retry'` while the budget has room, `'exhausted'` once
6141
+ * it does not.
6142
+ */
6143
+ function absorbTransportFailure(agent, failureCode, maxRetries) {
6144
+ if (failureCode !== "TRANSPORT") return "ignored";
6145
+ const used = counts.get(agent) ?? 0;
6146
+ if (used >= maxRetries) return "exhausted";
6147
+ counts.set(agent, used + 1);
6148
+ return "retry";
6149
+ }
6150
+ /** Clear one agent's transport budget (a new step starts, or the turn ended). */
6151
+ function resetTransportFailures(agent) {
6152
+ counts.delete(agent);
6153
+ }
6154
+ /**
6155
+ * What one session event means for the transport budget.
6156
+ *
6157
+ * Exported and pure on purpose: the choice of event is the part of this design
6158
+ * that is easy to get plausibly wrong (`agent/status` → `idle` looks like the
6159
+ * natural reset and is not one — see the module comment), so it is stated once,
6160
+ * here, where a test can pin it, instead of being implied by a listener body.
6161
+ *
6162
+ * @param type - the appended session event's type.
6163
+ * @returns `'reset'` for a new step (one model request), `'forget'` when the
6164
+ * turn ended and the session → agent mapping is no longer needed, otherwise
6165
+ * `'none'`.
6166
+ */
6167
+ function transportResetAction(type) {
6168
+ if (type === "step/start") return "reset";
6169
+ if (type === "turn/end") return "forget";
6170
+ return "none";
6171
+ }
6172
+ /**
6173
+ * The diagnosis a capped turn ends with. Bilingual, because the harness renders
6174
+ * a failed turn's message verbatim and this plugin's other user-facing failures
6175
+ * are bilingual too (see the adapter's error builders). It names the retry
6176
+ * budget and the checks that actually help, instead of the bare "fetch failed"
6177
+ * chain the retry chrome would otherwise show.
6178
+ */
6179
+ function transportBudgetMessage(failureMessage, maxRetries) {
6180
+ return `${failureMessage};Command Code API 连接在连续重试后仍失败——已停止重试以免整轮卡死。插件已自动重试 ${maxRetries} 次。通常说明当前网络到 api.commandcode.ai 的连接不通,常见原因是代理未生效:DSH 只读取环境变量 HTTPS_PROXY/HTTP_PROXY(系统代理/PAC 不会被读取),请确认启动 DSH 的终端里已导出代理,或先关闭代理直连后重试。网络恢复后直接重发即可 — Command Code API transport still failed after ${maxRetries} automatic retries, which were stopped so the turn could not stall for minutes. This is a connectivity problem between this machine and api.commandcode.ai, not a context-window or request-size rejection. Common cause: the proxy is not reaching the harness — DSH reads HTTPS_PROXY/HTTP_PROXY from the environment only (a Windows system proxy/PAC setting is not consulted), so export it in the launching shell, or turn the proxy off and retry. Resend when the network recovers; the budget is per request and has been reset`;
6181
+ }
6182
+ //#endregion
5585
6183
  //#region src/index.ts
5586
6184
  /**
5587
6185
  * dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
@@ -5626,6 +6224,7 @@ const Config = z.object({
5626
6224
  modelsCachePath: z.string(),
5627
6225
  requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
5628
6226
  streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
6227
+ transportMaxRetries: z.number().min(0).max(50),
5629
6228
  filterModelsByPlan: z.boolean(),
5630
6229
  visibleModels: z.array(z.string()),
5631
6230
  /**
@@ -5767,6 +6366,36 @@ function apply(ctx, config) {
5767
6366
  settingsPath: []
5768
6367
  }]);
5769
6368
  ctx.llm.registerAdapter([PROVIDER], adapter);
6369
+ const transportMaxRetries = () => {
6370
+ const value = current().transportMaxRetries;
6371
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 5;
6372
+ return Math.min(Math.trunc(value), 50);
6373
+ };
6374
+ const sessionAgents = /* @__PURE__ */ new WeakMap();
6375
+ ctx.on("agent/request-error", async ({ agent, failure, signal }, next) => {
6376
+ if (signal.aborted) return next();
6377
+ const session = Reflect.get(agent, "session");
6378
+ if (typeof session === "object" && session !== null) sessionAgents.set(session, agent);
6379
+ const decision = absorbTransportFailure(agent, failure.code, transportMaxRetries());
6380
+ if (decision === "ignored") return next();
6381
+ if (decision === "retry") return { kind: "retry" };
6382
+ const message = transportBudgetMessage(failure.message, transportMaxRetries());
6383
+ ctx.logger.warn(`llm-commandcode: transport retry budget exhausted for ${PROVIDER} (${TRANSPORT_FAILURE_CODE})`);
6384
+ throw new Error(message);
6385
+ });
6386
+ ctx.on("session/event", (session, event) => {
6387
+ const action = transportResetAction(event.type);
6388
+ if (action === "forget") {
6389
+ sessionAgents.delete(session);
6390
+ return;
6391
+ }
6392
+ if (action !== "reset") return;
6393
+ const agent = sessionAgents.get(session);
6394
+ if (agent !== void 0) resetTransportFailures(agent);
6395
+ });
6396
+ ctx.on("agent/status", ({ agent, status }) => {
6397
+ if (status === "idle") resetTransportFailures(agent);
6398
+ });
5770
6399
  const usageReports = async () => {
5771
6400
  const described = await pool.describeAccounts();
5772
6401
  const byId = new Map(described.map((account) => [account.slot.id, account]));