@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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/client.js +360 -319
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +80 -2
- package/lib/index.js +658 -29
- package/lib/index.js.map +1 -1
- package/package.json +42 -38
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
|
|
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
|
|
2560
|
-
* become placeholder text in place, the newest keep their
|
|
2561
|
-
* already inside the budget is returned as the SAME array
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
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]));
|