@mars-sea/dsh-commandcode-provider 0.11.2 → 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 +12 -0
- package/lib/client.js +221 -141
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +25 -0
- package/lib/index.js +258 -8
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -327,6 +327,19 @@ declare class CommandCodeAdapter<C extends CommandCodeConnectionOptions = Comman
|
|
|
327
327
|
* wins; otherwise a cached (not network-fetched) billing tier of Go is
|
|
328
328
|
* treated as CLI-only. Unknown accounts default to Provider API and fall
|
|
329
329
|
* back only after an `upgrade_required` rejection.
|
|
330
|
+
*
|
|
331
|
+
* Models the Provider API serves ONLY through `/provider/v1/messages` (the
|
|
332
|
+
* Claude family — `requiresMessagesEndpoint()`) always take the CLI
|
|
333
|
+
* transport, on any tier and even under a forced `'openai'` preference:
|
|
334
|
+
* that option is documented as "prefer the Provider API, still fall back",
|
|
335
|
+
* and the 400 these models answer with is not a preference to be honoured
|
|
336
|
+
* but a hard refusal. `/alpha/generate` routes every one of them, so this
|
|
337
|
+
* costs nothing.
|
|
338
|
+
*
|
|
339
|
+
* That decision is deliberately NOT written to `protocolCache`. The cache is
|
|
340
|
+
* keyed by API key alone, so remembering it would pin the whole ACCOUNT to
|
|
341
|
+
* the CLI transport and drag every other model — DeepSeek, GLM, Qwen, all of
|
|
342
|
+
* which the Provider API serves correctly — off it until the entry expired.
|
|
330
343
|
*/
|
|
331
344
|
private resolveProtocol;
|
|
332
345
|
/**
|
|
@@ -1544,6 +1557,18 @@ interface Config {
|
|
|
1544
1557
|
requestTimeoutMs?: number;
|
|
1545
1558
|
/** Milliseconds a stream may stall before being treated as a dead connection; defaults to 300s. */
|
|
1546
1559
|
streamIdleTimeoutMs?: number;
|
|
1560
|
+
/**
|
|
1561
|
+
* Transport failures one request absorbs before the failure is surfaced;
|
|
1562
|
+
* defaults to 5. The route's retry policy is near-unbounded on purpose (1000
|
|
1563
|
+
* attempts, waits doubling to 15 minutes) because that shape is for the
|
|
1564
|
+
* failures a provider asks to have retried — an exhausted rate-limit window,
|
|
1565
|
+
* a gateway 520. A transport failure is not one of those: the first attempts
|
|
1566
|
+
* recover an ordinary blip (the default 5 retries are scheduled 0.5/1/2/4/8 s
|
|
1567
|
+
* after the failures before them, so ~15.5 s of grace), and after that the
|
|
1568
|
+
* wait is pure stall, so the retries are capped here. Raise it on a genuinely
|
|
1569
|
+
* flaky link; 0 surfaces every transport failure immediately.
|
|
1570
|
+
*/
|
|
1571
|
+
transportMaxRetries?: number;
|
|
1547
1572
|
/**
|
|
1548
1573
|
* Whether the model picker hides models above the account's subscription
|
|
1549
1574
|
* tier; defaults to true. The filter fails open (unknown plan, billing
|
package/lib/index.js
CHANGED
|
@@ -386,6 +386,48 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
|
|
|
386
386
|
"inclusionai/ling-3.0-flash-sante:free"
|
|
387
387
|
]);
|
|
388
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
|
+
/**
|
|
389
431
|
* The minimum subscription plan a model is included in, per the official plan
|
|
390
432
|
* pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`, `/docs/plans/max`
|
|
391
433
|
* and `/docs/resources/pricing-limits`). Each plan's model list is a superset of
|
|
@@ -2361,6 +2403,28 @@ function numberValue(value) {
|
|
|
2361
2403
|
function booleanValue(value) {
|
|
2362
2404
|
return typeof value === "boolean" ? value : void 0;
|
|
2363
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
|
+
}
|
|
2364
2428
|
/** Parse a billing-period timestamp (ISO string or millis) into millis; 0 when absent/invalid. */
|
|
2365
2429
|
function periodEndValue(value) {
|
|
2366
2430
|
const asNumber = numberValue(value);
|
|
@@ -2640,6 +2704,44 @@ async function writeModelsCache(cachePath, models) {
|
|
|
2640
2704
|
}
|
|
2641
2705
|
}
|
|
2642
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
|
+
/**
|
|
2643
2745
|
* Collect the tool calls that have a paired tool result, plus each call's
|
|
2644
2746
|
* name. The name map feeds the `toolName` of replayed tool results: some
|
|
2645
2747
|
* backends (e.g. Google Gemini `functionResponse`) reject a result whose
|
|
@@ -2963,7 +3065,7 @@ async function messagesToCC(messages, readImage) {
|
|
|
2963
3065
|
};
|
|
2964
3066
|
for (const message of messages) {
|
|
2965
3067
|
if (message.role === "system") continue;
|
|
2966
|
-
if (message.role === "user" && message
|
|
3068
|
+
if (message.role === "user" && !isToolResultMessage(message)) {
|
|
2967
3069
|
flushPendingImages();
|
|
2968
3070
|
const parts = [];
|
|
2969
3071
|
for (const block of message.content) {
|
|
@@ -3006,7 +3108,7 @@ async function messagesToCC(messages, readImage) {
|
|
|
3006
3108
|
});
|
|
3007
3109
|
continue;
|
|
3008
3110
|
}
|
|
3009
|
-
if (message
|
|
3111
|
+
if (isToolResultMessage(message)) {
|
|
3010
3112
|
const block = message.content[0];
|
|
3011
3113
|
if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
|
|
3012
3114
|
const media = toolResultMedia(block);
|
|
@@ -3075,7 +3177,7 @@ async function messagesToOpenAI(messages, readImage) {
|
|
|
3075
3177
|
};
|
|
3076
3178
|
for (const message of messages) {
|
|
3077
3179
|
if (message.role === "system") continue;
|
|
3078
|
-
if (message.role === "user" && message
|
|
3180
|
+
if (message.role === "user" && !isToolResultMessage(message)) {
|
|
3079
3181
|
flushPendingImages();
|
|
3080
3182
|
const parts = [];
|
|
3081
3183
|
for (const block of message.content) {
|
|
@@ -3121,7 +3223,7 @@ async function messagesToOpenAI(messages, readImage) {
|
|
|
3121
3223
|
out.push(assistant);
|
|
3122
3224
|
continue;
|
|
3123
3225
|
}
|
|
3124
|
-
if (message
|
|
3226
|
+
if (isToolResultMessage(message)) {
|
|
3125
3227
|
const block = message.content[0];
|
|
3126
3228
|
if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
|
|
3127
3229
|
const media = toolResultMedia(block);
|
|
@@ -3717,10 +3819,25 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
3717
3819
|
* wins; otherwise a cached (not network-fetched) billing tier of Go is
|
|
3718
3820
|
* treated as CLI-only. Unknown accounts default to Provider API and fall
|
|
3719
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.
|
|
3720
3835
|
*/
|
|
3721
|
-
resolveProtocol(apiKey) {
|
|
3836
|
+
resolveProtocol(apiKey, model) {
|
|
3722
3837
|
const forced = this.deps.options().protocol;
|
|
3723
|
-
if (forced === "cli"
|
|
3838
|
+
if (forced === "cli") return forced;
|
|
3839
|
+
if (requiresMessagesEndpoint(model)) return "cli";
|
|
3840
|
+
if (forced === "openai") return forced;
|
|
3724
3841
|
const cached = this.cachedProtocolUseCli(apiKey);
|
|
3725
3842
|
if (cached !== void 0) return cached ? "cli" : "openai";
|
|
3726
3843
|
if (this.cachedBillingTierWeight(apiKey) === GO_TIER_WEIGHT) {
|
|
@@ -3835,7 +3952,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
3835
3952
|
const supported = KNOWN_EFFORTS[options.model];
|
|
3836
3953
|
const reasoningEffort = effort && effort !== "off" && supported?.includes(effort) ? effort : void 0;
|
|
3837
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");
|
|
3838
|
-
let protocol = this.resolveProtocol(apiKey);
|
|
3955
|
+
let protocol = this.resolveProtocol(apiKey, options.model);
|
|
3839
3956
|
let requestOptions = this.surfaceOffload === void 0 ? withImageBudget(options, REQUEST_IMAGE_BUDGETS[0], this.imagePolicy) : withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]);
|
|
3840
3957
|
let imageBudgetIndex = 0;
|
|
3841
3958
|
const buildBody = async (target) => target === "cli" ? buildCliBody(requestOptions, connection, {
|
|
@@ -4167,7 +4284,7 @@ function handleOpenAIEvent(asm, event) {
|
|
|
4167
4284
|
}
|
|
4168
4285
|
const choice = isRecord(choices[0]) ? choices[0] : {};
|
|
4169
4286
|
const delta = isRecord(choice.delta) ? choice.delta : {};
|
|
4170
|
-
const reasoningDelta = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content) ?? "";
|
|
4287
|
+
const reasoningDelta = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content) ?? reasoningDetailsText(delta.reasoning_details) ?? "";
|
|
4171
4288
|
if (reasoningDelta !== "") {
|
|
4172
4289
|
chunks.push(...closeText(asm));
|
|
4173
4290
|
if (asm.reasoningIndex < 0) {
|
|
@@ -5961,6 +6078,108 @@ function applyCommandCodeTuiSettings(ctx, deps) {
|
|
|
5961
6078
|
return refresh;
|
|
5962
6079
|
}
|
|
5963
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
|
|
5964
6183
|
//#region src/index.ts
|
|
5965
6184
|
/**
|
|
5966
6185
|
* dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
|
|
@@ -6005,6 +6224,7 @@ const Config = z.object({
|
|
|
6005
6224
|
modelsCachePath: z.string(),
|
|
6006
6225
|
requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
|
|
6007
6226
|
streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
|
|
6227
|
+
transportMaxRetries: z.number().min(0).max(50),
|
|
6008
6228
|
filterModelsByPlan: z.boolean(),
|
|
6009
6229
|
visibleModels: z.array(z.string()),
|
|
6010
6230
|
/**
|
|
@@ -6146,6 +6366,36 @@ function apply(ctx, config) {
|
|
|
6146
6366
|
settingsPath: []
|
|
6147
6367
|
}]);
|
|
6148
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
|
+
});
|
|
6149
6399
|
const usageReports = async () => {
|
|
6150
6400
|
const described = await pool.describeAccounts();
|
|
6151
6401
|
const byId = new Map(described.map((account) => [account.slot.id, account]));
|