@mars-sea/dsh-commandcode-provider 0.5.0 → 0.6.1
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 +21 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/lib/client.js +215 -34
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +19 -10
- package/lib/index.js +278 -202
- package/lib/index.js.map +1 -1
- package/package.json +19 -16
package/lib/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ declare const KNOWN_EFFORTS: Readonly<Record<string, readonly string[]>>;
|
|
|
24
24
|
*/
|
|
25
25
|
declare const KNOWN_IMAGE_MODELS: ReadonlySet<string>;
|
|
26
26
|
/**
|
|
27
|
-
* Models the official CLI's model table (`ZA` in command-code@1.
|
|
27
|
+
* Models the official CLI's model table (`ZA` in command-code@1.31.0) marks
|
|
28
28
|
* `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
|
|
29
29
|
* think automatically, with Command Code driving the depth. This is the
|
|
30
30
|
* authoritative "thinks, effort not adjustable" set: `KNOWN_EFFORTS` (which
|
|
@@ -32,7 +32,7 @@ declare const KNOWN_IMAGE_MODELS: ReadonlySet<string>;
|
|
|
32
32
|
* effort levels, and this snapshot is not surfaced in the picker's compact
|
|
33
33
|
* description — it exists for programmatic consumers.
|
|
34
34
|
*
|
|
35
|
-
* Source: the command-code@1.
|
|
35
|
+
* Source: the command-code@1.31.0 bundled model table (dist/cli.mjs, the `ZA`
|
|
36
36
|
* object), cross-checked with https://commandcode.ai/docs/reference/cli/models.
|
|
37
37
|
* Keep in sync via the dsh-commandcode-upstream skill.
|
|
38
38
|
*/
|
|
@@ -73,7 +73,7 @@ declare function compareByPlan(a: {
|
|
|
73
73
|
}): number;
|
|
74
74
|
/**
|
|
75
75
|
* Subscription plan table, synced from the official CLI bundle's plan maps
|
|
76
|
-
* (`Nn`/`$n` in command-code@1.
|
|
76
|
+
* (`Nn`/`$n` in command-code@1.31.0 `dist/cli.mjs`): subscription `planId`
|
|
77
77
|
* prefix → display name and the plan's monthly credit total. This is the
|
|
78
78
|
* account's own subscription (from `/alpha/billing/subscriptions`) — distinct
|
|
79
79
|
* from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
|
|
@@ -172,7 +172,7 @@ declare function peakPricingState(modelId: string, now?: number): 'peak' | 'off-
|
|
|
172
172
|
* for models without time-of-day pricing.
|
|
173
173
|
*/
|
|
174
174
|
declare function peakPricingLabel(modelId: string, now?: number): string | undefined;
|
|
175
|
-
declare const COMMAND_CODE_CLI_VERSION = "1.
|
|
175
|
+
declare const COMMAND_CODE_CLI_VERSION = "1.31.0";
|
|
176
176
|
declare const DEFAULT_API_BASE = "https://api.commandcode.ai";
|
|
177
177
|
declare const DEFAULT_GENERATE_MAX_TOKENS = 64000;
|
|
178
178
|
declare const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
|
|
@@ -331,12 +331,21 @@ declare class CommandCodeAdapter<C extends CommandCodeConnectionOptions = Comman
|
|
|
331
331
|
private readonly billingAccessInflight;
|
|
332
332
|
constructor(deps: CommandCodeAdapterDeps<C>);
|
|
333
333
|
/**
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
334
|
+
* Near-unbounded retry for transient failures only (`mode: 'normal'` with
|
|
335
|
+
* an explicit 1000-attempt cap — opencode-style persistence without the
|
|
336
|
+
* unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
|
|
337
|
+
* `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
|
|
338
|
+
* and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
|
|
339
|
+
* recovers in-session instead of failing after two tries. Permanent
|
|
340
|
+
* failures (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`,
|
|
341
|
+
* plan rejections) are absent from the whitelist and surface immediately
|
|
342
|
+
* instead of looping. Waits the pool/adapter attach as
|
|
343
|
+
* `providerRetryAfterMs` are honored verbatim at or below the 15-minute
|
|
344
|
+
* cap and never attached above it (in normal mode a longer attached wait
|
|
345
|
+
* makes the executor abandon the retry outright — see RETRY_MAX_DELAY_MS).
|
|
346
|
+
*
|
|
347
|
+
* Captured once at route registration (dsh-llm snapshots this value), so a
|
|
348
|
+
* future config knob for it would apply on profile restart, not per request.
|
|
340
349
|
*/
|
|
341
350
|
providerRetryPolicy(_provider: string): ResolvedRetryPolicy;
|
|
342
351
|
/** Refresh the catalog (live fetch, cache fallback) and return it. */
|
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`, "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)", "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.
|
|
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",
|
|
@@ -369,7 +569,7 @@ function compareByPlan(a, b) {
|
|
|
369
569
|
}
|
|
370
570
|
/**
|
|
371
571
|
* Subscription plan table, synced from the official CLI bundle's plan maps
|
|
372
|
-
* (`Nn`/`$n` in command-code@1.
|
|
572
|
+
* (`Nn`/`$n` in command-code@1.31.0 `dist/cli.mjs`): subscription `planId`
|
|
373
573
|
* prefix → display name and the plan's monthly credit total. This is the
|
|
374
574
|
* account's own subscription (from `/alpha/billing/subscriptions`) — distinct
|
|
375
575
|
* from {@link KNOWN_PLANS}, which maps catalog models to their minimum tier.
|
|
@@ -461,6 +661,10 @@ const KNOWN_DEALS = {
|
|
|
461
661
|
"poolside/laguna-s-2.1-free": {
|
|
462
662
|
label: "FREE",
|
|
463
663
|
free: true
|
|
664
|
+
},
|
|
665
|
+
"stealth/ox-alpha": {
|
|
666
|
+
label: "FREE",
|
|
667
|
+
free: true
|
|
464
668
|
}
|
|
465
669
|
};
|
|
466
670
|
/**
|
|
@@ -499,7 +703,7 @@ function peakPricingLabel(modelId, now = Date.now()) {
|
|
|
499
703
|
if (state === void 0) return void 0;
|
|
500
704
|
return state === "peak" ? "Peak" : "Half";
|
|
501
705
|
}
|
|
502
|
-
const COMMAND_CODE_CLI_VERSION = "1.
|
|
706
|
+
const COMMAND_CODE_CLI_VERSION = "1.31.0";
|
|
503
707
|
const DEFAULT_API_BASE = "https://api.commandcode.ai";
|
|
504
708
|
const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
|
|
505
709
|
const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
|
|
@@ -819,15 +1023,39 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
819
1023
|
this.resolveAttachments = deps.resolveAttachments;
|
|
820
1024
|
}
|
|
821
1025
|
/**
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
1026
|
+
* Near-unbounded retry for transient failures only (`mode: 'normal'` with
|
|
1027
|
+
* an explicit 1000-attempt cap — opencode-style persistence without the
|
|
1028
|
+
* unbounded loop): `RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`/
|
|
1029
|
+
* `EMPTY_RESPONSE` retry up to 1000 times with waits doubling from 500 ms
|
|
1030
|
+
* and capping at 15 minutes (±10% jitter), so an exhausted 5-hour window
|
|
1031
|
+
* recovers in-session instead of failing after two tries. Permanent
|
|
1032
|
+
* failures (an invalid key's `INVALID_CREDENTIAL`, `UNSUPPORTED_CONTENT`,
|
|
1033
|
+
* plan rejections) are absent from the whitelist and surface immediately
|
|
1034
|
+
* instead of looping. Waits the pool/adapter attach as
|
|
1035
|
+
* `providerRetryAfterMs` are honored verbatim at or below the 15-minute
|
|
1036
|
+
* cap and never attached above it (in normal mode a longer attached wait
|
|
1037
|
+
* makes the executor abandon the retry outright — see RETRY_MAX_DELAY_MS).
|
|
1038
|
+
*
|
|
1039
|
+
* Captured once at route registration (dsh-llm snapshots this value), so a
|
|
1040
|
+
* future config knob for it would apply on profile restart, not per request.
|
|
828
1041
|
*/
|
|
829
1042
|
providerRetryPolicy(_provider) {
|
|
830
|
-
return resolveRetryPolicy(
|
|
1043
|
+
return resolveRetryPolicy({
|
|
1044
|
+
mode: "normal",
|
|
1045
|
+
maxRetries: 1e3,
|
|
1046
|
+
retryableCodes: [
|
|
1047
|
+
"EMPTY_RESPONSE",
|
|
1048
|
+
"RATE_LIMIT",
|
|
1049
|
+
"SERVER",
|
|
1050
|
+
"TIMEOUT",
|
|
1051
|
+
"TRANSPORT"
|
|
1052
|
+
],
|
|
1053
|
+
backoff: {
|
|
1054
|
+
initialDelayMs: 500,
|
|
1055
|
+
maxDelayMs: RETRY_MAX_DELAY_MS,
|
|
1056
|
+
jitterRatio: .1
|
|
1057
|
+
}
|
|
1058
|
+
}, "llm-commandcode: retryPolicy");
|
|
831
1059
|
}
|
|
832
1060
|
/** Refresh the catalog (live fetch, cache fallback) and return it. */
|
|
833
1061
|
async loadCatalog(signal) {
|
|
@@ -1178,9 +1406,14 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
1178
1406
|
if (!response.ok) {
|
|
1179
1407
|
const errText = await response.text().catch(() => "");
|
|
1180
1408
|
cleanup();
|
|
1181
|
-
|
|
1409
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
1410
|
+
return retryAfterMs === void 0 ? {
|
|
1182
1411
|
status: response.status,
|
|
1183
1412
|
errText
|
|
1413
|
+
} : {
|
|
1414
|
+
status: response.status,
|
|
1415
|
+
errText,
|
|
1416
|
+
retryAfterMs
|
|
1184
1417
|
};
|
|
1185
1418
|
}
|
|
1186
1419
|
return {
|
|
@@ -1205,7 +1438,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
|
|
|
1205
1438
|
continue;
|
|
1206
1439
|
}
|
|
1207
1440
|
}
|
|
1208
|
-
throw generateHttpError(attempt.status, attempt.errText);
|
|
1441
|
+
throw generateHttpError(attempt.status, attempt.errText, attempt.retryAfterMs);
|
|
1209
1442
|
}
|
|
1210
1443
|
const { response, cleanup } = connected;
|
|
1211
1444
|
if (!response.body) {
|
|
@@ -1432,9 +1665,13 @@ const MAX_ACCOUNT_ROTATIONS = 16;
|
|
|
1432
1665
|
* Map a pre-stream generate HTTP failure onto a stable LlmError. Command
|
|
1433
1666
|
* Code folds several business rejections into 403 (plan limits, CLI version,
|
|
1434
1667
|
* model access): prefer the machine-readable `error.code` when present; the
|
|
1435
|
-
* status alone cannot distinguish them.
|
|
1668
|
+
* status alone cannot distinguish them. A 429's `Retry-After` rides along as
|
|
1669
|
+
* `providerRetryAfterMs` so dsh-llm-retry can wait exactly that long instead
|
|
1670
|
+
* of guessing at the backoff cadence — capped at RETRY_MAX_DELAY_MS, because
|
|
1671
|
+
* in normal mode a longer attached wait makes the executor abandon the retry
|
|
1672
|
+
* outright instead of falling back to local backoff.
|
|
1436
1673
|
*/
|
|
1437
|
-
function generateHttpError(status, errText) {
|
|
1674
|
+
function generateHttpError(status, errText, retryAfterMs) {
|
|
1438
1675
|
let providerCode;
|
|
1439
1676
|
try {
|
|
1440
1677
|
const parsed = JSON.parse(errText);
|
|
@@ -1442,7 +1679,30 @@ function generateHttpError(status, errText) {
|
|
|
1442
1679
|
} catch {}
|
|
1443
1680
|
const detail = providerCode ?? `HTTP ${status}`;
|
|
1444
1681
|
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`, "INVALID_CREDENTIAL", { status: 401 });
|
|
1445
|
-
return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
|
|
1682
|
+
return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, status === 429 ? "RATE_LIMIT" : "PROVIDER_HTTP_ERROR", {
|
|
1683
|
+
status,
|
|
1684
|
+
...retryAfterMs !== void 0 && retryAfterMs > 0 && retryAfterMs <= 9e5 ? { providerRetryAfterMs: retryAfterMs } : {}
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Parse an HTTP `Retry-After` value (delay-seconds or an HTTP-date) into
|
|
1689
|
+
* milliseconds; undefined when absent or unparseable. An HTTP-date in the
|
|
1690
|
+
* past yields 0, which the caller drops (LlmError wants a positive delay).
|
|
1691
|
+
* A delay-seconds value whose millisecond product is not finite (e.g. `1e308`)
|
|
1692
|
+
* also yields undefined: LlmError validates its options and would otherwise
|
|
1693
|
+
* replace the provider failure with an internal construction error.
|
|
1694
|
+
*/
|
|
1695
|
+
function parseRetryAfterMs(value, now = Date.now()) {
|
|
1696
|
+
if (value === void 0 || value === null) return void 0;
|
|
1697
|
+
const trimmed = value.trim();
|
|
1698
|
+
if (trimmed === "") return void 0;
|
|
1699
|
+
const seconds = Number(trimmed);
|
|
1700
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1701
|
+
const ms = seconds * 1e3;
|
|
1702
|
+
return Number.isFinite(ms) ? Math.round(ms) : void 0;
|
|
1703
|
+
}
|
|
1704
|
+
const date = Date.parse(trimmed);
|
|
1705
|
+
if (!Number.isNaN(date)) return Math.max(0, date - now);
|
|
1446
1706
|
}
|
|
1447
1707
|
function mapFinishReason(reason) {
|
|
1448
1708
|
if (reason === "tool-calls") return { kind: "tool-calls" };
|
|
@@ -1450,190 +1710,6 @@ function mapFinishReason(reason) {
|
|
|
1450
1710
|
return { kind: "stop" };
|
|
1451
1711
|
}
|
|
1452
1712
|
//#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
1713
|
//#region src/commands.ts
|
|
1638
1714
|
/** Format a dollar amount. */
|
|
1639
1715
|
function money(value) {
|