@mars-sea/dsh-commandcode-provider 0.6.0 → 0.6.2
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 +29 -0
- package/README.md +59 -135
- package/README.zh-CN.md +58 -136
- package/lib/client.js +278 -31
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +35 -12
- package/lib/index.js +313 -207
- package/lib/index.js.map +1 -1
- package/package.json +19 -16
package/lib/index.js
CHANGED
|
@@ -10,6 +10,201 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
10
10
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
13
|
+
//#region src/accounts.ts
|
|
14
|
+
/**
|
|
15
|
+
* Multi-account pool for the Command Code provider (host side).
|
|
16
|
+
*
|
|
17
|
+
* One Command Code subscription (e.g. the Go plan's 5-hour window) is
|
|
18
|
+
* metered; a user with several subscriptions wants a request that hits one
|
|
19
|
+
* account's limit to continue on the next account without a visible failure.
|
|
20
|
+
* This module owns that rotation:
|
|
21
|
+
*
|
|
22
|
+
* - {@link CommandCodeAccountPool.resolveKey} hands out the first account
|
|
23
|
+
* whose key is not currently marked exhausted, resolving each slot's key
|
|
24
|
+
* lazily (literal config key → credential seam → launch environment → the
|
|
25
|
+
* official CLI auth file for the default slot only).
|
|
26
|
+
* - {@link CommandCodeAccountPool.markRejected} records a 429 (rate limit,
|
|
27
|
+
* window unknown) or 401 (invalid key, disabled until the config changes)
|
|
28
|
+
* against the exact API key, so several slots sharing one key share one
|
|
29
|
+
* state.
|
|
30
|
+
* - When every account is marked, the pool probes each key's
|
|
31
|
+
* `/alpha/billing/credits` window limits (through the injected
|
|
32
|
+
* {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
|
|
33
|
+
* no longer reports `exceeded` is revived, otherwise the pool throws a
|
|
34
|
+
* `RATE_LIMIT` error naming the earliest reset time.
|
|
35
|
+
*
|
|
36
|
+
* The pool is deliberately cordis-free (like the adapter): every host fact
|
|
37
|
+
* arrives through injected thunks, so node tests can drive it directly.
|
|
38
|
+
*
|
|
39
|
+
* @module dsh-commandcode-provider/accounts
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Upper bound on the retry wait this pool attaches to the all-exhausted
|
|
43
|
+
* `RATE_LIMIT` error. Must equal the `backoff.maxDelayMs` in the adapter's
|
|
44
|
+
* `providerRetryPolicy` (which imports it from here): dsh-llm-retry honors a
|
|
45
|
+
* provider-specified wait verbatim only at or below that cap — in normal mode
|
|
46
|
+
* a LONGER attached wait makes the executor abandon the retry entirely
|
|
47
|
+
* instead of falling back to local backoff, which would turn "poll until the
|
|
48
|
+
* window opens" into "fail now".
|
|
49
|
+
*/
|
|
50
|
+
const RETRY_MAX_DELAY_MS = 9e5;
|
|
51
|
+
/** A labeled, human-readable clock reading for error messages. */
|
|
52
|
+
function clockLabel(ms) {
|
|
53
|
+
return new Date(ms).toLocaleString();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether an account with this rotation state can serve a request right now.
|
|
57
|
+
* `undefined` (never rejected) is usable; a cooldown becomes usable again
|
|
58
|
+
* once its reset time passes; `unknown` (429, reset unprobed) and
|
|
59
|
+
* `disabled` (401) are not.
|
|
60
|
+
*/
|
|
61
|
+
function accountUsable(state) {
|
|
62
|
+
if (state === void 0) return true;
|
|
63
|
+
if (state.kind === "cooldown") return state.until > 0 && Date.now() >= state.until;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Pick the account that should serve now: the manually preferred slot when it
|
|
68
|
+
* is usable, otherwise the first usable account in rotation order; undefined
|
|
69
|
+
* when no account is usable. Shared by the pool (request path) and the plugin
|
|
70
|
+
* entry (the usage view's active badge) so both always agree.
|
|
71
|
+
*/
|
|
72
|
+
function selectActiveAccount(accounts, preferredId) {
|
|
73
|
+
const usable = accounts.filter((account) => accountUsable(account.state));
|
|
74
|
+
if (preferredId !== void 0) {
|
|
75
|
+
const preferred = usable.find((account) => account.slot.id === preferredId);
|
|
76
|
+
if (preferred !== void 0) return preferred;
|
|
77
|
+
}
|
|
78
|
+
return usable[0];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The account pool. Rotation state is keyed by API key (never logged), so two
|
|
82
|
+
* slots resolving to the same credential share one mark, and a key changed in
|
|
83
|
+
* the credentials service starts with a clean slate.
|
|
84
|
+
*/
|
|
85
|
+
var CommandCodeAccountPool = class {
|
|
86
|
+
deps;
|
|
87
|
+
/** Rotation state by API key. */
|
|
88
|
+
states = /* @__PURE__ */ new Map();
|
|
89
|
+
constructor(deps) {
|
|
90
|
+
this.deps = deps;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve every slot's key, deduplicated by key (first slot wins). Slots
|
|
94
|
+
* without any resolvable key are omitted — they still appear in the
|
|
95
|
+
* settings page as unconfigured, they just cannot serve requests.
|
|
96
|
+
*/
|
|
97
|
+
async resolvedAccounts() {
|
|
98
|
+
const out = [];
|
|
99
|
+
const seen = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const slot of this.deps.slots()) {
|
|
101
|
+
const key = await this.resolveSlotKey(slot);
|
|
102
|
+
if (key === void 0 || seen.has(key)) continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
out.push({
|
|
105
|
+
slot,
|
|
106
|
+
key,
|
|
107
|
+
state: this.states.get(key)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Every slot paired with its resolved key and rotation state — NOT
|
|
114
|
+
* deduplicated: two slots sharing one credential both appear (the usage
|
|
115
|
+
* view reports them individually), while slots without any resolvable key
|
|
116
|
+
* are omitted. The serving path uses {@link resolvedAccounts} instead.
|
|
117
|
+
*/
|
|
118
|
+
async describeAccounts() {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const slot of this.deps.slots()) {
|
|
121
|
+
const key = await this.resolveSlotKey(slot);
|
|
122
|
+
if (key === void 0) continue;
|
|
123
|
+
out.push({
|
|
124
|
+
slot,
|
|
125
|
+
key,
|
|
126
|
+
state: this.states.get(key)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Hand out the first usable account's key (the manually preferred account
|
|
133
|
+
* when usable, else rotation order). Returns `undefined` when no account
|
|
134
|
+
* resolves any key at all (the caller then reports the missing credential).
|
|
135
|
+
* Throws `RATE_LIMIT` — naming the earliest window reset — or
|
|
136
|
+
* `INVALID_CREDENTIAL` when accounts exist but none can serve.
|
|
137
|
+
*
|
|
138
|
+
* `options.exclude` skips one key during the probe-revival pass: the
|
|
139
|
+
* rotation hook excludes the just-rejected key so a probe that clears its
|
|
140
|
+
* window cannot re-offer the same key within the same request (the adapter
|
|
141
|
+
* refuses already-tried keys; the next request picks the revived key up).
|
|
142
|
+
*/
|
|
143
|
+
async resolveKey(options) {
|
|
144
|
+
const accounts = await this.resolvedAccounts();
|
|
145
|
+
if (accounts.length === 0) return;
|
|
146
|
+
const chosen = selectActiveAccount(accounts, this.deps.preferredId?.());
|
|
147
|
+
if (chosen !== void 0) return this.pick(chosen);
|
|
148
|
+
await Promise.all(accounts.map(async (account) => {
|
|
149
|
+
if (account.state?.kind === "disabled") return;
|
|
150
|
+
if (options?.exclude !== void 0 && account.key === options.exclude) return;
|
|
151
|
+
const probe = await this.deps.probeWindow(account.key);
|
|
152
|
+
if (probe === void 0) return;
|
|
153
|
+
if (!probe.exceeded) this.states.delete(account.key);
|
|
154
|
+
else this.states.set(account.key, {
|
|
155
|
+
kind: "cooldown",
|
|
156
|
+
reason: account.state?.reason ?? "rate limited (429)",
|
|
157
|
+
until: probe.resetAt
|
|
158
|
+
});
|
|
159
|
+
}));
|
|
160
|
+
const revived = selectActiveAccount(await this.resolvedAccounts(), this.deps.preferredId?.());
|
|
161
|
+
if (revived !== void 0) return this.pick(revived);
|
|
162
|
+
const latest = await this.resolvedAccounts();
|
|
163
|
+
if (latest.filter((account) => account.state?.kind === "disabled").length === latest.length) throw new LlmError(`llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file;已配置的 ${latest.length} 个 Command Code 账户密钥均被拒绝(401)——请在设置页检查存储的 API 密钥,或重新运行 command-code login`, "INVALID_CREDENTIAL");
|
|
164
|
+
const resets = latest.map((account) => account.state).filter((state) => state !== void 0 && state.kind === "cooldown" && state.until > 0).map((state) => state.until);
|
|
165
|
+
const earliest = resets.length > 0 ? Math.min(...resets) : 0;
|
|
166
|
+
const wait = earliest > 0 ? Math.max(1e3, earliest - Date.now()) : 0;
|
|
167
|
+
throw new LlmError(`llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : "") + ` — requests will succeed again after the reset (or add another account);已用尽全部 ${latest.length} 个 Command Code 账户的用量窗口` + (earliest > 0 ? `,最早的重置时间为 ${clockLabel(earliest)}` : "") + "——窗口重置后请求会自动恢复(也可以添加更多账户)", "RATE_LIMIT", wait > 0 && wait <= 9e5 ? { providerRetryAfterMs: wait } : void 0);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Record a rejection against one key. `rate-limit` (429) marks the key
|
|
171
|
+
* exhausted with an unknown reset (probed lazily at the next resolution
|
|
172
|
+
* once every account is marked); `invalid-credential` (401) disables the
|
|
173
|
+
* key until the stored credential changes.
|
|
174
|
+
*/
|
|
175
|
+
markRejected(apiKey, rejection) {
|
|
176
|
+
if (rejection === "invalid-credential") this.states.set(apiKey, {
|
|
177
|
+
kind: "disabled",
|
|
178
|
+
reason: "invalid API key (401)",
|
|
179
|
+
until: 0
|
|
180
|
+
});
|
|
181
|
+
else this.states.set(apiKey, {
|
|
182
|
+
kind: "unknown",
|
|
183
|
+
reason: "rate limited (429)",
|
|
184
|
+
until: 0
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/** One account's key: literal → credential seam → auth file (default slot). */
|
|
188
|
+
async resolveSlotKey(slot) {
|
|
189
|
+
if (slot.literal !== void 0 && slot.literal !== "") return slot.literal;
|
|
190
|
+
if (slot.ref !== void 0) {
|
|
191
|
+
const hit = await this.deps.resolveRef(slot.ref);
|
|
192
|
+
if (hit !== void 0 && hit !== "") return hit;
|
|
193
|
+
}
|
|
194
|
+
if (slot.allowAuthFile) {
|
|
195
|
+
const fromFile = this.deps.authFileKey();
|
|
196
|
+
if (fromFile !== void 0 && fromFile !== "") return fromFile;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Hand out the chosen account's key. */
|
|
200
|
+
pick(account) {
|
|
201
|
+
return {
|
|
202
|
+
key: account.key,
|
|
203
|
+
slot: account.slot
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
//#endregion
|
|
13
208
|
//#region src/adapter.ts
|
|
14
209
|
/**
|
|
15
210
|
* DeepSeek Harness LLM adapter for the Command Code Provider API.
|
|
@@ -18,7 +213,9 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
|
18
213
|
* community-maintained integration; you need your own Command Code account
|
|
19
214
|
* and API key or subscription, and Command Code's terms apply.
|
|
20
215
|
*
|
|
21
|
-
* Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4
|
|
216
|
+
* Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4;
|
|
217
|
+
* re-verified against command-code@1.31.0 — endpoints, request shape, and
|
|
218
|
+
* stream events unchanged):
|
|
22
219
|
* POST {apiBase}/alpha/generate
|
|
23
220
|
* body: { config, memory, taste, skills, params: { model, messages, tools,
|
|
24
221
|
* system, max_tokens, temperature, stream, reasoning_effort? }, threadId }
|
|
@@ -223,6 +420,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
|
223
420
|
"moonshotai/Kimi-K2.7-Code-Highspeed",
|
|
224
421
|
"moonshotai/Kimi-K3",
|
|
225
422
|
"sakana/fugu-ultra",
|
|
423
|
+
"stealth/ox-alpha",
|
|
226
424
|
"stepfun/Step-3.7-Flash",
|
|
227
425
|
"thinkingmachines/inkling",
|
|
228
426
|
"thinkingmachines/inkling-small",
|
|
@@ -230,7 +428,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
|
230
428
|
"xiaomi/mimo-v2.5"
|
|
231
429
|
]);
|
|
232
430
|
/**
|
|
233
|
-
* Models the official CLI's model table (`ZA` in command-code@1.
|
|
431
|
+
* Models the official CLI's model table (`ZA` in command-code@1.31.0) marks
|
|
234
432
|
* `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
|
|
235
433
|
* think automatically, with Command Code driving the depth. This is the
|
|
236
434
|
* authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
|
|
@@ -238,7 +436,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
|
238
436
|
* effort levels, and this snapshot is not surfaced in the picker's compact
|
|
239
437
|
* description — it exists for programmatic consumers.
|
|
240
438
|
*
|
|
241
|
-
* Source: the command-code@1.
|
|
439
|
+
* Source: the command-code@1.31.0 bundled model table (dist/cli.mjs, the `ZA`
|
|
242
440
|
* object), cross-checked with https://commandcode.ai/docs/reference/cli/models.
|
|
243
441
|
* Keep in sync via the dsh-commandcode-upstream skill.
|
|
244
442
|
*/
|
|
@@ -261,7 +459,8 @@ const KNOWN_THINKING_MODELS = /* @__PURE__ */ new Set([
|
|
|
261
459
|
"poolside/laguna-s-2.1-free",
|
|
262
460
|
"meta/muse-spark-1.1",
|
|
263
461
|
"meta/muse-spark-1.2",
|
|
264
|
-
"meta/muse-spark-1.2-contributor"
|
|
462
|
+
"meta/muse-spark-1.2-contributor",
|
|
463
|
+
"stealth/ox-alpha"
|
|
265
464
|
]);
|
|
266
465
|
/**
|
|
267
466
|
* The minimum subscription plan a model is included in, per the official plan
|
|
@@ -300,6 +499,7 @@ const KNOWN_PLANS = {
|
|
|
300
499
|
"moonshotai/Kimi-K3": "go",
|
|
301
500
|
"nvidia/nemotron-3-ultra-550b-a55b": "go",
|
|
302
501
|
"poolside/laguna-s-2.1-free": "go",
|
|
502
|
+
"stealth/ox-alpha": "go",
|
|
303
503
|
"stepfun/Step-3.5-Flash": "go",
|
|
304
504
|
"stepfun/Step-3.7-Flash": "go",
|
|
305
505
|
"tencent/hy3-paid": "go",
|
|
@@ -356,10 +556,22 @@ const PLAN_ORDER = {
|
|
|
356
556
|
max: 4
|
|
357
557
|
};
|
|
358
558
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
559
|
+
* Whether a model is free (requests cost no credits), per the pricing page's
|
|
560
|
+
* deals (`KNOWN_DEALS` `free: true`). Free models lead the picker regardless
|
|
561
|
+
* of tier — they are usable by every account, so they are the best default
|
|
562
|
+
* candidates.
|
|
563
|
+
*/
|
|
564
|
+
function isFreeModel(modelId) {
|
|
565
|
+
return KNOWN_DEALS[modelId]?.free === true;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Comparator for the model picker: free models first (zero credit cost, usable
|
|
569
|
+
* by every account), then by plan tier (lowest first), then by model name,
|
|
570
|
+
* then by id as a tiebreak. Models with no known plan sort last.
|
|
361
571
|
*/
|
|
362
572
|
function compareByPlan(a, b) {
|
|
573
|
+
const freeDelta = Number(isFreeModel(b.id)) - Number(isFreeModel(a.id));
|
|
574
|
+
if (freeDelta !== 0) return freeDelta;
|
|
363
575
|
const pa = PLAN_ORDER[KNOWN_PLANS[a.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
|
|
364
576
|
const pb = PLAN_ORDER[KNOWN_PLANS[b.id] ?? ""] ?? Number.MAX_SAFE_INTEGER;
|
|
365
577
|
if (pa !== pb) return pa - pb;
|
|
@@ -369,7 +581,7 @@ function compareByPlan(a, b) {
|
|
|
369
581
|
}
|
|
370
582
|
/**
|
|
371
583
|
* Subscription plan table, synced from the official CLI bundle's plan maps
|
|
372
|
-
* (`Nn`/`$n` in command-code@1.
|
|
584
|
+
* (`Nn`/`$n` in command-code@1.31.0 `dist/cli.mjs`): subscription `planId`
|
|
373
585
|
* prefix → display name and the plan's monthly credit total. This is the
|
|
374
586
|
* account's own subscription (from `/alpha/billing/subscriptions`) — distinct
|
|
375
587
|
* from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
|
|
@@ -461,6 +673,10 @@ const KNOWN_DEALS = {
|
|
|
461
673
|
"poolside/laguna-s-2.1-free": {
|
|
462
674
|
label: "FREE",
|
|
463
675
|
free: true
|
|
676
|
+
},
|
|
677
|
+
"stealth/ox-alpha": {
|
|
678
|
+
label: "FREE",
|
|
679
|
+
free: true
|
|
464
680
|
}
|
|
465
681
|
};
|
|
466
682
|
/**
|
|
@@ -499,7 +715,7 @@ function peakPricingLabel(modelId, now = Date.now()) {
|
|
|
499
715
|
if (state === void 0) return void 0;
|
|
500
716
|
return state === "peak" ? "Peak" : "Half";
|
|
501
717
|
}
|
|
502
|
-
const COMMAND_CODE_CLI_VERSION = "1.
|
|
718
|
+
const COMMAND_CODE_CLI_VERSION = "1.31.0";
|
|
503
719
|
const DEFAULT_API_BASE = "https://api.commandcode.ai";
|
|
504
720
|
const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
|
|
505
721
|
const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
|
|
@@ -805,6 +1021,8 @@ async function messagesToCC(messages, readImage) {
|
|
|
805
1021
|
}
|
|
806
1022
|
return out;
|
|
807
1023
|
}
|
|
1024
|
+
/** Account endpoints fetched by one `getUsage()` run (see the classification there). */
|
|
1025
|
+
const USAGE_ENDPOINT_COUNT = 4;
|
|
808
1026
|
var CommandCodeAdapter = class extends LlmAdapter {
|
|
809
1027
|
deps;
|
|
810
1028
|
catalog = [];
|
|
@@ -819,15 +1037,39 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
819
1037
|
this.resolveAttachments = deps.resolveAttachments;
|
|
820
1038
|
}
|
|
821
1039
|
/**
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
1040
|
+
* Near-unbounded retry for transient failures only (`mode: 'normal'` with
|
|
1041
|
+
* an explicit 1000-attempt cap — opencode-style persistence without the
|
|
1042
|
+
* unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
|
|
1043
|
+
* `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
|
|
1044
|
+
* and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
|
|
1045
|
+
* recovers in-session instead of failing after two tries. Permanent
|
|
1046
|
+
* failures (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`,
|
|
1047
|
+
* plan rejections) are absent from the whitelist and surface immediately
|
|
1048
|
+
* instead of looping. Waits the pool/adapter attach as
|
|
1049
|
+
* `providerRetryAfterMs` are honored verbatim at or below the 15-minute
|
|
1050
|
+
* cap and never attached above it (in normal mode a longer attached wait
|
|
1051
|
+
* makes the executor abandon the retry outright — see RETRY_MAX_DELAY_MS).
|
|
1052
|
+
*
|
|
1053
|
+
* Captured once at route registration (dsh-llm snapshots this value), so a
|
|
1054
|
+
* future config knob for it would apply on profile restart, not per request.
|
|
828
1055
|
*/
|
|
829
1056
|
providerRetryPolicy(_provider) {
|
|
830
|
-
return resolveRetryPolicy(
|
|
1057
|
+
return resolveRetryPolicy({
|
|
1058
|
+
mode: "normal",
|
|
1059
|
+
maxRetries: 1e3,
|
|
1060
|
+
retryableCodes: [
|
|
1061
|
+
"EMPTY_RESPONSE",
|
|
1062
|
+
"RATE_LIMIT",
|
|
1063
|
+
"SERVER",
|
|
1064
|
+
"TIMEOUT",
|
|
1065
|
+
"TRANSPORT"
|
|
1066
|
+
],
|
|
1067
|
+
backoff: {
|
|
1068
|
+
initialDelayMs: 500,
|
|
1069
|
+
maxDelayMs: RETRY_MAX_DELAY_MS,
|
|
1070
|
+
jitterRatio: .1
|
|
1071
|
+
}
|
|
1072
|
+
}, "llm-commandcode: retryPolicy");
|
|
831
1073
|
}
|
|
832
1074
|
/** Refresh the catalog (live fetch, cache fallback) and return it. */
|
|
833
1075
|
async loadCatalog(signal) {
|
|
@@ -978,6 +1220,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
978
1220
|
const base = this.deps.options().apiBase;
|
|
979
1221
|
const headers = await this.accountHeaders(apiKey);
|
|
980
1222
|
const failures = [];
|
|
1223
|
+
const failedStatuses = [];
|
|
981
1224
|
const getJson = async (path) => {
|
|
982
1225
|
try {
|
|
983
1226
|
const response = await this.fetchImpl(`${base}${path}`, {
|
|
@@ -986,12 +1229,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
986
1229
|
});
|
|
987
1230
|
if (!response.ok) {
|
|
988
1231
|
failures.push(`${path}: HTTP ${response.status}`);
|
|
1232
|
+
failedStatuses.push(response.status);
|
|
989
1233
|
return;
|
|
990
1234
|
}
|
|
991
1235
|
const parsed = await response.json();
|
|
992
1236
|
return isRecord(parsed) ? parsed : void 0;
|
|
993
1237
|
} catch (error) {
|
|
994
1238
|
failures.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1239
|
+
failedStatuses.push(void 0);
|
|
995
1240
|
return;
|
|
996
1241
|
}
|
|
997
1242
|
};
|
|
@@ -1052,6 +1297,12 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
1052
1297
|
currentPeriodEnd: periodEndValue(subData?.currentPeriodEnd)
|
|
1053
1298
|
};
|
|
1054
1299
|
}
|
|
1300
|
+
if (failures.length === USAGE_ENDPOINT_COUNT) {
|
|
1301
|
+
const codes = failedStatuses.filter((status) => status !== void 0);
|
|
1302
|
+
if (codes.length === USAGE_ENDPOINT_COUNT && codes.every((code) => code === 401)) report.blocked = "invalid-key";
|
|
1303
|
+
else if (codes.length === USAGE_ENDPOINT_COUNT && codes.every((code) => code >= 500)) report.blocked = "service-unavailable";
|
|
1304
|
+
else if (codes.length === 0) report.blocked = "network";
|
|
1305
|
+
}
|
|
1055
1306
|
return report;
|
|
1056
1307
|
}
|
|
1057
1308
|
/**
|
|
@@ -1178,9 +1429,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
1178
1429
|
if (!response.ok) {
|
|
1179
1430
|
const errText = await response.text().catch(() => "");
|
|
1180
1431
|
cleanup();
|
|
1181
|
-
|
|
1432
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
1433
|
+
return retryAfterMs === void 0 ? {
|
|
1182
1434
|
status: response.status,
|
|
1183
1435
|
errText
|
|
1436
|
+
} : {
|
|
1437
|
+
status: response.status,
|
|
1438
|
+
errText,
|
|
1439
|
+
retryAfterMs
|
|
1184
1440
|
};
|
|
1185
1441
|
}
|
|
1186
1442
|
return {
|
|
@@ -1205,7 +1461,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
1205
1461
|
continue;
|
|
1206
1462
|
}
|
|
1207
1463
|
}
|
|
1208
|
-
throw generateHttpError(attempt.status, attempt.errText);
|
|
1464
|
+
throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs);
|
|
1209
1465
|
}
|
|
1210
1466
|
const { response, cleanup } = connected;
|
|
1211
1467
|
if (!response.body) {
|
|
@@ -1432,17 +1688,44 @@ const MAX_ACCOUNT_ROTATIONS = 16;
|
|
|
1432
1688
|
* Map a pre-stream generate HTTP failure onto a stable LlmError. Command
|
|
1433
1689
|
* Code folds several business rejections into 403 (plan limits, CLI version,
|
|
1434
1690
|
* model access): prefer the machine-readable `error.code` when present; the
|
|
1435
|
-
* status alone cannot distinguish them.
|
|
1691
|
+
* status alone cannot distinguish them. A 429's `Retry-After` rides along as
|
|
1692
|
+
* `providerRetryAfterMs` so dsh-llm-retry can wait exactly that long instead
|
|
1693
|
+
* of guessing at the backoff cadence — capped at RETRY_MAX_DELAY_MS, because
|
|
1694
|
+
* in normal mode a longer attached wait makes the executor abandon the retry
|
|
1695
|
+
* outright instead of falling back to local backoff.
|
|
1436
1696
|
*/
|
|
1437
|
-
function generateHttpError(status, errText) {
|
|
1697
|
+
function generateHttpError(status, errText, retryAfterMs) {
|
|
1438
1698
|
let providerCode;
|
|
1439
1699
|
try {
|
|
1440
1700
|
const parsed = JSON.parse(errText);
|
|
1441
1701
|
if (isRecord(parsed) && isRecord(parsed.error)) providerCode = stringValue(parsed.error.code);
|
|
1442
1702
|
} catch {}
|
|
1443
1703
|
const detail = providerCode ?? `HTTP ${status}`;
|
|
1444
|
-
if (status === 401) return new LlmError(`Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file
|
|
1445
|
-
return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
|
|
1704
|
+
if (status === 401) return new LlmError(`Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file;Command Code API 返回 401:API 密钥缺失或无效——请在设置页检查 COMMANDCODE_API_KEY 存储的密钥,或检查 auth 文件`, "INVALID_CREDENTIAL", { status: 401 });
|
|
1705
|
+
return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
|
|
1706
|
+
status,
|
|
1707
|
+
...retryAfterMs !== void 0 && retryAfterMs > 0 && retryAfterMs <= 9e5 ? { providerRetryAfterMs: retryAfterMs } : {}
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Parse an HTTP `Retry-After` value (delay-seconds or an HTTP-date) into
|
|
1712
|
+
* milliseconds; undefined when absent or unparseable. An HTTP-date in the
|
|
1713
|
+
* past yields 0, which the caller drops (LlmError wants a positive delay).
|
|
1714
|
+
* A delay-seconds value whose millisecond product is not finite (e.g. `1e308`)
|
|
1715
|
+
* also yields undefined: LlmError validates its options and would otherwise
|
|
1716
|
+
* replace the provider failure with an internal construction error.
|
|
1717
|
+
*/
|
|
1718
|
+
function parseRetryAfterMs(value, now = Date.now()) {
|
|
1719
|
+
if (value === void 0 || value === null) return void 0;
|
|
1720
|
+
const trimmed = value.trim();
|
|
1721
|
+
if (trimmed === "") return void 0;
|
|
1722
|
+
const seconds = Number(trimmed);
|
|
1723
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1724
|
+
const ms = seconds * 1e3;
|
|
1725
|
+
return Number.isFinite(ms) ? Math.round(ms) : void 0;
|
|
1726
|
+
}
|
|
1727
|
+
const date = Date.parse(trimmed);
|
|
1728
|
+
if (!Number.isNaN(date)) return Math.max(0, date - now);
|
|
1446
1729
|
}
|
|
1447
1730
|
function mapFinishReason(reason) {
|
|
1448
1731
|
if (reason === "tool-calls") return { kind: "tool-calls" };
|
|
@@ -1450,190 +1733,6 @@ function mapFinishReason(reason) {
|
|
|
1450
1733
|
return { kind: "stop" };
|
|
1451
1734
|
}
|
|
1452
1735
|
//#endregion
|
|
1453
|
-
//#region src/accounts.ts
|
|
1454
|
-
/**
|
|
1455
|
-
* Multi-account pool for the Command Code provider (host side).
|
|
1456
|
-
*
|
|
1457
|
-
* One Command Code subscription (e.g. the Go plan's 5-hour window) is
|
|
1458
|
-
* metered; a user with several subscriptions wants a request that hits one
|
|
1459
|
-
* account's limit to continue on the next account without a visible failure.
|
|
1460
|
-
* This module owns that rotation:
|
|
1461
|
-
*
|
|
1462
|
-
* - {@link CommandCodeAccountPool.resolveKey} hands out the first account
|
|
1463
|
-
* whose key is not currently marked exhausted, resolving each slot's key
|
|
1464
|
-
* lazily (literal config key → credential seam → launch environment → the
|
|
1465
|
-
* official CLI auth file for the default slot only).
|
|
1466
|
-
* - {@link CommandCodeAccountPool.markRejected} records a 429 (rate limit,
|
|
1467
|
-
* window unknown) or 401 (invalid key, disabled until the config changes)
|
|
1468
|
-
* against the exact API key, so several slots sharing one key share one
|
|
1469
|
-
* state.
|
|
1470
|
-
* - When every account is marked, the pool probes each key's
|
|
1471
|
-
* `/alpha/billing/credits` window limits (through the injected
|
|
1472
|
-
* {@link CommandCodeAccountPoolDeps.probeWindow}): an account whose window
|
|
1473
|
-
* no longer reports `exceeded` is revived, otherwise the pool throws a
|
|
1474
|
-
* `RATE_LIMIT` error naming the earliest reset time.
|
|
1475
|
-
*
|
|
1476
|
-
* The pool is deliberately cordis-free (like the adapter): every host fact
|
|
1477
|
-
* arrives through injected thunks, so node tests can drive it directly.
|
|
1478
|
-
*
|
|
1479
|
-
* @module dsh-commandcode-provider/accounts
|
|
1480
|
-
*/
|
|
1481
|
-
/** A labeled, human-readable clock reading for error messages. */
|
|
1482
|
-
function clockLabel(ms) {
|
|
1483
|
-
return new Date(ms).toLocaleString();
|
|
1484
|
-
}
|
|
1485
|
-
/**
|
|
1486
|
-
* Whether an account with this rotation state can serve a request right now.
|
|
1487
|
-
* `undefined` (never rejected) is usable; a cooldown becomes usable again
|
|
1488
|
-
* once its reset time passes; `unknown` (429, reset unprobed) and
|
|
1489
|
-
* `disabled` (401) are not.
|
|
1490
|
-
*/
|
|
1491
|
-
function accountUsable(state) {
|
|
1492
|
-
if (state === void 0) return true;
|
|
1493
|
-
if (state.kind === "cooldown") return state.until > 0 && Date.now() >= state.until;
|
|
1494
|
-
return false;
|
|
1495
|
-
}
|
|
1496
|
-
/**
|
|
1497
|
-
* Pick the account that should serve now: the manually preferred slot when it
|
|
1498
|
-
* is usable, otherwise the first usable account in rotation order; undefined
|
|
1499
|
-
* when no account is usable. Shared by the pool (request path) and the plugin
|
|
1500
|
-
* entry (the usage view's active badge) so both always agree.
|
|
1501
|
-
*/
|
|
1502
|
-
function selectActiveAccount(accounts, preferredId) {
|
|
1503
|
-
const usable = accounts.filter((account) => accountUsable(account.state));
|
|
1504
|
-
if (preferredId !== void 0) {
|
|
1505
|
-
const preferred = usable.find((account) => account.slot.id === preferredId);
|
|
1506
|
-
if (preferred !== void 0) return preferred;
|
|
1507
|
-
}
|
|
1508
|
-
return usable[0];
|
|
1509
|
-
}
|
|
1510
|
-
/**
|
|
1511
|
-
* The account pool. Rotation state is keyed by API key (never logged), so two
|
|
1512
|
-
* slots resolving to the same credential share one mark, and a key changed in
|
|
1513
|
-
* the credentials service starts with a clean slate.
|
|
1514
|
-
*/
|
|
1515
|
-
var CommandCodeAccountPool = class {
|
|
1516
|
-
deps;
|
|
1517
|
-
/** Rotation state by API key. */
|
|
1518
|
-
states = /* @__PURE__ */ new Map();
|
|
1519
|
-
constructor(deps) {
|
|
1520
|
-
this.deps = deps;
|
|
1521
|
-
}
|
|
1522
|
-
/**
|
|
1523
|
-
* Resolve every slot's key, deduplicated by key (first slot wins). Slots
|
|
1524
|
-
* without any resolvable key are omitted — they still appear in the
|
|
1525
|
-
* settings page as unconfigured, they just cannot serve requests.
|
|
1526
|
-
*/
|
|
1527
|
-
async resolvedAccounts() {
|
|
1528
|
-
const out = [];
|
|
1529
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1530
|
-
for (const slot of this.deps.slots()) {
|
|
1531
|
-
const key = await this.resolveSlotKey(slot);
|
|
1532
|
-
if (key === void 0 || seen.has(key)) continue;
|
|
1533
|
-
seen.add(key);
|
|
1534
|
-
out.push({
|
|
1535
|
-
slot,
|
|
1536
|
-
key,
|
|
1537
|
-
state: this.states.get(key)
|
|
1538
|
-
});
|
|
1539
|
-
}
|
|
1540
|
-
return out;
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* Every slot paired with its resolved key and rotation state — NOT
|
|
1544
|
-
* deduplicated: two slots sharing one credential both appear (the usage
|
|
1545
|
-
* view reports them individually), while slots without any resolvable key
|
|
1546
|
-
* are omitted. The serving path uses {@link resolvedAccounts} instead.
|
|
1547
|
-
*/
|
|
1548
|
-
async describeAccounts() {
|
|
1549
|
-
const out = [];
|
|
1550
|
-
for (const slot of this.deps.slots()) {
|
|
1551
|
-
const key = await this.resolveSlotKey(slot);
|
|
1552
|
-
if (key === void 0) continue;
|
|
1553
|
-
out.push({
|
|
1554
|
-
slot,
|
|
1555
|
-
key,
|
|
1556
|
-
state: this.states.get(key)
|
|
1557
|
-
});
|
|
1558
|
-
}
|
|
1559
|
-
return out;
|
|
1560
|
-
}
|
|
1561
|
-
/**
|
|
1562
|
-
* Hand out the first usable account's key (the manually preferred account
|
|
1563
|
-
* when usable, else rotation order). Returns `undefined` when no account
|
|
1564
|
-
* resolves any key at all (the caller then reports the missing credential).
|
|
1565
|
-
* Throws `RATE_LIMIT` — naming the earliest window reset — or
|
|
1566
|
-
* `INVALID_CREDENTIAL` when accounts exist but none can serve.
|
|
1567
|
-
*
|
|
1568
|
-
* `options.exclude` skips one key during the probe-revival pass: the
|
|
1569
|
-
* rotation hook excludes the just-rejected key so a probe that clears its
|
|
1570
|
-
* window cannot re-offer the same key within the same request (the adapter
|
|
1571
|
-
* refuses already-tried keys; the next request picks the revived key up).
|
|
1572
|
-
*/
|
|
1573
|
-
async resolveKey(options) {
|
|
1574
|
-
const accounts = await this.resolvedAccounts();
|
|
1575
|
-
if (accounts.length === 0) return;
|
|
1576
|
-
const chosen = selectActiveAccount(accounts, this.deps.preferredId?.());
|
|
1577
|
-
if (chosen !== void 0) return this.pick(chosen);
|
|
1578
|
-
await Promise.all(accounts.map(async (account) => {
|
|
1579
|
-
if (account.state?.kind === "disabled") return;
|
|
1580
|
-
if (options?.exclude !== void 0 && account.key === options.exclude) return;
|
|
1581
|
-
const probe = await this.deps.probeWindow(account.key);
|
|
1582
|
-
if (probe === void 0) return;
|
|
1583
|
-
if (!probe.exceeded) this.states.delete(account.key);
|
|
1584
|
-
else this.states.set(account.key, {
|
|
1585
|
-
kind: "cooldown",
|
|
1586
|
-
reason: account.state?.reason ?? "rate limited (429)",
|
|
1587
|
-
until: probe.resetAt
|
|
1588
|
-
});
|
|
1589
|
-
}));
|
|
1590
|
-
const revived = selectActiveAccount(await this.resolvedAccounts(), this.deps.preferredId?.());
|
|
1591
|
-
if (revived !== void 0) return this.pick(revived);
|
|
1592
|
-
const latest = await this.resolvedAccounts();
|
|
1593
|
-
if (latest.filter((account) => account.state?.kind === "disabled").length === latest.length) throw new LlmError(`llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401 — check the stored API keys (Models page / settings) or the auth file`, "INVALID_CREDENTIAL");
|
|
1594
|
-
const resets = latest.map((account) => account.state).filter((state) => state !== void 0 && state.kind === "cooldown" && state.until > 0).map((state) => state.until);
|
|
1595
|
-
const earliest = resets.length > 0 ? Math.min(...resets) : 0;
|
|
1596
|
-
throw new LlmError(`llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : "") + " — requests will succeed again after the reset (or add another account)", "RATE_LIMIT");
|
|
1597
|
-
}
|
|
1598
|
-
/**
|
|
1599
|
-
* Record a rejection against one key. `rate-limit` (429) marks the key
|
|
1600
|
-
* exhausted with an unknown reset (probed lazily at the next resolution
|
|
1601
|
-
* once every account is marked); `invalid-credential` (401) disables the
|
|
1602
|
-
* key until the stored credential changes.
|
|
1603
|
-
*/
|
|
1604
|
-
markRejected(apiKey, rejection) {
|
|
1605
|
-
if (rejection === "invalid-credential") this.states.set(apiKey, {
|
|
1606
|
-
kind: "disabled",
|
|
1607
|
-
reason: "invalid API key (401)",
|
|
1608
|
-
until: 0
|
|
1609
|
-
});
|
|
1610
|
-
else this.states.set(apiKey, {
|
|
1611
|
-
kind: "unknown",
|
|
1612
|
-
reason: "rate limited (429)",
|
|
1613
|
-
until: 0
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
/** One account's key: literal → credential seam → auth file (default slot). */
|
|
1617
|
-
async resolveSlotKey(slot) {
|
|
1618
|
-
if (slot.literal !== void 0 && slot.literal !== "") return slot.literal;
|
|
1619
|
-
if (slot.ref !== void 0) {
|
|
1620
|
-
const hit = await this.deps.resolveRef(slot.ref);
|
|
1621
|
-
if (hit !== void 0 && hit !== "") return hit;
|
|
1622
|
-
}
|
|
1623
|
-
if (slot.allowAuthFile) {
|
|
1624
|
-
const fromFile = this.deps.authFileKey();
|
|
1625
|
-
if (fromFile !== void 0 && fromFile !== "") return fromFile;
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
/** Hand out the chosen account's key. */
|
|
1629
|
-
pick(account) {
|
|
1630
|
-
return {
|
|
1631
|
-
key: account.key,
|
|
1632
|
-
slot: account.slot
|
|
1633
|
-
};
|
|
1634
|
-
}
|
|
1635
|
-
};
|
|
1636
|
-
//#endregion
|
|
1637
1736
|
//#region src/commands.ts
|
|
1638
1737
|
/** Format a dollar amount. */
|
|
1639
1738
|
function money(value) {
|
|
@@ -1643,8 +1742,7 @@ function money(value) {
|
|
|
1643
1742
|
function moneyShort(value) {
|
|
1644
1743
|
return `$${value.toFixed(2)}`;
|
|
1645
1744
|
}
|
|
1646
|
-
/** Format a token count
|
|
1647
|
-
/** Format a large token count compactly (1.9亿 style). */
|
|
1745
|
+
/** Format a large token count compactly (1.9M style). */
|
|
1648
1746
|
function tokensCompact(value) {
|
|
1649
1747
|
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
|
|
1650
1748
|
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
@@ -1678,6 +1776,9 @@ function renderReport(report, title) {
|
|
|
1678
1776
|
const lines = [];
|
|
1679
1777
|
const account = report.account ? ` (${report.account.userName || report.account.name})` : "";
|
|
1680
1778
|
lines.push(title ?? `📊 Command Code 用量${account}`, "");
|
|
1779
|
+
if (report.blocked === "invalid-key") lines.push("⛔ API 密钥无效或已过期 — 服务端拒绝了全部请求(401),请检查该账户的密钥配置", "");
|
|
1780
|
+
else if (report.blocked === "service-unavailable") lines.push("⚠️ Command Code 服务暂时不可用(5xx),稍后重试", "");
|
|
1781
|
+
else if (report.blocked === "network") lines.push("⚠️ 无法连接 Command Code 服务 — 请检查网络或 API 地址", "");
|
|
1681
1782
|
if (report.plan && report.plan.name !== "") {
|
|
1682
1783
|
const p = report.plan;
|
|
1683
1784
|
const status = p.status !== "" && p.status !== "active" ? ` (${p.status})` : "";
|
|
@@ -1788,6 +1889,11 @@ function parseUsageReport(value) {
|
|
|
1788
1889
|
const failures = source.failures;
|
|
1789
1890
|
if (!Array.isArray(failures) || failures.some((entry) => typeof entry !== "string")) reject("failures");
|
|
1790
1891
|
const report = { failures };
|
|
1892
|
+
if (source.blocked !== void 0) {
|
|
1893
|
+
const blocked = source.blocked;
|
|
1894
|
+
if (blocked !== "invalid-key" && blocked !== "service-unavailable" && blocked !== "network") reject("blocked");
|
|
1895
|
+
report.blocked = blocked;
|
|
1896
|
+
}
|
|
1791
1897
|
if (source.account !== void 0) {
|
|
1792
1898
|
const account = record(source.account, "account");
|
|
1793
1899
|
report.account = {
|