@mars-sea/dsh-commandcode-provider 0.11.14 → 0.11.17
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 +38 -0
- package/README.md +8 -26
- package/README.zh-CN.md +8 -26
- package/lib/client.js +355 -659
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +293 -469
- package/lib/index.js +755 -1669
- package/lib/index.js.map +1 -1
- package/package.json +37 -39
package/lib/index.js
CHANGED
|
@@ -2,12 +2,12 @@ import { homedir, tmpdir } 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, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, isContextWindowExceededError, offloadedImageText, projectOffloadedImages, requiredImageOffload, resolveRetryPolicy, textOnlyImageText } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ProviderRequestId, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, isContextWindowExceededError, offloadedImageText, projectOffloadedImages, requiredImageOffload, resolveRetryPolicy, textOnlyImageText } from "@deepseek-ai/dsh-llm";
|
|
6
6
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
7
7
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
8
8
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
9
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
10
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
12
12
|
import { createServer } from "node:http";
|
|
13
13
|
import { createServer as createServer$1 } from "node:net";
|
|
@@ -18,18 +18,12 @@ import { WebError } from "@deepseek-ai/dsh-web";
|
|
|
18
18
|
* reasoning-effort levels, vision/thinking flags, model → minimum plan tier,
|
|
19
19
|
* subscription-plan labels, deals, and hourly (peak/off-peak) pricing.
|
|
20
20
|
*
|
|
21
|
-
* Everything
|
|
22
|
-
*
|
|
23
|
-
* dsh-commandcode-upstream skill
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* these tables + read helpers.
|
|
28
|
-
*
|
|
29
|
-
* Snapshot read helpers (planLabel, dealLabel, formatContext,
|
|
30
|
-
* capabilityDescription, peakPricing*, compareByPlan, modelVisibleInPlan,
|
|
31
|
-
* subscriptionPlanInfo, isFreeModel) live here too — they exist only to read
|
|
32
|
-
* the tables, so a sync never has to touch src/adapter.ts.
|
|
21
|
+
* Everything here is synced from official sources — the command-code CLI
|
|
22
|
+
* bundle's model table (`dist/cli.mjs`, re-verified at command-code@1.66.0) and
|
|
23
|
+
* the official plan/pricing/model docs; see the dsh-commandcode-upstream skill
|
|
24
|
+
* for the extraction procedures. Keeping the snapshot in its own module
|
|
25
|
+
* confines those frequent sync diffs here: src/adapter.ts holds only the stable
|
|
26
|
+
* wire/runtime logic. The read helpers live here too, for the same reason.
|
|
33
27
|
*
|
|
34
28
|
* Ported from pi-commandcode-provider (MIT); originally part of src/adapter.ts
|
|
35
29
|
* and split out so upstream syncs stay reviewable.
|
|
@@ -313,6 +307,11 @@ const KNOWN_EFFORTS = {
|
|
|
313
307
|
"low",
|
|
314
308
|
"medium",
|
|
315
309
|
"high"
|
|
310
|
+
],
|
|
311
|
+
"stealth/pixel-canary": [
|
|
312
|
+
"low",
|
|
313
|
+
"medium",
|
|
314
|
+
"xhigh"
|
|
316
315
|
]
|
|
317
316
|
};
|
|
318
317
|
/**
|
|
@@ -378,6 +377,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
|
378
377
|
"moonshotai/Kimi-K2.7-Code-Highspeed",
|
|
379
378
|
"moonshotai/Kimi-K3",
|
|
380
379
|
"sakana/fugu-ultra",
|
|
380
|
+
"stealth/pixel-canary",
|
|
381
381
|
"stealth/space-bunny-alpha",
|
|
382
382
|
"stepfun/Step-3.7-Flash",
|
|
383
383
|
"stepfun/Step-5-Preview",
|
|
@@ -395,19 +395,22 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
|
395
395
|
]);
|
|
396
396
|
/**
|
|
397
397
|
* Models WITHOUT a zero-data-retention upstream, per the official CLI's own
|
|
398
|
-
* registry (`command-code@1.
|
|
398
|
+
* registry (`command-code@1.66.0` `dist/cli.mjs`): `modelSupportsZdr(id)` is
|
|
399
399
|
* exactly `!nonZdrSet.has(canonicalize(id))`, and `knownModelSupportsZdr`
|
|
400
|
-
* carries the same membership in the sibling route table — the
|
|
401
|
-
* set
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
400
|
+
* carries the same membership in the sibling route table — the UNION of both
|
|
401
|
+
* is this set. Reading only the sibling route table would drop `meituan/
|
|
402
|
+
* LongCat-2.0` and `stealth/pixel-canary`, which each appear in
|
|
403
|
+
* `modelSupportsZdr` alone. The official docs (commandcode.ai/docs/resources/
|
|
404
|
+
* zdr) put it in prose — "99% of our models have ZDR-capable upstreams … only
|
|
405
|
+
* a small handful of models are affected" — so the CLI's exclusion list is
|
|
406
|
+
* the only per-model evidence there is; a ZDR request naming one of these
|
|
407
|
+
* fails with HTTP 422 `cmd_zdr_no_providers` instead of routing through a
|
|
408
|
+
* provider that retains.
|
|
406
409
|
*
|
|
407
410
|
* Why a NEGATIVE set, and why "not listed" answers TRUE: 99% of the catalog is
|
|
408
411
|
* covered, so the maintained difference is the exception list. This helper is
|
|
409
412
|
* informational; the adapter sends the ZDR header for EVERY request when the
|
|
410
|
-
* switch is on
|
|
413
|
+
* switch is on — the provider remains the routing authority and refuses an
|
|
411
414
|
* unsupported model rather than silently dropping the privacy guarantee.
|
|
412
415
|
*
|
|
413
416
|
* `minimax/minimax-m3-free` is the one entry the public catalog
|
|
@@ -418,16 +421,9 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
|
418
421
|
* Keep in sync via the dsh-commandcode-upstream skill: the CLI's registry data
|
|
419
422
|
* (its `zdr:{only:[…]}` provider routes and the per-provider `zdr`/`noTraining`
|
|
420
423
|
* flags) is upstream-internal routing, not a per-model contract, so this table
|
|
421
|
-
* is the snapshot of the exclusion set and nothing more.
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* how rare a change here is expected to be. The 1.64.0 → 1.65.0 diff (2026-09-24)
|
|
425
|
-
* is the counterexample that proves the check still runs: exactly one member
|
|
426
|
-
* joins (`stealth/space-bunny-alpha`), and it is in both anchors. `meituan/
|
|
427
|
-
* LongCat-2.0` is the one member the two anchors disagree about (it is in
|
|
428
|
-
* `modelSupportsZdr`'s set in both releases and in neither `knownModelSupportsZdr`
|
|
429
|
-
* set), so the union is
|
|
430
|
-
* what this table follows; reading only the sibling route table would drop it.
|
|
424
|
+
* is the snapshot of the exclusion set and nothing more. It is a rare change:
|
|
425
|
+
* 20 members held across 1.62.0 → 1.64.0, and 1.65.0 and 1.66.0 each added
|
|
426
|
+
* exactly one (the two stealth-preview models below).
|
|
431
427
|
*/
|
|
432
428
|
const KNOWN_NON_ZDR_MODELS = /* @__PURE__ */ new Set([
|
|
433
429
|
"MiniMaxAI/MiniMax-M3",
|
|
@@ -442,6 +438,7 @@ const KNOWN_NON_ZDR_MODELS = /* @__PURE__ */ new Set([
|
|
|
442
438
|
"poolside/laguna-s-2.1-free",
|
|
443
439
|
"sakana/fugu-ultra",
|
|
444
440
|
"stealth/space-bunny-alpha",
|
|
441
|
+
"stealth/pixel-canary",
|
|
445
442
|
"stepfun/Step-3.7-Flash",
|
|
446
443
|
"stepfun/Step-5-Preview",
|
|
447
444
|
"xai/grok-4.5",
|
|
@@ -461,36 +458,18 @@ function supportsZeroDataRetention(modelId) {
|
|
|
461
458
|
return !KNOWN_NON_ZDR_MODELS.has(modelId);
|
|
462
459
|
}
|
|
463
460
|
/**
|
|
464
|
-
* Models the official CLI's model table
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* description — it exists for programmatic consumers.
|
|
461
|
+
* Models the official CLI's model table marks `reasoning:!0` but defines no
|
|
462
|
+
* selectable `reasoning_effort` levels — they think automatically, with
|
|
463
|
+
* Command Code driving the depth. `KNOWN_EFFORTS` (which mirrors the CLI's
|
|
464
|
+
* effort map exactly) stays the sole source for selectable effort levels, and
|
|
465
|
+
* this snapshot is not surfaced in the picker's compact description — it exists
|
|
466
|
+
* for programmatic consumers.
|
|
471
467
|
*
|
|
472
|
-
* Source: the
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
* joined this set in command-code@1.37.0 — reasoning:!0, no efforts, 1M
|
|
478
|
-
* context, routed through OpenRouter — then gained selectable
|
|
479
|
-
* `['low', 'medium', 'high']` efforts in command-code@1.38.0 and moved to
|
|
480
|
-
* `KNOWN_EFFORTS`. `moonshotai/Kimi-K3` followed the same path in
|
|
481
|
-
* command-code@1.39.3 — it gained `['low', 'high', 'max']` efforts and moved
|
|
482
|
-
* to `KNOWN_EFFORTS`. command-code@1.42.0 added LongCat 2.0 (then
|
|
483
|
-
* `meituan/LongCat-2.0:free`, now the paid `meituan/LongCat-2.0`; reasoning:!0,
|
|
484
|
-
* no efforts). command-code@1.45.0 gave the Muse Spark family
|
|
485
|
-
* (1.1, 1.2, 1.2-contributor, 1.3, 1.3-contributor) selectable
|
|
486
|
-
* `['low', 'medium', 'high', 'xhigh']` efforts — they moved to `KNOWN_EFFORTS`.
|
|
487
|
-
* command-code@1.51.3 gave `MiniMaxAI/MiniMax-M3` selectable
|
|
488
|
-
* `['low', 'medium', 'high']` efforts — it moved to `KNOWN_EFFORTS` too.
|
|
489
|
-
* command-code@1.52.0 added `inclusionai/ling-3.0-flash-sante:free`
|
|
490
|
-
* (reasoning:!0, no efforts).)
|
|
491
|
-
* command-code@1.53.0 added `deepseek/deepseek-v4.1-flash` with selectable
|
|
492
|
-
* ['low', 'high', 'max'] efforts, so it lives in `KNOWN_EFFORTS`, not here.)
|
|
493
|
-
* Keep in sync via the dsh-commandcode-upstream skill.
|
|
468
|
+
* Source: the bundled model table (dist/cli.mjs), cross-checked with
|
|
469
|
+
* https://commandcode.ai/docs/reference/cli/models. Keep in sync via the
|
|
470
|
+
* dsh-commandcode-upstream skill; a model that GAINS selectable efforts leaves
|
|
471
|
+
* this set for `KNOWN_EFFORTS` (Tencent Hy4 Preview, Kimi K3, the Muse Spark
|
|
472
|
+
* family and MiniMax M3 all took that path).
|
|
494
473
|
*/
|
|
495
474
|
const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
|
|
496
475
|
"Qwen/Qwen3.6-Max-Preview",
|
|
@@ -519,7 +498,7 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
|
|
|
519
498
|
*
|
|
520
499
|
* Measured 2026-09-16 against the live catalog (command-code@1.54.0): all 69
|
|
521
500
|
* models were posted to `/provider/v1/chat/completions`; exactly these eight
|
|
522
|
-
* — the whole Claude family — refused, and every one of them is routed
|
|
501
|
+
* — the whole Claude family then — refused, and every one of them is routed
|
|
523
502
|
* normally by `/alpha/generate` (a lower-plan key gets the ordinary
|
|
524
503
|
* `MODEL_NOT_IN_PLAN` 403 there, never a routing error). So the CLI transport
|
|
525
504
|
* is a complete fallback and the adapter does not need a Messages transport.
|
|
@@ -530,13 +509,15 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
|
|
|
530
509
|
* the Provider API by default — picking any Claude model there failed every
|
|
531
510
|
* request before this snapshot existed.
|
|
532
511
|
*
|
|
533
|
-
* Keep in sync when models ship (see the dsh-commandcode-upstream skill).
|
|
534
512
|
* `requiresMessagesEndpoint()` additionally treats any `claude-*` id as
|
|
535
|
-
* Messages-only, so a Claude model added upstream
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
513
|
+
* Messages-only, so a Claude model added upstream (`claude-opus-5-5` in
|
|
514
|
+
* command-code@1.64.0 was the first this snapshot missed) is still routed
|
|
515
|
+
* correctly by an un-updated plugin instead of hard-failing with the 400
|
|
516
|
+
* above. The worst case of that rule going stale the other way (upstream
|
|
517
|
+
* teaching `/provider/v1/chat/completions` to serve Claude) is one model
|
|
518
|
+
* riding the CLI transport it already works on.
|
|
519
|
+
*
|
|
520
|
+
* Keep in sync when models ship (see the dsh-commandcode-upstream skill).
|
|
540
521
|
*/
|
|
541
522
|
const MESSAGES_ONLY_MODELS = /* @__PURE__ */ new Set([
|
|
542
523
|
"claude-sonnet-5",
|
|
@@ -554,31 +535,14 @@ function requiresMessagesEndpoint(modelId) {
|
|
|
554
535
|
}
|
|
555
536
|
/**
|
|
556
537
|
* The minimum subscription plan a model is included in, per the official plan
|
|
557
|
-
* pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`,
|
|
558
|
-
* and `/docs/resources/pricing-limits`). Each plan's model
|
|
559
|
-
* the one below it: Go ⊂ GOAT ⊂ Pro ⊂ Provider/Max.
|
|
560
|
-
* plan list (Claude Opus/Fable, Fugu Ultra) are
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
* command-code@1.41.0 added `Qwen/Qwen3.8-Max-0902` (Go) and 1.42.0 added
|
|
566
|
-
* LongCat 2.0 (Go — a free promo until 2026-09-19, when the backend renamed
|
|
567
|
-
* `meituan/LongCat-2.0:free` to the paid `meituan/LongCat-2.0`); 1.43.0 added
|
|
568
|
-
* `google/gemini-3.8-flash` (GOAT) and 1.44.0 added `meta/muse-spark-1.3`
|
|
569
|
-
* (GOAT) plus its Contributor sibling (Go); command-code@1.52.0 added the
|
|
570
|
-
* free `inclusionai/ling-3.0-flash-sante:free` (Go); command-code@1.53.0
|
|
571
|
-
* added `deepseek/deepseek-v4.1-flash` (Go); command-code@1.56.0 added
|
|
572
|
-
* `Qwen/Qwen3.8-Omni-Flash` (Go); command-code@1.57.0 added
|
|
573
|
-
* `z-ai/glm-5.3-flashx` (Go); command-code@1.59.0 added `xai/grok-4.7` (GOAT)
|
|
574
|
-
* and 1.60.0 added `stepfun/Step-5-Preview` (Go); command-code@1.62.0 added the
|
|
575
|
-
* MiMo V2.6 family — `xiaomi/mimo-v2.6-flash` + `xiaomi/mimo-v2.6-pro` (Go) and
|
|
576
|
-
* `xiaomi/mimo-v2.6-pro-ultraspeed` (GOAT) — so the 1.58.0 -> 1.62.0 window's
|
|
577
|
-
* only tier changes are additions and the superset chain still holds. The
|
|
578
|
-
* 1.62.0 -> 1.65.0 window continues the pattern: 1.64.0 added
|
|
579
|
-
* `claude-opus-5-5` (Provider/Max), `gpt-6-sol` (Pro) and `gpt-6-luna` (Go),
|
|
580
|
-
* and 1.65.0 added `stealth/space-bunny-alpha` (Go, every tier) — additions
|
|
581
|
-
* only, no tier moves.
|
|
538
|
+
* pages (`/docs/plans/go`, `/docs/plans/goat`, `/docs/plans/pro`,
|
|
539
|
+
* `/docs/plans/max` and `/docs/resources/pricing-limits`). Each plan's model
|
|
540
|
+
* list is a superset of the one below it: Go ⊂ GOAT ⊂ Pro ⊂ Provider/Max.
|
|
541
|
+
* Models absent from every plan list (Claude Opus/Fable, Fugu Ultra) are
|
|
542
|
+
* Provider-tier. Re-verified at command-code@1.66.0 (2026-09-27): 82 catalog
|
|
543
|
+
* ids at 52/60/74/82 cumulative, a strict superset chain — every release since
|
|
544
|
+
* 1.49.0 has been additive with no tier move, and per-entry tags below name the
|
|
545
|
+
* release that added each row.
|
|
582
546
|
*
|
|
583
547
|
* The Provider API exposes no plan metadata, so this snapshot is the source of
|
|
584
548
|
* truth for the picker's plan annotation — it answers "which plan do I need to
|
|
@@ -623,6 +587,7 @@ const KNOWN_PLANS = {
|
|
|
623
587
|
"stepfun/Step-3.5-Flash": "go",
|
|
624
588
|
"stepfun/Step-3.7-Flash": "go",
|
|
625
589
|
"stepfun/Step-5-Preview": "go",
|
|
590
|
+
"stealth/pixel-canary": "go",
|
|
626
591
|
"stealth/space-bunny-alpha": "go",
|
|
627
592
|
"tencent/hy3-paid": "go",
|
|
628
593
|
"tencent/hy4-preview": "go",
|
|
@@ -716,11 +681,11 @@ function compareByPlan(a, b) {
|
|
|
716
681
|
}
|
|
717
682
|
/**
|
|
718
683
|
* Subscription plan table, synced from the official CLI bundle's plan maps
|
|
719
|
-
* (located by the `"individual-go"` key in
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
684
|
+
* (located by the `"individual-go"` key in `dist/cli.mjs`, re-verified unchanged
|
|
685
|
+
* through command-code@1.66.0): subscription `planId` prefix → display name and
|
|
686
|
+
* the plan's monthly credit total. This is the account's own subscription
|
|
687
|
+
* (from `/alpha/billing/subscriptions`) — distinct from {@link KNOWN_PLANS},
|
|
688
|
+
* which maps catalog models to their minimum tier.
|
|
724
689
|
*
|
|
725
690
|
* `tierWeight` is plugin-added (not from the CLI maps): the plan's rank on
|
|
726
691
|
* the {@link PLAN_ORDER} scale, used by the picker's plan filter
|
|
@@ -835,6 +800,10 @@ const KNOWN_DEALS = {
|
|
|
835
800
|
"stealth/space-bunny-alpha": {
|
|
836
801
|
label: "FREE",
|
|
837
802
|
free: true
|
|
803
|
+
},
|
|
804
|
+
"stealth/pixel-canary": {
|
|
805
|
+
label: "FREE",
|
|
806
|
+
free: true
|
|
838
807
|
}
|
|
839
808
|
};
|
|
840
809
|
/**
|
|
@@ -843,24 +812,11 @@ const KNOWN_DEALS = {
|
|
|
843
812
|
* charges by the hour: peak hours are 01:00–04:00 and 06:00–10:00 UTC (7h per
|
|
844
813
|
* weekday, full price) **Monday to Friday only**; the other 17 hours of a
|
|
845
814
|
* weekday and every hour of Saturday/Sunday (UTC) are off-peak at half price.
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
* The picker shows the
|
|
852
|
-
* *current* state as a compact
|
|
853
|
-
* label (`Peak`/`Half`) matching the English noun style of the other markers
|
|
854
|
-
* (`Image`, `FREE`), so a developer can tell at a glance whether calling the
|
|
855
|
-
* model right now is cheap or expensive.
|
|
856
|
-
*
|
|
857
|
-
* Authoritative extraction: the pricing page embeds a model JSON array whose
|
|
858
|
-
* hourly-priced entries carry a `timeOfDay` block
|
|
859
|
-
* (`{ windows: "01–04 & 06–10 UTC, Mon–Fri", peakHoursPerDay: 7,
|
|
860
|
-
* offPeakHoursPerDay: 17, peak: {...}, offPeak: {...} }`). Exactly four models
|
|
861
|
-
* carry it: V4 Pro, V4 Flash, V4 Flash Vision (exp), and V4.1 Flash (added in
|
|
862
|
-
* command-code@1.53.0 at $0.15/$0.60 off-peak, $0.30/$1.20 peak — the same
|
|
863
|
-
* schedule as the other three).
|
|
815
|
+
* Exactly four models carry the page's `timeOfDay` block (the four rows below).
|
|
816
|
+
* The picker shows the *current* state as a compact label (`Peak`/`Half`)
|
|
817
|
+
* matching the English noun style of the other markers (`Image`, `FREE`), so a
|
|
818
|
+
* developer can tell at a glance whether calling the model right now is cheap
|
|
819
|
+
* or expensive.
|
|
864
820
|
*
|
|
865
821
|
* Extraction caution: the rendered HTML rows are a trap. Each annotation div
|
|
866
822
|
* sits inside its OWN row's container, immediately before the NEXT row starts,
|
|
@@ -868,7 +824,11 @@ const KNOWN_DEALS = {
|
|
|
868
824
|
* to the model printed after it — that is how `deepseek/deepseek-v4-flash-fast`
|
|
869
825
|
* was wrongly added here (its row is flat-priced at $0.28/$0.56/$0.07 and has
|
|
870
826
|
* no `timeOfDay` block). Trust the embedded JSON's `timeOfDay` membership and
|
|
871
|
-
* the 2× price relation, never the flat-text neighbor.
|
|
827
|
+
* the 2× price relation, never the flat-text neighbor. A rate that is
|
|
828
|
+
* internally consistent can still be the wrong row (the V4 Flash Vision (exp)
|
|
829
|
+
* variant is priced from the page's own `timeOfDay` block, not from 2× its own
|
|
830
|
+
* off-peak figures), which is why the vendored price table
|
|
831
|
+
* (`./model-prices.ts`) is synced from the page and not hand-kept.
|
|
872
832
|
*
|
|
873
833
|
* Keep in sync with the official pricing page when the model set, the peak
|
|
874
834
|
* windows, or the weekday rule change (see the dsh-commandcode-upstream skill).
|
|
@@ -907,11 +867,8 @@ function isPeakPricingHour(now = Date.now()) {
|
|
|
907
867
|
return PEAK_HOUR_RANGES.some(([start, end]) => hour >= start && hour < end);
|
|
908
868
|
}
|
|
909
869
|
/**
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
* official rule charges Saturday and Sunday completely off-peak for all 24
|
|
913
|
-
* hours, so a weekend timestamp is off-peak even inside `PEAK_HOUR_RANGES`.
|
|
914
|
-
* `undefined` for models outside the snapshot.
|
|
870
|
+
* As {@link isPeakPricingHour}, plus the {@link KNOWN_PEAK_PRICING} membership
|
|
871
|
+
* test; `undefined` for models outside the snapshot.
|
|
915
872
|
*/
|
|
916
873
|
function peakPricingState(modelId, now = Date.now()) {
|
|
917
874
|
if (!KNOWN_PEAK_PRICING.has(modelId)) return void 0;
|
|
@@ -1801,7 +1758,7 @@ const MODEL_PRICE_ROWS = [
|
|
|
1801
1758
|
{
|
|
1802
1759
|
id: "step-3.5-flash",
|
|
1803
1760
|
rates: [
|
|
1804
|
-
.
|
|
1761
|
+
.09,
|
|
1805
1762
|
.3,
|
|
1806
1763
|
.02
|
|
1807
1764
|
]
|
|
@@ -1837,6 +1794,14 @@ const MODEL_PRICE_ROWS = [
|
|
|
1837
1794
|
2.501,
|
|
1838
1795
|
.042
|
|
1839
1796
|
]
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
id: "typesafe/jev",
|
|
1800
|
+
rates: [
|
|
1801
|
+
.042,
|
|
1802
|
+
0,
|
|
1803
|
+
0
|
|
1804
|
+
]
|
|
1840
1805
|
}
|
|
1841
1806
|
];
|
|
1842
1807
|
/**
|
|
@@ -1853,8 +1818,8 @@ const PRICE_SLUG_OVERRIDES = { "nvidia/nemotron-3-ultra-550b-a55b": "nemotron-3-
|
|
|
1853
1818
|
* not consistently: `tencent/hy4-preview` keeps its prefix while
|
|
1854
1819
|
* `Qwen/Qwen3.8-Max-0902` becomes `qwen-3.8-max-0902`, with a hyphen the
|
|
1855
1820
|
* catalog id does not have. Generating candidates and taking the first that
|
|
1856
|
-
* exists in the vendored table absorbs that drift without a hand-maintained
|
|
1857
|
-
*
|
|
1821
|
+
* exists in the vendored table absorbs that drift without a hand-maintained map
|
|
1822
|
+
* of eighty ids.
|
|
1858
1823
|
*/
|
|
1859
1824
|
function priceSlugCandidates(modelId) {
|
|
1860
1825
|
const lower = modelId.toLowerCase();
|
|
@@ -1909,13 +1874,10 @@ function wireRow(id, slug, row) {
|
|
|
1909
1874
|
* Rows are keyed by CATALOG id wherever the two namespaces reconcile, because
|
|
1910
1875
|
* that is what a session reports, and every row also carries its page slug as a
|
|
1911
1876
|
* second lookup key. Price rows no catalog model claims are served under the
|
|
1912
|
-
* slug alone, so drift in either direction still prices
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
* instead of
|
|
1916
|
-
*
|
|
1917
|
-
* The peak windows travel with the table, so the browser applies the very
|
|
1918
|
-
* schedule this snapshot knows instead of restating it.
|
|
1877
|
+
* slug alone, so drift in either direction still prices. Free models are served
|
|
1878
|
+
* explicitly at zero so the composer can say so instead of showing nothing; the
|
|
1879
|
+
* peak windows travel with the table so the browser applies the very schedule
|
|
1880
|
+
* this snapshot knows instead of restating it.
|
|
1919
1881
|
*/
|
|
1920
1882
|
function modelPriceTable() {
|
|
1921
1883
|
const bySlug = new Map(MODEL_PRICE_ROWS.map((row) => [row.id, row]));
|
|
@@ -1969,13 +1931,13 @@ function peakHour(at, windows) {
|
|
|
1969
1931
|
return time.getUTCDay() !== 0 && time.getUTCDay() !== 6 && windows.some(([start, end]) => time.getUTCHours() >= start && time.getUTCHours() < end);
|
|
1970
1932
|
}
|
|
1971
1933
|
/**
|
|
1972
|
-
* All prompt billing buckets determine a request's band, never cumulative
|
|
1934
|
+
* All prompt billing buckets determine a request's band, never cumulative
|
|
1935
|
+
* session input.
|
|
1973
1936
|
*
|
|
1974
1937
|
* A tiered model returns the matching band and does NOT then apply `peak`: the
|
|
1975
|
-
* page publishes those two dimensions independently
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
* choice, not a verified upstream rule.
|
|
1938
|
+
* page publishes those two dimensions independently and no row carries both
|
|
1939
|
+
* today, so the combination becoming real is visible before it reaches a user.
|
|
1940
|
+
* This ordering is a latent choice, not a verified upstream rule.
|
|
1979
1941
|
*/
|
|
1980
1942
|
function requestRates(price, at, contextTokens, table) {
|
|
1981
1943
|
const tier = price.contextTiers?.find((tier) => tier.maxContext === void 0 || contextTokens <= tier.maxContext);
|
|
@@ -2019,11 +1981,11 @@ function costGroupKey(group, table) {
|
|
|
2019
1981
|
//#endregion
|
|
2020
1982
|
//#region src/cost-projection.ts
|
|
2021
1983
|
const record$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
|
|
2022
|
-
const finite
|
|
1984
|
+
const finite = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
2023
1985
|
function tokensOf(value) {
|
|
2024
1986
|
const u = record$2(value);
|
|
2025
|
-
if (!u || !finite
|
|
2026
|
-
if (u.cacheReadTokens !== void 0 && !finite
|
|
1987
|
+
if (!u || !finite(u.inputTokens) || !finite(u.outputTokens)) return void 0;
|
|
1988
|
+
if (u.cacheReadTokens !== void 0 && !finite(u.cacheReadTokens) || u.cacheWriteTokens !== void 0 && !finite(u.cacheWriteTokens)) return void 0;
|
|
2027
1989
|
return {
|
|
2028
1990
|
uncachedInputTokens: u.inputTokens,
|
|
2029
1991
|
outputTokens: u.outputTokens,
|
|
@@ -2049,13 +2011,13 @@ function usageOf(type, data) {
|
|
|
2049
2011
|
}
|
|
2050
2012
|
function parseState(value) {
|
|
2051
2013
|
const s = record$2(value), facts = record$2(s?.facts);
|
|
2052
|
-
if (!s || !facts || !finite
|
|
2014
|
+
if (!s || !facts || !finite(facts.pricingKey) || !Array.isArray(facts.groups)) throw new TypeError("invalid commandCodeCost state");
|
|
2053
2015
|
const group = (v) => {
|
|
2054
2016
|
const g = record$2(v), t = record$2(g?.tokens);
|
|
2055
|
-
return !!g && typeof g.provider === "string" && typeof g.model === "string" && (g.at === null || finite
|
|
2017
|
+
return !!g && typeof g.provider === "string" && typeof g.model === "string" && (g.at === null || finite(g.at)) && finite(g.contextTokens) && !!t && COST_TOKEN_KEYS.every((k) => finite(t[k]));
|
|
2056
2018
|
};
|
|
2057
2019
|
const sel = record$2(s.selection), last = record$2(s.last);
|
|
2058
|
-
if (!(s.selection === null || sel && typeof sel.provider === "string" && typeof sel.model === "string") || !(s.at === null || finite
|
|
2020
|
+
if (!(s.selection === null || sel && typeof sel.provider === "string" && typeof sel.model === "string") || !(s.at === null || finite(s.at)) || !facts.groups.every(group) || !(s.last === null || last && finite(last.turn) && finite(last.step) && group(last.group))) throw new TypeError("invalid commandCodeCost state");
|
|
2059
2021
|
return value;
|
|
2060
2022
|
}
|
|
2061
2023
|
/** Optional registry uses only synchronous parse/view; no new runtime module is required. */
|
|
@@ -2089,11 +2051,11 @@ function createCostProjection(table = modelPriceTable()) {
|
|
|
2089
2051
|
}
|
|
2090
2052
|
if (event.type === "step/start" || event.type === "llm/retry-started") return {
|
|
2091
2053
|
...state,
|
|
2092
|
-
at: finite
|
|
2054
|
+
at: finite(event.time) ? event.time : null,
|
|
2093
2055
|
last: null
|
|
2094
2056
|
};
|
|
2095
2057
|
const tokens = usageOf(event.type, data);
|
|
2096
|
-
if (tokens === void 0 || !finite
|
|
2058
|
+
if (tokens === void 0 || !finite(data.turn) || !finite(data.step)) return state;
|
|
2097
2059
|
const previous = state.last?.turn === data.turn && state.last.step === data.step ? state.last.group : void 0;
|
|
2098
2060
|
const group = {
|
|
2099
2061
|
provider: state.selection?.provider ?? "",
|
|
@@ -2165,34 +2127,18 @@ const IDENTITY_ENCODING_HEADER = { "accept-encoding": "identity" };
|
|
|
2165
2127
|
/**
|
|
2166
2128
|
* Multi-account pool for the Command Code provider (host side).
|
|
2167
2129
|
*
|
|
2168
|
-
* One
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2130
|
+
* One subscription's 5-hour window is metered, and a user with several wants a
|
|
2131
|
+
* request that hits one account's limit to continue on the next without a
|
|
2132
|
+
* visible failure. Rotation is passive: a key is marked only on a real
|
|
2133
|
+
* pre-stream rejection, and when nothing can serve, marked keys are revived by
|
|
2134
|
+
* probing their real usage windows.
|
|
2172
2135
|
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2176
|
-
* usable, the routed account — resolving each slot's key lazily (literal
|
|
2177
|
-
* config key → credential seam → launch environment → the official CLI
|
|
2178
|
-
* auth file for the default slot only).
|
|
2179
|
-
* - {@link CommandCodeAccountPool.markRejected} records a 429 or 401 against
|
|
2180
|
-
* the exact API key, so several slots sharing one key share one state. A
|
|
2181
|
-
* 429 that NAMED a usage window marks the window exhausted; a 429 that
|
|
2182
|
-
* named none is recorded as a plain throttle, which rotates the same way
|
|
2183
|
-
* but is never reported as an exhausted window (issue #54).
|
|
2184
|
-
* - When every account is marked, the pool probes each key's
|
|
2185
|
-
* `/alpha/billing/credits` window limits (through the injected
|
|
2186
|
-
* {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
|
|
2187
|
-
* no longer reports `exceeded` is revived, otherwise the pool throws a
|
|
2188
|
-
* `RATE_LIMIT` error naming the earliest reset time.
|
|
2189
|
-
* - An account the user explicitly asked for — the manual pin
|
|
2190
|
-
* ({@link CommandCodeAccountPoolDeps.preferredId}) or a model rule's
|
|
2191
|
-
* routed account — is probed the same way when a mark would demote it, so
|
|
2192
|
-
* a fallback is never permanent (issue #51).
|
|
2136
|
+
* The non-obvious rules — which rejection may be REPORTED as a spent window, why
|
|
2137
|
+
* a fallback is never permanent, what an explicitly selected account's probe
|
|
2138
|
+
* costs — are stated at the members below, and recorded in full in AGENTS.md.
|
|
2193
2139
|
*
|
|
2194
|
-
*
|
|
2195
|
-
*
|
|
2140
|
+
* Cordis-free like the adapter: every host fact arrives through injected thunks,
|
|
2141
|
+
* so node tests can drive the pool directly.
|
|
2196
2142
|
*
|
|
2197
2143
|
* @module dsh-commandcode-provider/accounts
|
|
2198
2144
|
*/
|
|
@@ -2200,22 +2146,21 @@ const IDENTITY_ENCODING_HEADER = { "accept-encoding": "identity" };
|
|
|
2200
2146
|
* Upper bound on the retry wait this pool attaches to the all-exhausted
|
|
2201
2147
|
* `RATE_LIMIT` error. Must equal the `backoff.maxDelayMs` in the adapter's
|
|
2202
2148
|
* `providerRetryPolicy` (which imports it from here): dsh-llm-retry honors a
|
|
2203
|
-
* provider-specified wait verbatim only at or below that cap —
|
|
2204
|
-
*
|
|
2205
|
-
*
|
|
2206
|
-
* window opens" into "fail now".
|
|
2149
|
+
* provider-specified wait verbatim only at or below that cap — a LONGER one
|
|
2150
|
+
* makes the executor abandon the retry instead of falling back to local backoff
|
|
2151
|
+
* (in normal mode), turning "poll until the window opens" into "fail now".
|
|
2207
2152
|
*/
|
|
2208
2153
|
const RETRY_MAX_DELAY_MS = 9e5;
|
|
2209
2154
|
/**
|
|
2210
2155
|
* How long an explicitly selected account's rate-limit mark may stand before
|
|
2211
|
-
* its window is probed again.
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2215
|
-
*
|
|
2216
|
-
* the user's own
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2156
|
+
* its window is probed again.
|
|
2157
|
+
*
|
|
2158
|
+
* A 429 whose reset was never learned marks the key `unknown`, which
|
|
2159
|
+
* {@link accountUsable} refuses — for a key nobody asked for that is correct
|
|
2160
|
+
* (only the all-marked pass clears it, and it costs one billing call), but for
|
|
2161
|
+
* the user's own pinned account a single 429 would demote it until the process
|
|
2162
|
+
* restarts (issue #51). Once per interval per key bounds a probe endpoint that
|
|
2163
|
+
* keeps failing; a successful probe resolves the mark for good.
|
|
2219
2164
|
*/
|
|
2220
2165
|
const EXPLICIT_ACCOUNT_PROBE_INTERVAL_MS = 6e4;
|
|
2221
2166
|
/** A labeled, human-readable clock reading for error messages. */
|
|
@@ -2234,10 +2179,10 @@ function accountUsable(state) {
|
|
|
2234
2179
|
return false;
|
|
2235
2180
|
}
|
|
2236
2181
|
/**
|
|
2237
|
-
* Pick the account that should serve now: the manually preferred slot when it
|
|
2238
|
-
*
|
|
2239
|
-
*
|
|
2240
|
-
*
|
|
2182
|
+
* Pick the account that should serve now: the manually preferred slot when it is
|
|
2183
|
+
* usable, otherwise the first usable account in rotation order; undefined when
|
|
2184
|
+
* no account is usable. Shared by the pool (request path) and the plugin entry
|
|
2185
|
+
* (the usage view's active badge) so both always agree.
|
|
2241
2186
|
*/
|
|
2242
2187
|
function selectActiveAccount(accounts, preferredId) {
|
|
2243
2188
|
const usable = accounts.filter((account) => accountUsable(account.state));
|
|
@@ -2320,27 +2265,26 @@ var CommandCodeAccountPool = class {
|
|
|
2320
2265
|
return out;
|
|
2321
2266
|
}
|
|
2322
2267
|
/**
|
|
2323
|
-
* Hand out the key for a request: the model-routed account when the
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
* `
|
|
2268
|
+
* Hand out the key for a request: the model-routed account when the request's
|
|
2269
|
+
* model matches a rule (and that account is usable), else the manually
|
|
2270
|
+
* preferred account when usable, else the first usable account in rotation
|
|
2271
|
+
* order. Returns `undefined` when no account resolves any key at all, or when
|
|
2272
|
+
* an account this request already used is still UNMARKED — that rejection is
|
|
2273
|
+
* the honest answer, so the caller's own rejection surfaces as itself. Throws
|
|
2274
|
+
* `RATE_LIMIT` (naming the earliest window reset) or `INVALID_CREDENTIAL`
|
|
2275
|
+
* when accounts exist but every one of them is marked.
|
|
2330
2276
|
*
|
|
2331
2277
|
* `options.model` is the request's model id; routing rules re-read per
|
|
2332
|
-
* resolution, so a settings change applies live.
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
* account's plan) would otherwise be offered again on every attempt, and the
|
|
2339
|
-
* accounts behind it would never be reached.
|
|
2278
|
+
* resolution, so a settings change applies live. `options.tried` lists the
|
|
2279
|
+
* keys this request has already used — the just-rejected one included — and
|
|
2280
|
+
* they are removed from the resolution entirely, which is what lets one
|
|
2281
|
+
* request walk a four-account pool: a rejection that does not mark the key
|
|
2282
|
+
* (no credits, a model outside the account's plan) would otherwise be offered
|
|
2283
|
+
* again on every attempt and the accounts behind it never reached.
|
|
2340
2284
|
*
|
|
2341
|
-
* An explicit selection (the pin or a model rule) that a rate-limit mark
|
|
2342
|
-
*
|
|
2343
|
-
*
|
|
2285
|
+
* An explicit selection (the pin or a model rule) that a rate-limit mark would
|
|
2286
|
+
* demote is probed before the fallback serves, so "falls back while exhausted"
|
|
2287
|
+
* never becomes "stays demoted until the process restarts" (issue #51).
|
|
2344
2288
|
*/
|
|
2345
2289
|
async resolveKey(options) {
|
|
2346
2290
|
const accounts = await this.resolvedAccounts();
|
|
@@ -2396,21 +2340,18 @@ var CommandCodeAccountPool = class {
|
|
|
2396
2340
|
throw allAccountsUnusable(latestAccounts.length > 0 ? latestAccounts : accounts);
|
|
2397
2341
|
}
|
|
2398
2342
|
/**
|
|
2399
|
-
* Record a rejection against one key. `
|
|
2400
|
-
*
|
|
2401
|
-
*
|
|
2402
|
-
*
|
|
2403
|
-
*
|
|
2404
|
-
*
|
|
2405
|
-
*
|
|
2406
|
-
* (`invalid-credential`) disables the key until the stored credential
|
|
2343
|
+
* Record a rejection against one key. A `throttled` rejection (the plain 429
|
|
2344
|
+
* that named no window) marks the key with the `throttle` cause, leaving
|
|
2345
|
+
* rotation exactly like a window mark but keeping the pool's own diagnosis at
|
|
2346
|
+
* "rate limited" rather than inventing an exhausted window. `rate-limit`
|
|
2347
|
+
* marks a `cooldown` until `resetAtMs` when the body named the provider's own
|
|
2348
|
+
* reset, otherwise an `unknown` mark whose window is probed lazily.
|
|
2349
|
+
* `invalid-credential` (401) disables the key until the stored credential
|
|
2407
2350
|
* changes.
|
|
2408
2351
|
*
|
|
2409
|
-
* `resetAtMs` is seconds-to-millis converted by the adapter from
|
|
2410
|
-
*
|
|
2411
|
-
*
|
|
2412
|
-
* provider said. It only applies to a window mark; a plain throttle keeps the
|
|
2413
|
-
* window unknown on purpose, so a probe can still find out.
|
|
2352
|
+
* `resetAtMs` is seconds-to-millis converted by the adapter from
|
|
2353
|
+
* `error.rateLimit.reset`; it applies to a window mark only. A plain throttle
|
|
2354
|
+
* keeps its window unknown on purpose, so a probe can still find out.
|
|
2414
2355
|
*/
|
|
2415
2356
|
markRejected(apiKey, rejection, resetAtMs) {
|
|
2416
2357
|
if (rejection === "invalid-credential") this.states.set(apiKey, {
|
|
@@ -2442,15 +2383,12 @@ var CommandCodeAccountPool = class {
|
|
|
2442
2383
|
* One account's key: literal → credential seam → auth file (default slot).
|
|
2443
2384
|
*
|
|
2444
2385
|
* Every source is normalized here, at the single point where a slot's key
|
|
2445
|
-
* enters the pool. The adapter sends the key through
|
|
2446
|
-
* `assertUsableApiKey()`, which trims it
|
|
2447
|
-
*
|
|
2448
|
-
*
|
|
2449
|
-
*
|
|
2450
|
-
*
|
|
2451
|
-
* no mark, and the usage endpoints would 401 while chat kept working.
|
|
2452
|
-
* Normalizing once makes resolution, probing, marking, and the request path
|
|
2453
|
-
* agree on one string.
|
|
2386
|
+
* enters the pool. The adapter sends the key through
|
|
2387
|
+
* `assertUsableApiKey()`, which trims it, and reports that trimmed form back
|
|
2388
|
+
* to {@link markRejected}: returning the raw value would file every 429/401
|
|
2389
|
+
* mark under a key no later lookup can find — rotation would re-offer the same
|
|
2390
|
+
* account, the account card would show no mark, and the usage endpoints would
|
|
2391
|
+
* 401 while chat kept working.
|
|
2454
2392
|
*/
|
|
2455
2393
|
async resolveSlotKey(slot) {
|
|
2456
2394
|
if (slot.literal !== void 0) {
|
|
@@ -2473,12 +2411,11 @@ var CommandCodeAccountPool = class {
|
|
|
2473
2411
|
}
|
|
2474
2412
|
}
|
|
2475
2413
|
/**
|
|
2476
|
-
* The account the user explicitly asked for: the slot a model rule routes
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
* {@link
|
|
2481
|
-
* usable, which is exactly what {@link resolveKey} re-probes.
|
|
2414
|
+
* The account the user explicitly asked for: the slot a model rule routes the
|
|
2415
|
+
* request to when that id exists among the resolved slots, else the manually
|
|
2416
|
+
* pinned slot. Consulted only on the fallback path, so — unlike
|
|
2417
|
+
* {@link selectAccountForModel} — it does NOT require the account to be usable,
|
|
2418
|
+
* which is exactly what {@link resolveKey} re-probes.
|
|
2482
2419
|
*/
|
|
2483
2420
|
explicitAccount(accounts, model, preferredId) {
|
|
2484
2421
|
const rule = matchModelRule(model, this.deps.modelAccountRules?.());
|
|
@@ -2488,9 +2425,9 @@ var CommandCodeAccountPool = class {
|
|
|
2488
2425
|
/**
|
|
2489
2426
|
* Whether an explicitly selected account's mark is due for a window probe.
|
|
2490
2427
|
* Only an `unknown` mark (a 429 whose reset was never learned) is worth
|
|
2491
|
-
* re-probing: a `cooldown`
|
|
2492
|
-
*
|
|
2493
|
-
*
|
|
2428
|
+
* re-probing: a `cooldown` carries its reset and expires by itself, and a
|
|
2429
|
+
* `disabled` (401) key stays out until the stored credential changes. The
|
|
2430
|
+
* interval bounds a probe endpoint that keeps failing.
|
|
2494
2431
|
*/
|
|
2495
2432
|
canProbeExplicit(account) {
|
|
2496
2433
|
if (account.state?.kind !== "unknown") return false;
|
|
@@ -2504,8 +2441,8 @@ var CommandCodeAccountPool = class {
|
|
|
2504
2441
|
/**
|
|
2505
2442
|
* Probe one explicitly selected account's window and apply the answer. A
|
|
2506
2443
|
* window that is no longer exceeded drops the mark, so the user's own
|
|
2507
|
-
* selection serves again on this very request
|
|
2508
|
-
*
|
|
2444
|
+
* selection serves again on this very request; an exceeded one is stamped as
|
|
2445
|
+
* a cooldown carrying the provider's reset time, after which
|
|
2509
2446
|
* {@link accountUsable} lets the account back in with no further probe. A
|
|
2510
2447
|
* probe that fails changes nothing: the mark stays `unknown` and the next
|
|
2511
2448
|
* attempt waits out the interval.
|
|
@@ -2557,22 +2494,20 @@ var CommandCodeAccountPool = class {
|
|
|
2557
2494
|
* read a window as exceeded) → the window diagnosis, naming the earliest
|
|
2558
2495
|
* known reset when the provider published one;
|
|
2559
2496
|
* - every mark a plain throttle (a 429 that said nothing about a window, and
|
|
2560
|
-
* no probe could confirm one) → a throttle diagnosis that says so.
|
|
2561
|
-
* "all accounts have exhausted their usage window" here is issue
|
|
2562
|
-
*
|
|
2563
|
-
*
|
|
2497
|
+
* no probe could confirm one) → a throttle diagnosis that says so.
|
|
2498
|
+
* Claiming "all accounts have exhausted their usage window" here is issue
|
|
2499
|
+
* #54: a bare 429 was reported that way while the account card showed both
|
|
2500
|
+
* windows barely used.
|
|
2564
2501
|
*
|
|
2565
|
-
*
|
|
2566
|
-
*
|
|
2502
|
+
* The attached `providerRetryAfterMs` is the exact wait until the earliest known
|
|
2503
|
+
* window reset, so dsh-llm-retry sleeps through the window instead of polling at
|
|
2504
|
+
* its backoff cadence. It is capped at {@link RETRY_MAX_DELAY_MS} — see there for
|
|
2505
|
+
* why a longer attached wait is worse than a capped one; longer resets simply
|
|
2506
|
+
* ride the capped local backoff and the probe revival. A throttle diagnosis
|
|
2507
|
+
* attaches none: its marks carry no reset.
|
|
2567
2508
|
*
|
|
2568
|
-
*
|
|
2569
|
-
*
|
|
2570
|
-
* polling at its backoff cadence. It is capped at {@link RETRY_MAX_DELAY_MS}:
|
|
2571
|
-
* the executor honors a provider wait verbatim only at or below the policy's
|
|
2572
|
-
* `maxDelayMs` — a LONGER attached wait makes it abandon the retry entirely
|
|
2573
|
-
* (normal mode), which would turn "poll until the window opens" into "fail
|
|
2574
|
-
* now". Longer resets simply ride the capped local backoff and the probe
|
|
2575
|
-
* revival. A throttle diagnosis attaches none: its marks carry no reset.
|
|
2509
|
+
* Shared by the all-marked tail of {@link CommandCodeAccountPool.resolveKey} and
|
|
2510
|
+
* by its already-tried diagnosis, so the two can never drift apart.
|
|
2576
2511
|
*/
|
|
2577
2512
|
function allAccountsUnusable(accounts) {
|
|
2578
2513
|
if (accounts.filter((account) => account.state?.kind === "disabled").length === accounts.length) return new LlmError(`llm-commandcode: every configured Command Code account (${accounts.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file;已配置的 ${accounts.length} 个 Command Code 账户密钥均被拒绝(401)——请在设置页检查存储的 API 密钥,或重新运行 command-code login`, "INVALID_CREDENTIAL");
|
|
@@ -2615,14 +2550,14 @@ const REQUEST_IMAGE_MAX_LONG_EDGE = 1568;
|
|
|
2615
2550
|
const REQUEST_IMAGE_MAX_ENCODED_BYTES = 1048576;
|
|
2616
2551
|
/**
|
|
2617
2552
|
* Aspect-preserving projection onto a long-edge budget; never enlarges, and
|
|
2618
|
-
* never returns a zero dimension
|
|
2619
|
-
* an extreme aspect ratio can produce, and a 0 would make the encoder refuse).
|
|
2553
|
+
* never returns a zero dimension.
|
|
2620
2554
|
*
|
|
2621
2555
|
* `dsh-attachment` exports an equivalent `longEdgeDimensions`, but it does not
|
|
2622
2556
|
* guard its inputs: a reference whose declared size is 0 or NaN — a malformed
|
|
2623
2557
|
* or fabricated attachment record — would project to a zero-sized target and
|
|
2624
|
-
* make the encoder refuse the whole request.
|
|
2625
|
-
*
|
|
2558
|
+
* make the encoder refuse the whole request. This local guard is the ONLY
|
|
2559
|
+
* reason the function is not imported; do not replace it with the upstream one.
|
|
2560
|
+
* `tests/image-tokens.test.ts` pins the degenerate cases.
|
|
2626
2561
|
*/
|
|
2627
2562
|
function longEdgeDimensions(width, height, longEdge) {
|
|
2628
2563
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return {
|
|
@@ -2666,16 +2601,13 @@ function requestImageTarget(ref) {
|
|
|
2666
2601
|
* that the heuristic prices at ~0 tokens each, so context pressure is
|
|
2667
2602
|
* under-reported and the session walks into its own context window.
|
|
2668
2603
|
*
|
|
2669
|
-
* This route is a gateway in front of many upstreams
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2672
|
-
*
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
*
|
|
2676
|
-
* These are ESTIMATES for context accounting, not invoices — the provider's own
|
|
2677
|
-
* usage remains authoritative for a completed request, exactly as the published
|
|
2678
|
-
* per-token rates the session-cost readout uses are.
|
|
2604
|
+
* This route is a gateway in front of many upstreams, so the estimate is per
|
|
2605
|
+
* FAMILY, using each vendor's published rule, and the families are recognised
|
|
2606
|
+
* by the catalog id (which names the vendor). Every formula is stated here
|
|
2607
|
+
* rather than imported: the only in-tree implementations live inside the
|
|
2608
|
+
* official adapters, and this plugin deliberately does not depend on those
|
|
2609
|
+
* protocols. These are ESTIMATES for context accounting, not invoices — the
|
|
2610
|
+
* provider's own usage remains authoritative for a completed request.
|
|
2679
2611
|
*
|
|
2680
2612
|
* @module dsh-commandcode-provider/image-tokens
|
|
2681
2613
|
*/
|
|
@@ -2825,14 +2757,11 @@ function scaleBy(size, factor) {
|
|
|
2825
2757
|
* Opt-in raw-stream trace for the Command Code transports.
|
|
2826
2758
|
*
|
|
2827
2759
|
* Why this exists: this route's failure mode of record is a response that ENDS
|
|
2828
|
-
* EARLY
|
|
2829
|
-
*
|
|
2830
|
-
*
|
|
2831
|
-
*
|
|
2832
|
-
*
|
|
2833
|
-
* wire: which events actually arrived, when the last one landed, how long the
|
|
2834
|
-
* socket then sat silent, and how it ended. Without that, a truncated answer is
|
|
2835
|
-
* a report with no evidence attached.
|
|
2760
|
+
* EARLY — the gateway, a proxy or the network closes the socket mid-generation,
|
|
2761
|
+
* so the adapter's read loop sees `done` without the transport's terminal event.
|
|
2762
|
+
* A provider cut, a proxy timeout and a dropped socket are indistinguishable
|
|
2763
|
+
* from inside the adapter; only the raw wire separates them — which events
|
|
2764
|
+
* arrived, when the last one landed, how long the socket then sat silent.
|
|
2836
2765
|
*
|
|
2837
2766
|
* Enable it per process:
|
|
2838
2767
|
*
|
|
@@ -2842,24 +2771,25 @@ function scaleBy(size, factor) {
|
|
|
2842
2771
|
* ```
|
|
2843
2772
|
*
|
|
2844
2773
|
* Any value that is not a flag is a path, and the explicit off-spellings
|
|
2845
|
-
* (`0`, `false`, `no`, `off`) are
|
|
2846
|
-
* for a file name.
|
|
2774
|
+
* (`0`, `false`, `no`, `off`) are read as "off" rather than as a file name.
|
|
2847
2775
|
*
|
|
2848
2776
|
* Every model request appends one JSONL record per raw chunk plus lifecycle
|
|
2849
|
-
* records (`response`, `eof`, `idle-timeout`, `read-error`, `end`,
|
|
2850
|
-
* `stream-close`), each stamped with `at` — milliseconds since
|
|
2851
|
-
*
|
|
2852
|
-
*
|
|
2853
|
-
*
|
|
2854
|
-
*
|
|
2855
|
-
*
|
|
2856
|
-
*
|
|
2857
|
-
*
|
|
2858
|
-
*
|
|
2859
|
-
*
|
|
2860
|
-
*
|
|
2861
|
-
*
|
|
2862
|
-
*
|
|
2777
|
+
* records (`request`, `response`, `eof`, `idle-timeout`, `read-error`, `end`,
|
|
2778
|
+
* `stream-close`), each stamped with `streamId` and `at` — milliseconds since
|
|
2779
|
+
* the trace opened, before connecting. `stream-open.startedAt` is Unix time in
|
|
2780
|
+
* milliseconds, so interleaved calls and session logs can be correlated.
|
|
2781
|
+
*
|
|
2782
|
+
* PRIVACY: the chunk records carry the provider's own payload verbatim, because
|
|
2783
|
+
* a truncated answer is exactly what is being diagnosed — treat the file as
|
|
2784
|
+
* conversation content, not as a log safe to share. The request-side record
|
|
2785
|
+
* records structural counts and SHA-256 fingerprints only; it never carries the
|
|
2786
|
+
* raw request body, an API key, or any request header. Keep it that way.
|
|
2787
|
+
*
|
|
2788
|
+
* Trade-offs: OFF costs one `process.env` read per request; ON appends
|
|
2789
|
+
* synchronously, one file open per chunk, because the trace must survive a host
|
|
2790
|
+
* killed mid-diagnosis. A trace that cannot be written turns itself off rather
|
|
2791
|
+
* than failing the request it observes — a diagnostic must never become the
|
|
2792
|
+
* outage.
|
|
2863
2793
|
*
|
|
2864
2794
|
* @module
|
|
2865
2795
|
*/
|
|
@@ -2882,7 +2812,7 @@ const FLAG_VALUES = /* @__PURE__ */ new Set([
|
|
|
2882
2812
|
* A user turning the trace off writes `false` as readily as they write `1` to
|
|
2883
2813
|
* turn it on, and without this set every one of these spellings fell through to
|
|
2884
2814
|
* "…so it must be a path" — the trace switched ON and wrote the conversation to
|
|
2885
|
-
* a file NAMED `false`
|
|
2815
|
+
* a file NAMED `false` in the process's working directory.
|
|
2886
2816
|
*/
|
|
2887
2817
|
const OFF_VALUES = /* @__PURE__ */ new Set([
|
|
2888
2818
|
"0",
|
|
@@ -2934,6 +2864,7 @@ function openStreamTrace(scope, options = {}) {
|
|
|
2934
2864
|
if (path === void 0) return OFF;
|
|
2935
2865
|
const now = options.now ?? Date.now;
|
|
2936
2866
|
const startedAt = now();
|
|
2867
|
+
const streamId = randomUUID();
|
|
2937
2868
|
let written = 0;
|
|
2938
2869
|
let stopped = false;
|
|
2939
2870
|
let capped = false;
|
|
@@ -2953,6 +2884,7 @@ function openStreamTrace(scope, options = {}) {
|
|
|
2953
2884
|
line = `${JSON.stringify({
|
|
2954
2885
|
at: now() - startedAt,
|
|
2955
2886
|
scope,
|
|
2887
|
+
streamId,
|
|
2956
2888
|
...record
|
|
2957
2889
|
})}\n`;
|
|
2958
2890
|
} catch {
|
|
@@ -2966,6 +2898,7 @@ function openStreamTrace(scope, options = {}) {
|
|
|
2966
2898
|
line = `${JSON.stringify({
|
|
2967
2899
|
at: now() - startedAt,
|
|
2968
2900
|
scope,
|
|
2901
|
+
streamId,
|
|
2969
2902
|
event: "trace-capped",
|
|
2970
2903
|
bytes: written
|
|
2971
2904
|
})}\n`;
|
|
@@ -2977,9 +2910,14 @@ function openStreamTrace(scope, options = {}) {
|
|
|
2977
2910
|
stopped = true;
|
|
2978
2911
|
}
|
|
2979
2912
|
};
|
|
2980
|
-
write({
|
|
2913
|
+
write({
|
|
2914
|
+
event: "stream-open",
|
|
2915
|
+
startedAt
|
|
2916
|
+
});
|
|
2981
2917
|
return {
|
|
2982
|
-
enabled
|
|
2918
|
+
get enabled() {
|
|
2919
|
+
return !stopped && !capped;
|
|
2920
|
+
},
|
|
2983
2921
|
record(event, fields) {
|
|
2984
2922
|
write(fields === void 0 ? { event } : {
|
|
2985
2923
|
event,
|
|
@@ -3003,21 +2941,17 @@ function openStreamTrace(scope, options = {}) {
|
|
|
3003
2941
|
* community-maintained integration; you need your own Command Code account
|
|
3004
2942
|
* and API key or subscription, and Command Code's terms apply.
|
|
3005
2943
|
*
|
|
3006
|
-
*
|
|
3007
|
-
*
|
|
3008
|
-
* stream
|
|
3009
|
-
*
|
|
3010
|
-
*
|
|
3011
|
-
* system, max_tokens, temperature, stream, reasoning_effort? }, threadId }
|
|
3012
|
-
* SSE-ish JSONL events: text-delta | reasoning-start/delta/end | tool-call
|
|
3013
|
-
* | tool-result | finish | error
|
|
3014
|
-
* Model catalog: GET {apiBase}/provider/v1/models -> { object: 'list', data: [...] }
|
|
2944
|
+
* Owns the two chat transports (`/alpha/generate`, `/provider/v1/chat/completions`),
|
|
2945
|
+
* message conversion, SSE/JSONL parsing, the catalog + its on-disk cache, and
|
|
2946
|
+
* the pre-stream account rotation loop. The wire protocol is
|
|
2947
|
+
* reverse-engineered (command-code@1.28.4, re-verified through 1.66.0);
|
|
2948
|
+
* AGENTS.md holds the full protocol record.
|
|
3015
2949
|
*
|
|
3016
2950
|
* The adapter is deliberately free of cordis/schemastery: it receives a
|
|
3017
2951
|
* per-request options thunk and an API-key resolver from the plugin entry
|
|
3018
2952
|
* (src/index.ts), so a settings change reaches the very next request.
|
|
3019
2953
|
*/
|
|
3020
|
-
const COMMAND_CODE_CLI_VERSION = "1.
|
|
2954
|
+
const COMMAND_CODE_CLI_VERSION = "1.66.0";
|
|
3021
2955
|
const DEFAULT_API_BASE = "https://api.commandcode.ai";
|
|
3022
2956
|
const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
|
|
3023
2957
|
const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
|
|
@@ -3051,6 +2985,97 @@ function stringValue(value) {
|
|
|
3051
2985
|
function numberValue(value) {
|
|
3052
2986
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3053
2987
|
}
|
|
2988
|
+
/** Hash one request value without retaining or logging its contents. */
|
|
2989
|
+
function fingerprintValue(value) {
|
|
2990
|
+
let serialized;
|
|
2991
|
+
try {
|
|
2992
|
+
serialized = JSON.stringify(value) ?? String(value);
|
|
2993
|
+
} catch {
|
|
2994
|
+
serialized = String(value);
|
|
2995
|
+
}
|
|
2996
|
+
return {
|
|
2997
|
+
bytes: Buffer.byteLength(serialized),
|
|
2998
|
+
sha256: createHash("sha256").update(serialized).digest("hex")
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* Structural request facts for issue #64's cache diagnosis.
|
|
3003
|
+
*
|
|
3004
|
+
* This deliberately records hashes and counts, never the prompt, tool
|
|
3005
|
+
* descriptions, image bytes, headers, or credentials. `body` catches changes
|
|
3006
|
+
* in any wire field; the named sections make a changed system/tools/messages
|
|
3007
|
+
* prefix visible without making the trace a copy of the conversation.
|
|
3008
|
+
*/
|
|
3009
|
+
function requestFingerprint(protocol, body) {
|
|
3010
|
+
const params = protocol === "cli" ? recordOrEmpty(body.params) : body;
|
|
3011
|
+
const messages = Array.isArray(params.messages) ? params.messages : [];
|
|
3012
|
+
const tools = Array.isArray(params.tools) ? params.tools : [];
|
|
3013
|
+
return {
|
|
3014
|
+
protocol,
|
|
3015
|
+
model: typeof params.model === "string" ? params.model : void 0,
|
|
3016
|
+
threadId: typeof body.threadId === "string" ? body.threadId : void 0,
|
|
3017
|
+
body: fingerprintValue(body),
|
|
3018
|
+
config: fingerprintValue(body.config),
|
|
3019
|
+
system: fingerprintValue(protocol === "cli" ? params.system : messages.filter((m) => isRecord(m) && m.role === "system")),
|
|
3020
|
+
tools: {
|
|
3021
|
+
count: tools.length,
|
|
3022
|
+
...fingerprintValue(tools)
|
|
3023
|
+
},
|
|
3024
|
+
messages: {
|
|
3025
|
+
count: messages.length,
|
|
3026
|
+
roles: messages.map((message) => isRecord(message) && typeof message.role === "string" ? message.role : "unknown"),
|
|
3027
|
+
...fingerprintValue(messages),
|
|
3028
|
+
items: messages.map((message) => fingerprintValue(message))
|
|
3029
|
+
}
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
/** Only provider correlation headers, never a wholesale response-header dump. */
|
|
3033
|
+
const RESPONSE_ID_HEADERS = [
|
|
3034
|
+
"x-request-id",
|
|
3035
|
+
"request-id",
|
|
3036
|
+
"x-trace-id",
|
|
3037
|
+
"x-generation-id",
|
|
3038
|
+
"traceparent"
|
|
3039
|
+
];
|
|
3040
|
+
function responseIdentifiers(headers) {
|
|
3041
|
+
const result = {};
|
|
3042
|
+
for (const name of RESPONSE_ID_HEADERS) {
|
|
3043
|
+
const value = diagnosticId(headers.get(name));
|
|
3044
|
+
if (value !== void 0) result[name] = value;
|
|
3045
|
+
}
|
|
3046
|
+
return result;
|
|
3047
|
+
}
|
|
3048
|
+
function diagnosticId(value) {
|
|
3049
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= 512 ? value : void 0;
|
|
3050
|
+
}
|
|
3051
|
+
function captureResponseIdentifiers(metadata, event) {
|
|
3052
|
+
if (!isRecord(event)) return;
|
|
3053
|
+
if (metadata.protocol === "openai" && Array.isArray(event.choices) || event.type === "response-metadata") {
|
|
3054
|
+
const id = diagnosticId(event.id);
|
|
3055
|
+
if (id !== void 0) metadata.responseId = id;
|
|
3056
|
+
}
|
|
3057
|
+
for (const name of [
|
|
3058
|
+
"generationId",
|
|
3059
|
+
"providerRequestId",
|
|
3060
|
+
"traceId"
|
|
3061
|
+
]) {
|
|
3062
|
+
const id = diagnosticId(event[name]);
|
|
3063
|
+
if (id !== void 0) metadata[name] = id;
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
function hasResponseIdentifiers(metadata) {
|
|
3067
|
+
return Object.keys(metadata.headers).length > 0 || metadata.responseId !== void 0 || metadata.generationId !== void 0 || metadata.providerRequestId !== void 0 || metadata.traceId !== void 0;
|
|
3068
|
+
}
|
|
3069
|
+
/** Keep the provider's request id on DSH's durable failure, when it supplied one. */
|
|
3070
|
+
function failureWithRequestId(error, metadata) {
|
|
3071
|
+
const id = metadata.providerRequestId ?? metadata.headers["x-request-id"] ?? metadata.headers["request-id"];
|
|
3072
|
+
if (!(error instanceof LlmError) || error.failure.requestId !== void 0 || id === void 0) return error;
|
|
3073
|
+
return new LlmError(error.message, error.code, {
|
|
3074
|
+
...error.failure,
|
|
3075
|
+
requestId: ProviderRequestId(id),
|
|
3076
|
+
...error.cause === void 0 ? {} : { cause: error.cause }
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3054
3079
|
function booleanValue(value) {
|
|
3055
3080
|
return typeof value === "boolean" ? value : void 0;
|
|
3056
3081
|
}
|
|
@@ -3060,16 +3085,13 @@ function booleanValue(value) {
|
|
|
3060
3085
|
*
|
|
3061
3086
|
* Entries are read by their own text members rather than filtered on `type`:
|
|
3062
3087
|
* DeepSeek sends `{ type: 'reasoning.text', text }` while the OpenAI family
|
|
3063
|
-
* sends `{ type: 'reasoning.summary', summary
|
|
3064
|
-
*
|
|
3065
|
-
*
|
|
3066
|
-
*
|
|
3067
|
-
*
|
|
3068
|
-
*
|
|
3069
|
-
*
|
|
3070
|
-
* tests for non-empty rather than merely present. Entries carrying neither
|
|
3071
|
-
* member — the encrypted blobs — contribute nothing, and no entry contributes
|
|
3072
|
-
* both, so a summary is never counted twice.
|
|
3088
|
+
* sends `{ type: 'reasoning.summary', summary }` — the same summary text the
|
|
3089
|
+
* scalar `delta.reasoning` carries. A `type` filter would drop one family, and
|
|
3090
|
+
* losing thinking is the failure this exists to prevent. `text` wins per entry,
|
|
3091
|
+
* but an EMPTY `text` must not shadow a populated `summary` (the Responses wire
|
|
3092
|
+
* emits `text: ''` placeholders), so the fallback tests for non-empty rather
|
|
3093
|
+
* than merely present. Encrypted-blob entries carry neither member and
|
|
3094
|
+
* contribute nothing; no entry carries both, so nothing is counted twice.
|
|
3073
3095
|
*
|
|
3074
3096
|
* Returns undefined when the array yields nothing, so the caller's `??` chain
|
|
3075
3097
|
* keeps falling through instead of treating "no text" as a reasoning delta.
|
|
@@ -3090,9 +3112,7 @@ function reasoningDetailsText(value) {
|
|
|
3090
3112
|
* none" — the same rule {@link reasoningDetailsText} applies inside the array.
|
|
3091
3113
|
* The scalar and the array describe the same thinking, so an empty spelling of
|
|
3092
3114
|
* one must not shadow populated text in the other (the Responses wire pads with
|
|
3093
|
-
* `text: ''`)
|
|
3094
|
-
* so `reasoning: ''` beside a populated `reasoning_details` used to drop the
|
|
3095
|
-
* whole chunk's thinking.
|
|
3115
|
+
* `text: ''`), and `??` alone cannot express that.
|
|
3096
3116
|
*/
|
|
3097
3117
|
function nonEmptyText(value) {
|
|
3098
3118
|
return value === void 0 || value === "" ? void 0 : value;
|
|
@@ -3156,30 +3176,21 @@ function isZdrNoProviders(providerCode, providerDetail, errText) {
|
|
|
3156
3176
|
/**
|
|
3157
3177
|
* Classify one in-band stream `error` payload into the failure the caller
|
|
3158
3178
|
* throws. Shared by both transports — the CLI transport delivers it as an
|
|
3159
|
-
* `error` event
|
|
3160
|
-
*
|
|
3179
|
+
* `error` event, the Provider API transport as a top-level `error` member of
|
|
3180
|
+
* an SSE chunk — so the two cannot drift apart.
|
|
3161
3181
|
*
|
|
3162
3182
|
* The wording decides before the status does, mirroring the official CLI's
|
|
3163
|
-
* `classifyKind` (which reads the message first)
|
|
3164
|
-
* (which exist so thrown and in-band delivery share one classifier). Order is
|
|
3165
|
-
* load-bearing:
|
|
3183
|
+
* `classifyKind` (which reads the message first). Order is load-bearing:
|
|
3166
3184
|
*
|
|
3167
3185
|
* - A context-window rejection is terminal for THIS request but recoverable by
|
|
3168
3186
|
* the harness: `dsh-compaction-basic` listens on `agent/request-error` for
|
|
3169
3187
|
* `CONTEXT_WINDOW_EXCEEDED` and answers it with a compaction + retry of the
|
|
3170
|
-
* reduced surface.
|
|
3171
|
-
*
|
|
3172
|
-
*
|
|
3173
|
-
* this wording landed before — the adapter resent the byte-identical
|
|
3174
|
-
* oversized request up to `maxRetries` times and the session could never
|
|
3175
|
-
* recover, which is exactly the endless-retry-at-long-context report
|
|
3176
|
-
* (issue #39).
|
|
3188
|
+
* reduced surface. Reported as retryable `SERVER` instead, the adapter resent
|
|
3189
|
+
* the byte-identical oversized request up to `maxRetries` times and the
|
|
3190
|
+
* session could never recover (issue #39).
|
|
3177
3191
|
* - Credits/plan wording is terminal, so an exhausted balance or a model
|
|
3178
3192
|
* outside the plan is not retried as if it were a transient rate limit.
|
|
3179
|
-
* - Only then does the status/`isRetryable` pair decide
|
|
3180
|
-
* refusal (`isRetryable: false`) or a terminal marker OUTRANKS a status, so a
|
|
3181
|
-
* 5xx carrying "insufficient credits" stays terminal instead of entering the
|
|
3182
|
-
* retry cadence as `SERVER`.
|
|
3193
|
+
* - Only then does the status/`isRetryable` pair decide.
|
|
3183
3194
|
*/
|
|
3184
3195
|
function streamErrorToLlmError(value, fallbackMessage) {
|
|
3185
3196
|
const record = isRecord(value) ? value : void 0;
|
|
@@ -3283,6 +3294,8 @@ function mergeCombinatorBranches(node, depth) {
|
|
|
3283
3294
|
* resolved/merged; anything else (an array/scalar root, or no schema at all)
|
|
3284
3295
|
* degrades to a permissive free-form object, because a request the provider
|
|
3285
3296
|
* refuses helps no one and the tool's own description is still in the prompt.
|
|
3297
|
+
* Only the root is touched and every path returns a copy: the harness may
|
|
3298
|
+
* deep-freeze the caller's schema.
|
|
3286
3299
|
*/
|
|
3287
3300
|
function toolParametersSchema(parameters, depth = 0) {
|
|
3288
3301
|
if (!isRecord(parameters)) return {
|
|
@@ -3401,11 +3414,10 @@ async function writeModelsCache(cachePath, models) {
|
|
|
3401
3414
|
* The tool result one message answers, or undefined when it answers none.
|
|
3402
3415
|
*
|
|
3403
3416
|
* The harness models a tool result as its own `role: 'tool'` message carrying
|
|
3404
|
-
* `toolCallId`/`isError` beside raw content blocks (
|
|
3405
|
-
*
|
|
3406
|
-
*
|
|
3407
|
-
*
|
|
3408
|
-
* unanswered call on the wire.
|
|
3417
|
+
* `toolCallId`/`isError` beside raw content blocks (`MessageRoleMap.tool` is
|
|
3418
|
+
* the only role a tool result can arrive on). Pairing and emission MUST agree
|
|
3419
|
+
* on this same view, or a dropped result either drops its call too (making the
|
|
3420
|
+
* model forget completed work) or leaves an unanswered call on the wire.
|
|
3409
3421
|
*/
|
|
3410
3422
|
function toolResultOf(message) {
|
|
3411
3423
|
if (message.role !== "tool") return void 0;
|
|
@@ -3448,8 +3460,8 @@ function pairedToolCalls(messages) {
|
|
|
3448
3460
|
* another provider issued the call — so overlong paired ids are remapped to
|
|
3449
3461
|
* short per-request aliases. Correlation only needs to hold within one
|
|
3450
3462
|
* request (each call travels with its result in the same body), so a
|
|
3451
|
-
* sequential alias is enough: no durable state,
|
|
3452
|
-
*
|
|
3463
|
+
* sequential alias is enough: no durable state, and the harness log keeps the
|
|
3464
|
+
* original ids.
|
|
3453
3465
|
*/
|
|
3454
3466
|
const MAX_WIRE_TOOL_CALL_ID_LENGTH = 64;
|
|
3455
3467
|
/**
|
|
@@ -3481,7 +3493,7 @@ function blockText(block) {
|
|
|
3481
3493
|
/**
|
|
3482
3494
|
* Collect text and nested images of one tool result. The harness `read_image`
|
|
3483
3495
|
* tool returns both, so dropping the image half is what made a tool-read image
|
|
3484
|
-
* invisible to a Vision model (issue #30)
|
|
3496
|
+
* invisible to a Vision model (issue #30); deduplication keeps a result that
|
|
3485
3497
|
* repeats one attachment from paying for the same pixels twice.
|
|
3486
3498
|
*/
|
|
3487
3499
|
function toolResultMedia(block) {
|
|
@@ -3521,17 +3533,14 @@ const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result";
|
|
|
3521
3533
|
* tool it just ran rather than to the user; it also leads the part list,
|
|
3522
3534
|
* because an image-first content array is what some gateways reject.
|
|
3523
3535
|
*
|
|
3524
|
-
* The note names the tool call the image came out of
|
|
3525
|
-
*
|
|
3526
|
-
*
|
|
3527
|
-
*
|
|
3528
|
-
*
|
|
3529
|
-
*
|
|
3530
|
-
*
|
|
3531
|
-
*
|
|
3532
|
-
*
|
|
3533
|
-
* Count and pixel dimensions are appended when the tool's own text does not
|
|
3534
|
-
* already state them (a result that returns the image and nothing else).
|
|
3536
|
+
* The note names the tool call the image came out of: a turn's carriers are
|
|
3537
|
+
* emitted as a group after the whole tool group (issue #33), so position
|
|
3538
|
+
* cannot carry the association — and every `read_image` result renders the
|
|
3539
|
+
* same envelope text, so two parallel calls would produce two identical
|
|
3540
|
+
* notes. The id is the WIRE id, the one the model saw on the `tool-call` and
|
|
3541
|
+
* on the tool message just above, so an overlong cross-provider id is named
|
|
3542
|
+
* by the alias that replaced it. Count and pixel dimensions are appended
|
|
3543
|
+
* when the tool's own text does not already state them.
|
|
3535
3544
|
*/
|
|
3536
3545
|
function toolResultImageNote(media, toolCallId) {
|
|
3537
3546
|
const lead = `${TOOL_RESULT_IMAGE_TEXT} (${toolCallId}):`;
|
|
@@ -3562,10 +3571,10 @@ const ENGINE_IMAGE_POLICY = {
|
|
|
3562
3571
|
requiredImageOffload
|
|
3563
3572
|
};
|
|
3564
3573
|
/**
|
|
3565
|
-
* The failure a route raises instead of evicting on its own. The
|
|
3566
|
-
*
|
|
3567
|
-
*
|
|
3568
|
-
*
|
|
3574
|
+
* The failure a route raises instead of evicting on its own. The count is the
|
|
3575
|
+
* ONE thing the harness needs: `dsh-compaction-image-offload` records it as an
|
|
3576
|
+
* `image/offload` event, marks that many oldest retained occurrences, and
|
|
3577
|
+
* retries the step.
|
|
3569
3578
|
*
|
|
3570
3579
|
* Deliberately outside `providerRetryPolicy()`'s whitelist: a byte-identical
|
|
3571
3580
|
* resend cannot succeed, so the retry belongs to the surface mutation, not to
|
|
@@ -3627,8 +3636,9 @@ async function readRequestImage(attachments, ref) {
|
|
|
3627
3636
|
* Price one request occurrence.
|
|
3628
3637
|
*
|
|
3629
3638
|
* Two cases cost no vision tokens at all, and both are priced as their
|
|
3630
|
-
* model-visible TEXT so the caller's estimator can charge that instead
|
|
3631
|
-
*
|
|
3639
|
+
* model-visible TEXT so the caller's estimator can charge that instead (which
|
|
3640
|
+
* is what stops the meter counting an evicted image as context): a model that
|
|
3641
|
+
* accepts text only (the harness projects those images to placeholder text
|
|
3632
3642
|
* before the request), and an occurrence the session surface has already
|
|
3633
3643
|
* offloaded (the same placeholder text rides in every later request). A retained
|
|
3634
3644
|
* occurrence on this route contributes no text at all — the pixels are inlined —
|
|
@@ -3883,7 +3893,6 @@ function parseUsageTotals(usage) {
|
|
|
3883
3893
|
periodBasis: stringValue(usage.periodBasis) ?? "billing-period"
|
|
3884
3894
|
};
|
|
3885
3895
|
}
|
|
3886
|
-
/** Parse one window-limit block (`fiveHour` / `weekly`). */
|
|
3887
3896
|
/**
|
|
3888
3897
|
* Parse one window block into a window limit, or undefined when the endpoint
|
|
3889
3898
|
* reported no such window.
|
|
@@ -3951,6 +3960,46 @@ function classifyTotalFailure(failures, failed) {
|
|
|
3951
3960
|
if (failed.every((item) => item.kind === "invalid-response")) return "invalid-response";
|
|
3952
3961
|
if (failed.every((item) => item.kind === "transport")) return "network";
|
|
3953
3962
|
}
|
|
3963
|
+
/** UUID shape accepted by the official CLI's `toWireThreadId` helper. */
|
|
3964
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
3965
|
+
const THREAD_ID_NAMESPACE = Buffer.from("6ba7b8119dad11d180b400c04fd430c8", "hex");
|
|
3966
|
+
/**
|
|
3967
|
+
* Map the harness session identity to the CLI transport's UUID thread ID.
|
|
3968
|
+
*
|
|
3969
|
+
* The official CLI creates one thread ID per agent run and reuses it for the
|
|
3970
|
+
* tool-loop requests that follow. DSH stamps its loop-owned session ID onto
|
|
3971
|
+
* GenerateOptions, but ordinary IDs are `session-<UUID>`, not bare UUIDs.
|
|
3972
|
+
* UUIDv5 gives every non-UUID session ID a stable, wire-compatible mapping.
|
|
3973
|
+
* An already valid UUID remains unchanged; a one-shot call without a session
|
|
3974
|
+
* ID still gets a fresh thread ID.
|
|
3975
|
+
* This alone does not establish cache affinity (issue #64's A/B found none).
|
|
3976
|
+
*/
|
|
3977
|
+
function cliThreadId(sessionId) {
|
|
3978
|
+
if (!sessionId) return randomUUID();
|
|
3979
|
+
if (UUID_PATTERN.test(sessionId)) return sessionId;
|
|
3980
|
+
const digest = createHash("sha1").update(THREAD_ID_NAMESPACE).update("dsh-commandcode-provider/session/").update(sessionId).digest();
|
|
3981
|
+
digest[6] = digest[6] & 15 | 80;
|
|
3982
|
+
digest[8] = digest[8] & 63 | 128;
|
|
3983
|
+
const hex = digest.toString("hex", 0, 16);
|
|
3984
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
3985
|
+
}
|
|
3986
|
+
/**
|
|
3987
|
+
* The CLI wire's explicit system-section form.
|
|
3988
|
+
*
|
|
3989
|
+
* The official CLI's current transport sends system text as an array and marks
|
|
3990
|
+
* stable sections with Anthropic-compatible ephemeral cache control. DSH's
|
|
3991
|
+
* adapter has one already-folded system string rather than the CLI's internal
|
|
3992
|
+
* section list, so preserve that text as one cacheable section. This does not
|
|
3993
|
+
* recover the CLI's separate static/dynamic boundaries or guarantee a hit.
|
|
3994
|
+
*/
|
|
3995
|
+
function cliSystem(systemText) {
|
|
3996
|
+
if (!systemText) return "";
|
|
3997
|
+
return [{
|
|
3998
|
+
type: "text",
|
|
3999
|
+
text: systemText,
|
|
4000
|
+
cache_control: { type: "ephemeral" }
|
|
4001
|
+
}];
|
|
4002
|
+
}
|
|
3954
4003
|
/** Build the legacy CLI (`/alpha/generate`) request body for one call. */
|
|
3955
4004
|
async function buildCliBody(options, connection, facts) {
|
|
3956
4005
|
return {
|
|
@@ -3977,13 +4026,13 @@ async function buildCliBody(options, connection, facts) {
|
|
|
3977
4026
|
description: tool.description,
|
|
3978
4027
|
input_schema: toolParametersSchema(tool.parameters)
|
|
3979
4028
|
})),
|
|
3980
|
-
system: facts.systemText,
|
|
4029
|
+
system: cliSystem(facts.systemText),
|
|
3981
4030
|
max_tokens: facts.maxTokens,
|
|
3982
4031
|
temperature: options.temperature ?? .3,
|
|
3983
4032
|
stream: true,
|
|
3984
4033
|
...facts.reasoningEffort ? { reasoning_effort: facts.reasoningEffort } : {}
|
|
3985
4034
|
},
|
|
3986
|
-
threadId:
|
|
4035
|
+
threadId: cliThreadId(options.sessionId)
|
|
3987
4036
|
};
|
|
3988
4037
|
}
|
|
3989
4038
|
/** Build the documented Provider Chat Completions request body for one call. */
|
|
@@ -4012,12 +4061,11 @@ async function buildOpenAIBody(options, facts) {
|
|
|
4012
4061
|
}
|
|
4013
4062
|
/**
|
|
4014
4063
|
* One pre-stream connect attempt: POST the body and wait for response
|
|
4015
|
-
* headers only (`requestTimeoutMs` must never bound the body stream
|
|
4016
|
-
* the
|
|
4017
|
-
*
|
|
4018
|
-
*
|
|
4019
|
-
*
|
|
4020
|
-
* streaming tail calls cleanup.
|
|
4064
|
+
* headers only (`requestTimeoutMs` must never bound the body stream). Returns
|
|
4065
|
+
* the live response plus its cleanup, or the rejection facts for the rotation
|
|
4066
|
+
* loop to classify. Every failure path cleans up before returning or throwing;
|
|
4067
|
+
* on success the caller-abort listener outlives the connect phase (it aborts a
|
|
4068
|
+
* stalled body read), so the streaming tail calls cleanup.
|
|
4021
4069
|
*/
|
|
4022
4070
|
async function connectGenerate(deps, key, protocol, body) {
|
|
4023
4071
|
const { options, connection, fetchImpl } = deps;
|
|
@@ -4074,6 +4122,7 @@ async function connectGenerate(deps, key, protocol, body) {
|
|
|
4074
4122
|
if (connectTimedOut || error instanceof DOMException && error.name === "TimeoutError") throw new LlmError(`Command Code API request to ${endpoint} did not respond within ${connection.requestTimeoutMs}ms: ${errorChain(error)};Command Code API 请求在 ${connection.requestTimeoutMs} 毫秒内未收到响应——通常是网络或代理问题,请检查后重试`, "TIMEOUT", { cause: error });
|
|
4075
4123
|
throw new LlmError(`Command Code API request to ${endpoint} failed: ${errorChain(error)};Command Code API 请求连接失败——通常是网络或代理问题,请检查网络或代理设置后重试`, "TRANSPORT", { cause: error });
|
|
4076
4124
|
}
|
|
4125
|
+
deps.onResponse(response);
|
|
4077
4126
|
if (!response.ok) {
|
|
4078
4127
|
const errText = await response.text().catch(() => "");
|
|
4079
4128
|
cleanup();
|
|
@@ -4102,6 +4151,7 @@ function createBlockAssembler() {
|
|
|
4102
4151
|
reasoningContent: "",
|
|
4103
4152
|
sawContent: false,
|
|
4104
4153
|
finishReason: void 0,
|
|
4154
|
+
cliCacheWriteTokens: void 0,
|
|
4105
4155
|
openAiToolCalls: []
|
|
4106
4156
|
};
|
|
4107
4157
|
}
|
|
@@ -4202,13 +4252,13 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4202
4252
|
* unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
|
|
4203
4253
|
* `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
|
|
4204
4254
|
* and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
|
|
4205
|
-
* recovers in-session instead of failing after two tries. Permanent
|
|
4206
|
-
*
|
|
4207
|
-
*
|
|
4208
|
-
*
|
|
4209
|
-
*
|
|
4210
|
-
*
|
|
4211
|
-
*
|
|
4255
|
+
* recovers in-session instead of failing after two tries. Permanent failures
|
|
4256
|
+
* (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`, plan
|
|
4257
|
+
* rejections) are absent from the whitelist and surface immediately instead
|
|
4258
|
+
* of looping. Waits the pool/adapter attach as `providerRetryAfterMs` are
|
|
4259
|
+
* honored verbatim at or below the 15-minute cap and never attached above it
|
|
4260
|
+
* (in normal mode a longer attached wait makes the executor abandon the
|
|
4261
|
+
* retry outright — see RETRY_MAX_DELAY_MS).
|
|
4212
4262
|
*
|
|
4213
4263
|
* Captured once at route registration (dsh-llm snapshots this value), so a
|
|
4214
4264
|
* future config knob for it would apply on profile restart, not per request.
|
|
@@ -4239,12 +4289,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4239
4289
|
* session reports far less context than it is actually carrying. The price is
|
|
4240
4290
|
* computed at the dimensions this route would send (the same request target
|
|
4241
4291
|
* `readImageRequest` encodes to), so the estimate tracks the wire rather than
|
|
4242
|
-
* the stored original.
|
|
4243
|
-
*
|
|
4244
|
-
* Both payload generations are handled, because they disagree: ≤0.1.5 hands
|
|
4245
|
-
* over bare `ImageAttachmentRef`s, ≥0.1.6 hands over `ImageBlock`s that also
|
|
4246
|
-
* carry the surface's `offloaded` mark. Returning a price per occurrence, in
|
|
4247
|
-
* order, is a hard requirement — the meter throws when the counts differ.
|
|
4292
|
+
* the stored original. One price per occurrence, in order, is a hard
|
|
4293
|
+
* requirement — the meter throws when the counts differ.
|
|
4248
4294
|
*/
|
|
4249
4295
|
imageRequestPricing(_provider, model) {
|
|
4250
4296
|
const imageCapable = KNOWN_IMAGE_MODELS.has(model);
|
|
@@ -4304,6 +4350,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4304
4350
|
name: entry ? `${entry.name} (CC)` : model,
|
|
4305
4351
|
description: capabilityDescription(model, entry?.contextWindow),
|
|
4306
4352
|
inputModalities: vision ? ["text", "image"] : ["text"],
|
|
4353
|
+
toolUpdate: "addition-only",
|
|
4307
4354
|
...entry ? {
|
|
4308
4355
|
context: { contextWindow: entry.contextWindow },
|
|
4309
4356
|
defaultMaxTokens: Math.min(entry.maxTokens, DEFAULT_GENERATE_MAX_TOKENS)
|
|
@@ -4329,10 +4376,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4329
4376
|
}
|
|
4330
4377
|
/**
|
|
4331
4378
|
* Fetch one account endpoint and parse its JSON body. Returns the HTTP
|
|
4332
|
-
* status alongside the parsed record so each caller applies its own
|
|
4333
|
-
*
|
|
4334
|
-
*
|
|
4335
|
-
*
|
|
4379
|
+
* status alongside the parsed record so each caller applies its own failure
|
|
4380
|
+
* accounting: the billing probe fails open silently, the usage report books
|
|
4381
|
+
* failures per endpoint. Non-2xx and invalid JSON bodies come back without a
|
|
4382
|
+
* record; only a fetch failure propagates to the caller.
|
|
4336
4383
|
*
|
|
4337
4384
|
* `timeoutMs` is the caller's budget for this one request. The default is the
|
|
4338
4385
|
* CATALOG probe's short cap, which fits the picker's fail-open reads; the
|
|
@@ -4369,9 +4416,6 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4369
4416
|
* per picker load). `undefined` means the filter cannot be evaluated at all
|
|
4370
4417
|
* — no key resolved — which {@link modelVisibleForAnyAccount} reads as
|
|
4371
4418
|
* "show everything".
|
|
4372
|
-
*
|
|
4373
|
-
* The serving account is only the first entry: with several accounts the
|
|
4374
|
-
* model list must not depend on which of them rotation happens to be using.
|
|
4375
4419
|
*/
|
|
4376
4420
|
async loadPoolBillingAccess() {
|
|
4377
4421
|
const keys = await this.poolAccountKeys();
|
|
@@ -4484,10 +4528,9 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4484
4528
|
* but a hard refusal. `/alpha/generate` routes every one of them, so this
|
|
4485
4529
|
* costs nothing.
|
|
4486
4530
|
*
|
|
4487
|
-
* That decision is deliberately NOT written to `protocolCache
|
|
4488
|
-
* keyed by API key alone
|
|
4489
|
-
*
|
|
4490
|
-
* which the Provider API serves correctly — off it until the entry expired.
|
|
4531
|
+
* That decision is deliberately NOT written to `protocolCache`, which is
|
|
4532
|
+
* keyed by API key alone: remembering it would pin the whole ACCOUNT to the
|
|
4533
|
+
* CLI transport and drag every other model off it until the entry expired.
|
|
4491
4534
|
*/
|
|
4492
4535
|
resolveProtocol(apiKey, model) {
|
|
4493
4536
|
const forced = this.deps.options().protocol;
|
|
@@ -4513,18 +4556,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4513
4556
|
* the default resolves the currently active account.
|
|
4514
4557
|
*
|
|
4515
4558
|
* Two failure modes are NOT "the network is down", and the report must say
|
|
4516
|
-
* so rather than let {@link classifyTotalFailure} blame the connection:
|
|
4517
|
-
*
|
|
4518
|
-
*
|
|
4519
|
-
*
|
|
4520
|
-
*
|
|
4521
|
-
*
|
|
4522
|
-
*
|
|
4523
|
-
*
|
|
4524
|
-
* (`connection.requestTimeoutMs`, default 60 s), not the catalog's 10 s
|
|
4525
|
-
* probe budget: on a slow link a 10 s cap made every account query time
|
|
4526
|
-
* out while chat kept working, which is exactly a "check your network"
|
|
4527
|
-
* banner that never clears.
|
|
4559
|
+
* so rather than let {@link classifyTotalFailure} blame the connection: a
|
|
4560
|
+
* key that no HTTP header can carry (a stray newline from a paste, a
|
|
4561
|
+
* full-width character) makes `fetch` throw a `TypeError` before any I/O,
|
|
4562
|
+
* for every endpoint at once, so this path runs `assertUsableApiKey` too and
|
|
4563
|
+
* answers `blocked: 'invalid-key'`; and the four endpoints get the SAME
|
|
4564
|
+
* per-request budget as a chat call (`connection.requestTimeoutMs`, default
|
|
4565
|
+
* 60 s), not the catalog's 10 s probe budget, because on a slow link a 10 s
|
|
4566
|
+
* cap made every account query time out while chat kept working.
|
|
4528
4567
|
*/
|
|
4529
4568
|
async getUsage(apiKey) {
|
|
4530
4569
|
const connection = this.deps.options();
|
|
@@ -4602,17 +4641,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4602
4641
|
* the same time (the two are metered separately), and reading only the
|
|
4603
4642
|
* shorter one revived such an account as "usable" — the pool then handed it
|
|
4604
4643
|
* out, the provider rejected the request again, and the next all-marked pass
|
|
4605
|
-
* revived it once more
|
|
4606
|
-
*
|
|
4607
|
-
*
|
|
4608
|
-
*
|
|
4609
|
-
*
|
|
4644
|
+
* revived it once more (issue #51's follow-up: "切到一个不可用账号").
|
|
4645
|
+
* `exceeded` is therefore true when ANY published window is exceeded, and
|
|
4646
|
+
* `resetAt` is the LATEST reset among the exceeded ones — the binding
|
|
4647
|
+
* constraint, because a clear five-hour window buys nothing while the weekly
|
|
4648
|
+
* quota is spent.
|
|
4610
4649
|
*
|
|
4611
4650
|
* Returns `undefined` when the probe itself failed (transport, non-200, or a
|
|
4612
4651
|
* payload with no window limits at all) — a failed probe never changes pool
|
|
4613
|
-
* state. The
|
|
4614
|
-
*
|
|
4615
|
-
* time, and the server remains the final gate on them.
|
|
4652
|
+
* state. The credit balances are deliberately NOT part of this answer: they
|
|
4653
|
+
* carry no reset time, and the server remains the final gate on them.
|
|
4616
4654
|
*/
|
|
4617
4655
|
async probeWindowLimits(apiKey) {
|
|
4618
4656
|
try {
|
|
@@ -4678,73 +4716,110 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4678
4716
|
readImage
|
|
4679
4717
|
});
|
|
4680
4718
|
let body = await buildBody(protocol);
|
|
4719
|
+
const trace = openStreamTrace(`${options.provider}/${options.model}`);
|
|
4681
4720
|
const tried = /* @__PURE__ */ new Set();
|
|
4721
|
+
let attemptNumber = 0;
|
|
4722
|
+
let responseMetadata = {
|
|
4723
|
+
protocol,
|
|
4724
|
+
headers: {}
|
|
4725
|
+
};
|
|
4682
4726
|
const connectDeps = {
|
|
4683
4727
|
options,
|
|
4684
4728
|
connection,
|
|
4685
|
-
fetchImpl: this.fetchImpl
|
|
4729
|
+
fetchImpl: this.fetchImpl,
|
|
4730
|
+
onResponse: (response) => {
|
|
4731
|
+
responseMetadata = {
|
|
4732
|
+
protocol,
|
|
4733
|
+
headers: responseIdentifiers(response.headers)
|
|
4734
|
+
};
|
|
4735
|
+
trace.record("response", {
|
|
4736
|
+
protocol,
|
|
4737
|
+
endpoint: protocol === "cli" ? `${connection.apiBase}/alpha/generate` : `${connection.apiBase}/provider/v1/chat/completions`,
|
|
4738
|
+
status: response.status,
|
|
4739
|
+
attempt: attemptNumber,
|
|
4740
|
+
accountsTried: tried.size,
|
|
4741
|
+
maxTokens,
|
|
4742
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort },
|
|
4743
|
+
headers: responseMetadata.headers
|
|
4744
|
+
});
|
|
4745
|
+
}
|
|
4686
4746
|
};
|
|
4687
4747
|
let connected;
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
}
|
|
4701
|
-
if (attempt.status === 413 && REQUEST_IMAGE_BUDGETS.length > 1) {
|
|
4702
|
-
const missing = this.surfaceOffload.requiredImageOffload(requestOptions.messages, coreBudget(REQUEST_IMAGE_BUDGETS[1]), imageVersionBytes);
|
|
4703
|
-
if (missing > 0) throw imageOffloadRequired(missing);
|
|
4704
|
-
}
|
|
4705
|
-
const rotate = this.deps.rotateApiKey;
|
|
4706
|
-
const rejection = classifyAccountRejection(attempt.status, attempt.errText);
|
|
4707
|
-
if (rejection !== void 0 && rotate !== void 0 && options.signal?.aborted !== true && tried.size < MAX_ACCOUNT_ROTATIONS) {
|
|
4708
|
-
const next = await rotate(apiKey, rejection.reason, connection, options.model, {
|
|
4709
|
-
tried: [...tried],
|
|
4710
|
-
...rejection.resetAtMs !== void 0 && { resetAtMs: rejection.resetAtMs }
|
|
4748
|
+
try {
|
|
4749
|
+
for (;;) {
|
|
4750
|
+
tried.add(apiKey);
|
|
4751
|
+
attemptNumber++;
|
|
4752
|
+
responseMetadata = {
|
|
4753
|
+
protocol,
|
|
4754
|
+
headers: {}
|
|
4755
|
+
};
|
|
4756
|
+
if (trace.enabled) trace.record("request", {
|
|
4757
|
+
attempt: attemptNumber,
|
|
4758
|
+
sessionId: options.sessionId,
|
|
4759
|
+
...requestFingerprint(protocol, body)
|
|
4711
4760
|
});
|
|
4712
|
-
|
|
4713
|
-
|
|
4761
|
+
const attempt = await connectGenerate(connectDeps, apiKey, protocol, body);
|
|
4762
|
+
if ("response" in attempt) {
|
|
4763
|
+
connected = attempt;
|
|
4764
|
+
break;
|
|
4765
|
+
}
|
|
4766
|
+
if (protocol === "openai" && isUpgradeRequiredError(attempt.status, attempt.errText)) {
|
|
4767
|
+
protocol = "cli";
|
|
4768
|
+
this.rememberProtocol(apiKey, true);
|
|
4769
|
+
body = await buildBody("cli");
|
|
4714
4770
|
continue;
|
|
4715
4771
|
}
|
|
4772
|
+
if (attempt.status === 413 && REQUEST_IMAGE_BUDGETS.length > 1) {
|
|
4773
|
+
const missing = this.surfaceOffload.requiredImageOffload(requestOptions.messages, coreBudget(REQUEST_IMAGE_BUDGETS[1]), imageVersionBytes);
|
|
4774
|
+
if (missing > 0) throw imageOffloadRequired(missing);
|
|
4775
|
+
}
|
|
4776
|
+
const rotate = this.deps.rotateApiKey;
|
|
4777
|
+
const rejection = classifyAccountRejection(attempt.status, attempt.errText);
|
|
4778
|
+
if (rejection !== void 0 && rotate !== void 0 && options.signal?.aborted !== true && tried.size < MAX_ACCOUNT_ROTATIONS) {
|
|
4779
|
+
const next = await rotate(apiKey, rejection.reason, connection, options.model, {
|
|
4780
|
+
tried: [...tried],
|
|
4781
|
+
...rejection.resetAtMs !== void 0 && { resetAtMs: rejection.resetAtMs }
|
|
4782
|
+
});
|
|
4783
|
+
if (next !== void 0 && !tried.has(next)) {
|
|
4784
|
+
apiKey = next;
|
|
4785
|
+
continue;
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
if (rejection !== void 0 && (rejection.reason === "rate-limit" || rejection.reason === "throttled") && (rejection.resetAtMs !== void 0 || attempt.status !== 429)) {
|
|
4789
|
+
const reset = rejection.resetAtMs;
|
|
4790
|
+
const wait = reset === void 0 ? 0 : Math.min(Math.max(1e3, reset - Date.now()), RETRY_MAX_DELAY_MS);
|
|
4791
|
+
const when = reset === void 0 ? void 0 : new Date(reset).toISOString();
|
|
4792
|
+
const window = rejection.reason === "rate-limit";
|
|
4793
|
+
throw new LlmError((window ? "llm-commandcode: the Command Code account is rate limited" : "llm-commandcode: the Command Code account is rate limited (429) — the provider did not report an exhausted usage window") + (when === void 0 ? "" : ` — the provider reports it resets at ${when}`) + (window ? ";当前 Command Code 账户已被限流" : ";当前 Command Code 账户被限流(429),服务商未报告用量窗口用尽") + (when === void 0 ? "" : `,服务商给出的重置时间为 ${when}`), "RATE_LIMIT", wait > 0 ? { providerRetryAfterMs: wait } : void 0);
|
|
4794
|
+
}
|
|
4795
|
+
throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs);
|
|
4716
4796
|
}
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs);
|
|
4797
|
+
} catch (error) {
|
|
4798
|
+
trace.record("connect-error", {
|
|
4799
|
+
message: boundTraceText(errorChain(error)),
|
|
4800
|
+
responseMetadata
|
|
4801
|
+
});
|
|
4802
|
+
trace.close();
|
|
4803
|
+
throw options.signal?.aborted ? error : failureWithRequestId(error, responseMetadata);
|
|
4725
4804
|
}
|
|
4726
4805
|
if (connected === void 0) throw new LlmError("Command Code API connection failed without a response", "TRANSPORT");
|
|
4727
4806
|
const { response, cleanup } = connected;
|
|
4728
4807
|
if (!response.body) {
|
|
4729
4808
|
cleanup();
|
|
4730
|
-
|
|
4809
|
+
trace.record("end", {
|
|
4810
|
+
outcome: "empty",
|
|
4811
|
+
code: "PROVIDER_PROTOCOL_ERROR"
|
|
4812
|
+
});
|
|
4813
|
+
trace.close();
|
|
4814
|
+
throw failureWithRequestId(new LlmError("Command Code API returned no response body", "PROVIDER_PROTOCOL_ERROR"), responseMetadata);
|
|
4731
4815
|
}
|
|
4732
4816
|
const reader = response.body.getReader();
|
|
4733
4817
|
const decoder = new TextDecoder();
|
|
4734
4818
|
let buffer = "";
|
|
4735
|
-
const trace = openStreamTrace(`${options.provider}/${options.model}`);
|
|
4736
4819
|
const streamStartedAt = Date.now();
|
|
4737
4820
|
let chunkCount = 0;
|
|
4738
4821
|
let totalBytes = 0;
|
|
4739
4822
|
let lastChunkAt = streamStartedAt;
|
|
4740
|
-
trace.record("response", {
|
|
4741
|
-
protocol,
|
|
4742
|
-
endpoint: protocol === "cli" ? `${connection.apiBase}/alpha/generate` : `${connection.apiBase}/provider/v1/chat/completions`,
|
|
4743
|
-
status: response.status,
|
|
4744
|
-
attempts: tried.size,
|
|
4745
|
-
maxTokens,
|
|
4746
|
-
...reasoningEffort === void 0 ? {} : { reasoningEffort }
|
|
4747
|
-
});
|
|
4748
4823
|
let idleTimer;
|
|
4749
4824
|
let idleFired = false;
|
|
4750
4825
|
const armIdle = () => {
|
|
@@ -4766,6 +4841,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4766
4841
|
let usageEmitted = false;
|
|
4767
4842
|
let endRecorded = false;
|
|
4768
4843
|
function* handle(event) {
|
|
4844
|
+
captureResponseIdentifiers(responseMetadata, event);
|
|
4769
4845
|
for (const chunk of handleEvent(asm, protocol, event)) if (chunk.type === "finish") finish = chunk;
|
|
4770
4846
|
else if (chunk.type === "usage") usage = chunk.usage;
|
|
4771
4847
|
else yield chunk;
|
|
@@ -4836,11 +4912,15 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4836
4912
|
chunks: chunkCount,
|
|
4837
4913
|
bytes: totalBytes,
|
|
4838
4914
|
totalMs: Date.now() - streamStartedAt,
|
|
4839
|
-
sawContent: asm.sawContent
|
|
4915
|
+
sawContent: asm.sawContent,
|
|
4916
|
+
responseMetadata
|
|
4840
4917
|
});
|
|
4841
4918
|
endRecorded = true;
|
|
4842
4919
|
if (failure !== void 0) throw failure;
|
|
4843
|
-
yield
|
|
4920
|
+
yield hasResponseIdentifiers(responseMetadata) ? {
|
|
4921
|
+
...finish,
|
|
4922
|
+
replayState: { response: responseMetadata }
|
|
4923
|
+
} : finish;
|
|
4844
4924
|
} else {
|
|
4845
4925
|
const elapsed = Date.now() - streamStartedAt;
|
|
4846
4926
|
const detail = `${chunkCount} chunk(s), ${totalBytes} bytes, ${elapsed}ms`;
|
|
@@ -4849,7 +4929,8 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4849
4929
|
chunks: chunkCount,
|
|
4850
4930
|
bytes: totalBytes,
|
|
4851
4931
|
totalMs: elapsed,
|
|
4852
|
-
sawContent: asm.sawContent
|
|
4932
|
+
sawContent: asm.sawContent,
|
|
4933
|
+
responseMetadata
|
|
4853
4934
|
});
|
|
4854
4935
|
endRecorded = true;
|
|
4855
4936
|
if (!asm.sawContent) throw new LlmError("Command Code returned an empty response;Command Code 返回了空响应,重试通常可恢复", "EMPTY_RESPONSE");
|
|
@@ -4869,9 +4950,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4869
4950
|
sawContent: asm.sawContent,
|
|
4870
4951
|
chunks: chunkCount,
|
|
4871
4952
|
bytes: totalBytes,
|
|
4872
|
-
totalMs: Date.now() - streamStartedAt
|
|
4953
|
+
totalMs: Date.now() - streamStartedAt,
|
|
4954
|
+
responseMetadata
|
|
4873
4955
|
});
|
|
4874
|
-
throw error;
|
|
4956
|
+
throw options.signal?.aborted ? error : failureWithRequestId(error, responseMetadata);
|
|
4875
4957
|
} finally {
|
|
4876
4958
|
clearIdle();
|
|
4877
4959
|
cleanup();
|
|
@@ -4888,16 +4970,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
4888
4970
|
* status alone cannot distinguish them. The status also decides the failure
|
|
4889
4971
|
* CODE via {@link httpErrorCode}, which is what the retry policy routes on —
|
|
4890
4972
|
* a transient 5xx must not be reported as a permanent provider rejection. A
|
|
4891
|
-
* 429's `Retry-After` header rides along as
|
|
4892
|
-
*
|
|
4893
|
-
*
|
|
4894
|
-
*
|
|
4895
|
-
* outright instead of falling back to local backoff.
|
|
4973
|
+
* 429's `Retry-After` header rides along as `providerRetryAfterMs` so
|
|
4974
|
+
* dsh-llm-retry can wait exactly that long instead of guessing at the backoff
|
|
4975
|
+
* cadence — capped at RETRY_MAX_DELAY_MS, because in normal mode a longer
|
|
4976
|
+
* attached wait makes the executor abandon the retry outright.
|
|
4896
4977
|
*
|
|
4897
4978
|
* One body is read, not just its status: a client-side rejection whose
|
|
4898
4979
|
* `error.code`/`type`/`message` names the model context window is reported as
|
|
4899
|
-
* `CONTEXT_WINDOW_EXCEEDED
|
|
4900
|
-
*
|
|
4980
|
+
* `CONTEXT_WINDOW_EXCEEDED`, because that failure has a recovery the harness
|
|
4981
|
+
* performs and a generic provider error does not. Every message built here is
|
|
4982
|
+
* bilingual — the harness renders it verbatim in its retry chrome.
|
|
4901
4983
|
*/
|
|
4902
4984
|
function generateHttpError(status, errText, retryAfterMs) {
|
|
4903
4985
|
let providerCode;
|
|
@@ -4930,16 +5012,11 @@ function generateHttpError(status, errText, retryAfterMs) {
|
|
|
4930
5012
|
* status, never the provider's `error.type` — so a status that arrives here as
|
|
4931
5013
|
* a permanent code is never retried, whatever it says about itself.
|
|
4932
5014
|
*
|
|
4933
|
-
* 5xx is the provider's own "
|
|
4934
|
-
* 520-527 included
|
|
4935
|
-
*
|
|
4936
|
-
*
|
|
4937
|
-
*
|
|
4938
|
-
* failure that asks to be retried the only one that never was, while the same
|
|
4939
|
-
* status arriving as an in-band stream `error` event was classified retryable
|
|
4940
|
-
* `SERVER` (see the `error` case of {@link handleCliEvent}). 408 is the same
|
|
4941
|
-
* story for the request itself. Everything else stays permanent: a 400/403/404/
|
|
4942
|
-
* 409/422 rejection repeats identically on every attempt.
|
|
5015
|
+
* 5xx is the provider's own "temporarily unavailable" class (Cloudflare's
|
|
5016
|
+
* 520-527 included) and the only sane answer to it is the byte-identical
|
|
5017
|
+
* resend the retry policy exists to make; 408 is the same story for the
|
|
5018
|
+
* request itself. Everything else stays permanent: a 400/403/404/409/422
|
|
5019
|
+
* rejection repeats identically on every attempt.
|
|
4943
5020
|
*/
|
|
4944
5021
|
function httpErrorCode(status) {
|
|
4945
5022
|
if (status === 429) return "RATE_LIMIT";
|
|
@@ -5029,22 +5106,29 @@ function handleCliEvent(asm, event) {
|
|
|
5029
5106
|
});
|
|
5030
5107
|
break;
|
|
5031
5108
|
}
|
|
5109
|
+
case "cache-write-tokens": {
|
|
5110
|
+
const cacheWrite = numberValue(event.cacheWriteTokens);
|
|
5111
|
+
if (cacheWrite !== void 0 && cacheWrite >= 0) asm.cliCacheWriteTokens = cacheWrite;
|
|
5112
|
+
break;
|
|
5113
|
+
}
|
|
5032
5114
|
case "finish": {
|
|
5033
5115
|
asm.finishReason = stringValue(event.finishReason);
|
|
5034
5116
|
chunks.push(...closeText(asm), ...closeReasoning(asm));
|
|
5035
5117
|
const usage = isRecord(event.totalUsage) ? event.totalUsage : void 0;
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
const
|
|
5118
|
+
const capturedCacheWrite = asm.cliCacheWriteTokens;
|
|
5119
|
+
if (usage || capturedCacheWrite !== void 0) {
|
|
5120
|
+
const details = isRecord(usage?.inputTokenDetails) ? usage.inputTokenDetails : void 0;
|
|
5121
|
+
const totalInput = numberValue(usage?.inputTokens) ?? 0;
|
|
5039
5122
|
const cacheRead = numberValue(details?.cacheReadTokens) ?? 0;
|
|
5040
|
-
const
|
|
5123
|
+
const reportedCacheWrite = numberValue(details?.cacheWriteTokens) ?? 0;
|
|
5124
|
+
const cacheWrite = reportedCacheWrite === 0 && capturedCacheWrite !== void 0 ? capturedCacheWrite : reportedCacheWrite;
|
|
5041
5125
|
const tokenUsage = {
|
|
5042
5126
|
inputTokens: numberValue(details?.noCacheTokens) ?? Math.max(0, totalInput - cacheRead - cacheWrite),
|
|
5043
|
-
outputTokens: numberValue(usage
|
|
5127
|
+
outputTokens: numberValue(usage?.outputTokens) ?? 0,
|
|
5044
5128
|
cacheReadTokens: cacheRead,
|
|
5045
5129
|
cacheWriteTokens: cacheWrite
|
|
5046
5130
|
};
|
|
5047
|
-
const reasoningTokens = numberValue((isRecord(usage
|
|
5131
|
+
const reasoningTokens = numberValue((isRecord(usage?.outputTokenDetails) ? usage.outputTokenDetails : void 0)?.reasoningTokens) ?? numberValue(usage?.reasoningTokens);
|
|
5048
5132
|
if (reasoningTokens !== void 0) tokenUsage.reasoningTokens = reasoningTokens;
|
|
5049
5133
|
chunks.push({
|
|
5050
5134
|
type: "usage",
|
|
@@ -5197,12 +5281,6 @@ function emptyCompletionError(reason, maxTokens, usage) {
|
|
|
5197
5281
|
return new LlmError(`Command Code finished without producing answer text or a tool call (${detail});Command Code 返回了空响应(可能只有思考内容),重试通常可恢复`, "EMPTY_RESPONSE");
|
|
5198
5282
|
}
|
|
5199
5283
|
/**
|
|
5200
|
-
* True when a Provider API pre-stream rejection is Command Code's Go-plan
|
|
5201
|
-
* gate (`upgrade_required`). Only this exact class of rejection should fall
|
|
5202
|
-
* back to the CLI /alpha/generate transport; other 4xx/5xx must surface as
|
|
5203
|
-
* ordinary errors so real account/model problems are not masked.
|
|
5204
|
-
*/
|
|
5205
|
-
/**
|
|
5206
5284
|
* Classify a pre-stream rejection that is about the ACCOUNT rather than about
|
|
5207
5285
|
* the request, using the official CLI's own rules (command-code@1.56.0:
|
|
5208
5286
|
* `parseWindowLimitError`, `isInsufficientCreditsRequestError`,
|
|
@@ -5212,46 +5290,31 @@ function emptyCompletionError(reason, maxTokens, usage) {
|
|
|
5212
5290
|
* oversized body or a context overflow must fail fast instead of walking the
|
|
5213
5291
|
* whole account pool.
|
|
5214
5292
|
*
|
|
5215
|
-
*
|
|
5216
|
-
*
|
|
5217
|
-
*
|
|
5218
|
-
*
|
|
5219
|
-
*
|
|
5220
|
-
*
|
|
5221
|
-
* function's:
|
|
5293
|
+
* It must recognize the whole account-scoped class, not just 429/401: when it
|
|
5294
|
+
* did not, an account that could not pay, or could not run the model, stayed
|
|
5295
|
+
* "usable" in the pool and was handed out again on the next request (issue
|
|
5296
|
+
* #51's follow-up). The four reasons are defined on
|
|
5297
|
+
* {@link AccountRotationReason}; what this function owes them is the evidence
|
|
5298
|
+
* split:
|
|
5222
5299
|
*
|
|
5223
|
-
* - `rate-limit
|
|
5224
|
-
*
|
|
5225
|
-
*
|
|
5226
|
-
*
|
|
5227
|
-
*
|
|
5228
|
-
*
|
|
5229
|
-
*
|
|
5230
|
-
* NOT
|
|
5231
|
-
*
|
|
5232
|
-
* `resolveWindowLabel` can name one, and a burst/model/spend limiter the
|
|
5233
|
-
* billing endpoint cannot see is exactly this shape (issue #54's report: a
|
|
5234
|
-
* 429 whose probe read five-hour 2% and weekly 10% while the error claimed
|
|
5235
|
-
* both were exhausted).
|
|
5236
|
-
* - `invalid-credential`: 401; the key itself is bad.
|
|
5237
|
-
* - `unavailable`: the key is fine but this account cannot serve: no credits
|
|
5238
|
-
* (`400 Insufficient credits`, the codes `INSUFFICIENT_CREDITS`,
|
|
5239
|
-
* `USAGE_EXCEEDED`, `PREMIUM_CREDITS_EXHAUSTED`, or a spend-cap wording)
|
|
5240
|
-
* or a model outside its plan (`MODEL_NOT_IN_PLAN`). Rotating must NOT mark
|
|
5241
|
-
* the account: the fact is about the model or the balance, it can change
|
|
5242
|
-
* without the key changing, and a `:free` model can still be served by an
|
|
5243
|
-
* account with an empty balance. These codes are recognized on EVERY
|
|
5244
|
-
* status — a 5xx that proxies the provider's own credits/plan body is
|
|
5245
|
-
* still an account fact, and only the prose scan is confined to 4xx.
|
|
5300
|
+
* - `rate-limit` / `throttled`: the code `RATE_LIMITED` is the authoritative
|
|
5301
|
+
* signal — the CLI accepts the code OR a 429 status — and only a body that
|
|
5302
|
+
* NAMES a window (`error.rateLimit.window`, see {@link readWindowLimitEvidence})
|
|
5303
|
+
* may be called a spent one. A bare 429 is a burst/model/spend limiter the
|
|
5304
|
+
* billing endpoint cannot see, so it stays `throttled` and the host claims
|
|
5305
|
+
* no window (issue #54).
|
|
5306
|
+
* - `unavailable`: no credits, or a model outside this account's plan, so it
|
|
5307
|
+
* rotates but must NOT mark the key — the fact is about the model or the
|
|
5308
|
+
* balance, and either can change without the key changing.
|
|
5246
5309
|
*/
|
|
5247
5310
|
/**
|
|
5248
5311
|
* The provider's structured "this account cannot serve this request" codes.
|
|
5249
5312
|
*
|
|
5250
5313
|
* Status-independent on purpose, mirroring the CLI's classifier, which reads
|
|
5251
|
-
* the code before any status guard
|
|
5252
|
-
*
|
|
5253
|
-
*
|
|
5254
|
-
*
|
|
5314
|
+
* the code before any status guard: a 5xx carrying one of these must rotate
|
|
5315
|
+
* past the account instead of being retried as a transient provider failure,
|
|
5316
|
+
* because the same request against the same account cannot succeed. Only the
|
|
5317
|
+
* prose scan below is confined to 4xx.
|
|
5255
5318
|
*/
|
|
5256
5319
|
const ACCOUNT_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
|
|
5257
5320
|
"USAGE_EXCEEDED",
|
|
@@ -5301,12 +5364,12 @@ const MAX_TRUSTED_RESET_MS = 2592e6;
|
|
|
5301
5364
|
* `parseWindowLimitError` → `resolveWindowLabel`/`extractResetAtMs` pair.
|
|
5302
5365
|
*
|
|
5303
5366
|
* `window` is present only when the body actually names one: a
|
|
5304
|
-
* `error.rateLimit.window` of `fiveHour`/`weekly`/`daily`, or the
|
|
5305
|
-
*
|
|
5306
|
-
*
|
|
5307
|
-
*
|
|
5308
|
-
*
|
|
5309
|
-
*
|
|
5367
|
+
* `error.rateLimit.window` of `fiveHour`/`weekly`/`daily`, or the "usage limit
|
|
5368
|
+
* for your plan" wording (read as `weekly` when it says so, else `fiveHour`).
|
|
5369
|
+
* That is the CLI's own bar for calling a rejection a WINDOW limit, and it is
|
|
5370
|
+
* what keeps a bare 429 — a burst limiter, a model-level limit, a spend cap
|
|
5371
|
+
* `windowLimits` cannot show — from being reported as an exhausted window
|
|
5372
|
+
* (issue #54).
|
|
5310
5373
|
*
|
|
5311
5374
|
* `resetAtMs` is the reset the body publishes: `error.rateLimit.reset` is
|
|
5312
5375
|
* SECONDS (`1e3 * reset`, like the CLI), and a `resets at <ISO>` stamp in the
|
|
@@ -5337,6 +5400,13 @@ function readWindowLimitEvidence(errText) {
|
|
|
5337
5400
|
...resetAtMs === void 0 ? {} : { resetAtMs }
|
|
5338
5401
|
};
|
|
5339
5402
|
}
|
|
5403
|
+
/**
|
|
5404
|
+
* True when a pre-stream rejection is Command Code's Go-plan gate
|
|
5405
|
+
* (`upgrade_required`) — the only plan without Provider API access. Only this
|
|
5406
|
+
* class of rejection should fall back to the CLI `/alpha/generate` transport;
|
|
5407
|
+
* other 4xx/5xx must surface as ordinary errors so real account/model
|
|
5408
|
+
* problems are not masked.
|
|
5409
|
+
*/
|
|
5340
5410
|
function isUpgradeRequiredError(status, errText) {
|
|
5341
5411
|
if (status !== 403) return false;
|
|
5342
5412
|
const lower = errText.toLowerCase();
|
|
@@ -5352,7 +5422,9 @@ function isUpgradeRequiredError(status, errText) {
|
|
|
5352
5422
|
const message = stringValue(error.message) ?? "";
|
|
5353
5423
|
if (message.toLowerCase().includes("go plan") && message.toLowerCase().includes("api access")) return true;
|
|
5354
5424
|
}
|
|
5355
|
-
} catch {
|
|
5425
|
+
} catch {
|
|
5426
|
+
return false;
|
|
5427
|
+
}
|
|
5356
5428
|
return false;
|
|
5357
5429
|
}
|
|
5358
5430
|
/** Map an OpenAI Chat Completions usage payload to harness disjoint counts. */
|
|
@@ -5405,7 +5477,6 @@ const commandcodeCommand = {
|
|
|
5405
5477
|
weeklyLine: " 📅 每周 {used} / {cap}{warn}",
|
|
5406
5478
|
windowBarLine: " └ {bar} 重置 {when}",
|
|
5407
5479
|
exceededWarning: " ⚠️ 超限!",
|
|
5408
|
-
resetSuffix: "重置 {when}",
|
|
5409
5480
|
partialFailures: "⚠️ 部分端点失败: {list}",
|
|
5410
5481
|
noData: "(无数据 — 请检查 API 密钥)",
|
|
5411
5482
|
errorText: "获取 Command Code 用量失败:{message}"
|
|
@@ -5437,25 +5508,19 @@ const commandcodeCommand = {
|
|
|
5437
5508
|
weeklyLine: " 📅 Weekly {used} / {cap}{warn}",
|
|
5438
5509
|
windowBarLine: " └ {bar} resets {when}",
|
|
5439
5510
|
exceededWarning: " ⚠️ exceeded!",
|
|
5440
|
-
resetSuffix: "resets {when}",
|
|
5441
5511
|
partialFailures: "⚠️ some endpoints failed: {list}",
|
|
5442
5512
|
noData: "(no data — check your API key)",
|
|
5443
5513
|
errorText: "Could not fetch Command Code usage: {message}"
|
|
5444
5514
|
}
|
|
5445
5515
|
};
|
|
5446
5516
|
/**
|
|
5447
|
-
* Resolve the active locale for a Host-side command run
|
|
5448
|
-
*
|
|
5449
|
-
*
|
|
5450
|
-
* the conventional fallback (`'zh'`, matching the existing single-language
|
|
5451
|
-
* behavior so unconfigured deployments keep their current output).
|
|
5517
|
+
* Resolve the active locale for a Host-side command run: explicit `override`
|
|
5518
|
+
* (from `Config.lang`) → `LC_ALL` → `LANG` → the `'zh'` fallback that keeps
|
|
5519
|
+
* unconfigured deployments on their current output.
|
|
5452
5520
|
*
|
|
5453
|
-
*
|
|
5454
|
-
*
|
|
5455
|
-
*
|
|
5456
|
-
* already has Chinese in the terminal is the closest sensible default;
|
|
5457
|
-
* a Western shell that happens to be neither keeps the existing Chinese
|
|
5458
|
-
* output rather than swapping to half-translated English).
|
|
5521
|
+
* Matched on the leading tag only — `zh_CN.UTF-8`, `zh-Hans` and `zh` all
|
|
5522
|
+
* map to `'zh'`, anything starting with `en` maps to `'en'`, everything else
|
|
5523
|
+
* falls back to `'zh'` rather than to half-translated English.
|
|
5459
5524
|
*/
|
|
5460
5525
|
function pickCommandLocale(override, env = process.env) {
|
|
5461
5526
|
if (override === "zh" || override === "en") return override;
|
|
@@ -5468,11 +5533,10 @@ function commandCopy(locale, key) {
|
|
|
5468
5533
|
}
|
|
5469
5534
|
//#endregion
|
|
5470
5535
|
//#region src/commands.ts
|
|
5471
|
-
/** Format a dollar amount. */
|
|
5472
5536
|
function money(value) {
|
|
5473
5537
|
return `$${value.toFixed(4)}`;
|
|
5474
5538
|
}
|
|
5475
|
-
/**
|
|
5539
|
+
/** Compact dollar amount (2 decimals), for the summary and window rows. */
|
|
5476
5540
|
function moneyShort(value) {
|
|
5477
5541
|
return `$${value.toFixed(2)}`;
|
|
5478
5542
|
}
|
|
@@ -5484,14 +5548,14 @@ function tokensCompact(value) {
|
|
|
5484
5548
|
return String(value);
|
|
5485
5549
|
}
|
|
5486
5550
|
/**
|
|
5487
|
-
*
|
|
5488
|
-
*
|
|
5551
|
+
* Success-rate percentage (already in percent units): at most two decimals,
|
|
5552
|
+
* trailing zeros trimmed — mirrors `formatSuccessRate` in
|
|
5489
5553
|
* `./client/usage.ts`, which the settings card uses.
|
|
5490
5554
|
*/
|
|
5491
5555
|
function successRateText(value) {
|
|
5492
5556
|
return String(Number(value.toFixed(2)));
|
|
5493
5557
|
}
|
|
5494
|
-
/**
|
|
5558
|
+
/** A local date, or `n/a` when the provider published no reset time. */
|
|
5495
5559
|
function resetLabel(ms) {
|
|
5496
5560
|
if (ms <= 0) return "n/a";
|
|
5497
5561
|
return new Date(ms).toLocaleString();
|
|
@@ -5595,16 +5659,16 @@ const REMOTE_SERVICE = "commandcodeUsage";
|
|
|
5595
5659
|
/** The wire namespace all Command Code endpoints share. */
|
|
5596
5660
|
const REMOTE_NAMESPACE = "commandcode";
|
|
5597
5661
|
/**
|
|
5598
|
-
* Build the validator helpers one endpoint uses. `prefix` names the
|
|
5599
|
-
*
|
|
5600
|
-
*
|
|
5601
|
-
* bodies.
|
|
5662
|
+
* Build the validator helpers one endpoint uses. `prefix` names the endpoint
|
|
5663
|
+
* in the rejection message (e.g. `commandcode/report result:`), so each wire
|
|
5664
|
+
* file keeps its own diagnostic phrasing while sharing the helper bodies.
|
|
5602
5665
|
*
|
|
5603
5666
|
* The helpers return the reject call directly in the failure branch: since
|
|
5604
5667
|
* `reject` is typed `never`, the ternary's union collapses to the success type
|
|
5605
5668
|
* without relying on TypeScript's control-flow analysis of a never-returning
|
|
5606
5669
|
* call (which only recognizes function declarations, not the destructured
|
|
5607
|
-
* arrow `reject` callers receive from this factory).
|
|
5670
|
+
* arrow `reject` callers receive from this factory). Callers that need the
|
|
5671
|
+
* narrowing in an `if` must therefore assign inside the positive branch.
|
|
5608
5672
|
*/
|
|
5609
5673
|
function makeBoundaryValidator(prefix) {
|
|
5610
5674
|
const reject = (field) => {
|
|
@@ -5632,10 +5696,8 @@ function makeStrictCodec(typeSymbol, schema) {
|
|
|
5632
5696
|
}
|
|
5633
5697
|
/**
|
|
5634
5698
|
* Build one strict invocation descriptor. Every Command Code Remote shares the
|
|
5635
|
-
* `commandcode` namespace, the `commandcodeUsage` service, and a strict
|
|
5636
|
-
*
|
|
5637
|
-
* schema differ — so the boilerplate lives here once and each endpoint supplies
|
|
5638
|
-
* only its own facts.
|
|
5699
|
+
* `commandcode` namespace, the `commandcodeUsage` service, and a strict result
|
|
5700
|
+
* — only the endpoint, method, result type symbol, and schema differ.
|
|
5639
5701
|
*/
|
|
5640
5702
|
function makeRemoteDescriptor(endpoint, method, typeSymbol, schema) {
|
|
5641
5703
|
return {
|
|
@@ -5659,7 +5721,6 @@ const USAGE_REPORT_ENDPOINT = "commandcode/report";
|
|
|
5659
5721
|
* so rejection messages name the offending boundary.
|
|
5660
5722
|
*/
|
|
5661
5723
|
const { reject: reject$1, record: record$1, stringField: stringField$1, numberField, booleanField } = makeBoundaryValidator("commandcode/report result:");
|
|
5662
|
-
/** Validate one window-limit block (`fiveHour` / `weekly`). */
|
|
5663
5724
|
/**
|
|
5664
5725
|
* Parse one quota window, or undefined when the frame carries no such window.
|
|
5665
5726
|
*
|
|
@@ -5960,12 +6021,10 @@ var CommandCodeUsageService = class extends TypertRemoteService {
|
|
|
5960
6021
|
this.deps = deps;
|
|
5961
6022
|
}
|
|
5962
6023
|
/**
|
|
5963
|
-
* Account, usage, and credit state for the settings page's account card
|
|
5964
|
-
*
|
|
5965
|
-
*
|
|
5966
|
-
*
|
|
5967
|
-
* `MISSING_CREDENTIAL` when no key resolves, which the Gateway folds into
|
|
5968
|
-
* the failure branch the page renders as a hint.
|
|
6024
|
+
* Account, usage, and credit state for the settings page's account card.
|
|
6025
|
+
* Degrades per endpoint like the `/commandcode` command (failures land in
|
|
6026
|
+
* `report.failures`); throws `MISSING_CREDENTIAL` when no key resolves, which
|
|
6027
|
+
* the Gateway folds into the failure branch the page renders as a hint.
|
|
5969
6028
|
*/
|
|
5970
6029
|
async report() {
|
|
5971
6030
|
if (this.deps.reports !== void 0) return this.deps.reports();
|
|
@@ -5980,20 +6039,19 @@ var CommandCodeUsageService = class extends TypertRemoteService {
|
|
|
5980
6039
|
}] };
|
|
5981
6040
|
}
|
|
5982
6041
|
/**
|
|
5983
|
-
* The full model catalog for the settings page's model editors
|
|
5984
|
-
*
|
|
5985
|
-
*
|
|
5986
|
-
*
|
|
5987
|
-
* the live list instead of typed by hand.
|
|
6042
|
+
* The full model catalog for the settings page's model editors. The browser
|
|
6043
|
+
* never calls the Command Code API directly — the Host serves the catalog
|
|
6044
|
+
* (already fetched/cached by the adapter) so models can be picked from the
|
|
6045
|
+
* live list instead of typed by hand.
|
|
5988
6046
|
*/
|
|
5989
6047
|
async models() {
|
|
5990
6048
|
return this.deps.listModels?.() ?? { models: [] };
|
|
5991
6049
|
}
|
|
5992
6050
|
/**
|
|
5993
6051
|
* The model price table the composer prices an in-progress session with.
|
|
5994
|
-
* Static vendored data
|
|
5995
|
-
*
|
|
5996
|
-
*
|
|
6052
|
+
* Static vendored data, served Host-side so the browser bundle never carries
|
|
6053
|
+
* a copy that could drift from the snapshot, and so a price update reaches an
|
|
6054
|
+
* open page without a rebuild.
|
|
5997
6055
|
*/
|
|
5998
6056
|
async prices() {
|
|
5999
6057
|
return (this.deps.prices ?? modelPriceTable)();
|
|
@@ -6001,8 +6059,7 @@ var CommandCodeUsageService = class extends TypertRemoteService {
|
|
|
6001
6059
|
/**
|
|
6002
6060
|
* Start (or rejoin) a browser-login attempt and return its fresh status —
|
|
6003
6061
|
* `waiting` carrying the Studio URL. Rejects when the flow cannot start
|
|
6004
|
-
* (no free loopback port, disposed plugin)
|
|
6005
|
-
* into the failure branch the page renders.
|
|
6062
|
+
* (no free loopback port, disposed plugin).
|
|
6006
6063
|
*/
|
|
6007
6064
|
async loginBegin(targetRef) {
|
|
6008
6065
|
return this.requireLogin().begin(targetRef);
|
|
@@ -6045,11 +6102,8 @@ function applyUsageRemote(ctx, deps) {
|
|
|
6045
6102
|
//#endregion
|
|
6046
6103
|
//#region src/login.ts
|
|
6047
6104
|
/**
|
|
6048
|
-
* Host half of the Command Code browser login (the loopback flow)
|
|
6049
|
-
*
|
|
6050
|
-
* Mirrors what the official `command-code login` CLI command performs
|
|
6051
|
-
* (reverse-engineered from `command-code@1.32.1`, `createAuthFlowController`
|
|
6052
|
-
* + `createAuthServer` in its bundle):
|
|
6105
|
+
* Host half of the Command Code browser login (the loopback flow), mirroring
|
|
6106
|
+
* what the official `command-code login` CLI command performs.
|
|
6053
6107
|
*
|
|
6054
6108
|
* 1. Bind a temporary HTTP server on `127.0.0.1`, first available port from
|
|
6055
6109
|
* 5959 upward (10 attempts).
|
|
@@ -6061,12 +6115,12 @@ function applyUsageRemote(ctx, deps) {
|
|
|
6061
6115
|
* 4. The delivered key is validated against `GET {apiBase}/alpha/whoami`
|
|
6062
6116
|
* before anything is stored.
|
|
6063
6117
|
*
|
|
6064
|
-
*
|
|
6065
|
-
* cap, JSON responses (`{success:true}` / `{success:false,error}`),
|
|
6066
|
-
*
|
|
6067
|
-
*
|
|
6068
|
-
*
|
|
6069
|
-
*
|
|
6118
|
+
* The server contract is CLI-mirrored: POST-only `/callback`, a 10 KB body
|
|
6119
|
+
* cap, JSON responses (`{success:true}` / `{success:false,error}`), and
|
|
6120
|
+
* state-token equality as the anti-forgery check. Three deliberate hardenings
|
|
6121
|
+
* over the CLI build: the CORS origin is echoed only when allowlisted (the
|
|
6122
|
+
* CLI falls back to the first origin), `Connection: close`, and the denial
|
|
6123
|
+
* branch checks the state token before it ends the attempt.
|
|
6070
6124
|
*
|
|
6071
6125
|
* Storage stays out of this module: the plugin entry supplies
|
|
6072
6126
|
* {@link CommandCodeLoginFlowDeps.storeKey}, which writes through the dsh
|
|
@@ -6176,16 +6230,15 @@ var CommandCodeLoginFlow = class {
|
|
|
6176
6230
|
* asynchronously (`complete()`), and that window is open to a cancel or a
|
|
6177
6231
|
* fresh `begin()`; the generation lets a late completion recognize that it
|
|
6178
6232
|
* no longer owns the status face and stop instead of storing a credential
|
|
6179
|
-
* the user cancelled
|
|
6233
|
+
* the user cancelled.
|
|
6180
6234
|
*/
|
|
6181
6235
|
attemptSeq = 0;
|
|
6182
6236
|
/**
|
|
6183
|
-
* The start currently binding a port, if any. Every `begin()`
|
|
6184
|
-
*
|
|
6185
|
-
*
|
|
6186
|
-
*
|
|
6187
|
-
*
|
|
6188
|
-
* the port window so browser login dies until the Host restarts.
|
|
6237
|
+
* The start currently binding a port, if any. Every `begin()` arriving before
|
|
6238
|
+
* it settles joins it: two independent starts would each bind a loopback
|
|
6239
|
+
* server, and only the LAST one is reachable by `teardown()` — the orphan
|
|
6240
|
+
* keeps answering `/callback` for the process's lifetime, and ten of them
|
|
6241
|
+
* exhaust the port window so browser login dies until the Host restarts.
|
|
6189
6242
|
*/
|
|
6190
6243
|
starting;
|
|
6191
6244
|
/** A live attempt's destination; different rows may not rejoin it. */
|
|
@@ -6539,20 +6592,15 @@ function corsOrigin(origin) {
|
|
|
6539
6592
|
//#endregion
|
|
6540
6593
|
//#region src/web-search.ts
|
|
6541
6594
|
/**
|
|
6542
|
-
*
|
|
6595
|
+
* Command Code web-search provider over `ctx.web`.
|
|
6543
6596
|
*
|
|
6544
|
-
* The official
|
|
6597
|
+
* The official CLI's built-in `web_search` tool POSTs
|
|
6545
6598
|
* `{ query, numResults, allowedDomains?, blockedDomains? }` to
|
|
6546
6599
|
* `{apiBase}/alpha/web-search` and reads `{ results: [{ title, url, snippet }] }`
|
|
6547
|
-
* back
|
|
6548
|
-
* `x-command-code-version` the model adapter uses
|
|
6549
|
-
* plugin's existing credential chain
|
|
6550
|
-
*
|
|
6551
|
-
*
|
|
6552
|
-
* This mirrors the host-side `@deepseek-ai/dsh-web-search-deepseek` provider in
|
|
6553
|
-
* shape: a cordis-free class registered into the web seam, resolving its key per
|
|
6554
|
-
* search, mapping each server-side result to the harness's normalized
|
|
6555
|
-
* `WebSearchSource`. The web seam owns `maxResults` truncation.
|
|
6600
|
+
* back, authenticating with the SAME `Authorization: Bearer <key>` and
|
|
6601
|
+
* `x-command-code-version` headers the model adapter uses. This provider
|
|
6602
|
+
* therefore reuses the plugin's existing credential chain and `apiBase`, so
|
|
6603
|
+
* the model-facing `web_search` tool needs no separate configuration.
|
|
6556
6604
|
*
|
|
6557
6605
|
* @module dsh-commandcode-provider/web-search
|
|
6558
6606
|
*/
|
|
@@ -6560,12 +6608,9 @@ function corsOrigin(origin) {
|
|
|
6560
6608
|
const COMMANDCODE_SEARCH_PROVIDER_ID = "commandcode";
|
|
6561
6609
|
/**
|
|
6562
6610
|
* The factory-declared search provider id dsh ships by default (from
|
|
6563
|
-
* `dsh-base`'s cordis patch `web.config.searchProvider`).
|
|
6564
|
-
*
|
|
6565
|
-
*
|
|
6566
|
-
* {@link applyCommandCodeSearchSelection}) — it never forces this default,
|
|
6567
|
-
* because forcing it is what used to silence sibling search plugins such as
|
|
6568
|
-
* modsearch even with Command Code search turned off (issue #26).
|
|
6611
|
+
* `dsh-base`'s cordis patch `web.config.searchProvider`). A documented
|
|
6612
|
+
* reference only: disabling this plugin's `webSearch` toggle restores the
|
|
6613
|
+
* PREVIOUSLY selected backend, never this default (issue #26).
|
|
6569
6614
|
*/
|
|
6570
6615
|
const DEFAULT_WEB_SEARCH_PROVIDER_ID = "deepseek-official";
|
|
6571
6616
|
/** Fresh selection state: the plugin starts out not owning the selection. */
|
|
@@ -6581,24 +6626,15 @@ function commandCodeSearchSelection() {
|
|
|
6581
6626
|
* providers (issue #26).
|
|
6582
6627
|
*
|
|
6583
6628
|
* - Enabling writes `commandcode` and remembers whatever it displaced. When
|
|
6584
|
-
* the plugin already owns the selection
|
|
6585
|
-
*
|
|
6586
|
-
*
|
|
6587
|
-
* - Disabling hands the selection back to the remembered backend
|
|
6588
|
-
*
|
|
6589
|
-
*
|
|
6590
|
-
*
|
|
6591
|
-
* auto-select
|
|
6592
|
-
*
|
|
6593
|
-
* surviving runtime the plugin did not set, or a manual
|
|
6594
|
-
* `searchProvider: commandcode` pin), `displaced` stays undefined and
|
|
6595
|
-
* `preexisting` is set, so the later disable is a no-op rather than a guess
|
|
6596
|
-
* at the factory default. A "no-op" means the field is left ALONE: writing
|
|
6597
|
-
* that `undefined` back would destroy the user's own pin, and dsh-web reads
|
|
6598
|
-
* a cleared `searchProviderId` as auto-select — where a second usable
|
|
6599
|
-
* provider (the shipped `deepseek-official` is usable whenever a DeepSeek
|
|
6600
|
-
* key resolves) makes EVERY later search throw
|
|
6601
|
-
* `WEB_PROVIDER_AMBIGUOUS`.
|
|
6629
|
+
* the plugin already owns the selection, the original `displaced` is kept —
|
|
6630
|
+
* the field currently holds our own id, which must never be mistaken for the
|
|
6631
|
+
* user's backend.
|
|
6632
|
+
* - Disabling hands the selection back to the remembered backend only when
|
|
6633
|
+
* this plugin actually took it over. A fresh boot straight into
|
|
6634
|
+
* `webSearch: false`, or a `preexisting` field, leaves it ALONE: writing the
|
|
6635
|
+
* empty memory back would clear the user's own `searchProvider: commandcode`
|
|
6636
|
+
* pin and hand the selection to dsh-web's auto-select, where a second usable
|
|
6637
|
+
* provider makes every later search throw `WEB_PROVIDER_AMBIGUOUS`.
|
|
6602
6638
|
*
|
|
6603
6639
|
* Never throws: like the low-level rewrite, a hardened runtime shape degrades
|
|
6604
6640
|
* to registered-but-unselected.
|
|
@@ -6652,7 +6688,7 @@ function toSource(result) {
|
|
|
6652
6688
|
...snippet !== void 0 && snippet.length > 0 ? { snippet } : {}
|
|
6653
6689
|
};
|
|
6654
6690
|
}
|
|
6655
|
-
function isAbortError
|
|
6691
|
+
function isAbortError(error) {
|
|
6656
6692
|
return error instanceof DOMException && error.name === "AbortError";
|
|
6657
6693
|
}
|
|
6658
6694
|
/** Build the provider's stable cancellation error while retaining the caller's reason. */
|
|
@@ -6663,11 +6699,11 @@ function throwIfAborted(signal) {
|
|
|
6663
6699
|
if (signal?.aborted === true) throw searchAborted(signal, void 0);
|
|
6664
6700
|
}
|
|
6665
6701
|
/**
|
|
6666
|
-
* A `ctx.web` search provider backed by the Command Code Provider API
|
|
6667
|
-
* the plugin's credential chain and `apiBase
|
|
6668
|
-
*
|
|
6669
|
-
*
|
|
6670
|
-
*
|
|
6702
|
+
* A `ctx.web` search provider backed by the Command Code Provider API, reusing
|
|
6703
|
+
* the plugin's credential chain and `apiBase` so the model-facing `web_search`
|
|
6704
|
+
* tool needs no separate configuration. Selection between multiple search
|
|
6705
|
+
* providers is the web seam's job (pin `searchProvider: commandcode` if
|
|
6706
|
+
* ambiguous).
|
|
6671
6707
|
*/
|
|
6672
6708
|
var CommandCodeSearchProvider = class {
|
|
6673
6709
|
deps;
|
|
@@ -6707,7 +6743,7 @@ var CommandCodeSearchProvider = class {
|
|
|
6707
6743
|
...signal !== void 0 ? { signal } : {}
|
|
6708
6744
|
});
|
|
6709
6745
|
} catch (error) {
|
|
6710
|
-
if (signal?.aborted === true || isAbortError
|
|
6746
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
6711
6747
|
throw new WebError(`Command Code web search request failed: ${error instanceof Error ? error.message : String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
6712
6748
|
}
|
|
6713
6749
|
if (!response.ok) {
|
|
@@ -6722,7 +6758,7 @@ var CommandCodeSearchProvider = class {
|
|
|
6722
6758
|
if (typeof code === "string" || typeof inner === "string") message += `: ${typeof code === "string" ? code : ""}${typeof code === "string" && typeof inner === "string" ? " — " : ""}${typeof inner === "string" ? inner : ""}`;
|
|
6723
6759
|
}
|
|
6724
6760
|
} catch (error) {
|
|
6725
|
-
if (signal?.aborted === true || isAbortError
|
|
6761
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
6726
6762
|
const slice = (await response.text().catch(() => "")).trim().slice(0, 200);
|
|
6727
6763
|
if (slice !== "") message += `: ${slice}`;
|
|
6728
6764
|
}
|
|
@@ -6732,7 +6768,7 @@ var CommandCodeSearchProvider = class {
|
|
|
6732
6768
|
try {
|
|
6733
6769
|
payload = await response.json();
|
|
6734
6770
|
} catch (error) {
|
|
6735
|
-
if (signal?.aborted === true || isAbortError
|
|
6771
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
6736
6772
|
throw new WebError("Command Code web search returned an unparseable response body", "WEB_PROVIDER_ERROR", { cause: error });
|
|
6737
6773
|
}
|
|
6738
6774
|
const results = payload?.results;
|
|
@@ -6756,7 +6792,7 @@ var CommandCodeSearchProvider = class {
|
|
|
6756
6792
|
try {
|
|
6757
6793
|
key = await this.deps.resolveKey();
|
|
6758
6794
|
} catch (error) {
|
|
6759
|
-
if (signal?.aborted === true || isAbortError
|
|
6795
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
6760
6796
|
if (error instanceof Error && typeof error.code === "string") {
|
|
6761
6797
|
const code = error.code;
|
|
6762
6798
|
if (code === "MISSING_CREDENTIAL") throw new WebError(error.message, "WEB_PROVIDER_CREDENTIAL_MISSING", { cause: error });
|
|
@@ -6797,11 +6833,10 @@ const OTHER_GROUP_ID = "models-other";
|
|
|
6797
6833
|
* free before paid inside a tier, then by id.
|
|
6798
6834
|
*
|
|
6799
6835
|
* The list is the static capability snapshot rather than a live catalog read
|
|
6800
|
-
* on purpose — a settings page must draw synchronously
|
|
6801
|
-
*
|
|
6802
|
-
*
|
|
6803
|
-
*
|
|
6804
|
-
* here is rendered by the "Other" group instead of disappearing.
|
|
6836
|
+
* on purpose — a settings page must draw synchronously. A model added
|
|
6837
|
+
* upstream after this build still reaches the user: an empty allowlist shows
|
|
6838
|
+
* everything, and a model named in the allowlist but absent here is rendered
|
|
6839
|
+
* by the "Other" group instead of disappearing.
|
|
6805
6840
|
*/
|
|
6806
6841
|
function commandCodeTuiModelChoices() {
|
|
6807
6842
|
return Object.keys(KNOWN_PLANS).map((id) => ({
|
|
@@ -6829,14 +6864,13 @@ function storedIds(value) {
|
|
|
6829
6864
|
* Build the section descriptor. Pure, so tests can pin the exact fields
|
|
6830
6865
|
* without a dsh-TUI host.
|
|
6831
6866
|
*
|
|
6832
|
-
*
|
|
6833
|
-
* `
|
|
6834
|
-
*
|
|
6835
|
-
*
|
|
6836
|
-
*
|
|
6837
|
-
* unset
|
|
6838
|
-
*
|
|
6839
|
-
* the screen's "(empty)".
|
|
6867
|
+
* The two option-bearing fields (`activeAccount`, `lang`) are `text` +
|
|
6868
|
+
* `options` rather than `select`, because a `select` cannot express "unset" —
|
|
6869
|
+
* cycling only ever lands on a declared option, so it would strand the user on
|
|
6870
|
+
* a pinned value with no way back to automatic. The `auto` sentinel plus a
|
|
6871
|
+
* `parse` that clears the path keeps unset reachable. `filterModelsByPlan`
|
|
6872
|
+
* formats its EFFECTIVE default (unset means true at the adapter) so a fresh
|
|
6873
|
+
* install reads true instead of the screen's "(empty)".
|
|
6840
6874
|
*/
|
|
6841
6875
|
function buildCommandCodeTuiSection(deps) {
|
|
6842
6876
|
const ref = deps.apiKeyRef();
|
|
@@ -6866,12 +6900,9 @@ function buildCommandCodeTuiSection(deps) {
|
|
|
6866
6900
|
* ONE draft: every one of them then parses that same draft on save, all N
|
|
6867
6901
|
* write ops address the same path, and only the LAST field's op survives —
|
|
6868
6902
|
* which silently rewrote the allowlist from the last catalog model instead
|
|
6869
|
-
* of the one that was toggled.
|
|
6870
|
-
*
|
|
6871
|
-
*
|
|
6872
|
-
* An override equal to what the array already says is written as a CLEAR, so
|
|
6873
|
-
* toggling a model back to its inherited state leaves no residue and the
|
|
6874
|
-
* document stays minimal.
|
|
6903
|
+
* of the one that was toggled. An override equal to what the array already
|
|
6904
|
+
* says is written as a CLEAR, so toggling a model back to its inherited
|
|
6905
|
+
* state leaves no residue.
|
|
6875
6906
|
*/
|
|
6876
6907
|
const modelField = (id, hint, group) => ({
|
|
6877
6908
|
path: ["modelVisibility", id],
|
|
@@ -6944,54 +6975,6 @@ function buildCommandCodeTuiSection(deps) {
|
|
|
6944
6975
|
};
|
|
6945
6976
|
}
|
|
6946
6977
|
},
|
|
6947
|
-
{
|
|
6948
|
-
path: ["commandGuard"],
|
|
6949
|
-
group: "advanced",
|
|
6950
|
-
kind: "boolean",
|
|
6951
|
-
label: "AI command guard",
|
|
6952
|
-
descriptions: { zh: "AI 命令安全预判" },
|
|
6953
|
-
hint: "typesafe/jev judges whether a shell command is safe; sandbox escalations also need separate scope and necessity verdicts. All must be confident enough; everything else asks as usual. Off by default.",
|
|
6954
|
-
hintDescriptions: { zh: "由 typesafe/jev 判断 shell 命令是否安全;沙箱提权还要分别判断范围与必要性,三项都足够确定才自动放行,其余照常弹窗。默认关闭。" },
|
|
6955
|
-
format: (value) => value === true ? "true" : "false",
|
|
6956
|
-
parse: (text) => ({
|
|
6957
|
-
kind: "set",
|
|
6958
|
-
value: text.trim() === "true"
|
|
6959
|
-
})
|
|
6960
|
-
},
|
|
6961
|
-
{
|
|
6962
|
-
path: ["commandGuardLevel"],
|
|
6963
|
-
group: "advanced",
|
|
6964
|
-
kind: "select",
|
|
6965
|
-
label: "Auto-approve threshold",
|
|
6966
|
-
descriptions: { zh: "自动放行阈值" },
|
|
6967
|
-
hint: "How confident typesafe/jev must be before a command runs without asking. Default: medium.",
|
|
6968
|
-
hintDescriptions: { zh: "typesafe/jev 需要多确定才免弹窗放行;默认「中」。" },
|
|
6969
|
-
options: [
|
|
6970
|
-
{
|
|
6971
|
-
value: "high",
|
|
6972
|
-
label: "High (0.95, approves least)",
|
|
6973
|
-
descriptions: { zh: "高(0.95,放行最少)" }
|
|
6974
|
-
},
|
|
6975
|
-
{
|
|
6976
|
-
value: "medium",
|
|
6977
|
-
label: "Medium (0.9)",
|
|
6978
|
-
descriptions: { zh: "中(0.9)" }
|
|
6979
|
-
},
|
|
6980
|
-
{
|
|
6981
|
-
value: "low",
|
|
6982
|
-
label: "Low (0.8, approves most)",
|
|
6983
|
-
descriptions: { zh: "低(0.8,放行最多)" }
|
|
6984
|
-
}
|
|
6985
|
-
],
|
|
6986
|
-
format: (value) => value === "high" || value === "low" ? value : "medium",
|
|
6987
|
-
parse: (text) => {
|
|
6988
|
-
const trimmed = text.trim();
|
|
6989
|
-
return trimmed === "high" || trimmed === "medium" || trimmed === "low" ? {
|
|
6990
|
-
kind: "set",
|
|
6991
|
-
value: trimmed
|
|
6992
|
-
} : void 0;
|
|
6993
|
-
}
|
|
6994
|
-
},
|
|
6995
6978
|
{
|
|
6996
6979
|
path: ["zdr"],
|
|
6997
6980
|
group: "advanced",
|
|
@@ -7126,12 +7109,10 @@ function sectionSignature(section) {
|
|
|
7126
7109
|
/**
|
|
7127
7110
|
* Register the Command Code section on a dsh-TUI host.
|
|
7128
7111
|
*
|
|
7129
|
-
* @param ctx - the context of an activated `tuiSettingsSections` injection.
|
|
7130
|
-
* @param deps - plugin-owned facts the section reads.
|
|
7131
7112
|
* @returns a refresh function that re-registers the section when a fact it
|
|
7132
|
-
* renders changed
|
|
7133
|
-
*
|
|
7134
|
-
* inert after the fiber is torn down.
|
|
7113
|
+
* renders changed — the plugin entry calls it from its
|
|
7114
|
+
* `loader/volatile-update` listener — or `undefined` when the seam is
|
|
7115
|
+
* unusable. The returned function is inert after the fiber is torn down.
|
|
7135
7116
|
*/
|
|
7136
7117
|
function applyCommandCodeTuiSettings(ctx, deps) {
|
|
7137
7118
|
const service = tuiSettingsService(ctx);
|
|
@@ -7167,45 +7148,26 @@ function applyCommandCodeTuiSettings(ctx, deps) {
|
|
|
7167
7148
|
/**
|
|
7168
7149
|
* A bounded retry budget for `TRANSPORT` failures (issue #39, second report).
|
|
7169
7150
|
*
|
|
7170
|
-
* The route
|
|
7171
|
-
*
|
|
7172
|
-
*
|
|
7173
|
-
*
|
|
7174
|
-
*
|
|
7175
|
-
*
|
|
7176
|
-
*
|
|
7177
|
-
*
|
|
7178
|
-
*
|
|
7179
|
-
*
|
|
7180
|
-
* `
|
|
7181
|
-
*
|
|
7182
|
-
*
|
|
7183
|
-
*
|
|
7184
|
-
*
|
|
7185
|
-
*
|
|
7186
|
-
*
|
|
7187
|
-
*
|
|
7188
|
-
*
|
|
7189
|
-
*
|
|
7190
|
-
* the user's call — the next send starts a fresh budget — and
|
|
7191
|
-
* `dsh-llm-retry`'s window for rate limits is untouched.
|
|
7192
|
-
*
|
|
7193
|
-
* Deliberately NOT counted here: the pre-stream recovery the ADAPTER performs
|
|
7194
|
-
* inside one `stream()` call (account rotation on 429/401, the 413 image-budget
|
|
7195
|
-
* step-down, the Provider-API → CLI protocol switch). Those are invisible to
|
|
7196
|
-
* the caller and bounded on their own; this budget caps only the harness's
|
|
7197
|
-
* retry loop.
|
|
7198
|
-
*
|
|
7199
|
-
* Reset rule: the budget clears at each new STEP — one step is exactly one
|
|
7200
|
-
* model request (`step/start` is appended immediately before the call) — so the
|
|
7201
|
-
* cap is per logical request and the next step of the same turn gets its own
|
|
7202
|
-
* grace. `agent/status` → `idle` is NOT the reset signal even though it looks
|
|
7203
|
-
* like the natural one: the agent loop's `setPhase` emits it only on a status
|
|
7204
|
-
* CHANGE, and a turn's steps all run inside one `running` phase, so it never
|
|
7205
|
-
* fires between them — a budget reset only there would be per-turn, and an
|
|
7206
|
-
* outage would fail every later step of that turn immediately instead of
|
|
7207
|
-
* retrying each. `assistant/attempt` is wrong for the opposite reason: the loop
|
|
7208
|
-
* appends it for the FAILED attempt itself, *before* dispatching
|
|
7151
|
+
* The route policy (`providerRetryPolicy` in `./adapter.ts`) is near-unbounded
|
|
7152
|
+
* on purpose — 1000 attempts doubling to a 15-minute cap — because it exists
|
|
7153
|
+
* for failures the provider ASKS to have retried, where the wait is the point.
|
|
7154
|
+
* A connection that cannot be established is not that: either a blip the first
|
|
7155
|
+
* few attempts absorb, or an outage no in-request waiting fixes (and its waits
|
|
7156
|
+
* count BEFORE each attempt, so attempt 11 alone already waits 512 s). So
|
|
7157
|
+
* transport failures get their own budget; waiting out a real outage is the
|
|
7158
|
+
* user's call, and the next send starts a fresh budget.
|
|
7159
|
+
*
|
|
7160
|
+
* Not counted here: the pre-stream recovery the ADAPTER performs inside one
|
|
7161
|
+
* `stream()` call (account rotation, the 413 image-budget step-down, the
|
|
7162
|
+
* Provider-API → CLI switch) — invisible to the caller and bounded on its own.
|
|
7163
|
+
* This budget caps only the harness's retry loop.
|
|
7164
|
+
*
|
|
7165
|
+
* Reset rule: the budget clears at each new STEP (one step = one model request).
|
|
7166
|
+
* `agent/status` → `idle` is NOT it, though it looks natural: the loop's
|
|
7167
|
+
* `setPhase` emits it only on a status CHANGE, and a turn's steps all run inside
|
|
7168
|
+
* one `running` phase, so a reset there would fail every later step of an outage
|
|
7169
|
+
* turn at once instead of retrying each. `assistant/attempt` is wrong the other
|
|
7170
|
+
* way — the loop appends it for the FAILED attempt itself, before dispatching
|
|
7209
7171
|
* `agent/request-error`, so resetting on it would clear the budget on every
|
|
7210
7172
|
* failure and restore the very loop this module exists to stop.
|
|
7211
7173
|
*
|
|
@@ -7239,15 +7201,13 @@ function resetTransportFailures(agent) {
|
|
|
7239
7201
|
/**
|
|
7240
7202
|
* What one session event means for the transport budget.
|
|
7241
7203
|
*
|
|
7242
|
-
*
|
|
7243
|
-
* that is easy to get plausibly wrong (
|
|
7244
|
-
*
|
|
7245
|
-
* here, where a test can pin it, instead of being implied by a listener body.
|
|
7204
|
+
* Pure and exported on purpose: the choice of event is the part of this design
|
|
7205
|
+
* that is easy to get plausibly wrong (see the module comment), so it is stated
|
|
7206
|
+
* once, where a test can pin it.
|
|
7246
7207
|
*
|
|
7247
7208
|
* @param type - the appended session event's type.
|
|
7248
|
-
* @returns `'reset'` for a new step
|
|
7249
|
-
*
|
|
7250
|
-
* `'none'`.
|
|
7209
|
+
* @returns `'reset'` for a new step, `'forget'` when the turn ended (the
|
|
7210
|
+
* session → agent mapping is no longer needed), otherwise `'none'`.
|
|
7251
7211
|
*/
|
|
7252
7212
|
function transportResetAction(type) {
|
|
7253
7213
|
if (type === "step/start") return "reset";
|
|
@@ -7257,880 +7217,44 @@ function transportResetAction(type) {
|
|
|
7257
7217
|
/**
|
|
7258
7218
|
* The diagnosis a capped turn ends with. Bilingual, because the harness renders
|
|
7259
7219
|
* a failed turn's message verbatim and this plugin's other user-facing failures
|
|
7260
|
-
* are
|
|
7261
|
-
*
|
|
7262
|
-
* chain the retry chrome would otherwise show.
|
|
7220
|
+
* are too; it names the retry budget and the checks that actually help instead
|
|
7221
|
+
* of the bare "fetch failed" chain the retry chrome would otherwise show.
|
|
7263
7222
|
*/
|
|
7264
7223
|
function transportBudgetMessage(failureMessage, maxRetries) {
|
|
7265
7224
|
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`;
|
|
7266
7225
|
}
|
|
7267
7226
|
//#endregion
|
|
7268
|
-
//#region src/systemone.ts
|
|
7269
|
-
/**
|
|
7270
|
-
* Host-side client for Command Code's System One decision endpoint
|
|
7271
|
-
* (`POST {apiBase}/provider/v1/systemone`).
|
|
7272
|
-
*
|
|
7273
|
-
* `typesafe/jev` is **not a chat model**: it takes a `state` plus typed
|
|
7274
|
-
* questions and answers with probabilities, never text, and it never streams.
|
|
7275
|
-
* It is therefore absent from `/provider/v1/models` (so the picker can never
|
|
7276
|
-
* offer it) and must never be registered as a model route — this plugin uses it
|
|
7277
|
-
* as an internal advisor instead: the command guard in `./command-guard.ts`
|
|
7278
|
-
* asks it whether a shell command is safe to auto-approve.
|
|
7279
|
-
*
|
|
7280
|
-
* The three question types are the whole vocabulary (command-code@1.65.0
|
|
7281
|
-
* `systemOneRequestSchema`, unchanged since 1.64.0):
|
|
7282
|
-
*
|
|
7283
|
-
* - `noul` — "is this true?", optional `criteria: { true, false }` describing
|
|
7284
|
-
* each verdict. Answers with `noul`, the probability of *yes*.
|
|
7285
|
-
* - `choice` — "which one?", `criteria` maps each option key to its
|
|
7286
|
-
* description. Answers with `choice`, plus optional `confidence` and
|
|
7287
|
-
* `probabilities`.
|
|
7288
|
-
* - `score` — "where on this rubric?", `criteria` is an array of at least two
|
|
7289
|
-
* levels, lowest first. Answers with `score`, `legend` and `probabilities`.
|
|
7290
|
-
*
|
|
7291
|
-
* A request carries at most 20 questions and every answer is keyed by the
|
|
7292
|
-
* question id it belongs to. `usage` is reported per request, in tokens.
|
|
7293
|
-
*
|
|
7294
|
-
* Billing: free through 2026-09-24T23:59:59Z, then $0.042 per 1M input tokens
|
|
7295
|
-
* with output free. The model has **no ZDR-capable upstream**, so a request
|
|
7296
|
-
* carrying `x-cmd-zdr: 1` is refused with HTTP 422 `cmd_zdr_no_providers` —
|
|
7297
|
-
* this client never sends that header, and it must stay that way even though
|
|
7298
|
-
* the plugin now ships a ZDR switch for chat (`Config.zdr` → the `x-cmd-zdr: 1`
|
|
7299
|
-
* header per model, gated on `KNOWN_NON_ZDR_MODELS`): the decision endpoint is
|
|
7300
|
-
* reached through THIS module's own request path, never through the adapter's
|
|
7301
|
-
* chat connection, so the two cannot drift — and a deployment that later widens
|
|
7302
|
-
* ZDR must exclude this endpoint rather than route it through the switch.
|
|
7303
|
-
*
|
|
7304
|
-
* Auth is the plugin's ordinary chain (the multi-account pool, the credentials
|
|
7305
|
-
* seam, the CLI auth file) through the same `Authorization: Bearer <key>` and
|
|
7306
|
-
* `x-command-code-version` headers every other endpoint uses.
|
|
7307
|
-
*
|
|
7308
|
-
* @module dsh-commandcode-provider/systemone
|
|
7309
|
-
*/
|
|
7310
|
-
/** Path of the decision endpoint, relative to `apiBase`. */
|
|
7311
|
-
const SYSTEMONE_ROUTE = "/provider/v1/systemone";
|
|
7312
|
-
/** The one error type this client throws. Callers fail closed on it. */
|
|
7313
|
-
var SystemOneError = class extends Error {
|
|
7314
|
-
kind;
|
|
7315
|
-
status;
|
|
7316
|
-
constructor(message, kind, status, options) {
|
|
7317
|
-
super(message, options);
|
|
7318
|
-
this.kind = kind;
|
|
7319
|
-
this.status = status;
|
|
7320
|
-
this.name = "SystemOneError";
|
|
7321
|
-
}
|
|
7322
|
-
};
|
|
7323
|
-
/**
|
|
7324
|
-
* Combine the caller's signal with a timeout, keeping the two distinguishable:
|
|
7325
|
-
* a timeout is this client's own budget expiring (the guard delegates to the
|
|
7326
|
-
* user) while a caller abort means the approval was already withdrawn (answer
|
|
7327
|
-
* nothing) — both fail closed, but the diagnosis must not lie about which.
|
|
7328
|
-
*
|
|
7329
|
-
* The timer is a plain `setTimeout` rather than `AbortSignal.timeout()`, whose
|
|
7330
|
-
* timer Node unrefs: with nothing else pending (a test double, a socket that
|
|
7331
|
-
* never opened) an unref'd timer lets the process exit while the promise is
|
|
7332
|
-
* still pending, which reads as a hang rather than as the timeout it is.
|
|
7333
|
-
*/
|
|
7334
|
-
function deadlineOf(signal, timeoutMs) {
|
|
7335
|
-
const controller = new AbortController();
|
|
7336
|
-
let timedOut = false;
|
|
7337
|
-
const timer = setTimeout(() => {
|
|
7338
|
-
timedOut = true;
|
|
7339
|
-
controller.abort();
|
|
7340
|
-
}, timeoutMs);
|
|
7341
|
-
const onCallerAbort = () => controller.abort();
|
|
7342
|
-
if (signal !== void 0) {
|
|
7343
|
-
if (signal.aborted) controller.abort();
|
|
7344
|
-
else signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
7345
|
-
}
|
|
7346
|
-
return {
|
|
7347
|
-
signal: controller.signal,
|
|
7348
|
-
callerAborted: () => signal?.aborted === true,
|
|
7349
|
-
timedOut: () => timedOut,
|
|
7350
|
-
dispose: () => {
|
|
7351
|
-
clearTimeout(timer);
|
|
7352
|
-
signal?.removeEventListener("abort", onCallerAbort);
|
|
7353
|
-
}
|
|
7354
|
-
};
|
|
7355
|
-
}
|
|
7356
|
-
/** Whether a caught value is an abort-shaped failure. */
|
|
7357
|
-
function isAbortError(error) {
|
|
7358
|
-
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
7359
|
-
}
|
|
7360
|
-
/** Stop waiting for an operation that does not accept an AbortSignal. */
|
|
7361
|
-
function withinDeadline(operation, deadline, timeoutMs) {
|
|
7362
|
-
return new Promise((resolve, reject) => {
|
|
7363
|
-
const onAbort = () => {
|
|
7364
|
-
reject(deadline.callerAborted() ? new SystemOneError("decision request aborted", "aborted") : new SystemOneError(`decision request timed out after ${timeoutMs}ms`, "timeout"));
|
|
7365
|
-
};
|
|
7366
|
-
deadline.signal.addEventListener("abort", onAbort, { once: true });
|
|
7367
|
-
operation.then((value) => {
|
|
7368
|
-
deadline.signal.removeEventListener("abort", onAbort);
|
|
7369
|
-
resolve(value);
|
|
7370
|
-
}, (error) => {
|
|
7371
|
-
deadline.signal.removeEventListener("abort", onAbort);
|
|
7372
|
-
reject(error);
|
|
7373
|
-
});
|
|
7374
|
-
if (deadline.signal.aborted) onAbort();
|
|
7375
|
-
});
|
|
7376
|
-
}
|
|
7377
|
-
/** A finite number in `[0, 1]`, or undefined. */
|
|
7378
|
-
function probability(value) {
|
|
7379
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : void 0;
|
|
7380
|
-
}
|
|
7381
|
-
/** A finite number, or undefined. */
|
|
7382
|
-
function finite(value) {
|
|
7383
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
7384
|
-
}
|
|
7385
|
-
/** A record of finite numbers (probabilities), or undefined when the shape is off. */
|
|
7386
|
-
function numberRecord(value) {
|
|
7387
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
7388
|
-
const out = {};
|
|
7389
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
7390
|
-
const parsed = finite(entry);
|
|
7391
|
-
if (parsed === void 0) return void 0;
|
|
7392
|
-
out[key] = parsed;
|
|
7393
|
-
}
|
|
7394
|
-
return out;
|
|
7395
|
-
}
|
|
7396
|
-
/**
|
|
7397
|
-
* Parse one answer entry strictly by its own `type`. A body whose answer does
|
|
7398
|
-
* not carry the member its type promises is rejected rather than defaulted:
|
|
7399
|
-
* an absent probability is exactly the case the caller must not read as "safe".
|
|
7400
|
-
*
|
|
7401
|
-
* @param value - one raw entry from the response's `answers` object.
|
|
7402
|
-
* @returns the parsed answer, or undefined when the entry is malformed.
|
|
7403
|
-
*/
|
|
7404
|
-
function parseSystemOneAnswer(value) {
|
|
7405
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
7406
|
-
const raw = value;
|
|
7407
|
-
if (raw.type === "noul") {
|
|
7408
|
-
const noul = probability(raw.noul);
|
|
7409
|
-
return noul === void 0 ? void 0 : {
|
|
7410
|
-
type: "noul",
|
|
7411
|
-
noul
|
|
7412
|
-
};
|
|
7413
|
-
}
|
|
7414
|
-
if (raw.type === "choice") {
|
|
7415
|
-
if (typeof raw.choice !== "string" || raw.choice === "") return void 0;
|
|
7416
|
-
const confidence = raw.confidence === void 0 ? void 0 : probability(raw.confidence);
|
|
7417
|
-
if (raw.confidence !== void 0 && confidence === void 0) return void 0;
|
|
7418
|
-
const probabilities = raw.probabilities === void 0 ? void 0 : numberRecord(raw.probabilities);
|
|
7419
|
-
if (raw.probabilities !== void 0 && probabilities === void 0) return void 0;
|
|
7420
|
-
return {
|
|
7421
|
-
type: "choice",
|
|
7422
|
-
choice: raw.choice,
|
|
7423
|
-
...confidence === void 0 ? {} : { confidence },
|
|
7424
|
-
...probabilities === void 0 ? {} : { probabilities }
|
|
7425
|
-
};
|
|
7426
|
-
}
|
|
7427
|
-
if (raw.type === "score") {
|
|
7428
|
-
const score = finite(raw.score);
|
|
7429
|
-
if (score === void 0) return void 0;
|
|
7430
|
-
const confidence = raw.confidence === void 0 ? void 0 : probability(raw.confidence);
|
|
7431
|
-
if (raw.confidence !== void 0 && confidence === void 0) return void 0;
|
|
7432
|
-
const probabilities = raw.probabilities === void 0 ? void 0 : numberRecord(raw.probabilities);
|
|
7433
|
-
if (raw.probabilities !== void 0 && probabilities === void 0) return void 0;
|
|
7434
|
-
return {
|
|
7435
|
-
type: "score",
|
|
7436
|
-
score,
|
|
7437
|
-
...confidence === void 0 ? {} : { confidence },
|
|
7438
|
-
...probabilities === void 0 ? {} : { probabilities }
|
|
7439
|
-
};
|
|
7440
|
-
}
|
|
7441
|
-
}
|
|
7442
|
-
/**
|
|
7443
|
-
* Parse a decision response body. Every answer entry must be well formed — one
|
|
7444
|
-
* malformed entry fails the whole response, because a partially understood
|
|
7445
|
-
* decision is not a decision. Unknown extra members are ignored so a server-side
|
|
7446
|
-
* addition cannot break the guard.
|
|
7447
|
-
*
|
|
7448
|
-
* @param value - the decoded JSON body.
|
|
7449
|
-
* @returns the parsed response, or undefined when the body is not one.
|
|
7450
|
-
*/
|
|
7451
|
-
function parseSystemOneResponse(value) {
|
|
7452
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
7453
|
-
const raw = value;
|
|
7454
|
-
const rawAnswers = raw.answers;
|
|
7455
|
-
if (typeof rawAnswers !== "object" || rawAnswers === null || Array.isArray(rawAnswers)) return void 0;
|
|
7456
|
-
const entries = Object.entries(rawAnswers);
|
|
7457
|
-
if (entries.length === 0) return void 0;
|
|
7458
|
-
const answers = {};
|
|
7459
|
-
for (const [id, entry] of entries) {
|
|
7460
|
-
const answer = parseSystemOneAnswer(entry);
|
|
7461
|
-
if (answer === void 0) return void 0;
|
|
7462
|
-
answers[id] = answer;
|
|
7463
|
-
}
|
|
7464
|
-
const usage = raw.usage === void 0 ? void 0 : parseUsage(raw.usage);
|
|
7465
|
-
if (raw.usage !== void 0 && usage === void 0) return void 0;
|
|
7466
|
-
return {
|
|
7467
|
-
...typeof raw.model === "string" ? { model: raw.model } : {},
|
|
7468
|
-
answers,
|
|
7469
|
-
...usage === void 0 ? {} : { usage }
|
|
7470
|
-
};
|
|
7471
|
-
}
|
|
7472
|
-
/** Parse the optional `usage` member; an unreadable one is a malformed body. */
|
|
7473
|
-
function parseUsage(value) {
|
|
7474
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
7475
|
-
const raw = value;
|
|
7476
|
-
const inputTokens = raw.input_tokens === void 0 ? void 0 : finite(raw.input_tokens);
|
|
7477
|
-
if (raw.input_tokens !== void 0 && inputTokens === void 0) return void 0;
|
|
7478
|
-
const outputTokens = raw.output_tokens === void 0 ? void 0 : finite(raw.output_tokens);
|
|
7479
|
-
if (raw.output_tokens !== void 0 && outputTokens === void 0) return void 0;
|
|
7480
|
-
return {
|
|
7481
|
-
...inputTokens === void 0 ? {} : { inputTokens },
|
|
7482
|
-
...outputTokens === void 0 ? {} : { outputTokens }
|
|
7483
|
-
};
|
|
7484
|
-
}
|
|
7485
|
-
/** Validate the outgoing request where the server would only reject it with a 400. */
|
|
7486
|
-
function assertRequest(request) {
|
|
7487
|
-
const ids = Object.keys(request.questions);
|
|
7488
|
-
if (ids.length === 0) throw new SystemOneError("a decision request needs at least one question", "invalid-request");
|
|
7489
|
-
if (ids.length > 20) throw new SystemOneError(`a decision request carries at most 20 questions (got ${ids.length})`, "invalid-request");
|
|
7490
|
-
for (const id of ids) if (id.trim() === "") throw new SystemOneError("every decision question needs a non-empty id", "invalid-request");
|
|
7491
|
-
}
|
|
7492
|
-
/** Read the endpoint's error body into a one-line diagnosis. */
|
|
7493
|
-
async function httpFailure(response) {
|
|
7494
|
-
let detail = "";
|
|
7495
|
-
try {
|
|
7496
|
-
const slice = (await response.text()).trim().slice(0, 200);
|
|
7497
|
-
if (slice !== "") detail = `: ${slice}`;
|
|
7498
|
-
} catch {}
|
|
7499
|
-
return new SystemOneError(`Command Code decision request failed (HTTP ${response.status})${detail}`, "http", response.status);
|
|
7500
|
-
}
|
|
7501
|
-
/**
|
|
7502
|
-
* Run one decision request.
|
|
7503
|
-
*
|
|
7504
|
-
* @param deps - connection facts, credential chain and fetch seam.
|
|
7505
|
-
* @param request - the state and typed questions.
|
|
7506
|
-
* @param options - caller cancellation and the per-call budget.
|
|
7507
|
-
* @returns the parsed decision.
|
|
7508
|
-
* @throws {SystemOneError} on every failure path; callers fail closed.
|
|
7509
|
-
*/
|
|
7510
|
-
async function runSystemOne(deps, request, options = {}) {
|
|
7511
|
-
assertRequest(request);
|
|
7512
|
-
const apiBase = deps.apiBase();
|
|
7513
|
-
if (!URL.canParse(apiBase)) throw new SystemOneError(`Command Code decision request is misconfigured: apiBase ${JSON.stringify(apiBase)} is not a valid URL`, "invalid-request");
|
|
7514
|
-
if (options.signal?.aborted === true) throw new SystemOneError("decision request aborted", "aborted");
|
|
7515
|
-
const timeoutMs = options.timeoutMs ?? 1500;
|
|
7516
|
-
const deadline = deadlineOf(options.signal, timeoutMs);
|
|
7517
|
-
try {
|
|
7518
|
-
let key;
|
|
7519
|
-
try {
|
|
7520
|
-
key = await withinDeadline(Promise.resolve().then(() => deps.resolveApiKey()), deadline, timeoutMs);
|
|
7521
|
-
} catch (error) {
|
|
7522
|
-
if (error instanceof SystemOneError && (error.kind === "aborted" || error.kind === "timeout")) throw error;
|
|
7523
|
-
throw new SystemOneError(`Command Code decision request has no usable credential: ${error instanceof Error ? error.message : String(error)}`, "credential", void 0, { cause: error });
|
|
7524
|
-
}
|
|
7525
|
-
const body = {
|
|
7526
|
-
model: request.model ?? "typesafe/jev",
|
|
7527
|
-
state: request.state,
|
|
7528
|
-
questions: request.questions
|
|
7529
|
-
};
|
|
7530
|
-
if (deadline.signal.aborted) throw deadline.callerAborted() ? new SystemOneError("decision request aborted", "aborted") : new SystemOneError(`decision request timed out after ${timeoutMs}ms`, "timeout");
|
|
7531
|
-
let response;
|
|
7532
|
-
try {
|
|
7533
|
-
response = await (deps.fetchImpl ?? fetch)(`${apiBase.replace(/\/$/, "")}${SYSTEMONE_ROUTE}`, {
|
|
7534
|
-
method: "POST",
|
|
7535
|
-
headers: {
|
|
7536
|
-
"Content-Type": "application/json",
|
|
7537
|
-
...IDENTITY_ENCODING_HEADER,
|
|
7538
|
-
Authorization: `Bearer ${key}`,
|
|
7539
|
-
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
|
|
7540
|
-
...attributionHeaders()
|
|
7541
|
-
},
|
|
7542
|
-
body: JSON.stringify(body),
|
|
7543
|
-
signal: deadline.signal
|
|
7544
|
-
});
|
|
7545
|
-
} catch (error) {
|
|
7546
|
-
if (deadline.callerAborted()) throw new SystemOneError("decision request aborted", "aborted", void 0, { cause: error });
|
|
7547
|
-
if (deadline.timedOut() || isAbortError(error)) throw new SystemOneError(`decision request timed out after ${timeoutMs}ms`, "timeout", void 0, { cause: error });
|
|
7548
|
-
throw new SystemOneError(`Command Code decision request failed: ${error instanceof Error ? error.message : String(error)}`, "network", void 0, { cause: error });
|
|
7549
|
-
}
|
|
7550
|
-
if (!response.ok) throw await httpFailure(response);
|
|
7551
|
-
let payload;
|
|
7552
|
-
try {
|
|
7553
|
-
payload = await response.json();
|
|
7554
|
-
} catch (error) {
|
|
7555
|
-
if (deadline.callerAborted()) throw new SystemOneError("decision request aborted", "aborted", void 0, { cause: error });
|
|
7556
|
-
throw new SystemOneError("Command Code returned an unparseable decision body", "invalid-response", void 0, { cause: error });
|
|
7557
|
-
}
|
|
7558
|
-
const parsed = parseSystemOneResponse(payload);
|
|
7559
|
-
if (parsed === void 0) throw new SystemOneError("Command Code returned a decision body this plugin cannot read", "invalid-response");
|
|
7560
|
-
return parsed;
|
|
7561
|
-
} finally {
|
|
7562
|
-
deadline.dispose();
|
|
7563
|
-
}
|
|
7564
|
-
}
|
|
7565
|
-
//#endregion
|
|
7566
|
-
//#region src/command-guard.ts
|
|
7567
|
-
/** Tool names whose `arguments.command` this guard judges. */
|
|
7568
|
-
const COMMAND_GUARD_SHELL_TOOLS = ["bash", "pwsh"];
|
|
7569
|
-
/** Question id the primary safety verdict is keyed by. */
|
|
7570
|
-
const COMMAND_GUARD_QUESTION_ID = "safe";
|
|
7571
|
-
/** Question id for the narrow-purpose part of a sandbox escalation. */
|
|
7572
|
-
const COMMAND_GUARD_ESCALATION_SCOPE_QUESTION_ID = "escalation_scope";
|
|
7573
|
-
/** Question id for the necessity/proportionality part of a sandbox escalation. */
|
|
7574
|
-
const COMMAND_GUARD_ESCALATION_NECESSITY_QUESTION_ID = "escalation_necessity";
|
|
7575
|
-
/** The selectable levels, strictest first (the order the settings surfaces list them in). */
|
|
7576
|
-
const COMMAND_GUARD_LEVELS = [
|
|
7577
|
-
"high",
|
|
7578
|
-
"medium",
|
|
7579
|
-
"low"
|
|
7580
|
-
];
|
|
7581
|
-
/** Each level's probability threshold. */
|
|
7582
|
-
const COMMAND_GUARD_LEVEL_THRESHOLDS = {
|
|
7583
|
-
high: .95,
|
|
7584
|
-
medium: .9,
|
|
7585
|
-
low: .8
|
|
7586
|
-
};
|
|
7587
|
-
/** The level an unset config reads as. */
|
|
7588
|
-
const COMMAND_GUARD_DEFAULT_LEVEL = "medium";
|
|
7589
|
-
/** Probability of "safe" at or above which the guard grants `allowed-once` by default. */
|
|
7590
|
-
const COMMAND_GUARD_DEFAULT_THRESHOLD = COMMAND_GUARD_LEVEL_THRESHOLDS[COMMAND_GUARD_DEFAULT_LEVEL];
|
|
7591
|
-
/** The threshold for a stored level; anything unrecognized reads as the default level. */
|
|
7592
|
-
function commandGuardThreshold(level) {
|
|
7593
|
-
return typeof level === "string" && Object.hasOwn(COMMAND_GUARD_LEVEL_THRESHOLDS, level) ? COMMAND_GUARD_LEVEL_THRESHOLDS[level] : COMMAND_GUARD_DEFAULT_THRESHOLD;
|
|
7594
|
-
}
|
|
7595
|
-
/**
|
|
7596
|
-
* Decision budget; the approval prompt must not wait longer than this. Fixed
|
|
7597
|
-
* rather than configurable: it only bounds how long a late verdict can delay
|
|
7598
|
-
* the prompt, and every failure (a timeout included) already falls back to
|
|
7599
|
-
* asking the user, so there is nothing for a user to tune. 3 s leaves room for
|
|
7600
|
-
* credential resolution plus one decision round trip on a slow link.
|
|
7601
|
-
*/
|
|
7602
|
-
const COMMAND_GUARD_TIMEOUT_MS = 3e3;
|
|
7603
|
-
/**
|
|
7604
|
-
* Longest command the guard will send for judgement. A longer one is delegated
|
|
7605
|
-
* instead of truncated: a verdict on the first N characters of a command is a
|
|
7606
|
-
* verdict on a different command.
|
|
7607
|
-
*/
|
|
7608
|
-
const COMMAND_GUARD_MAX_COMMAND_CHARS = 6e3;
|
|
7609
|
-
/**
|
|
7610
|
-
* Hard denylist: commands the guard never auto-approves, and never even sends
|
|
7611
|
-
* for judgement. Each entry is a deliberate, narrow reading of one destructive
|
|
7612
|
-
* family — the goal is not to classify every dangerous command (that is the
|
|
7613
|
-
* model's job) but to make sure the classics cannot be talked into a grant by a
|
|
7614
|
-
* persuasive-looking command string.
|
|
7615
|
-
*
|
|
7616
|
-
* Every pattern is allowed to OVER-match: a false positive costs one ordinary
|
|
7617
|
-
* human prompt, while a false negative would hand a destructive command to a
|
|
7618
|
-
* model that has to guess. That asymmetry is why `sudo` is matched anywhere in
|
|
7619
|
-
* the line rather than only in command position, and why `fdisk` is listed even
|
|
7620
|
-
* though `fdisk -l` only lists partitions.
|
|
7621
|
-
*/
|
|
7622
|
-
const COMMAND_GUARD_DENY_LIST = [
|
|
7623
|
-
{
|
|
7624
|
-
id: "rm-root",
|
|
7625
|
-
pattern: /\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*[rR][a-zA-Z]*|--recursive[^\n]*--force|--force[^\n]*--recursive)\s+(?:\/|\/\*|~|~\/\*|\$HOME(?:\/\*)?|\*|\.\/\*|\.)(?:\s|$)/
|
|
7626
|
-
},
|
|
7627
|
-
{
|
|
7628
|
-
id: "rm-home",
|
|
7629
|
-
pattern: /\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(?:~|\$HOME)\//
|
|
7630
|
-
},
|
|
7631
|
-
{
|
|
7632
|
-
id: "filesystem-write",
|
|
7633
|
-
pattern: /\b(?:mkfs(?:\.\w+)?|wipefs|fdisk|parted|shred)\b/
|
|
7634
|
-
},
|
|
7635
|
-
{
|
|
7636
|
-
id: "raw-device-write",
|
|
7637
|
-
pattern: /\bdd\b[^\n]*\bof=\/dev\//
|
|
7638
|
-
},
|
|
7639
|
-
{
|
|
7640
|
-
id: "device-redirect",
|
|
7641
|
-
pattern: />{1,2}\s*\/dev\/(?:sd|disk|nvme|hd)/
|
|
7642
|
-
},
|
|
7643
|
-
{
|
|
7644
|
-
id: "fork-bomb",
|
|
7645
|
-
pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/
|
|
7646
|
-
},
|
|
7647
|
-
{
|
|
7648
|
-
id: "privilege-escalation",
|
|
7649
|
-
pattern: /\b(?:sudo|doas)\b/
|
|
7650
|
-
},
|
|
7651
|
-
{
|
|
7652
|
-
id: "su",
|
|
7653
|
-
pattern: /\bsu\s/
|
|
7654
|
-
},
|
|
7655
|
-
{
|
|
7656
|
-
id: "chmod-world-write",
|
|
7657
|
-
pattern: /\bchmod\b[^\n]*\b(?:777|a\+w|o\+w)\b/
|
|
7658
|
-
},
|
|
7659
|
-
{
|
|
7660
|
-
id: "chown-root",
|
|
7661
|
-
pattern: /\bchown\b[^\n]*\b(?:root|0):/
|
|
7662
|
-
},
|
|
7663
|
-
{
|
|
7664
|
-
id: "git-push",
|
|
7665
|
-
pattern: /\bgit\s+push\b/
|
|
7666
|
-
},
|
|
7667
|
-
{
|
|
7668
|
-
id: "git-history-rewrite",
|
|
7669
|
-
pattern: /\bgit\s+(?:filter-branch|filter-repo)\b/
|
|
7670
|
-
},
|
|
7671
|
-
{
|
|
7672
|
-
id: "git-hard-reset",
|
|
7673
|
-
pattern: /\bgit\s+reset\s+--hard\b/
|
|
7674
|
-
},
|
|
7675
|
-
{
|
|
7676
|
-
id: "git-clean-force",
|
|
7677
|
-
pattern: /\bgit\s+clean\b[^\n]*\s-[a-zA-Z]*f/
|
|
7678
|
-
},
|
|
7679
|
-
{
|
|
7680
|
-
id: "git-discard-worktree",
|
|
7681
|
-
pattern: /\bgit\s+(?:checkout|restore)\s+(?:--\s+)?\.(?:\s|$)/
|
|
7682
|
-
},
|
|
7683
|
-
{
|
|
7684
|
-
id: "git-stash-drop",
|
|
7685
|
-
pattern: /\bgit\s+stash\s+(?:drop|clear)\b/
|
|
7686
|
-
},
|
|
7687
|
-
{
|
|
7688
|
-
id: "publish",
|
|
7689
|
-
pattern: /\b(?:npm|pnpm|yarn|bun)\s+publish\b/
|
|
7690
|
-
},
|
|
7691
|
-
{
|
|
7692
|
-
id: "release-upload",
|
|
7693
|
-
pattern: /\b(?:gh\s+release|twine\s+upload)\b/
|
|
7694
|
-
},
|
|
7695
|
-
{
|
|
7696
|
-
id: "docker-push",
|
|
7697
|
-
pattern: /\bdocker\s+push\b/
|
|
7698
|
-
},
|
|
7699
|
-
{
|
|
7700
|
-
id: "cloud-upload",
|
|
7701
|
-
pattern: /\b(?:aws\s+s3|gsutil|gcloud\s+storage|az\s+storage)\b[^\n]*\b(?:cp|sync|mv|rm|rb)\b/
|
|
7702
|
-
},
|
|
7703
|
-
{
|
|
7704
|
-
id: "remote-copy",
|
|
7705
|
-
pattern: /\b(?:scp|rsync)\b[^\n]*(?::|@)/
|
|
7706
|
-
},
|
|
7707
|
-
{
|
|
7708
|
-
id: "pipe-to-shell",
|
|
7709
|
-
pattern: /\b(?:curl|wget|fetch)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|python\d*|node|ruby|perl)\b/
|
|
7710
|
-
},
|
|
7711
|
-
{
|
|
7712
|
-
id: "power",
|
|
7713
|
-
pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/
|
|
7714
|
-
},
|
|
7715
|
-
{
|
|
7716
|
-
id: "kill-everything",
|
|
7717
|
-
pattern: /\bkill\s+(?:-9\s+)?-1\b|\b(?:killall|pkill)\s+-9\b/
|
|
7718
|
-
},
|
|
7719
|
-
{
|
|
7720
|
-
id: "credential-write",
|
|
7721
|
-
pattern: /(?:>{1,2}|tee|cp|mv|rm|chmod|chown)\s[^\n]*(?:\.ssh\b|\.aws\b|\.gnupg\b|\.commandcode\/auth\.json|id_rsa|id_ed25519)/
|
|
7722
|
-
},
|
|
7723
|
-
{
|
|
7724
|
-
id: "history-clear",
|
|
7725
|
-
pattern: /\bhistory\s+-c\b|\bunset\s+HISTFILE\b/
|
|
7726
|
-
},
|
|
7727
|
-
{
|
|
7728
|
-
id: "global-install",
|
|
7729
|
-
pattern: /\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add)\s+(?:-g\b|--global\b)/
|
|
7730
|
-
},
|
|
7731
|
-
{
|
|
7732
|
-
id: "system-install",
|
|
7733
|
-
pattern: /\b(?:apt|apt-get|dnf|yum|zypper|apk|pacman|brew)\s+(?:install|remove|purge|uninstall|upgrade)\b/
|
|
7734
|
-
},
|
|
7735
|
-
{
|
|
7736
|
-
id: "language-install",
|
|
7737
|
-
pattern: /\b(?:pip3?|pipx|gem|cargo)\s+(?:install|add)\b/
|
|
7738
|
-
}
|
|
7739
|
-
];
|
|
7740
|
-
/**
|
|
7741
|
-
* Why the guard refuses to judge a command, when it does.
|
|
7742
|
-
*
|
|
7743
|
-
* @param command - the raw command text.
|
|
7744
|
-
* @returns the matching denylist id, or undefined.
|
|
7745
|
-
*/
|
|
7746
|
-
function denyReasonOf(command) {
|
|
7747
|
-
for (const entry of COMMAND_GUARD_DENY_LIST) if (entry.pattern.test(command)) return entry.id;
|
|
7748
|
-
}
|
|
7749
|
-
/** A non-empty string member, or undefined. */
|
|
7750
|
-
function stringMember(value, key) {
|
|
7751
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
7752
|
-
const member = value[key];
|
|
7753
|
-
return typeof member === "string" && member.trim() !== "" ? member : void 0;
|
|
7754
|
-
}
|
|
7755
|
-
/**
|
|
7756
|
-
* Read a shell command out of a tool execution, or undefined when this call is
|
|
7757
|
-
* not one the guard judges (a different tool, or arguments without a command).
|
|
7758
|
-
*
|
|
7759
|
-
* @param name - the executed tool's name.
|
|
7760
|
-
* @param args - the execution's parsed `arguments`.
|
|
7761
|
-
* @returns the fact to judge, without the asker's reason (that arrives later).
|
|
7762
|
-
*/
|
|
7763
|
-
function shellCommandOf(name, args) {
|
|
7764
|
-
if (!COMMAND_GUARD_SHELL_TOOLS.includes(name)) return void 0;
|
|
7765
|
-
const command = stringMember(args, "command");
|
|
7766
|
-
if (command === void 0) return void 0;
|
|
7767
|
-
const description = stringMember(args, "description");
|
|
7768
|
-
const workdir = stringMember(args, "workdir");
|
|
7769
|
-
const sandboxPermissions = stringMember(args, "sandbox_permissions");
|
|
7770
|
-
return {
|
|
7771
|
-
tool: name,
|
|
7772
|
-
command,
|
|
7773
|
-
...description === void 0 ? {} : { description },
|
|
7774
|
-
...workdir === void 0 ? {} : { workdir },
|
|
7775
|
-
...sandboxPermissions === void 0 ? {} : { sandboxPermissions }
|
|
7776
|
-
};
|
|
7777
|
-
}
|
|
7778
|
-
/**
|
|
7779
|
-
* Build the decision request for one command.
|
|
7780
|
-
*
|
|
7781
|
-
* A normal approval asks one question. A call that explicitly requests a wider
|
|
7782
|
-
* sandbox gets the same safety question plus two veto questions. This keeps the
|
|
7783
|
-
* common case cheap while making the broader permission change an explicit
|
|
7784
|
-
* decision: JEV must agree that the extra access has a narrow purpose and is
|
|
7785
|
-
* necessary, not merely that the command text looks familiar.
|
|
7786
|
-
*
|
|
7787
|
-
* The agent's own `description` and the harness's `reason` are included as
|
|
7788
|
-
* context, but the criteria say the command text itself decides — a
|
|
7789
|
-
* model-written description is a claim, not evidence.
|
|
7790
|
-
*
|
|
7791
|
-
* @param fact - the command and its context.
|
|
7792
|
-
* @returns the request to send.
|
|
7793
|
-
*/
|
|
7794
|
-
function buildSafetyRequest(fact) {
|
|
7795
|
-
const escalated = fact.sandboxPermissions !== void 0;
|
|
7796
|
-
const lines = [
|
|
7797
|
-
`Tool: ${fact.tool}`,
|
|
7798
|
-
"Command:",
|
|
7799
|
-
fact.command,
|
|
7800
|
-
...fact.description === void 0 ? [] : ["", `The agent describes this command as: ${fact.description}`],
|
|
7801
|
-
...fact.workdir === void 0 ? [] : ["", `Working directory: ${fact.workdir}`],
|
|
7802
|
-
...fact.sandboxPermissions === void 0 ? [] : ["", `The harness is asking because this call requests a wider sandbox mode (${fact.sandboxPermissions}) than the one currently in force.`],
|
|
7803
|
-
...fact.reason === void 0 ? [] : ["", `The asker's reason for needing approval: ${fact.reason}`]
|
|
7804
|
-
];
|
|
7805
|
-
const question = {
|
|
7806
|
-
type: "noul",
|
|
7807
|
-
instructions: escalated ? "Is it safe to run this command right now WITHOUT asking the user first, including the requested sandbox escalation? Judge the command text itself; a description or stated reason is only a claim about it." : "Is it safe to run this command right now WITHOUT asking the user first? Judge the command text itself; a description is only a claim about it.",
|
|
7808
|
-
criteria: {
|
|
7809
|
-
true: escalated ? "A normal, non-obfuscated read, build, or test operation whose outside-workspace effects are limited to conventional build/dependency caches or temporary files and are easily reversible: no unrelated user or system data is accessed, no data leaves the machine, and no credentials, git history or remote state are touched." : "Read-only, or its effects stay inside the current project and are trivially reversible: no data leaves the machine, nothing outside the project is modified, and no credentials, git history or remote state are touched.",
|
|
7810
|
-
false: "Anything else. Broad or unrelated outside-workspace access, deleting or overwriting files outside the project, rewriting or publishing git history, uploading data anywhere, installing system or global packages, changing permissions, ownership or credentials, killing processes, running as another user or as root, piping a download into a shell, or a command obfuscated enough that its effect cannot be read."
|
|
7811
|
-
}
|
|
7812
|
-
};
|
|
7813
|
-
const questions = { [COMMAND_GUARD_QUESTION_ID]: question };
|
|
7814
|
-
if (escalated) {
|
|
7815
|
-
questions[COMMAND_GUARD_ESCALATION_SCOPE_QUESTION_ID] = {
|
|
7816
|
-
type: "noul",
|
|
7817
|
-
instructions: "Is the requested wider sandbox access limited to a narrow, conventional purpose supported by this command, such as a build cache, dependency cache, or temporary directory, rather than unrelated user files, credentials, other projects, or the network?",
|
|
7818
|
-
criteria: {
|
|
7819
|
-
true: "The wider access is plausibly limited to conventional local build/dependency caches or temporary files required by the command, with no unrelated host data or remote transfer in scope.",
|
|
7820
|
-
false: "The command needs broad or unrelated host access, cannot establish a narrow purpose, or would use the wider permission to reach credentials, other projects, user files, or the network."
|
|
7821
|
-
}
|
|
7822
|
-
};
|
|
7823
|
-
questions[COMMAND_GUARD_ESCALATION_NECESSITY_QUESTION_ID] = {
|
|
7824
|
-
type: "noul",
|
|
7825
|
-
instructions: "Is the requested sandbox escalation necessary and proportionate for this one command, rather than broader than the command needs? Treat the stated reason as a claim to check against the command, not as proof.",
|
|
7826
|
-
criteria: {
|
|
7827
|
-
true: "The command and its stated purpose support a one-shot escalation that is needed for the task and is no broader than the requested effect requires.",
|
|
7828
|
-
false: "The wider mode is unnecessary, broader than needed, unsupported by the command, or the command is too ambiguous to justify the extra authority."
|
|
7829
|
-
}
|
|
7830
|
-
};
|
|
7831
|
-
}
|
|
7832
|
-
return {
|
|
7833
|
-
state: lines.join("\n"),
|
|
7834
|
-
questions
|
|
7835
|
-
};
|
|
7836
|
-
}
|
|
7837
|
-
/**
|
|
7838
|
-
* Read a probability out of a decision response.
|
|
7839
|
-
*
|
|
7840
|
-
* @param response - the parsed response.
|
|
7841
|
-
* @param questionId - the question id to read (defaults to the guard's own).
|
|
7842
|
-
* @returns the probability, or undefined when the answer is missing or is not
|
|
7843
|
-
* a `noul` verdict.
|
|
7844
|
-
*/
|
|
7845
|
-
function safeProbabilityOf(response, questionId = COMMAND_GUARD_QUESTION_ID) {
|
|
7846
|
-
const answer = response.answers[questionId];
|
|
7847
|
-
return answer !== void 0 && answer.type === "noul" ? answer.noul : void 0;
|
|
7848
|
-
}
|
|
7849
|
-
/**
|
|
7850
|
-
* One-line, length-bounded excerpt of a command for the log. The full text is
|
|
7851
|
-
* already in the session's tool call and was already sent to the decision
|
|
7852
|
-
* endpoint, so this adds no exposure — it is what makes "why did this run
|
|
7853
|
-
* without asking?" answerable from the log alone.
|
|
7854
|
-
*/
|
|
7855
|
-
function commandExcerpt(command, limit = 160) {
|
|
7856
|
-
const flat = command.replace(/\s+/g, " ").trim();
|
|
7857
|
-
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
7858
|
-
}
|
|
7859
|
-
/**
|
|
7860
|
-
* The guard's stateful half: what was executed (by call id) and what has
|
|
7861
|
-
* already been decided (by agent and complete decision context), both bounded.
|
|
7862
|
-
*
|
|
7863
|
-
* The execution cache is the bridge between the two seams — `tools/pre-execute`
|
|
7864
|
-
* knows the arguments, `approval/request` knows the call id — and it is keyed by
|
|
7865
|
-
* call id with an agent-identity check when read: an entry for an execution
|
|
7866
|
-
* that never asked is never read, so there is nothing to clean up on the result
|
|
7867
|
-
* side (and the ask happens before the result anyway).
|
|
7868
|
-
*/
|
|
7869
|
-
var CommandGuard = class {
|
|
7870
|
-
deps;
|
|
7871
|
-
executions = /* @__PURE__ */ new Map();
|
|
7872
|
-
decisions = /* @__PURE__ */ new WeakMap();
|
|
7873
|
-
constructor(deps) {
|
|
7874
|
-
this.deps = deps;
|
|
7875
|
-
}
|
|
7876
|
-
/**
|
|
7877
|
-
* Remember one execution's command. Never throws, never changes a decision.
|
|
7878
|
-
*
|
|
7879
|
-
* @param execution - the execution about to run.
|
|
7880
|
-
*/
|
|
7881
|
-
noteExecution(execution) {
|
|
7882
|
-
const callId = execution?.callId;
|
|
7883
|
-
if (typeof callId !== "string" || callId === "") return;
|
|
7884
|
-
const fact = shellCommandOf(execution.name, execution.arguments);
|
|
7885
|
-
if (fact === void 0) return;
|
|
7886
|
-
this.executions.delete(callId);
|
|
7887
|
-
this.executions.set(callId, {
|
|
7888
|
-
fact,
|
|
7889
|
-
agent: execution.agent
|
|
7890
|
-
});
|
|
7891
|
-
while (this.executions.size > 64) {
|
|
7892
|
-
const oldest = this.executions.keys().next();
|
|
7893
|
-
if (oldest.done === true) break;
|
|
7894
|
-
this.executions.delete(oldest.value);
|
|
7895
|
-
}
|
|
7896
|
-
}
|
|
7897
|
-
/** Forget every remembered execution and decision (disposal, tests). */
|
|
7898
|
-
clear() {
|
|
7899
|
-
this.executions.clear();
|
|
7900
|
-
this.decisions = /* @__PURE__ */ new WeakMap();
|
|
7901
|
-
}
|
|
7902
|
-
/**
|
|
7903
|
-
* Judge one approval request.
|
|
7904
|
-
*
|
|
7905
|
-
* @param request - the pending approval.
|
|
7906
|
-
* @returns the verdict; `approve` is the only outcome that grants anything.
|
|
7907
|
-
*/
|
|
7908
|
-
async judge(request) {
|
|
7909
|
-
const settings = this.deps.settings();
|
|
7910
|
-
if (!settings.enabled) return {
|
|
7911
|
-
kind: "delegate",
|
|
7912
|
-
reason: "guard disabled"
|
|
7913
|
-
};
|
|
7914
|
-
const callId = request.callId;
|
|
7915
|
-
if (callId === void 0) return {
|
|
7916
|
-
kind: "delegate",
|
|
7917
|
-
reason: "approval carries no call id"
|
|
7918
|
-
};
|
|
7919
|
-
const observed = this.executions.get(callId);
|
|
7920
|
-
if (observed === void 0 || observed.agent !== request.agent) return {
|
|
7921
|
-
kind: "delegate",
|
|
7922
|
-
reason: "the guard never saw this call for this agent"
|
|
7923
|
-
};
|
|
7924
|
-
const fact = observed.fact;
|
|
7925
|
-
const excerpt = commandExcerpt(fact.command);
|
|
7926
|
-
if (!COMMAND_GUARD_SHELL_TOOLS.includes(request.toolName)) return {
|
|
7927
|
-
kind: "delegate",
|
|
7928
|
-
reason: `not a shell tool (${request.toolName})`
|
|
7929
|
-
};
|
|
7930
|
-
if (fact.command.length > 6e3) return {
|
|
7931
|
-
kind: "delegate",
|
|
7932
|
-
reason: `command longer than ${COMMAND_GUARD_MAX_COMMAND_CHARS} characters`,
|
|
7933
|
-
command: excerpt
|
|
7934
|
-
};
|
|
7935
|
-
const denied = denyReasonOf(fact.command);
|
|
7936
|
-
if (denied !== void 0) return {
|
|
7937
|
-
kind: "delegate",
|
|
7938
|
-
reason: `denylisted command (${denied})`,
|
|
7939
|
-
command: excerpt
|
|
7940
|
-
};
|
|
7941
|
-
const full = {
|
|
7942
|
-
...fact,
|
|
7943
|
-
...request.reason === void 0 ? {} : { reason: request.reason }
|
|
7944
|
-
};
|
|
7945
|
-
const memoKey = JSON.stringify(full);
|
|
7946
|
-
const now = this.deps.now?.() ?? Date.now();
|
|
7947
|
-
const agentDecisions = request.agent === void 0 ? void 0 : this.decisions.get(request.agent);
|
|
7948
|
-
const memo = agentDecisions?.get(memoKey);
|
|
7949
|
-
if (memo !== void 0) {
|
|
7950
|
-
if (now - memo.at < 6e5) return this.verdict(memo, settings.threshold, "memo", excerpt, full.sandboxPermissions !== void 0);
|
|
7951
|
-
agentDecisions?.delete(memoKey);
|
|
7952
|
-
}
|
|
7953
|
-
let response;
|
|
7954
|
-
try {
|
|
7955
|
-
response = await this.deps.decide(buildSafetyRequest(full), {
|
|
7956
|
-
...request.signal === void 0 ? {} : { signal: request.signal },
|
|
7957
|
-
timeoutMs: settings.timeoutMs
|
|
7958
|
-
});
|
|
7959
|
-
} catch (error) {
|
|
7960
|
-
return {
|
|
7961
|
-
kind: "delegate",
|
|
7962
|
-
reason: `decision unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
7963
|
-
command: excerpt
|
|
7964
|
-
};
|
|
7965
|
-
}
|
|
7966
|
-
const probability = safeProbabilityOf(response);
|
|
7967
|
-
if (probability === void 0) return {
|
|
7968
|
-
kind: "delegate",
|
|
7969
|
-
reason: "the decision carried no safe verdict",
|
|
7970
|
-
command: excerpt
|
|
7971
|
-
};
|
|
7972
|
-
let escalation;
|
|
7973
|
-
if (full.sandboxPermissions !== void 0) {
|
|
7974
|
-
const scope = safeProbabilityOf(response, COMMAND_GUARD_ESCALATION_SCOPE_QUESTION_ID);
|
|
7975
|
-
const necessity = safeProbabilityOf(response, COMMAND_GUARD_ESCALATION_NECESSITY_QUESTION_ID);
|
|
7976
|
-
if (scope === void 0 || necessity === void 0) return {
|
|
7977
|
-
kind: "delegate",
|
|
7978
|
-
reason: "the decision carried no complete sandbox-escalation verdict",
|
|
7979
|
-
command: excerpt
|
|
7980
|
-
};
|
|
7981
|
-
escalation = {
|
|
7982
|
-
scope,
|
|
7983
|
-
necessity
|
|
7984
|
-
};
|
|
7985
|
-
}
|
|
7986
|
-
const memoEntry = {
|
|
7987
|
-
probability,
|
|
7988
|
-
...escalation === void 0 ? {} : { escalation },
|
|
7989
|
-
at: now
|
|
7990
|
-
};
|
|
7991
|
-
if (request.agent !== void 0) {
|
|
7992
|
-
const decisions = agentDecisions ?? /* @__PURE__ */ new Map();
|
|
7993
|
-
decisions.set(memoKey, memoEntry);
|
|
7994
|
-
while (decisions.size > 64) {
|
|
7995
|
-
const oldest = decisions.keys().next();
|
|
7996
|
-
if (oldest.done === true) break;
|
|
7997
|
-
decisions.delete(oldest.value);
|
|
7998
|
-
}
|
|
7999
|
-
this.decisions.set(request.agent, decisions);
|
|
8000
|
-
}
|
|
8001
|
-
return this.verdict(memoEntry, settings.threshold, "model", excerpt, full.sandboxPermissions !== void 0);
|
|
8002
|
-
}
|
|
8003
|
-
/** Apply the threshold to the primary verdict and any escalation vetoes. */
|
|
8004
|
-
verdict(entry, threshold, source, command, escalated) {
|
|
8005
|
-
if (entry.probability < threshold) return {
|
|
8006
|
-
kind: "delegate",
|
|
8007
|
-
reason: `safe probability ${entry.probability.toFixed(3)} below ${threshold}`,
|
|
8008
|
-
probability: entry.probability,
|
|
8009
|
-
command
|
|
8010
|
-
};
|
|
8011
|
-
if (!escalated) return {
|
|
8012
|
-
kind: "approve",
|
|
8013
|
-
probability: entry.probability,
|
|
8014
|
-
source,
|
|
8015
|
-
command
|
|
8016
|
-
};
|
|
8017
|
-
if (entry.escalation === void 0) return {
|
|
8018
|
-
kind: "delegate",
|
|
8019
|
-
reason: "the cached sandbox-escalation verdict was incomplete",
|
|
8020
|
-
command
|
|
8021
|
-
};
|
|
8022
|
-
if (entry.escalation.scope < threshold) return {
|
|
8023
|
-
kind: "delegate",
|
|
8024
|
-
reason: `sandbox scope probability ${entry.escalation.scope.toFixed(3)} below ${threshold}`,
|
|
8025
|
-
probability: entry.probability,
|
|
8026
|
-
command
|
|
8027
|
-
};
|
|
8028
|
-
if (entry.escalation.necessity < threshold) return {
|
|
8029
|
-
kind: "delegate",
|
|
8030
|
-
reason: `sandbox necessity probability ${entry.escalation.necessity.toFixed(3)} below ${threshold}`,
|
|
8031
|
-
probability: entry.probability,
|
|
8032
|
-
command
|
|
8033
|
-
};
|
|
8034
|
-
return {
|
|
8035
|
-
kind: "approve",
|
|
8036
|
-
probability: entry.probability,
|
|
8037
|
-
source,
|
|
8038
|
-
...entry.escalation === void 0 ? {} : { escalation: entry.escalation },
|
|
8039
|
-
command
|
|
8040
|
-
};
|
|
8041
|
-
}
|
|
8042
|
-
};
|
|
8043
|
-
/**
|
|
8044
|
-
* Register the guard on a context that has the approval seam.
|
|
8045
|
-
*
|
|
8046
|
-
* Call this from inside `ctx.inject(['approval'], …)`: the seam's presence is
|
|
8047
|
-
* what makes the feature meaningful (no approval service means no asks), and
|
|
8048
|
-
* tying the listeners to that injected fiber is what makes them go away with it.
|
|
8049
|
-
*
|
|
8050
|
-
* @param ctx - the injected context (or a test double with `on`).
|
|
8051
|
-
* @param deps - settings, the decision client, clock and logger.
|
|
8052
|
-
* @returns a disposer for both listeners.
|
|
8053
|
-
*/
|
|
8054
|
-
function applyCommandGuard(ctx, deps) {
|
|
8055
|
-
const guard = new CommandGuard(deps);
|
|
8056
|
-
const events = ctx;
|
|
8057
|
-
const offPreExecute = events.on("tools/pre-execute", (execution, next) => {
|
|
8058
|
-
guard.noteExecution(execution);
|
|
8059
|
-
return next();
|
|
8060
|
-
}, { prepend: true });
|
|
8061
|
-
const offApproval = events.on("approval/request", async (request, next) => {
|
|
8062
|
-
try {
|
|
8063
|
-
if (!deps.settings().enabled) return next();
|
|
8064
|
-
} catch (error) {
|
|
8065
|
-
deps.log?.(`command guard failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8066
|
-
return next();
|
|
8067
|
-
}
|
|
8068
|
-
let verdict;
|
|
8069
|
-
try {
|
|
8070
|
-
verdict = await guard.judge(request);
|
|
8071
|
-
} catch (error) {
|
|
8072
|
-
deps.log?.(`command guard failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8073
|
-
return next();
|
|
8074
|
-
}
|
|
8075
|
-
if (verdict.kind === "approve") {
|
|
8076
|
-
deps.log?.(`command guard auto-approved a ${request.toolName} call (safe probability ${verdict.probability.toFixed(3)}${verdict.escalation === void 0 ? "" : `, sandbox scope ${verdict.escalation.scope.toFixed(3)}, necessity ${verdict.escalation.necessity.toFixed(3)}`}, ${verdict.source}): ${verdict.command ?? "(command unknown)"}`);
|
|
8077
|
-
return "allowed-once";
|
|
8078
|
-
}
|
|
8079
|
-
deps.log?.(`command guard delegated a ${request.toolName} call: ${verdict.reason}${verdict.command === void 0 ? "" : ` — ${verdict.command}`}`);
|
|
8080
|
-
return next();
|
|
8081
|
-
}, { prepend: true });
|
|
8082
|
-
return () => {
|
|
8083
|
-
guard.clear();
|
|
8084
|
-
for (const off of [offPreExecute, offApproval]) if (typeof off === "function") off();
|
|
8085
|
-
};
|
|
8086
|
-
}
|
|
8087
|
-
//#endregion
|
|
8088
7227
|
//#region src/config-volatile.ts
|
|
8089
7228
|
/**
|
|
8090
7229
|
* Volatile-config helpers for dsh 0.1.7's schema-derived profile Config.
|
|
8091
7230
|
*
|
|
8092
|
-
* 0.1.7
|
|
8093
|
-
*
|
|
8094
|
-
*
|
|
8095
|
-
*
|
|
8096
|
-
*
|
|
8097
|
-
*
|
|
8098
|
-
*
|
|
8099
|
-
*
|
|
8100
|
-
*
|
|
8101
|
-
*
|
|
8102
|
-
*
|
|
8103
|
-
* -
|
|
8104
|
-
*
|
|
8105
|
-
*
|
|
8106
|
-
*
|
|
8107
|
-
* -
|
|
8108
|
-
* reference when one is present — returning a FRESH object per call, since
|
|
8109
|
-
* a reference's identity is stable while its value changes — and returns the
|
|
8110
|
-
* config untouched when none is.
|
|
8111
|
-
*
|
|
8112
|
-
* Only TOP-LEVEL fields are marked (form writes name top-level paths such as
|
|
8113
|
-
* `accounts` or `modelVisibility.<id>`, whose first segment is the marked
|
|
8114
|
-
* node), so one unwrap level is complete. The literal `apiKey` secret stays
|
|
8115
|
-
* unmarked on purpose: no settings surface writes it — the settings page and
|
|
8116
|
-
* the dsh-TUI section both write keys through the credentials seam — so it
|
|
8117
|
-
* keeps composition-only semantics, and a config-file edit to it reloading the
|
|
8118
|
-
* fiber is the correct behavior for a secret literal.
|
|
7231
|
+
* 0.1.7's `settings.describe()` projects each entry's schema through
|
|
7232
|
+
* `volatileForm()`, so a form contains ONLY nodes carrying `meta.volatile`
|
|
7233
|
+
* and a form edit is refused unless its path lies beneath a marked node. The
|
|
7234
|
+
* loader then resolves each marked field into a frozen `{ get() }` reference
|
|
7235
|
+
* that is written IN PLACE (no remount) and announced with
|
|
7236
|
+
* `loader/volatile-update`. Two helpers keep the rest of the plugin reading a
|
|
7237
|
+
* PLAIN `Config`: `markVolatile()` applies the mark, and
|
|
7238
|
+
* `unwrapVolatileConfig()` reads every top-level field through its reference
|
|
7239
|
+
* when one is present — FRESH per call, because a reference's identity is
|
|
7240
|
+
* stable while its value changes.
|
|
7241
|
+
*
|
|
7242
|
+
* Only TOP-LEVEL fields are marked (a form write names a top-level path such
|
|
7243
|
+
* as `accounts` or `modelVisibility.<id>`), so one unwrap level is complete.
|
|
7244
|
+
* The literal `apiKey` secret stays unmarked on purpose: no settings surface
|
|
7245
|
+
* writes it — page and TUI both write keys through the credentials seam — so a
|
|
7246
|
+
* config-file edit to it reloading the fiber is correct for a secret literal.
|
|
8119
7247
|
*
|
|
8120
7248
|
* @module dsh-commandcode-provider/config-volatile
|
|
8121
7249
|
*/
|
|
8122
7250
|
/**
|
|
8123
7251
|
* Mark one schema field volatile.
|
|
8124
7252
|
*
|
|
8125
|
-
* `.volatile()` is unconditional:
|
|
8126
|
-
*
|
|
8127
|
-
*
|
|
8128
|
-
*
|
|
8129
|
-
*
|
|
8130
|
-
* consumer reads while the schema parses to live references.
|
|
8131
|
-
*
|
|
8132
|
-
* @param schema - The field schema.
|
|
8133
|
-
* @returns The schema carrying `meta.volatile`.
|
|
7253
|
+
* `.volatile()` is unconditional: it is a schemastery 3.18.3 feature and the
|
|
7254
|
+
* only supported engine (`dsh 0.1.7-rc.2`) pins `~3.18.4`, so the method is
|
|
7255
|
+
* always present. It is reached through a structural member so the caller's
|
|
7256
|
+
* declared field type survives — the mark changes what the schema PARSES to (a
|
|
7257
|
+
* live reference), not the plugin-facing `Config` shape.
|
|
8134
7258
|
*/
|
|
8135
7259
|
function markVolatile(schema) {
|
|
8136
7260
|
return schema.volatile();
|
|
@@ -8138,10 +7262,6 @@ function markVolatile(schema) {
|
|
|
8138
7262
|
/**
|
|
8139
7263
|
* Mark every field of a Config field dict volatile, except the named
|
|
8140
7264
|
* composition-only secrets (`apiKey` here, which no settings surface writes).
|
|
8141
|
-
*
|
|
8142
|
-
* @param fields - Freshly built field schemas.
|
|
8143
|
-
* @param skip - Field names that must stay unmarked.
|
|
8144
|
-
* @returns The field dict with every non-skipped field marked.
|
|
8145
7265
|
*/
|
|
8146
7266
|
function markVolatileFields(fields, skip = []) {
|
|
8147
7267
|
return Object.fromEntries(Object.entries(fields).map(([name, schema]) => [name, skip.includes(name) ? schema : markVolatile(schema)]));
|
|
@@ -8163,8 +7283,8 @@ function isVolatileRef(value) {
|
|
|
8163
7283
|
*
|
|
8164
7284
|
* Every top-level field found to be a volatile reference is read through
|
|
8165
7285
|
* `.get()` — freshly on every call, so a live update lands on the very next
|
|
8166
|
-
* read
|
|
8167
|
-
* included, so a loader-parsed config always yields a fresh object here
|
|
7286
|
+
* read (the loader resolves EVERY marked node into a reference, unset fields
|
|
7287
|
+
* included, so a loader-parsed config always yields a fresh object here). The
|
|
8168
7288
|
* pass-through for a field that is not a reference is what keeps the unmarked
|
|
8169
7289
|
* `apiKey` literal readable.
|
|
8170
7290
|
*/
|
|
@@ -8179,15 +7299,14 @@ function unwrapVolatileConfig(config) {
|
|
|
8179
7299
|
//#region src/index.ts
|
|
8180
7300
|
/**
|
|
8181
7301
|
* dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
|
|
8182
|
-
* Code (unofficial; ported from pi-commandcode-provider@0.5.1).
|
|
7302
|
+
* Code (unofficial; ported from pi-commandcode-provider@0.5.1, MIT).
|
|
8183
7303
|
*
|
|
8184
7304
|
* Registers the `commandcode` provider route on `ctx.llm` and declares it in
|
|
8185
7305
|
* the configurable-provider directory, so the web Models page shows a
|
|
8186
|
-
* "Command Code" card
|
|
8187
|
-
*
|
|
8188
|
-
*
|
|
8189
|
-
*
|
|
8190
|
-
* next request without a restart.
|
|
7306
|
+
* "Command Code" card and the model picker lists the live catalog. Connection
|
|
7307
|
+
* facts are resolved per request from the live profile Config (volatile
|
|
7308
|
+
* fields, unwrapped per read) and the credential seam, so a changed key,
|
|
7309
|
+
* endpoint or cache path reaches the next request without a restart.
|
|
8191
7310
|
*
|
|
8192
7311
|
* ```yaml
|
|
8193
7312
|
* - id: llm-commandcode
|
|
@@ -8214,14 +7333,12 @@ const PROVIDER = "commandcode";
|
|
|
8214
7333
|
const DEFAULT_MODELS_CACHE_PATH = join(homedir(), ".commandcode", "models-cache.json");
|
|
8215
7334
|
/**
|
|
8216
7335
|
* The Config schema: every field volatile except the composition-only `apiKey`
|
|
8217
|
-
* secret.
|
|
8218
|
-
*
|
|
8219
|
-
*
|
|
8220
|
-
*
|
|
8221
|
-
*
|
|
8222
|
-
*
|
|
8223
|
-
* without remounting this fiber (the `loader/volatile-update` listener at the
|
|
8224
|
-
* bottom of `apply` covers the facts that are not re-derived per read).
|
|
7336
|
+
* secret. 0.1.7's settings forms are projected from the schema's
|
|
7337
|
+
* `meta.volatile` nodes, so an unmarked field would be invisible to AND
|
|
7338
|
+
* unwritable from the settings page. The loader also hands `apply()` a live
|
|
7339
|
+
* reference per marked field, so writes commit in place without remounting
|
|
7340
|
+
* this fiber — the `loader/volatile-update` listener at the bottom of `apply`
|
|
7341
|
+
* re-derives the two facts that are not re-read per request.
|
|
8225
7342
|
*/
|
|
8226
7343
|
const Config = z.object(markVolatileFields({
|
|
8227
7344
|
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
@@ -8234,14 +7351,6 @@ const Config = z.object(markVolatileFields({
|
|
|
8234
7351
|
transportMaxRetries: z.number().min(0).max(50),
|
|
8235
7352
|
filterModelsByPlan: z.boolean(),
|
|
8236
7353
|
visibleModels: z.array(z.string()),
|
|
8237
|
-
/**
|
|
8238
|
-
* Per-model visibility overrides for the terminal settings page's checkbox
|
|
8239
|
-
* list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
|
|
8240
|
-
* PATH, so two checkboxes sharing one path would overwrite each other's
|
|
8241
|
-
* draft and the section's last model would decide every write; a map gives
|
|
8242
|
-
* each checkbox a path of its own. An id listed here wins over
|
|
8243
|
-
* {@link Config.visibleModels}; ids absent here keep following it.
|
|
8244
|
-
*/
|
|
8245
7354
|
modelVisibility: z.dict(z.boolean()),
|
|
8246
7355
|
webSearch: z.boolean().default(true),
|
|
8247
7356
|
showSidebarQuota: z.boolean().default(false),
|
|
@@ -8256,8 +7365,6 @@ const Config = z.object(markVolatileFields({
|
|
|
8256
7365
|
models: z.array(z.string()),
|
|
8257
7366
|
account: z.string()
|
|
8258
7367
|
})),
|
|
8259
|
-
commandGuard: z.boolean().default(false),
|
|
8260
|
-
commandGuardLevel: z.union([...COMMAND_GUARD_LEVELS]).default(COMMAND_GUARD_DEFAULT_LEVEL),
|
|
8261
7368
|
zdr: z.boolean().default(false),
|
|
8262
7369
|
lang: z.string().pattern(/^(zh|en)$/).default("zh")
|
|
8263
7370
|
}, ["apiKey"]));
|
|
@@ -8265,7 +7372,7 @@ const Config = z.object(markVolatileFields({
|
|
|
8265
7372
|
* The one explicit resolve step from raw config to validated connection
|
|
8266
7373
|
* facts. Programmatic construction may bypass Schemastery normalization, so
|
|
8267
7374
|
* every default is re-judged here — for the composition entry at load and for
|
|
8268
|
-
*
|
|
7375
|
+
* every settings-backed read.
|
|
8269
7376
|
*/
|
|
8270
7377
|
function resolveAdapterOptions(config) {
|
|
8271
7378
|
return {
|
|
@@ -8282,11 +7389,10 @@ function resolveAdapterOptions(config) {
|
|
|
8282
7389
|
};
|
|
8283
7390
|
}
|
|
8284
7391
|
/**
|
|
8285
|
-
* Per-model visibility overrides, cleaned for the adapter
|
|
8286
|
-
*
|
|
8287
|
-
*
|
|
8288
|
-
*
|
|
8289
|
-
* @returns A frozen id → boolean map, or undefined when nothing is set.
|
|
7392
|
+
* Per-model visibility overrides, cleaned for the adapter: a non-object or a
|
|
7393
|
+
* non-boolean entry is dropped rather than reaching the picker filter, and an
|
|
7394
|
+
* all-empty map reads as no map. Returns a fresh id → boolean map, or
|
|
7395
|
+
* undefined when nothing is set.
|
|
8290
7396
|
*/
|
|
8291
7397
|
function readModelVisibility(raw) {
|
|
8292
7398
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
@@ -8484,26 +7590,6 @@ function apply(ctx, config) {
|
|
|
8484
7590
|
applyCommandCodeSearchSelection(webCtx.web, searchSelection, false);
|
|
8485
7591
|
}, "dsh-commandcode-provider: web search selection");
|
|
8486
7592
|
});
|
|
8487
|
-
const commandGuardSettings = () => {
|
|
8488
|
-
const raw = current();
|
|
8489
|
-
return {
|
|
8490
|
-
enabled: raw.commandGuard === true,
|
|
8491
|
-
threshold: commandGuardThreshold(raw.commandGuardLevel),
|
|
8492
|
-
timeoutMs: COMMAND_GUARD_TIMEOUT_MS
|
|
8493
|
-
};
|
|
8494
|
-
};
|
|
8495
|
-
ctx.inject(["approval"], (approvalCtx) => {
|
|
8496
|
-
applyCommandGuard(approvalCtx, {
|
|
8497
|
-
settings: commandGuardSettings,
|
|
8498
|
-
decide: (request, decideOptions) => runSystemOne({
|
|
8499
|
-
apiBase: () => options().apiBase,
|
|
8500
|
-
resolveApiKey: () => resolveApiKey(options())
|
|
8501
|
-
}, request, decideOptions),
|
|
8502
|
-
log: (message) => {
|
|
8503
|
-
ctx.logger.info(`llm-commandcode: ${message}`);
|
|
8504
|
-
}
|
|
8505
|
-
});
|
|
8506
|
-
});
|
|
8507
7593
|
let refreshTuiSettings;
|
|
8508
7594
|
ctx.inject(["tuiSettingsSections"], (tuiCtx) => {
|
|
8509
7595
|
refreshTuiSettings = applyCommandCodeTuiSettings(tuiCtx, {
|