dsh-packycode-api 1.0.7 → 1.1.0
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 +22 -0
- package/README.en.md +46 -9
- package/README.md +51 -10
- package/compatibility.json +1 -1
- package/lib/client.js +1155 -726
- package/lib/index.js +2637 -914
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,19 +1,167 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
2
3
|
import { isAbsolute, join, posix, win32 } from "node:path";
|
|
3
|
-
import { LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
4
|
-
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
5
|
-
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
4
|
import z from "@deepseek-ai/schemastery";
|
|
7
|
-
import {
|
|
5
|
+
import { HarnessError, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
8
6
|
import { spawn } from "node:child_process";
|
|
9
7
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
8
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
10
9
|
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
11
|
-
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
10
|
+
import { readFile, readdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
12
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
13
13
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
14
14
|
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
15
15
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
16
16
|
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
//#region src/request.js
|
|
19
|
+
/**
|
|
20
|
+
* The host-side HTTP plumbing every reader shares: fetch resolved at call
|
|
21
|
+
* time, one request bounded by a timeout and the caller's cancellation with
|
|
22
|
+
* redirects refused, and a shared request one caller's cancellation cannot
|
|
23
|
+
* abort for the others.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-packycode-api/request
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Resolve fetch at call time. A sibling plugin may wrap `globalThis.fetch`
|
|
29
|
+
* and later dismantle that wrapper; a binding captured during `apply()` would
|
|
30
|
+
* keep the dead wrapper for the process lifetime.
|
|
31
|
+
*/
|
|
32
|
+
function ambientFetch(input, init) {
|
|
33
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
34
|
+
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
35
|
+
return globalThis.fetch(input, {
|
|
36
|
+
...init,
|
|
37
|
+
headers
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** One caller's cancellation must not abort a request other callers share. */
|
|
41
|
+
function waitForSharedRequest(promise, signal) {
|
|
42
|
+
if (signal === void 0) return promise;
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const onAbort = () => reject(signal.reason);
|
|
46
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
47
|
+
promise.then((value) => {
|
|
48
|
+
signal.removeEventListener("abort", onAbort);
|
|
49
|
+
resolve(value);
|
|
50
|
+
}, (error) => {
|
|
51
|
+
signal.removeEventListener("abort", onAbort);
|
|
52
|
+
reject(error);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A request that got no answer. Node's fetch reports every transport fault as
|
|
58
|
+
* a bare `fetch failed`; the message names the part a reader can act on.
|
|
59
|
+
*/
|
|
60
|
+
var TransportError = class extends Error {
|
|
61
|
+
/**
|
|
62
|
+
* @param cause - what fetch threw.
|
|
63
|
+
* @param state - whether the timeout or the caller ended the request.
|
|
64
|
+
*/
|
|
65
|
+
constructor(cause, { timedOut, aborted, timeoutMs }) {
|
|
66
|
+
const code = typeof cause?.cause?.code === "string" ? cause.cause.code : typeof cause?.code === "string" ? cause.code : void 0;
|
|
67
|
+
const text = cause instanceof Error ? cause.message : String(cause);
|
|
68
|
+
super(timedOut ? `no answer within ${Math.round(timeoutMs / 1e3)}s` : code === void 0 ? text : `${text} (${code})`, { cause });
|
|
69
|
+
this.name = "TransportError";
|
|
70
|
+
/** The system error code, when the transport reported one. */
|
|
71
|
+
this.code = code;
|
|
72
|
+
/** The timeout ended the request. */
|
|
73
|
+
this.timedOut = timedOut;
|
|
74
|
+
/** The caller ended the request. */
|
|
75
|
+
this.aborted = aborted;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* One request, bounded by `timeoutMs` and the caller's `signal`, refusing
|
|
80
|
+
* redirects. A transport failure throws a {@link TransportError}.
|
|
81
|
+
* @param url - the target.
|
|
82
|
+
* @param init - fetch options; `redirect` and `signal` are set here.
|
|
83
|
+
* @param options - the fetch to use, the timeout, and the caller's signal.
|
|
84
|
+
* @returns the response, whatever its status.
|
|
85
|
+
*/
|
|
86
|
+
async function boundedFetch(url, init, { fetchImpl = ambientFetch, timeoutMs, signal } = {}) {
|
|
87
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
88
|
+
const requestSignal = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
|
|
89
|
+
try {
|
|
90
|
+
return await fetchImpl(url, {
|
|
91
|
+
...init,
|
|
92
|
+
redirect: "error",
|
|
93
|
+
signal: requestSignal
|
|
94
|
+
});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const aborted = signal?.aborted === true;
|
|
97
|
+
throw new TransportError(error, {
|
|
98
|
+
aborted,
|
|
99
|
+
timedOut: !aborted && timeout.aborted,
|
|
100
|
+
timeoutMs
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/cached-read.js
|
|
106
|
+
/**
|
|
107
|
+
* The read cache the balance, group, and version readers share.
|
|
108
|
+
*
|
|
109
|
+
* A read inside the TTL answers from the cache. Concurrent unforced reads
|
|
110
|
+
* share one request, and one caller cancelling does not abort it for the
|
|
111
|
+
* others. A forced read always goes out under the caller's own signal. A read
|
|
112
|
+
* that started before an invalidation never repopulates the cache.
|
|
113
|
+
*
|
|
114
|
+
* @module dsh-packycode-api/cached-read
|
|
115
|
+
*/
|
|
116
|
+
const clone = (value) => structuredClone(value);
|
|
117
|
+
/**
|
|
118
|
+
* @param load - `(signal) => Promise<value>`; the value carries `fetchedAt`.
|
|
119
|
+
* @param options - the TTL and clock.
|
|
120
|
+
* @returns `{ read({ force, signal }), snapshot(), peek(), invalidate() }`.
|
|
121
|
+
*/
|
|
122
|
+
function createCachedRead(load, { ttlMs, now = Date.now }) {
|
|
123
|
+
let cached;
|
|
124
|
+
let inFlight;
|
|
125
|
+
let generation = 0;
|
|
126
|
+
const start = (signal) => {
|
|
127
|
+
const observed = generation;
|
|
128
|
+
return load(signal).then((value) => {
|
|
129
|
+
if (generation === observed) cached = value;
|
|
130
|
+
return value;
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
/** A copy of the current value; fresh from the network when forced or expired. */
|
|
135
|
+
async read({ force = false, signal } = {}) {
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
|
|
138
|
+
if (force) return clone(await start(signal));
|
|
139
|
+
if (inFlight === void 0) {
|
|
140
|
+
const pending = start();
|
|
141
|
+
inFlight = pending;
|
|
142
|
+
const clear = () => {
|
|
143
|
+
if (inFlight === pending) inFlight = void 0;
|
|
144
|
+
};
|
|
145
|
+
pending.then(clear, clear);
|
|
146
|
+
}
|
|
147
|
+
return clone(await waitForSharedRequest(inFlight, signal));
|
|
148
|
+
},
|
|
149
|
+
/** A copy of the last value, however old, without a request. */
|
|
150
|
+
snapshot() {
|
|
151
|
+
return cached === void 0 ? void 0 : clone(cached);
|
|
152
|
+
},
|
|
153
|
+
/** The last value itself, for synchronous lookups that do not keep it. */
|
|
154
|
+
peek() {
|
|
155
|
+
return cached;
|
|
156
|
+
},
|
|
157
|
+
/** Drop the value; a read already in flight will not restore it. */
|
|
158
|
+
invalidate() {
|
|
159
|
+
generation += 1;
|
|
160
|
+
cached = void 0;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
17
165
|
//#region src/constants.js
|
|
18
166
|
/**
|
|
19
167
|
* Identities and defaults this plugin owns.
|
|
@@ -61,6 +209,63 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
|
|
|
61
209
|
/** Effort level the route requests when a session names none. */
|
|
62
210
|
const DEFAULT_REASONING_LEVEL = "high";
|
|
63
211
|
//#endregion
|
|
212
|
+
//#region src/hosts.js
|
|
213
|
+
/**
|
|
214
|
+
* Which hosts a read goes to.
|
|
215
|
+
*
|
|
216
|
+
* PackyCode serves models and the billing endpoints from `cf.api.fan`, and
|
|
217
|
+
* the per-key `/api/*` family plus public pricing from the PackyAPI site
|
|
218
|
+
* (`cf.api.fan` answers 404 there). A saved legacy status host
|
|
219
|
+
* (`www.packyapi.com`, DNS-poisoned on mainland networks) is tried after the
|
|
220
|
+
* current default, never before it.
|
|
221
|
+
*
|
|
222
|
+
* @module dsh-packycode-api/hosts
|
|
223
|
+
*/
|
|
224
|
+
/** Accept an absolute http(s) origin and drop a trailing slash. */
|
|
225
|
+
function normalizeHost(value, fallback) {
|
|
226
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
227
|
+
if (text === "") return fallback;
|
|
228
|
+
let url;
|
|
229
|
+
try {
|
|
230
|
+
url = new URL(text);
|
|
231
|
+
} catch {
|
|
232
|
+
return fallback;
|
|
233
|
+
}
|
|
234
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return fallback;
|
|
235
|
+
return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
|
|
236
|
+
}
|
|
237
|
+
const unique = (hosts) => [...new Set(hosts.filter((host) => typeof host === "string" && host !== ""))];
|
|
238
|
+
/**
|
|
239
|
+
* The status hosts one per-key read tries, in order: an explicit override,
|
|
240
|
+
* the current default, the legacy defaults, then the model hosts.
|
|
241
|
+
* @param hosts - the resolved host settings.
|
|
242
|
+
* @returns unique origins.
|
|
243
|
+
*/
|
|
244
|
+
function statusHostOrder(hosts) {
|
|
245
|
+
const configured = hosts?.statusHost;
|
|
246
|
+
const override = configured === void 0 || LEGACY_STATUS_HOSTS.includes(configured) ? [] : [configured];
|
|
247
|
+
return unique([
|
|
248
|
+
...override,
|
|
249
|
+
DEFAULT_STATUS_HOST,
|
|
250
|
+
...LEGACY_STATUS_HOSTS,
|
|
251
|
+
hosts?.apiHost,
|
|
252
|
+
DEFAULT_API_HOST
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The hosts the account read tries: the model hosts first (they serve the
|
|
257
|
+
* billing endpoints), then the status hosts.
|
|
258
|
+
* @param hosts - the resolved host settings.
|
|
259
|
+
* @returns unique origins.
|
|
260
|
+
*/
|
|
261
|
+
function accountHostOrder(hosts) {
|
|
262
|
+
return unique([
|
|
263
|
+
hosts?.apiHost,
|
|
264
|
+
DEFAULT_API_HOST,
|
|
265
|
+
...statusHostOrder(hosts)
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
64
269
|
//#region src/balance.js
|
|
65
270
|
/**
|
|
66
271
|
* Account and per-key balance.
|
|
@@ -78,8 +283,7 @@ const DEFAULT_REASONING_LEVEL = "high";
|
|
|
78
283
|
*
|
|
79
284
|
* @module dsh-packycode-api/balance
|
|
80
285
|
*/
|
|
81
|
-
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
82
|
-
const clone$1 = (value) => structuredClone(value);
|
|
286
|
+
const record$2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
83
287
|
function number(value) {
|
|
84
288
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
85
289
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -107,8 +311,8 @@ function quotaFields(prefix, quota) {
|
|
|
107
311
|
* @returns a small projection, or undefined when the shape is unrecognized.
|
|
108
312
|
*/
|
|
109
313
|
function parseTokenUsage(payload) {
|
|
110
|
-
const data = record(payload?.data) ? payload.data : payload;
|
|
111
|
-
if (!record(data)) return void 0;
|
|
314
|
+
const data = record$2(payload?.data) ? payload.data : payload;
|
|
315
|
+
if (!record$2(data)) return void 0;
|
|
112
316
|
if (![
|
|
113
317
|
"name",
|
|
114
318
|
"quota_limit",
|
|
@@ -132,7 +336,7 @@ function parseTokenUsage(payload) {
|
|
|
132
336
|
}
|
|
133
337
|
/** Normalize `/v1/dashboard/billing/subscription`. */
|
|
134
338
|
function parseBillingSubscription(payload) {
|
|
135
|
-
if (!record(payload)) return void 0;
|
|
339
|
+
if (!record$2(payload)) return void 0;
|
|
136
340
|
return {
|
|
137
341
|
...number(payload.soft_limit_usd) === void 0 ? {} : { softLimitUsd: number(payload.soft_limit_usd) },
|
|
138
342
|
...number(payload.hard_limit_usd) === void 0 ? {} : { hardLimitUsd: number(payload.hard_limit_usd) },
|
|
@@ -142,7 +346,7 @@ function parseBillingSubscription(payload) {
|
|
|
142
346
|
}
|
|
143
347
|
/** Normalize `/v1/dashboard/billing/usage`. */
|
|
144
348
|
function parseBillingUsage(payload) {
|
|
145
|
-
if (!record(payload)) return void 0;
|
|
349
|
+
if (!record$2(payload)) return void 0;
|
|
146
350
|
const total = number(payload.total_usage);
|
|
147
351
|
return total === void 0 ? {} : { totalUsageUsd: Math.round(total * 1e6) / 1e6 };
|
|
148
352
|
}
|
|
@@ -152,68 +356,24 @@ function statusMessage(status) {
|
|
|
152
356
|
if (status === 429) return "the gateway is rate limiting balance reads (429)";
|
|
153
357
|
return `the gateway answered HTTP ${status}`;
|
|
154
358
|
}
|
|
155
|
-
/**
|
|
156
|
-
* Say why a request never got an answer. Node's fetch reports every transport
|
|
157
|
-
* fault as a bare `fetch failed`; the cause carries the part a reader can act
|
|
158
|
-
* on (a DNS failure, a refused or reset connection, a certificate problem).
|
|
159
|
-
*/
|
|
160
|
-
function transportMessage(error, timeoutSignal, timeoutMs) {
|
|
161
|
-
if (timeoutSignal.aborted) return `no answer within ${Math.round(timeoutMs / 1e3)}s`;
|
|
162
|
-
const cause = error?.cause;
|
|
163
|
-
const code = typeof cause?.code === "string" ? cause.code : typeof error?.code === "string" ? error.code : void 0;
|
|
164
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
-
return code === void 0 ? message : `${message} (${code})`;
|
|
166
|
-
}
|
|
167
359
|
async function getJson(url, key, { fetchImpl, timeoutMs, signal }) {
|
|
168
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
169
|
-
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
170
360
|
let response;
|
|
171
361
|
try {
|
|
172
|
-
response = await
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
signal
|
|
362
|
+
response = await boundedFetch(url, { headers: {
|
|
363
|
+
Authorization: `Bearer ${key}`,
|
|
364
|
+
Accept: "application/json"
|
|
365
|
+
} }, {
|
|
366
|
+
fetchImpl,
|
|
367
|
+
timeoutMs,
|
|
368
|
+
signal
|
|
179
369
|
});
|
|
180
370
|
} catch (error) {
|
|
181
|
-
if (
|
|
182
|
-
throw new Error(
|
|
371
|
+
if (error.aborted) throw error.cause;
|
|
372
|
+
throw new Error(error.message, { cause: error });
|
|
183
373
|
}
|
|
184
374
|
if (!response.ok) throw new Error(statusMessage(response.status));
|
|
185
375
|
return response.json();
|
|
186
376
|
}
|
|
187
|
-
/**
|
|
188
|
-
* The status hosts one per-key read tries, in order: an explicit override,
|
|
189
|
-
* the current default, the legacy defaults, then the model hosts.
|
|
190
|
-
* @param hosts - the resolved host settings.
|
|
191
|
-
* @returns unique origins.
|
|
192
|
-
*/
|
|
193
|
-
function statusHostOrder(hosts) {
|
|
194
|
-
const configured = hosts?.statusHost;
|
|
195
|
-
const override = configured === void 0 || LEGACY_STATUS_HOSTS.includes(configured) ? [] : [configured];
|
|
196
|
-
return [...new Set([
|
|
197
|
-
...override,
|
|
198
|
-
DEFAULT_STATUS_HOST,
|
|
199
|
-
...LEGACY_STATUS_HOSTS,
|
|
200
|
-
hosts?.apiHost,
|
|
201
|
-
DEFAULT_API_HOST
|
|
202
|
-
].filter((host) => typeof host === "string" && host !== ""))];
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* The hosts the account read tries: the model hosts first (they serve the
|
|
206
|
-
* billing endpoints), then the status hosts.
|
|
207
|
-
* @param hosts - the resolved host settings.
|
|
208
|
-
* @returns unique origins.
|
|
209
|
-
*/
|
|
210
|
-
function accountHostOrder(hosts) {
|
|
211
|
-
return [...new Set([
|
|
212
|
-
hosts?.apiHost,
|
|
213
|
-
DEFAULT_API_HOST,
|
|
214
|
-
...statusHostOrder(hosts)
|
|
215
|
-
].filter((host) => typeof host === "string" && host !== ""))];
|
|
216
|
-
}
|
|
217
377
|
async function withFallback(hosts, path, key, options) {
|
|
218
378
|
const tried = [];
|
|
219
379
|
for (const host of hosts) try {
|
|
@@ -237,12 +397,9 @@ async function withFallback(hosts, path, key, options) {
|
|
|
237
397
|
* @param options - key listing, host resolution, and transport knobs.
|
|
238
398
|
* @returns `{ read({force, signal}), invalidate() }`.
|
|
239
399
|
*/
|
|
240
|
-
function createBalanceReader({ listKeys, getHosts, fetchImpl =
|
|
400
|
+
function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now = Date.now, ttlMs = DEFAULT_BALANCE_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
|
|
241
401
|
if (typeof listKeys !== "function") throw new Error("PackyCode balance reader requires listKeys");
|
|
242
402
|
if (typeof getHosts !== "function") throw new Error("PackyCode balance reader requires getHosts");
|
|
243
|
-
let cached;
|
|
244
|
-
let inFlight;
|
|
245
|
-
let generation = 0;
|
|
246
403
|
/** Last successful per-key usage, by key id, and the last account figures. */
|
|
247
404
|
const lastGood = /* @__PURE__ */ new Map();
|
|
248
405
|
let lastAccount;
|
|
@@ -359,70 +516,225 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
359
516
|
fetchedAt: now()
|
|
360
517
|
};
|
|
361
518
|
};
|
|
519
|
+
const cache = createCachedRead(load, {
|
|
520
|
+
ttlMs,
|
|
521
|
+
now
|
|
522
|
+
});
|
|
362
523
|
return Object.freeze({
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone$1(cached);
|
|
366
|
-
const createPending = (requestSignal) => {
|
|
367
|
-
const observedGeneration = generation;
|
|
368
|
-
return load(requestSignal).then((value) => {
|
|
369
|
-
if (generation === observedGeneration) cached = value;
|
|
370
|
-
return value;
|
|
371
|
-
});
|
|
372
|
-
};
|
|
373
|
-
if (force) return clone$1(await createPending(signal));
|
|
374
|
-
const pending = inFlight ?? createPending();
|
|
375
|
-
if (inFlight === void 0) {
|
|
376
|
-
inFlight = pending;
|
|
377
|
-
const clear = () => {
|
|
378
|
-
if (inFlight === pending) inFlight = void 0;
|
|
379
|
-
};
|
|
380
|
-
pending.then(clear, clear);
|
|
381
|
-
}
|
|
382
|
-
return clone$1(await pending);
|
|
383
|
-
},
|
|
384
|
-
invalidate() {
|
|
385
|
-
generation += 1;
|
|
386
|
-
cached = void 0;
|
|
387
|
-
}
|
|
524
|
+
read: (options) => cache.read(options),
|
|
525
|
+
invalidate: () => cache.invalidate()
|
|
388
526
|
});
|
|
389
527
|
}
|
|
390
528
|
//#endregion
|
|
391
|
-
//#region src/
|
|
529
|
+
//#region src/catalog.js
|
|
392
530
|
/**
|
|
393
|
-
*
|
|
531
|
+
* The built-in PackyCode model catalog.
|
|
394
532
|
*
|
|
395
|
-
* The gateway
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
533
|
+
* The gateway's own guidance for these routes, which the base URLs and pinned
|
|
534
|
+
* protocols below exist to satisfy:
|
|
535
|
+
* - OpenAI/Codex traffic takes `/v1` in the base URL and never an endpoint
|
|
536
|
+
* path — the SDK appends `/responses` or `/chat/completions` itself.
|
|
537
|
+
* - Anthropic traffic takes `https://cf.api.fan` with **no** `/v1`: the
|
|
538
|
+
* Anthropic client appends `/v1/messages` itself.
|
|
539
|
+
* - A model asked over the wrong protocol does not fail cleanly; the stream
|
|
540
|
+
* ends early. So every model pins the protocol its vendor serves here.
|
|
402
541
|
*
|
|
403
|
-
*
|
|
542
|
+
* Effort levels come from each vendor's documentation, because an effort the
|
|
543
|
+
* model does not support is refused before the request is sent: Claude Opus
|
|
544
|
+
* 5.5 (platform.claude.com effort docs), GPT-6.1 Sol and GPT-6 Astra
|
|
545
|
+
* `reasoning.effort` (developers.openai.com model pages), DeepSeek thinking
|
|
546
|
+
* mode (`thinking` toggle plus `low|high|max` effort).
|
|
547
|
+
*
|
|
548
|
+
* @module dsh-packycode-api/catalog
|
|
404
549
|
*/
|
|
405
|
-
/**
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
550
|
+
/** pi-ai never reads this gateway's pricing; the harness zeroes cost anyway. */
|
|
551
|
+
const NO_COST = Object.freeze({
|
|
552
|
+
input: 0,
|
|
553
|
+
output: 0,
|
|
554
|
+
cacheRead: 0,
|
|
555
|
+
cacheWrite: 0
|
|
556
|
+
});
|
|
557
|
+
const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
558
|
+
const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
559
|
+
const MESSAGES_BASE = DEFAULT_API_HOST;
|
|
410
560
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
561
|
+
* pi-ai thinking-level maps. A level pinned to `null` is unsupported; an
|
|
562
|
+
* absent key keeps pi-ai's own default for that level, which is how `off`
|
|
563
|
+
* stays available for DeepSeek (`off` sends nothing and the deepseek dialect
|
|
564
|
+
* then emits `thinking: disabled`).
|
|
415
565
|
*/
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
566
|
+
const DEEPSEEK_LEVELS = Object.freeze({
|
|
567
|
+
minimal: null,
|
|
568
|
+
low: "low",
|
|
569
|
+
medium: null,
|
|
570
|
+
high: "high",
|
|
571
|
+
xhigh: null,
|
|
572
|
+
max: "max"
|
|
573
|
+
});
|
|
421
574
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*/
|
|
575
|
+
* Claude 5.5 models, GPT-6.1 Sol, and GPT-6 Astra cannot disable reasoning, so `off` is
|
|
576
|
+
* pinned unsupported. The Claude models run through the CLI, which picks its
|
|
577
|
+
* own effort, so for them these levels only shape the picker.
|
|
578
|
+
*/
|
|
579
|
+
const ALWAYS_THINKING_LEVELS = Object.freeze({
|
|
580
|
+
off: null,
|
|
581
|
+
minimal: null,
|
|
582
|
+
low: "low",
|
|
583
|
+
medium: "medium",
|
|
584
|
+
high: "high",
|
|
585
|
+
xhigh: "xhigh",
|
|
586
|
+
max: "max"
|
|
587
|
+
});
|
|
588
|
+
const PACKYCODE_MODELS = Object.freeze([
|
|
589
|
+
Object.freeze({
|
|
590
|
+
id: "deepseek-flash",
|
|
591
|
+
name: "DeepSeek Flash",
|
|
592
|
+
api: "openai-completions",
|
|
593
|
+
provider: PROVIDER,
|
|
594
|
+
baseUrl: COMPLETIONS_BASE,
|
|
595
|
+
reasoning: true,
|
|
596
|
+
input: Object.freeze(["text"]),
|
|
597
|
+
cost: NO_COST,
|
|
598
|
+
contextWindow: 1e6,
|
|
599
|
+
maxTokens: 384e3,
|
|
600
|
+
thinkingLevelMap: DEEPSEEK_LEVELS,
|
|
601
|
+
compat: Object.freeze({
|
|
602
|
+
thinkingFormat: "deepseek",
|
|
603
|
+
maxTokensField: "max_tokens",
|
|
604
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
605
|
+
supportsDeveloperRole: false
|
|
606
|
+
})
|
|
607
|
+
}),
|
|
608
|
+
Object.freeze({
|
|
609
|
+
id: "gpt-6.1-sol",
|
|
610
|
+
name: "GPT-6.1 Sol",
|
|
611
|
+
api: "openai-responses",
|
|
612
|
+
provider: PROVIDER,
|
|
613
|
+
baseUrl: RESPONSES_BASE,
|
|
614
|
+
reasoning: true,
|
|
615
|
+
input: Object.freeze(["text", "image"]),
|
|
616
|
+
cost: NO_COST,
|
|
617
|
+
contextWindow: 105e4,
|
|
618
|
+
maxTokens: 128e3,
|
|
619
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
620
|
+
}),
|
|
621
|
+
Object.freeze({
|
|
622
|
+
id: "gpt-6-astra",
|
|
623
|
+
name: "GPT-6 Astra",
|
|
624
|
+
api: "openai-responses",
|
|
625
|
+
provider: PROVIDER,
|
|
626
|
+
baseUrl: RESPONSES_BASE,
|
|
627
|
+
reasoning: true,
|
|
628
|
+
input: Object.freeze(["text", "image"]),
|
|
629
|
+
cost: NO_COST,
|
|
630
|
+
contextWindow: 105e4,
|
|
631
|
+
maxTokens: 128e3,
|
|
632
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
633
|
+
}),
|
|
634
|
+
Object.freeze({
|
|
635
|
+
id: "claude-opus-5-5",
|
|
636
|
+
name: "Claude Opus 5.5",
|
|
637
|
+
api: "anthropic-messages",
|
|
638
|
+
provider: PROVIDER,
|
|
639
|
+
baseUrl: MESSAGES_BASE,
|
|
640
|
+
reasoning: true,
|
|
641
|
+
input: Object.freeze(["text", "image"]),
|
|
642
|
+
cost: NO_COST,
|
|
643
|
+
contextWindow: 1e6,
|
|
644
|
+
maxTokens: 128e3,
|
|
645
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
646
|
+
compat: Object.freeze({
|
|
647
|
+
forceAdaptiveThinking: true,
|
|
648
|
+
supportsTemperature: false
|
|
649
|
+
})
|
|
650
|
+
}),
|
|
651
|
+
Object.freeze({
|
|
652
|
+
id: "claude-sonnet-5-5",
|
|
653
|
+
name: "Claude Sonnet 5.5",
|
|
654
|
+
api: "anthropic-messages",
|
|
655
|
+
provider: PROVIDER,
|
|
656
|
+
baseUrl: MESSAGES_BASE,
|
|
657
|
+
reasoning: true,
|
|
658
|
+
input: Object.freeze(["text", "image"]),
|
|
659
|
+
cost: NO_COST,
|
|
660
|
+
contextWindow: 1e6,
|
|
661
|
+
maxTokens: 128e3,
|
|
662
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
663
|
+
compat: Object.freeze({
|
|
664
|
+
forceAdaptiveThinking: true,
|
|
665
|
+
supportsTemperature: false
|
|
666
|
+
})
|
|
667
|
+
}),
|
|
668
|
+
Object.freeze({
|
|
669
|
+
id: "claude-haiku-5-5",
|
|
670
|
+
name: "Claude Haiku 5.5",
|
|
671
|
+
api: "anthropic-messages",
|
|
672
|
+
provider: PROVIDER,
|
|
673
|
+
baseUrl: MESSAGES_BASE,
|
|
674
|
+
reasoning: true,
|
|
675
|
+
input: Object.freeze(["text", "image"]),
|
|
676
|
+
cost: NO_COST,
|
|
677
|
+
contextWindow: 1e6,
|
|
678
|
+
maxTokens: 128e3,
|
|
679
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
680
|
+
compat: Object.freeze({
|
|
681
|
+
forceAdaptiveThinking: true,
|
|
682
|
+
supportsTemperature: false
|
|
683
|
+
})
|
|
684
|
+
})
|
|
685
|
+
]);
|
|
686
|
+
/** Every model id this catalog advertises, in picker order. */
|
|
687
|
+
const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/failures.js
|
|
690
|
+
/**
|
|
691
|
+
* Failure classification for the two conditions that move the key ring along.
|
|
692
|
+
*
|
|
693
|
+
* The gateway reports an exhausted key and a rejected key in Chinese, and the
|
|
694
|
+
* harness classifies failures by fixed English/status wording it recognizes
|
|
695
|
+
* (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
|
|
696
|
+
* harness's retry policy re-run the step — and therefore what lets the retry
|
|
697
|
+
* pick up the next key. Rewriting is honest about what happened: this plugin
|
|
698
|
+
* knows the key's condition from the gateway's own words plus its balance
|
|
699
|
+
* reader, so the sentence it substitutes says no more than that.
|
|
700
|
+
*
|
|
701
|
+
* @module dsh-packycode-api/failures
|
|
702
|
+
*/
|
|
703
|
+
/** Terminal quota, balance, or credit wording, in the gateway's own languages. */
|
|
704
|
+
const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
|
|
705
|
+
/** A rejected key names itself; a bare 403 does not, and must not disable one. */
|
|
706
|
+
const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
|
|
707
|
+
const INVALID_KEY_STATUS = /\b401\b/;
|
|
708
|
+
/**
|
|
709
|
+
* A gateway group that only serves its vendor's official client. These arrive
|
|
710
|
+
* as 403, which the harness reads as an authentication failure and renders as
|
|
711
|
+
* an invalid-API-key problem — the opposite of what happened, and a condition
|
|
712
|
+
* no retry can clear.
|
|
713
|
+
*/
|
|
714
|
+
const CLIENT_POLICY_WORDING = /only accessible via the official|only accepts requests from the official|official\s+claude(?:\s+code)?\s+cli|官方\s*claude(?:\s*code)?\s*cli|请使用标准\s*codex\s*客户端|客户端存在异常|二次分发|API\s*转接/iu;
|
|
715
|
+
/**
|
|
716
|
+
* The gateway's answer when a key's groups do not include the model's
|
|
717
|
+
* supplier: `model_not_found` / 当前令牌未覆盖供应商. The key is fine for other
|
|
718
|
+
* models, so it is never moved aside for this.
|
|
719
|
+
*/
|
|
720
|
+
const COVERAGE_WORDING = /model_not_found|未覆盖供应商|无可用渠道|no available channel/iu;
|
|
721
|
+
/**
|
|
722
|
+
* Whether the gateway refused the model for this key's groups.
|
|
723
|
+
* @param message - the failure text.
|
|
724
|
+
* @returns true for a group-coverage refusal.
|
|
725
|
+
*/
|
|
726
|
+
function classifyCoverage(message) {
|
|
727
|
+
return COVERAGE_WORDING.test(typeof message === "string" ? message : "");
|
|
728
|
+
}
|
|
729
|
+
/** The key's quota is exhausted; move to the next key. */
|
|
730
|
+
const QUOTA_FAILURE = "quota";
|
|
731
|
+
/** The gateway rejected the key itself; move to the next key. */
|
|
732
|
+
const INVALID_KEY_FAILURE = "invalid-key";
|
|
733
|
+
/**
|
|
734
|
+
* Whether the gateway is refusing this client rather than this credential.
|
|
735
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
736
|
+
* @returns true when the route is gated to a vendor's official client.
|
|
737
|
+
*/
|
|
426
738
|
function classifyClientPolicy(message) {
|
|
427
739
|
return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
|
|
428
740
|
}
|
|
@@ -507,6 +819,24 @@ function explainCliClientPolicyFailure(message) {
|
|
|
507
819
|
return `PackyCode 的上游拒绝了这次 Claude Code CLI 请求,认为它不是官方 Claude Code CLI 发出的。这不是 API Key 失效。请先把插件和 Claude Code CLI 都更新到最新版本再试;仍然失败时,请把下面的原始错误(含 request id)发给 PackyCode 客服,确认所用分组是否支持 Claude Code 的 -p(非交互)模式。 / The gateway refused this Claude Code CLI request as not coming from the official CLI; the API key is fine. Gateway said: ${(typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").replace(/\b(?:401|403)\b/gu, "HTTP").slice(0, 600)}`;
|
|
508
820
|
}
|
|
509
821
|
/**
|
|
822
|
+
* Explain a CLI refusal that came from the key, not the CLI: the key's groups
|
|
823
|
+
* cannot reach the requested model. PackyCode answers such a Claude request
|
|
824
|
+
* with the same "only the official Claude Code CLI" wording a real client
|
|
825
|
+
* fault gets, so without the key's groups it read as a CLI problem and sent
|
|
826
|
+
* the user to update the CLI.
|
|
827
|
+
* @param key - the run's key: label, groups, the model, and the groups that
|
|
828
|
+
* can serve it, as the group reader last saw them.
|
|
829
|
+
* @param message - the CLI failure text.
|
|
830
|
+
* @returns the replacement sentence, with the gateway's own words kept.
|
|
831
|
+
*/
|
|
832
|
+
function explainCliGroupFailure(key, message) {
|
|
833
|
+
const detail = (typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").replace(/\b(?:400|401|403)\b/gu, "HTTP").slice(0, 400);
|
|
834
|
+
const groups = Array.isArray(key?.groups) && key.groups.length > 0 ? key.groups.join("、") : "未读到";
|
|
835
|
+
const required = Array.isArray(key?.required) && key.required.length > 0 ? key.required.join(" / ") : "cc / cc-sale / cc-expensive / claude-officially";
|
|
836
|
+
const model = typeof key?.model === "string" && key.model !== "" ? key.model : "Claude";
|
|
837
|
+
return `PackyCode 拒绝了这次 ${model} 请求:本轮使用的 Key「${key?.label ?? "?"}」所在分组(${groups})不包含能用 ${model} 的分组(${required})。这不是 Claude Code CLI 的问题,不需要更新 CLI。请在 设置 → PackyCode 添加分组包含上述分组的 Key(每个 Key 后面会列出它的分组),插件会自动用它跑 Claude;或在 PackyCode 控制台给这个 Key 加上对应分组。 / The key "${key?.label ?? "?"}" is in no group that serves ${model} (needs one of: ${required}); the CLI is fine. Gateway said: ${detail}`;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
510
840
|
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
511
841
|
*
|
|
512
842
|
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
@@ -529,100 +859,441 @@ function neutralizeAuthStatus(text) {
|
|
|
529
859
|
return text.replace(/\b(?:401|403)\b/gu, "HTTP");
|
|
530
860
|
}
|
|
531
861
|
//#endregion
|
|
532
|
-
//#region src/
|
|
862
|
+
//#region src/groups.js
|
|
533
863
|
/**
|
|
534
|
-
*
|
|
864
|
+
* Which PackyCode token groups each stored key belongs to, and which catalog
|
|
865
|
+
* models those groups can serve.
|
|
535
866
|
*
|
|
536
|
-
* PackyCode
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
867
|
+
* PackyCode routes a request by the token's groups: a key without a Claude
|
|
868
|
+
* Code group (`cc`, `cc-sale`, …) is refused for Claude, a key without a Codex
|
|
869
|
+
* group for GPT. None of that is visible from the key itself, so a key that
|
|
870
|
+
* cannot serve the selected model looked exactly like a client or CLI fault.
|
|
540
871
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
* works, its thinking and tool activity stream into a reasoning block and its
|
|
549
|
-
* answer streams as text.
|
|
872
|
+
* Three host-side reads answer it, none of them billed:
|
|
873
|
+
* - the gateway refuses a model id it cannot route before contacting any
|
|
874
|
+
* upstream, and its refusal names the token's groups
|
|
875
|
+
* (`已选分组=[codex deepseek-officially kimi-sale]`);
|
|
876
|
+
* - `/v1/models` lists the models the key can reach;
|
|
877
|
+
* - the public `/api/pricing` lists each model's groups and each group's
|
|
878
|
+
* description.
|
|
550
879
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
880
|
+
* Every request is bounded by a timeout and refuses redirects. Nothing here
|
|
881
|
+
* returns key material: the projection carries group names, their public
|
|
882
|
+
* descriptions, and per-model booleans.
|
|
554
883
|
*
|
|
555
|
-
* @module dsh-packycode-api/
|
|
884
|
+
* @module dsh-packycode-api/groups
|
|
556
885
|
*/
|
|
557
|
-
/**
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
]
|
|
563
|
-
|
|
564
|
-
const
|
|
886
|
+
/** A model id no gateway routes; its refusal is what names the groups. */
|
|
887
|
+
const GROUP_PROBE_MODEL = "dsh-packycode-group-probe";
|
|
888
|
+
/** Group membership changes rarely; a forced read bypasses this. */
|
|
889
|
+
const DEFAULT_GROUP_TTL_MS = 10 * 6e4;
|
|
890
|
+
const SELECTED_GROUPS = /已选分组\s*[==::]\s*\[([^\]]*)\]/u;
|
|
891
|
+
const GROUP_NAME = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
892
|
+
const INVALID_KEY = /无效的令牌|令牌无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication/iu;
|
|
893
|
+
const MAX_GROUPS = 32;
|
|
894
|
+
const MAX_DESCRIPTION = 80;
|
|
895
|
+
/** Keys read at once. */
|
|
896
|
+
const KEY_CONCURRENCY = 4;
|
|
897
|
+
const record$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
898
|
+
/** Run `task` over `items`, at most `limit` at a time, keeping their order. */
|
|
899
|
+
async function mapLimited(items, limit, task) {
|
|
900
|
+
const results = new Array(items.length);
|
|
901
|
+
let next = 0;
|
|
902
|
+
const worker = async () => {
|
|
903
|
+
while (next < items.length) {
|
|
904
|
+
const index = next;
|
|
905
|
+
next += 1;
|
|
906
|
+
results[index] = await task(items[index], index);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
910
|
+
return results;
|
|
911
|
+
}
|
|
912
|
+
/** The gateway refused the key itself; it reaches no model at all. */
|
|
913
|
+
var RejectedKeyError = class extends Error {
|
|
914
|
+
constructor() {
|
|
915
|
+
super("the gateway rejected this key");
|
|
916
|
+
}
|
|
917
|
+
};
|
|
565
918
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* are untouched.
|
|
919
|
+
* Read the token's groups out of the gateway's routing refusal.
|
|
920
|
+
* @param message - the refusal text.
|
|
921
|
+
* @returns the group names, or undefined when the text names none.
|
|
570
922
|
*/
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
923
|
+
function parseSelectedGroups(message) {
|
|
924
|
+
const match = SELECTED_GROUPS.exec(typeof message === "string" ? message : "");
|
|
925
|
+
if (match === null) return void 0;
|
|
926
|
+
return [...new Set(match[1].split(/[\s,,、]+/u).filter((name) => GROUP_NAME.test(name)))].slice(0, MAX_GROUPS);
|
|
927
|
+
}
|
|
576
928
|
/**
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
929
|
+
* Normalize `/api/pricing`: the groups each catalog model is enabled in, and
|
|
930
|
+
* each group's public description.
|
|
931
|
+
* @param payload - the parsed pricing body.
|
|
932
|
+
* @returns `{ modelGroups, descriptions }`, or undefined when unrecognized.
|
|
581
933
|
*/
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
return
|
|
934
|
+
function parsePricing(payload) {
|
|
935
|
+
if (!record$1(payload) || !Array.isArray(payload.data)) return void 0;
|
|
936
|
+
const modelGroups = {};
|
|
937
|
+
for (const item of payload.data) {
|
|
938
|
+
if (!record$1(item) || !PACKYCODE_MODEL_IDS.includes(item.model_name) || !Array.isArray(item.enable_groups)) continue;
|
|
939
|
+
modelGroups[item.model_name] = [...new Set(item.enable_groups.filter((name) => typeof name === "string" && GROUP_NAME.test(name)))].sort();
|
|
940
|
+
}
|
|
941
|
+
const descriptions = {};
|
|
942
|
+
if (record$1(payload.usable_group)) for (const [name, description] of Object.entries(payload.usable_group)) {
|
|
943
|
+
if (!GROUP_NAME.test(name) || typeof description !== "string") continue;
|
|
944
|
+
const clean = description.replace(/\s+/gu, " ").trim().slice(0, MAX_DESCRIPTION);
|
|
945
|
+
if (clean !== "") descriptions[name] = clean;
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
modelGroups,
|
|
949
|
+
descriptions
|
|
950
|
+
};
|
|
596
951
|
}
|
|
597
|
-
/**
|
|
598
|
-
|
|
952
|
+
/**
|
|
953
|
+
* Which catalog models a key can reach. Either source decides: a model the
|
|
954
|
+
* key's groups are enabled for, or one its model list shows. A model is only
|
|
955
|
+
* marked unreachable on evidence; with neither source it is left unknown.
|
|
956
|
+
* @param options - the key's groups, its model ids, and pricing's model groups.
|
|
957
|
+
* @returns catalog model id → reachable, for the models with evidence.
|
|
958
|
+
*/
|
|
959
|
+
function catalogCoverage({ groups, models, modelGroups }) {
|
|
960
|
+
const coverage = {};
|
|
961
|
+
for (const id of PACKYCODE_MODEL_IDS) {
|
|
962
|
+
const enabled = modelGroups?.[id];
|
|
963
|
+
const byGroups = groups === void 0 || enabled === void 0 ? void 0 : enabled.some((name) => groups.includes(name));
|
|
964
|
+
const byModels = models === void 0 ? void 0 : models.has(id);
|
|
965
|
+
if (byGroups === true || byModels === true) coverage[id] = true;
|
|
966
|
+
else if (byGroups === false || byModels === false) coverage[id] = false;
|
|
967
|
+
}
|
|
968
|
+
return coverage;
|
|
969
|
+
}
|
|
970
|
+
async function bodyText(response) {
|
|
599
971
|
try {
|
|
600
|
-
|
|
601
|
-
return typeof value === "string" && value !== "" ? value : void 0;
|
|
972
|
+
return await response.text();
|
|
602
973
|
} catch {
|
|
603
|
-
return;
|
|
974
|
+
return "";
|
|
604
975
|
}
|
|
605
976
|
}
|
|
977
|
+
function errorMessage(text) {
|
|
978
|
+
try {
|
|
979
|
+
const parsed = JSON.parse(text);
|
|
980
|
+
const message = parsed?.error?.message ?? parsed?.message;
|
|
981
|
+
if (typeof message === "string") return message;
|
|
982
|
+
} catch {}
|
|
983
|
+
return text;
|
|
984
|
+
}
|
|
606
985
|
/**
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
986
|
+
* Cached, single-flight group reader.
|
|
987
|
+
* @param options - key listing, host resolution, and transport knobs.
|
|
988
|
+
* @returns `{ read, snapshot, covers, requiredGroups, invalidate }`.
|
|
610
989
|
*/
|
|
611
|
-
function
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
990
|
+
function createGroupReader({ listKeys, getHosts, fetchImpl = ambientFetch, now = Date.now, ttlMs = DEFAULT_GROUP_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
|
|
991
|
+
if (typeof listKeys !== "function") throw new Error("PackyCode group reader requires listKeys");
|
|
992
|
+
if (typeof getHosts !== "function") throw new Error("PackyCode group reader requires getHosts");
|
|
993
|
+
/** Last good reading per key id, kept when a later read of that key fails. */
|
|
994
|
+
const lastGood = /* @__PURE__ */ new Map();
|
|
995
|
+
let lastPricing;
|
|
996
|
+
const request = (url, init, signal) => boundedFetch(url, init, {
|
|
997
|
+
fetchImpl,
|
|
998
|
+
timeoutMs,
|
|
999
|
+
signal
|
|
1000
|
+
});
|
|
1001
|
+
const loadPricing = async (hosts, signal) => {
|
|
1002
|
+
const tried = [];
|
|
1003
|
+
for (const host of statusHostOrder(hosts)) try {
|
|
1004
|
+
const response = await request(`${host}/api/pricing`, { headers: { Accept: "application/json" } }, signal);
|
|
1005
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1006
|
+
const pricing = parsePricing(await response.json());
|
|
1007
|
+
if (pricing === void 0) throw new Error("unrecognized pricing payload");
|
|
1008
|
+
return pricing;
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
signal?.throwIfAborted();
|
|
1011
|
+
tried.push(`${host}: ${error.message}`);
|
|
1012
|
+
}
|
|
1013
|
+
throw new Error(tried.join("; "));
|
|
1014
|
+
};
|
|
1015
|
+
/** The token's groups, from the gateway's refusal of a model it cannot route. */
|
|
1016
|
+
const loadGroups = async (entry, hosts, signal) => {
|
|
1017
|
+
const response = await request(`${hosts.apiHost}/v1/chat/completions`, {
|
|
1018
|
+
method: "POST",
|
|
1019
|
+
headers: {
|
|
1020
|
+
Authorization: `Bearer ${entry.key}`,
|
|
1021
|
+
Accept: "application/json",
|
|
1022
|
+
"Content-Type": "application/json"
|
|
1023
|
+
},
|
|
1024
|
+
body: JSON.stringify({
|
|
1025
|
+
model: GROUP_PROBE_MODEL,
|
|
1026
|
+
max_tokens: 1,
|
|
1027
|
+
messages: [{
|
|
1028
|
+
role: "user",
|
|
1029
|
+
content: "."
|
|
1030
|
+
}]
|
|
1031
|
+
})
|
|
1032
|
+
}, signal);
|
|
1033
|
+
const message = errorMessage(await bodyText(response));
|
|
1034
|
+
if (response.status === 401 || INVALID_KEY.test(message)) throw new RejectedKeyError();
|
|
1035
|
+
return parseSelectedGroups(message);
|
|
1036
|
+
};
|
|
1037
|
+
/** The models the key can reach. */
|
|
1038
|
+
const loadModels = async (entry, hosts, signal) => {
|
|
1039
|
+
const response = await request(`${hosts.apiHost}/v1/models`, { headers: {
|
|
1040
|
+
Authorization: `Bearer ${entry.key}`,
|
|
1041
|
+
Accept: "application/json"
|
|
1042
|
+
} }, signal);
|
|
1043
|
+
if (response.status === 401) throw new RejectedKeyError();
|
|
1044
|
+
if (!response.ok) return void 0;
|
|
1045
|
+
const payload = await response.json();
|
|
1046
|
+
if (!Array.isArray(payload?.data)) return void 0;
|
|
1047
|
+
return new Set(payload.data.map((item) => item?.id).filter((id) => typeof id === "string"));
|
|
1048
|
+
};
|
|
1049
|
+
/**
|
|
1050
|
+
* One key's groups and model list. The two are independent evidence: one
|
|
1051
|
+
* failing in transit does not discard the other, and a rejection from
|
|
1052
|
+
* either means the gateway refuses the key itself.
|
|
1053
|
+
*/
|
|
1054
|
+
const readKey = async (entry, hosts, signal) => {
|
|
1055
|
+
const results = await Promise.allSettled([loadGroups(entry, hosts, signal), loadModels(entry, hosts, signal)]);
|
|
1056
|
+
if (results.some((result) => result.status === "rejected" && result.reason instanceof RejectedKeyError)) return { rejected: true };
|
|
1057
|
+
const [groups, models] = results.map((result) => result.status === "fulfilled" ? result.value : void 0);
|
|
1058
|
+
if (groups === void 0 && models === void 0) throw results.find((result) => result.status === "rejected")?.reason ?? /* @__PURE__ */ new Error("the gateway named no groups for this key");
|
|
1059
|
+
return {
|
|
1060
|
+
groups,
|
|
1061
|
+
models
|
|
1062
|
+
};
|
|
1063
|
+
};
|
|
1064
|
+
/**
|
|
1065
|
+
* Pricing and every key are read concurrently (a few keys at a time), so a
|
|
1066
|
+
* slow or broken key does not hold up the reading for the others.
|
|
1067
|
+
*/
|
|
1068
|
+
const load = async (signal) => {
|
|
1069
|
+
signal?.throwIfAborted();
|
|
1070
|
+
const hosts = getHosts();
|
|
1071
|
+
const keys = await listKeys();
|
|
1072
|
+
for (const id of lastGood.keys()) if (!keys.some((entry) => entry.id === id)) lastGood.delete(id);
|
|
1073
|
+
const pricingTask = loadPricing(hosts, signal).then((pricing) => {
|
|
1074
|
+
lastPricing = pricing;
|
|
1075
|
+
return pricing;
|
|
1076
|
+
}, () => {
|
|
1077
|
+
signal?.throwIfAborted();
|
|
1078
|
+
return lastPricing;
|
|
1079
|
+
});
|
|
1080
|
+
const readings = await mapLimited(keys, KEY_CONCURRENCY, (entry) => readKey(entry, hosts, signal).then((value) => ({ value }), (error) => ({ error })));
|
|
1081
|
+
const pricing = await pricingTask;
|
|
1082
|
+
signal?.throwIfAborted();
|
|
1083
|
+
return {
|
|
1084
|
+
keys: keys.map((entry, index) => {
|
|
1085
|
+
const { value, error } = readings[index];
|
|
1086
|
+
if (value?.rejected === true) {
|
|
1087
|
+
lastGood.delete(entry.id);
|
|
1088
|
+
return {
|
|
1089
|
+
id: entry.id,
|
|
1090
|
+
rejected: true,
|
|
1091
|
+
coverage: Object.fromEntries(PACKYCODE_MODEL_IDS.map((id) => [id, false])),
|
|
1092
|
+
error: new RejectedKeyError().message
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
if (value !== void 0) {
|
|
1096
|
+
const reading = {
|
|
1097
|
+
id: entry.id,
|
|
1098
|
+
...value.groups === void 0 ? {} : { groups: value.groups },
|
|
1099
|
+
coverage: catalogCoverage({
|
|
1100
|
+
groups: value.groups,
|
|
1101
|
+
models: value.models,
|
|
1102
|
+
modelGroups: pricing?.modelGroups
|
|
1103
|
+
})
|
|
1104
|
+
};
|
|
1105
|
+
lastGood.set(entry.id, {
|
|
1106
|
+
value: reading,
|
|
1107
|
+
fetchedAt: now()
|
|
1108
|
+
});
|
|
1109
|
+
return reading;
|
|
1110
|
+
}
|
|
1111
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1112
|
+
const previous = lastGood.get(entry.id);
|
|
1113
|
+
return previous === void 0 ? {
|
|
1114
|
+
id: entry.id,
|
|
1115
|
+
coverage: {},
|
|
1116
|
+
error: message
|
|
1117
|
+
} : {
|
|
1118
|
+
...previous.value,
|
|
1119
|
+
stale: {
|
|
1120
|
+
fetchedAt: previous.fetchedAt,
|
|
1121
|
+
error: message
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
}),
|
|
1125
|
+
descriptions: pricing?.descriptions ?? {},
|
|
1126
|
+
requirements: pricing?.modelGroups ?? {},
|
|
1127
|
+
fetchedAt: now()
|
|
1128
|
+
};
|
|
1129
|
+
};
|
|
1130
|
+
const cache = createCachedRead(load, {
|
|
1131
|
+
ttlMs,
|
|
1132
|
+
now
|
|
1133
|
+
});
|
|
1134
|
+
const keyEntry = (keyId) => cache.peek()?.keys.find((candidate) => candidate.id === keyId);
|
|
1135
|
+
return Object.freeze({
|
|
1136
|
+
/**
|
|
1137
|
+
* Read every key's groups; cached for the TTL unless forced.
|
|
1138
|
+
* @returns the projection the settings panel shows.
|
|
1139
|
+
*/
|
|
1140
|
+
read: (options) => cache.read(options),
|
|
1141
|
+
/** The last reading, however old, without touching the network. */
|
|
1142
|
+
snapshot: () => cache.snapshot(),
|
|
1143
|
+
/** Whether any reading exists yet. */
|
|
1144
|
+
hasReading: () => cache.peek() !== void 0,
|
|
1145
|
+
/**
|
|
1146
|
+
* Whether a key's groups reach a model, from the last reading.
|
|
1147
|
+
* @returns true, false, or undefined when there is no evidence.
|
|
1148
|
+
*/
|
|
1149
|
+
covers(keyId, modelId) {
|
|
1150
|
+
const value = keyEntry(keyId)?.coverage?.[modelId];
|
|
1151
|
+
return typeof value === "boolean" ? value : void 0;
|
|
1152
|
+
},
|
|
1153
|
+
/** The groups pricing lists for a model, from the last reading. */
|
|
1154
|
+
requiredGroups(modelId) {
|
|
1155
|
+
const groups = cache.peek()?.requirements?.[modelId];
|
|
1156
|
+
return Array.isArray(groups) ? [...groups] : void 0;
|
|
1157
|
+
},
|
|
1158
|
+
/** The groups the last reading found for a key. */
|
|
1159
|
+
groupsOf(keyId) {
|
|
1160
|
+
const groups = keyEntry(keyId)?.groups;
|
|
1161
|
+
return Array.isArray(groups) ? [...groups] : void 0;
|
|
1162
|
+
},
|
|
1163
|
+
/** Drop the reading so the next read goes to the network. */
|
|
1164
|
+
invalidate: () => cache.invalidate()
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Pick the key a request for `modelId` runs with.
|
|
1169
|
+
*
|
|
1170
|
+
* The active key serves every model it is not known to be excluded from.
|
|
1171
|
+
* When the last reading shows its groups cannot reach the model, the next
|
|
1172
|
+
* usable key in ring order that can — or, failing that, one with no evidence
|
|
1173
|
+
* either way — serves this request instead. The active pointer itself does
|
|
1174
|
+
* not move; with no better key the active one is returned, marked uncovered.
|
|
1175
|
+
* @param modelId - the catalog model of this request.
|
|
1176
|
+
* @param active - the ring's active key.
|
|
1177
|
+
* @param usable - the ring's usable keys, in ring order.
|
|
1178
|
+
* @param covers - `(keyId, modelId) => true | false | undefined`.
|
|
1179
|
+
* @returns the chosen key, with `switchedFrom` or `uncovered` when it is not
|
|
1180
|
+
* simply the active key.
|
|
1181
|
+
*/
|
|
1182
|
+
function pickKeyForModel(modelId, active, usable, covers) {
|
|
1183
|
+
if (active === void 0) return void 0;
|
|
1184
|
+
if (typeof modelId !== "string" || covers(active.id, modelId) !== false) return { ...active };
|
|
1185
|
+
const start = usable.findIndex((entry) => entry.id === active.id);
|
|
1186
|
+
const others = start < 0 ? usable.filter((entry) => entry.id !== active.id) : [...usable.slice(start + 1), ...usable.slice(0, start)];
|
|
1187
|
+
const pick = others.find((entry) => covers(entry.id, modelId) === true) ?? others.find((entry) => covers(entry.id, modelId) === void 0);
|
|
1188
|
+
if (pick === void 0) return {
|
|
1189
|
+
...active,
|
|
1190
|
+
uncovered: true
|
|
1191
|
+
};
|
|
1192
|
+
return {
|
|
1193
|
+
id: pick.id,
|
|
1194
|
+
label: pick.label,
|
|
1195
|
+
key: pick.key,
|
|
1196
|
+
switchedFrom: {
|
|
1197
|
+
id: active.id,
|
|
1198
|
+
label: active.label
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
//#endregion
|
|
1203
|
+
//#region src/claude-adapter.js
|
|
1204
|
+
/**
|
|
1205
|
+
* Route Claude models through the local Claude Code CLI.
|
|
1206
|
+
*
|
|
1207
|
+
* PackyCode's `cc` group serves its Claude models only to the official CLI, so
|
|
1208
|
+
* selecting one of those models runs that CLI instead of an HTTP request. The
|
|
1209
|
+
* other models on the route keep using the pi-ai adapter untouched — this
|
|
1210
|
+
* adapter only dispatches.
|
|
1211
|
+
*
|
|
1212
|
+
* A CLI-backed turn is a *delegation*: the CLI runs its own agent loop with
|
|
1213
|
+
* its own tools and returns one answer. The CLI cannot call the harness's
|
|
1214
|
+
* tools, so `options.tools` is deliberately not forwarded, and the harness's
|
|
1215
|
+
* system prompt is dropped in favour of the CLI's own. The delegation
|
|
1216
|
+
* instructions travel as `--append-system-prompt`; the recent conversation
|
|
1217
|
+
* travels as one user message whose images are real image blocks, read from
|
|
1218
|
+
* the attachment store, so the CLI's model sees them as images. While it
|
|
1219
|
+
* works, its thinking and tool activity stream into a reasoning block and its
|
|
1220
|
+
* answer streams as text.
|
|
1221
|
+
*
|
|
1222
|
+
* A session-title request is not a delegation: it runs on the small model,
|
|
1223
|
+
* read-only and outside the workspace, with the title instructions appended,
|
|
1224
|
+
* so naming a session does not cost an Opus agent turn.
|
|
1225
|
+
*
|
|
1226
|
+
* @module dsh-packycode-api/claude-adapter
|
|
1227
|
+
*/
|
|
1228
|
+
/** Models this route serves through the CLI rather than an HTTP API. */
|
|
1229
|
+
const CLI_BACKED_MODEL_IDS = Object.freeze([
|
|
1230
|
+
"claude-opus-5-5",
|
|
1231
|
+
"claude-sonnet-5-5",
|
|
1232
|
+
"claude-haiku-5-5"
|
|
1233
|
+
]);
|
|
1234
|
+
/** The model a session title is generated with when the session runs on the CLI. */
|
|
1235
|
+
const TITLE_MODEL_ID = "claude-haiku-5-5";
|
|
1236
|
+
/**
|
|
1237
|
+
* HTTP models whose session titles run on another model of the same route.
|
|
1238
|
+
* A title is a few words, so the flagship `gpt-6-astra` hands it to
|
|
1239
|
+
* `gpt-6.1-sol`, which the same Codex group serves; the session's own turns
|
|
1240
|
+
* are untouched.
|
|
1241
|
+
*/
|
|
1242
|
+
const TITLE_MODEL_ROUTES = Object.freeze({ "gpt-6-astra": "gpt-6.1-sol" });
|
|
1243
|
+
/** How much of the conversation a delegated prompt carries. */
|
|
1244
|
+
const MAX_PROMPT_CHARS$1 = 48e3;
|
|
1245
|
+
const MAX_BLOCK_CHARS = 8e3;
|
|
1246
|
+
const TRUNCATED_MARK = "…(较早对话已截断)";
|
|
1247
|
+
/**
|
|
1248
|
+
* Image bounds for one delegated turn. Each image is re-encoded by the
|
|
1249
|
+
* attachment store to the same request version the HTTP route sends (at most
|
|
1250
|
+
* 2048×2048 pixels, about 1 MiB); the oldest images are left out once the turn
|
|
1251
|
+
* would carry more than 20 of them or 20 MiB of base64.
|
|
1252
|
+
*/
|
|
1253
|
+
const CLI_IMAGE_LIMITS = Object.freeze({
|
|
1254
|
+
maxPixels: 2048 * 2048,
|
|
1255
|
+
maxBytesPerImage: 1024 * 1024,
|
|
1256
|
+
maxTotalBase64Bytes: 20 * 1024 * 1024,
|
|
1257
|
+
maxImages: 20
|
|
1258
|
+
});
|
|
1259
|
+
/** The vendor's per-image cap on base64 data; a fallback read above it is not sent. */
|
|
1260
|
+
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
|
|
1261
|
+
const base64Length = (bytes) => Math.ceil(bytes / 3) * 4;
|
|
1262
|
+
/** A short name for an image or file a reader can match to the chat. */
|
|
1263
|
+
function attachmentName(ref) {
|
|
1264
|
+
if (typeof ref?.name === "string" && ref.name !== "") return ref.name;
|
|
1265
|
+
const id = String(ref?.attachmentId ?? "");
|
|
1266
|
+
return id.startsWith("sha256:") ? id.slice(7, 15) : id.slice(0, 8);
|
|
1267
|
+
}
|
|
1268
|
+
/** The host path of a stored attachment, when the store is file-backed. */
|
|
1269
|
+
function hostPath(lookup) {
|
|
1270
|
+
try {
|
|
1271
|
+
const value = lookup();
|
|
1272
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
1273
|
+
} catch {
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Flatten one message's content into ordered parts: text, image, and file.
|
|
1279
|
+
* Tool calls and results become short text so a turn that started on another
|
|
1280
|
+
* model still reads coherently; reasoning is not carried.
|
|
1281
|
+
*/
|
|
1282
|
+
function collectParts(blocks, parts) {
|
|
1283
|
+
for (const block of Array.isArray(blocks) ? blocks : []) {
|
|
1284
|
+
if (block === null || typeof block !== "object") continue;
|
|
1285
|
+
switch (block.type) {
|
|
1286
|
+
case "text":
|
|
1287
|
+
if (typeof block.text === "string" && block.text !== "") parts.push({
|
|
1288
|
+
kind: "text",
|
|
1289
|
+
text: block.text
|
|
1290
|
+
});
|
|
1291
|
+
break;
|
|
1292
|
+
case "image":
|
|
1293
|
+
if (block.attachment !== void 0) parts.push({
|
|
1294
|
+
kind: "image",
|
|
1295
|
+
ref: block.attachment
|
|
1296
|
+
});
|
|
626
1297
|
else parts.push({
|
|
627
1298
|
kind: "text",
|
|
628
1299
|
text: "[图片]"
|
|
@@ -996,6 +1667,23 @@ function turnUsage(lastRequest, finished) {
|
|
|
996
1667
|
cacheWriteTokens: finished?.cacheWriteTokens ?? 0
|
|
997
1668
|
};
|
|
998
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* One line naming the key a CLI run uses — by its label only — so a key
|
|
1672
|
+
* switch is visible on the very next turn, and a key passed over for its
|
|
1673
|
+
* groups is never a silent change.
|
|
1674
|
+
* @param key - the runner's `key` event.
|
|
1675
|
+
* @returns the reasoning note, newline-terminated.
|
|
1676
|
+
*/
|
|
1677
|
+
function keyNote(key) {
|
|
1678
|
+
const model = typeof key.model === "string" && key.model !== "" ? key.model : "Claude";
|
|
1679
|
+
const groups = Array.isArray(key.groups) && key.groups.length > 0 ? `(分组:${key.groups.join("、")})` : "";
|
|
1680
|
+
if (key.switchedFrom !== void 0) return `▸ 当前 Key「${key.switchedFrom}」的分组不能用 ${model},本轮改用 Key「${key.label}」${groups}\n`;
|
|
1681
|
+
if (key.uncovered === true) {
|
|
1682
|
+
const required = Array.isArray(key.required) && key.required.length > 0 ? `(需要 ${key.required.join(" / ")})` : "";
|
|
1683
|
+
return `▸ 使用 Key「${key.label}」${groups}——注意:已存的 Key 都没有能用 ${model} 的分组${required}\n`;
|
|
1684
|
+
}
|
|
1685
|
+
return `▸ 使用 Key「${key.label}」${groups}\n`;
|
|
1686
|
+
}
|
|
999
1687
|
/** One line describing a retry the CLI is making on its own. */
|
|
1000
1688
|
function retryNote(event) {
|
|
1001
1689
|
return `网关返回 ${event.status === void 0 ? "出错" : `HTTP ${event.status}`}${typeof event.error === "string" && event.error !== "" ? `(${event.error})` : ""},CLI 自动重试${event.attempt === void 0 ? "" : event.maxRetries === void 0 ? ` ${event.attempt}` : ` ${event.attempt}/${event.maxRetries}`}${event.delayMs === void 0 ? "" : `,${(event.delayMs / 1e3).toFixed(1)} 秒后`}`;
|
|
@@ -1033,11 +1721,12 @@ async function cliCall(options, { titleModel, cwdOf, modeOf, attachmentsOf, imag
|
|
|
1033
1721
|
*
|
|
1034
1722
|
* @param options - the inner adapter, the CLI runner, the CLI-backed model ids,
|
|
1035
1723
|
* the model session titles run on, the HTTP models whose titles run on
|
|
1036
|
-
* another model, the mode and working directory each turn runs with,
|
|
1037
|
-
* attachment store images are read from
|
|
1724
|
+
* another model, the mode and working directory each turn runs with, the
|
|
1725
|
+
* attachment store images are read from, and an optional refresh of a run's
|
|
1726
|
+
* key description (its groups) for explaining a refusal.
|
|
1038
1727
|
* @returns an adapter with the same provider surface.
|
|
1039
1728
|
*/
|
|
1040
|
-
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, titleRoutes = TITLE_MODEL_ROUTES, resolveMode, resolveCwd, resolveAttachments, imageLimits = CLI_IMAGE_LIMITS }) {
|
|
1729
|
+
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, titleRoutes = TITLE_MODEL_ROUTES, resolveMode, resolveCwd, resolveAttachments, imageLimits = CLI_IMAGE_LIMITS, explainKey }) {
|
|
1041
1730
|
if (inner === void 0 || inner === null) throw new Error("the CLI adapter needs an inner adapter");
|
|
1042
1731
|
if (runner === void 0 || runner === null) throw new Error("the CLI adapter needs a runner");
|
|
1043
1732
|
const cliModels = new Set(modelIds);
|
|
@@ -1060,6 +1749,34 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1060
1749
|
return;
|
|
1061
1750
|
}
|
|
1062
1751
|
};
|
|
1752
|
+
/**
|
|
1753
|
+
* Say why a CLI run failed. The request came from the official CLI, so a
|
|
1754
|
+
* client-policy refusal gets its own explanation rather than the HTTP
|
|
1755
|
+
* route's advice. PackyCode refuses a key without a Claude group either as
|
|
1756
|
+
* a routing miss (400 未覆盖供应商, which names the key's groups) or with the
|
|
1757
|
+
* official-CLI wording (403); either way the key is the cause, not the CLI.
|
|
1758
|
+
* When the run started before the key's groups were known, a fresh reading
|
|
1759
|
+
* is consulted first. A busy gateway is named as such after the CLI's own
|
|
1760
|
+
* retries ran out.
|
|
1761
|
+
*/
|
|
1762
|
+
async function explainFailure(raw, runKey, { model, retries }) {
|
|
1763
|
+
const policy = classifyClientPolicy(raw);
|
|
1764
|
+
const coverage = classifyCoverage(raw);
|
|
1765
|
+
if (!policy && !coverage) return classifyCapacity(raw) ? explainCliCapacityFailure(raw, retries) : raw;
|
|
1766
|
+
let key = runKey === void 0 ? void 0 : {
|
|
1767
|
+
model,
|
|
1768
|
+
...runKey
|
|
1769
|
+
};
|
|
1770
|
+
if (key !== void 0 && key.covers !== true && typeof explainKey === "function") key = await explainKey(key).catch(() => key) ?? key;
|
|
1771
|
+
if (coverage) return explainCliGroupFailure({
|
|
1772
|
+
model,
|
|
1773
|
+
...key,
|
|
1774
|
+
groups: parseSelectedGroups(raw) ?? key?.groups
|
|
1775
|
+
}, raw);
|
|
1776
|
+
if (key?.covers === false) return explainCliGroupFailure(key, raw);
|
|
1777
|
+
const used = key === void 0 ? "" : `(本轮使用 Key「${key.label}」${Array.isArray(key.groups) ? `,分组:${key.groups.join("、")}` : ""})`;
|
|
1778
|
+
return `${explainCliClientPolicyFailure(raw)}${used}`;
|
|
1779
|
+
}
|
|
1063
1780
|
async function* cliStream(options) {
|
|
1064
1781
|
const quiet = options?.purpose === "session-title";
|
|
1065
1782
|
const blocks = createBlockWriter();
|
|
@@ -1095,6 +1812,8 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1095
1812
|
return true;
|
|
1096
1813
|
};
|
|
1097
1814
|
let retries = 0;
|
|
1815
|
+
/** The key this run uses, as the runner described it (never the token). */
|
|
1816
|
+
let runKey;
|
|
1098
1817
|
try {
|
|
1099
1818
|
let finished;
|
|
1100
1819
|
let lastRequest;
|
|
@@ -1102,6 +1821,10 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1102
1821
|
...call,
|
|
1103
1822
|
signal: options.signal
|
|
1104
1823
|
})) switch (event.type) {
|
|
1824
|
+
case "key":
|
|
1825
|
+
runKey = event;
|
|
1826
|
+
if (!quiet) yield* blocks.note(keyNote(event));
|
|
1827
|
+
break;
|
|
1105
1828
|
case "started":
|
|
1106
1829
|
if (!quiet) {
|
|
1107
1830
|
const detail = [event.version, event.model].filter((part) => typeof part === "string" && part !== "").join(" · ");
|
|
@@ -1146,14 +1869,16 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1146
1869
|
};
|
|
1147
1870
|
} catch (error) {
|
|
1148
1871
|
yield* blocks.close();
|
|
1149
|
-
const raw = String(error?.message ?? error);
|
|
1150
1872
|
yield {
|
|
1151
1873
|
type: "finish",
|
|
1152
1874
|
reason: {
|
|
1153
1875
|
kind: "error",
|
|
1154
1876
|
failure: {
|
|
1155
1877
|
code: "PI_AI_ERROR",
|
|
1156
|
-
message:
|
|
1878
|
+
message: await explainFailure(String(error?.message ?? error), runKey, {
|
|
1879
|
+
model: options.model,
|
|
1880
|
+
retries
|
|
1881
|
+
})
|
|
1157
1882
|
}
|
|
1158
1883
|
}
|
|
1159
1884
|
};
|
|
@@ -1721,6 +2446,31 @@ function readClaudeStreamEvents(event) {
|
|
|
1721
2446
|
}];
|
|
1722
2447
|
return [];
|
|
1723
2448
|
}
|
|
2449
|
+
const nameList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string" && item !== "").slice(0, 32) : void 0;
|
|
2450
|
+
/**
|
|
2451
|
+
* What a run may say about the key it used: its ring id and label, why it was
|
|
2452
|
+
* chosen, and the groups involved. Never the token.
|
|
2453
|
+
* @param credential - what `resolveCredential` answered.
|
|
2454
|
+
* @returns the description, or undefined when the credential carries no label.
|
|
2455
|
+
*/
|
|
2456
|
+
function describeRunKey(credential) {
|
|
2457
|
+
if (credential === null || typeof credential !== "object") return void 0;
|
|
2458
|
+
const label = typeof credential.label === "string" && credential.label !== "" ? credential.label.slice(0, 60) : void 0;
|
|
2459
|
+
if (label === void 0) return void 0;
|
|
2460
|
+
const groups = nameList(credential.groups);
|
|
2461
|
+
const required = nameList(credential.required);
|
|
2462
|
+
const from = typeof credential.switchedFrom?.label === "string" && credential.switchedFrom.label !== "" ? credential.switchedFrom.label.slice(0, 60) : void 0;
|
|
2463
|
+
return {
|
|
2464
|
+
...typeof credential.id === "string" && credential.id !== "" ? { id: credential.id } : {},
|
|
2465
|
+
label,
|
|
2466
|
+
...typeof credential.model === "string" && credential.model !== "" ? { model: credential.model } : {},
|
|
2467
|
+
...groups === void 0 ? {} : { groups },
|
|
2468
|
+
...required === void 0 ? {} : { required },
|
|
2469
|
+
...typeof credential.covers === "boolean" ? { covers: credential.covers } : {},
|
|
2470
|
+
...from === void 0 ? {} : { switchedFrom: from },
|
|
2471
|
+
...credential.uncovered === true ? { uncovered: true } : {}
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
1724
2474
|
/**
|
|
1725
2475
|
* A Claude Code CLI runner.
|
|
1726
2476
|
* @param options - binary (a path, or a function resolving one per run so a
|
|
@@ -1748,7 +2498,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1748
2498
|
content,
|
|
1749
2499
|
stream
|
|
1750
2500
|
});
|
|
1751
|
-
const
|
|
2501
|
+
const credential = await resolveCredential({ model });
|
|
2502
|
+
const { baseUrl, token } = credential;
|
|
1752
2503
|
const program = typeof binary === "function" ? await binary() : binary;
|
|
1753
2504
|
const command = resolveSpawnCommand(program, { platform });
|
|
1754
2505
|
const env = withProgramDirOnPath(buildClaudeEnvironment({
|
|
@@ -1852,7 +2603,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1852
2603
|
child,
|
|
1853
2604
|
state,
|
|
1854
2605
|
closed,
|
|
1855
|
-
kill
|
|
2606
|
+
kill,
|
|
2607
|
+
key: describeRunKey(credential)
|
|
1856
2608
|
};
|
|
1857
2609
|
};
|
|
1858
2610
|
return Object.freeze({
|
|
@@ -1901,7 +2653,7 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1901
2653
|
let buffer = "";
|
|
1902
2654
|
let sawResult;
|
|
1903
2655
|
const lines = [];
|
|
1904
|
-
const { state, closed, kill } = await start({
|
|
2656
|
+
const { state, closed, kill, key } = await start({
|
|
1905
2657
|
prompt,
|
|
1906
2658
|
content,
|
|
1907
2659
|
mode,
|
|
@@ -1920,6 +2672,10 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1920
2672
|
}
|
|
1921
2673
|
}
|
|
1922
2674
|
});
|
|
2675
|
+
if (key !== void 0) yield {
|
|
2676
|
+
type: "key",
|
|
2677
|
+
...key
|
|
2678
|
+
};
|
|
1923
2679
|
const drain = function* () {
|
|
1924
2680
|
while (lines.length > 0) {
|
|
1925
2681
|
const line = lines.shift().trim();
|
|
@@ -2355,72 +3111,355 @@ function createClaudeCliManager({ explicitPath = () => "", platform = process.pl
|
|
|
2355
3111
|
});
|
|
2356
3112
|
}
|
|
2357
3113
|
//#endregion
|
|
2358
|
-
//#region src/
|
|
3114
|
+
//#region src/permission.js
|
|
2359
3115
|
/**
|
|
2360
|
-
* The
|
|
3116
|
+
* The Claude Code CLI mode a session's own permissions allow.
|
|
2361
3117
|
*
|
|
2362
|
-
*
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
2365
|
-
*
|
|
2366
|
-
* attempt — the harness retry of the failed step — resolves a different key
|
|
2367
|
-
* through the adapter's per-request `resolveApiKey`.
|
|
3118
|
+
* A CLI-backed turn runs the CLI's own tool loop, outside the harness sandbox
|
|
3119
|
+
* and approval flow. Deriving its mode from the session's permission preset
|
|
3120
|
+
* means the user grants it once, in the place they already grant the harness
|
|
3121
|
+
* itself, instead of in a separate plugin setting.
|
|
2368
3122
|
*
|
|
2369
|
-
* @module dsh-packycode-api/
|
|
3123
|
+
* @module dsh-packycode-api/permission
|
|
2370
3124
|
*/
|
|
2371
|
-
/**
|
|
2372
|
-
const
|
|
2373
|
-
const
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
function assertApiKey(value) {
|
|
2379
|
-
const key = text(value).trim();
|
|
2380
|
-
if (key.length < 8 || key.length > 512 || !LEGAL_KEY.test(key)) throw new Error("PackyCode API key must be 8-512 printable ASCII characters without spaces");
|
|
2381
|
-
return key;
|
|
2382
|
-
}
|
|
2383
|
-
function entry(value) {
|
|
2384
|
-
if (value === null || typeof value !== "object") return void 0;
|
|
2385
|
-
const key = text(value.key);
|
|
2386
|
-
if (key === "" || !LEGAL_KEY.test(key)) return void 0;
|
|
2387
|
-
const id = text(value.id);
|
|
2388
|
-
return {
|
|
2389
|
-
id: id === "" ? randomUUID().slice(0, 8) : id,
|
|
2390
|
-
label: text(value.label),
|
|
2391
|
-
key,
|
|
2392
|
-
addedAt: Number.isFinite(value.addedAt) ? value.addedAt : 0,
|
|
2393
|
-
...Number.isFinite(value.disabledAt) ? { disabledAt: value.disabledAt } : {},
|
|
2394
|
-
...text(value.disabledReason) === "" ? {} : { disabledReason: text(value.disabledReason) }
|
|
2395
|
-
};
|
|
2396
|
-
}
|
|
2397
|
-
/** Tolerate a hand-edited or older record instead of failing the whole route. */
|
|
2398
|
-
function normalizeState(record) {
|
|
2399
|
-
const payload = record !== null && typeof record === "object" && record.kind === "grant" && record.payload !== null && typeof record.payload === "object" ? record.payload : void 0;
|
|
2400
|
-
const keys = (Array.isArray(payload?.keys) ? payload.keys : []).map(entry).filter((candidate) => candidate !== void 0);
|
|
2401
|
-
const activeId = text(payload?.activeId);
|
|
2402
|
-
return {
|
|
2403
|
-
activeId: keys.some((candidate) => candidate.id === activeId) ? activeId : keys[0]?.id,
|
|
2404
|
-
keys
|
|
2405
|
-
};
|
|
2406
|
-
}
|
|
3125
|
+
/** Configured value meaning "follow the session's permission preset". */
|
|
3126
|
+
const AUTO_CLAUDE_MODE = "auto";
|
|
3127
|
+
const RANK = Object.freeze({
|
|
3128
|
+
"read-only": 0,
|
|
3129
|
+
edit: 1,
|
|
3130
|
+
full: 2
|
|
3131
|
+
});
|
|
2407
3132
|
/**
|
|
2408
|
-
*
|
|
2409
|
-
*
|
|
2410
|
-
* the
|
|
2411
|
-
*
|
|
2412
|
-
* @
|
|
3133
|
+
* Map the harness's two permission knobs onto a CLI mode.
|
|
3134
|
+
*
|
|
3135
|
+
* The CLI cannot ask the harness user for approval, so it skips its own
|
|
3136
|
+
* permission checks only when the session itself neither sandboxes nor asks.
|
|
3137
|
+
* @param sandbox - the session's resolved sandbox mode.
|
|
3138
|
+
* @param approval - the session's effective approval policy.
|
|
3139
|
+
* @returns a CLI mode, or `undefined` for an unknown sandbox mode.
|
|
2413
3140
|
*/
|
|
2414
|
-
function
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
3141
|
+
function claudeModeFor(sandbox, approval) {
|
|
3142
|
+
switch (sandbox) {
|
|
3143
|
+
case "read-only": return "read-only";
|
|
3144
|
+
case "workspace-write": return "edit";
|
|
3145
|
+
case "danger-full-access": return approval === "never" ? "full" : "edit";
|
|
3146
|
+
default: return;
|
|
3147
|
+
}
|
|
2419
3148
|
}
|
|
2420
|
-
/**
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
3149
|
+
/**
|
|
3150
|
+
* Read a session's CLI mode from the host's permission services.
|
|
3151
|
+
* @param ctx - the plugin context; services are read without an inject requirement.
|
|
3152
|
+
* @param session - the session whose permission state is read.
|
|
3153
|
+
* @returns a CLI mode, or `undefined` when the session or the services are absent.
|
|
3154
|
+
*/
|
|
3155
|
+
function claudeModeForSession(ctx, session) {
|
|
3156
|
+
if (session === void 0 || session === null) return void 0;
|
|
3157
|
+
try {
|
|
3158
|
+
const sandbox = ctx.get?.("sandboxPolicy")?.resolve?.({ session })?.mode;
|
|
3159
|
+
const approval = ctx.get?.("approval")?.effectivePolicy?.(session);
|
|
3160
|
+
return claudeModeFor(sandbox, approval);
|
|
3161
|
+
} catch {
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* The narrower of two modes; an unknown ceiling leaves the request as it is.
|
|
3167
|
+
* @param requested - the mode a caller asked for.
|
|
3168
|
+
* @param ceiling - the most the session allows.
|
|
3169
|
+
* @returns the mode the CLI runs with.
|
|
3170
|
+
*/
|
|
3171
|
+
function capClaudeMode(requested, ceiling) {
|
|
3172
|
+
if (ceiling === void 0 || RANK[ceiling] === void 0) return requested;
|
|
3173
|
+
if (requested === void 0 || RANK[requested] === void 0) return ceiling;
|
|
3174
|
+
return RANK[requested] > RANK[ceiling] ? ceiling : requested;
|
|
3175
|
+
}
|
|
3176
|
+
//#endregion
|
|
3177
|
+
//#region src/claude-tool.js
|
|
3178
|
+
/**
|
|
3179
|
+
* The explicit `claude_code` delegation tool, for asking Claude Code for
|
|
3180
|
+
* something while another model drives the session. It shares the runner the
|
|
3181
|
+
* CLI-backed models use.
|
|
3182
|
+
*
|
|
3183
|
+
* @module dsh-packycode-api/claude-tool
|
|
3184
|
+
*/
|
|
3185
|
+
/** What a `claude_code` tool run is told about where its task came from. */
|
|
3186
|
+
const CLAUDE_TOOL_INSTRUCTIONS = "This task was delegated to Claude Code by another agent in DeepSeek Harness through its claude_code tool. The task text is self-contained; complete it with your own tools and reply with the final result only.";
|
|
3187
|
+
/**
|
|
3188
|
+
* @param options - the CLI runner, and the most a run may do for a session.
|
|
3189
|
+
* @returns the tool definition to register.
|
|
3190
|
+
*/
|
|
3191
|
+
function createClaudeCodeTool({ runner, modeCeiling }) {
|
|
3192
|
+
return defineTool({
|
|
3193
|
+
name: "claude_code",
|
|
3194
|
+
description: "Delegate a task to the local Claude Code CLI (the official `claude` binary), which runs it with its own tools and returns its final answer. Use it when the user asks for Claude or when a task suits Claude Code. The child runs with this plugin's PackyCode endpoint and API key, in an isolated config home. `mode` bounds what it may do on its own: \"read-only\" (Read/Grep/Glob), \"edit\" (also Edit/Write), \"full\" (bypasses the CLI's permission checks, including shell commands); it defaults to, and is capped at, what this session's permission preset allows. It starts a fresh CLI session per call, takes tens of seconds, and bills through the same PackyCode key. Its internal tool calls are not part of this session's transcript — only the returned answer is.",
|
|
3195
|
+
parameters: {
|
|
3196
|
+
prompt: {
|
|
3197
|
+
type: "string",
|
|
3198
|
+
required: true,
|
|
3199
|
+
description: "The task for Claude Code, written as a self-contained instruction (it does not see this conversation)."
|
|
3200
|
+
},
|
|
3201
|
+
mode: {
|
|
3202
|
+
type: "string",
|
|
3203
|
+
enum: Object.keys(CLAUDE_CLI_MODES),
|
|
3204
|
+
description: "What the CLI may do on its own. Defaults to the most this session's permission preset allows; a wider mode is capped to it. Ask for \"full\" only when the task genuinely needs shell commands."
|
|
3205
|
+
},
|
|
3206
|
+
model: {
|
|
3207
|
+
type: "string",
|
|
3208
|
+
description: "Model id passed to the CLI (for example \"claude-opus-5-5\", \"claude-sonnet-5-5\", or \"claude-haiku-5-5\"). Omit to use the CLI's own default."
|
|
3209
|
+
},
|
|
3210
|
+
cwd: {
|
|
3211
|
+
type: "string",
|
|
3212
|
+
description: "Working directory for the CLI. Defaults to this session's workspace; a relative path resolves against it."
|
|
3213
|
+
}
|
|
3214
|
+
},
|
|
3215
|
+
output: {
|
|
3216
|
+
schema: {
|
|
3217
|
+
type: "object",
|
|
3218
|
+
additionalProperties: false,
|
|
3219
|
+
properties: {
|
|
3220
|
+
result: {
|
|
3221
|
+
type: "string",
|
|
3222
|
+
required: true
|
|
3223
|
+
},
|
|
3224
|
+
model: {
|
|
3225
|
+
type: "string",
|
|
3226
|
+
required: true
|
|
3227
|
+
},
|
|
3228
|
+
mode: {
|
|
3229
|
+
type: "string",
|
|
3230
|
+
required: true
|
|
3231
|
+
},
|
|
3232
|
+
costUsd: {
|
|
3233
|
+
type: "number",
|
|
3234
|
+
required: true
|
|
3235
|
+
},
|
|
3236
|
+
durationMs: {
|
|
3237
|
+
type: "number",
|
|
3238
|
+
required: true
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
},
|
|
3242
|
+
render: (_args, value) => [{
|
|
3243
|
+
type: "text",
|
|
3244
|
+
text: `${value.result}\n\n— Claude Code CLI · ${value.model || "default model"} · ${value.mode} · ${Math.round(value.durationMs / 1e3)}s · $${value.costUsd.toFixed(4)}`
|
|
3245
|
+
}]
|
|
3246
|
+
},
|
|
3247
|
+
async execute(args, exec) {
|
|
3248
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd;
|
|
3249
|
+
const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
|
|
3250
|
+
const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
|
|
3251
|
+
return await runner.run({
|
|
3252
|
+
prompt: args.prompt,
|
|
3253
|
+
mode: capClaudeMode(args.mode, modeCeiling(exec?.agent?.session)) ?? "edit",
|
|
3254
|
+
model: args.model,
|
|
3255
|
+
appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
|
|
3256
|
+
cwd,
|
|
3257
|
+
signal: exec?.signal
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3262
|
+
//#endregion
|
|
3263
|
+
//#region src/key-chooser.js
|
|
3264
|
+
/**
|
|
3265
|
+
* Which stored key a request runs with, and what happens when the gateway
|
|
3266
|
+
* rejects one.
|
|
3267
|
+
*
|
|
3268
|
+
* The ring's active key serves every request it can. A request for a model
|
|
3269
|
+
* the active key's groups cannot reach — as the last group reading shows —
|
|
3270
|
+
* runs on the next stored key that can; the active pointer itself does not
|
|
3271
|
+
* move. A key the gateway reports as exhausted or invalid is marked unusable
|
|
3272
|
+
* and the ring moves on, so the retry of the failed step resolves the next
|
|
3273
|
+
* key. Only keys stored in this plugin's ring are ever chosen.
|
|
3274
|
+
*
|
|
3275
|
+
* @module dsh-packycode-api/key-chooser
|
|
3276
|
+
*/
|
|
3277
|
+
/** How long a request with a choice of keys waits for the first group reading. */
|
|
3278
|
+
const DEFAULT_GROUP_WAIT_MS = 6e3;
|
|
3279
|
+
/**
|
|
3280
|
+
* @param options - the key ring, the group reader, what to do after a key
|
|
3281
|
+
* moved aside, a logger, and how long to wait for the first group reading.
|
|
3282
|
+
* @returns the chooser.
|
|
3283
|
+
*/
|
|
3284
|
+
function createKeyChooser({ ring, groups, onRotated, logger, waitMs = DEFAULT_GROUP_WAIT_MS }) {
|
|
3285
|
+
/**
|
|
3286
|
+
* Key material to ring id. A failure is reported from inside a stream, which
|
|
3287
|
+
* carries the request's key but not its id; this index is refreshed on every
|
|
3288
|
+
* resolution so the right key is the one that moves aside.
|
|
3289
|
+
*/
|
|
3290
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
3291
|
+
/** The ring as of the last resolution, for the synchronous per-request choice. */
|
|
3292
|
+
let snapshot = [];
|
|
3293
|
+
const remember = async () => {
|
|
3294
|
+
const entries = await ring.secrets();
|
|
3295
|
+
snapshot = entries;
|
|
3296
|
+
for (const entry of entries) idByKey.set(entry.key, entry.id);
|
|
3297
|
+
};
|
|
3298
|
+
const usable = () => snapshot.filter((entry) => entry.disabled !== true);
|
|
3299
|
+
const covers = (keyId, modelId) => groups.covers(keyId, modelId);
|
|
3300
|
+
/**
|
|
3301
|
+
* Keep the group reading fresh for the choice. It only matters with two
|
|
3302
|
+
* usable keys or more; the first reading is awaited for at most `waitMs`,
|
|
3303
|
+
* later ones refresh in the background.
|
|
3304
|
+
*/
|
|
3305
|
+
const warm = async () => {
|
|
3306
|
+
if (usable().length < 2) return;
|
|
3307
|
+
const pending = groups.read().catch(() => void 0);
|
|
3308
|
+
if (groups.hasReading()) return;
|
|
3309
|
+
let timer;
|
|
3310
|
+
await Promise.race([pending, new Promise((resolve) => {
|
|
3311
|
+
timer = setTimeout(resolve, waitMs);
|
|
3312
|
+
})]);
|
|
3313
|
+
clearTimeout(timer);
|
|
3314
|
+
};
|
|
3315
|
+
/** The active key, after refreshing the ring and, when it matters, the groups. */
|
|
3316
|
+
const active = async () => {
|
|
3317
|
+
await remember();
|
|
3318
|
+
const key = await ring.activeKey();
|
|
3319
|
+
if (key !== void 0) await warm();
|
|
3320
|
+
return key;
|
|
3321
|
+
};
|
|
3322
|
+
return Object.freeze({
|
|
3323
|
+
remember,
|
|
3324
|
+
covers,
|
|
3325
|
+
active,
|
|
3326
|
+
/**
|
|
3327
|
+
* The key one HTTP request for `modelId` runs with, given the active key
|
|
3328
|
+
* pi-ai resolved. Synchronous because pi-ai opens its stream synchronously;
|
|
3329
|
+
* it reads the ring and groups as of the last {@link active}.
|
|
3330
|
+
* @returns the key material to send.
|
|
3331
|
+
*/
|
|
3332
|
+
forRequest(modelId, apiKey) {
|
|
3333
|
+
const current = snapshot.find((entry) => entry.key === apiKey);
|
|
3334
|
+
if (current === void 0) return apiKey;
|
|
3335
|
+
const chosen = pickKeyForModel(modelId, current, usable(), covers);
|
|
3336
|
+
if (chosen?.switchedFrom !== void 0) logger?.info?.("packycode-api: key %s cannot reach %s by its last group reading; this request uses key %s", current.id, modelId, chosen.id);
|
|
3337
|
+
return chosen?.key ?? apiKey;
|
|
3338
|
+
},
|
|
3339
|
+
/**
|
|
3340
|
+
* The key one CLI run for `modelId` uses, with what the run may say about
|
|
3341
|
+
* it: its label, its groups, the groups the model needs, and why it was
|
|
3342
|
+
* chosen.
|
|
3343
|
+
* @returns the choice, or undefined when no usable key is stored.
|
|
3344
|
+
*/
|
|
3345
|
+
async forRun(modelId) {
|
|
3346
|
+
const current = await active();
|
|
3347
|
+
if (current === void 0) return void 0;
|
|
3348
|
+
const chosen = pickKeyForModel(modelId, current, usable(), covers);
|
|
3349
|
+
return {
|
|
3350
|
+
...chosen,
|
|
3351
|
+
model: modelId,
|
|
3352
|
+
groups: groups.groupsOf(chosen.id),
|
|
3353
|
+
required: groups.requiredGroups(modelId),
|
|
3354
|
+
covers: covers(chosen.id, modelId)
|
|
3355
|
+
};
|
|
3356
|
+
},
|
|
3357
|
+
/**
|
|
3358
|
+
* Refresh what a run said about its key from the group reading, waiting
|
|
3359
|
+
* at most `waitMs` for a reading in flight.
|
|
3360
|
+
* @param runKey - the run's key description (`id`, `model`, …).
|
|
3361
|
+
* @returns the description with the reading's groups and coverage.
|
|
3362
|
+
*/
|
|
3363
|
+
async describe(runKey) {
|
|
3364
|
+
let timer;
|
|
3365
|
+
await Promise.race([groups.read().catch(() => void 0), new Promise((resolve) => {
|
|
3366
|
+
timer = setTimeout(resolve, waitMs);
|
|
3367
|
+
})]);
|
|
3368
|
+
clearTimeout(timer);
|
|
3369
|
+
if (typeof runKey?.id !== "string") return runKey;
|
|
3370
|
+
return {
|
|
3371
|
+
...runKey,
|
|
3372
|
+
groups: groups.groupsOf(runKey.id) ?? runKey.groups,
|
|
3373
|
+
required: groups.requiredGroups(runKey.model) ?? runKey.required,
|
|
3374
|
+
covers: covers(runKey.id, runKey.model) ?? runKey.covers
|
|
3375
|
+
};
|
|
3376
|
+
},
|
|
3377
|
+
/**
|
|
3378
|
+
* The gateway exhausted or rejected a key: mark it unusable so the ring
|
|
3379
|
+
* moves on, then let the caller drop what depended on it.
|
|
3380
|
+
* @param kind - `quota` or `invalid-key`.
|
|
3381
|
+
* @param _message - the failure text (unused; the kind decides).
|
|
3382
|
+
* @param key - the key material the failed request carried.
|
|
3383
|
+
* @param knownId - the key's ring id, when the caller has it.
|
|
3384
|
+
*/
|
|
3385
|
+
onKeyFailure(kind, _message, key, knownId) {
|
|
3386
|
+
const id = knownId ?? (key === void 0 ? void 0 : idByKey.get(key));
|
|
3387
|
+
if (id === void 0) {
|
|
3388
|
+
logger?.warn?.("packycode-api: %s reported for an unidentified key; not rotating", kind);
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
logger?.warn?.("packycode-api: %s on key %s; the next key serves the retry", kind, id);
|
|
3392
|
+
ring.disable(id, kind).then(() => onRotated?.()).catch((error) => logger?.warn?.("packycode-api: could not move past key %s: %s", id, error.message));
|
|
3393
|
+
}
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
//#endregion
|
|
3397
|
+
//#region src/key-ring.js
|
|
3398
|
+
/**
|
|
3399
|
+
* The PackyCode key ring.
|
|
3400
|
+
*
|
|
3401
|
+
* Keys live in one DSH credential *record* (`packycode-api/keys`), which is
|
|
3402
|
+
* the cross-process-serialized read-decide-replace space the credential
|
|
3403
|
+
* service owns. A key the gateway exhausts or rejects is marked unusable and
|
|
3404
|
+
* the ring moves the active pointer to the next usable key, so the next
|
|
3405
|
+
* attempt — the harness retry of the failed step — resolves a different key
|
|
3406
|
+
* through the adapter's per-request `resolveApiKey`.
|
|
3407
|
+
*
|
|
3408
|
+
* @module dsh-packycode-api/key-ring
|
|
3409
|
+
*/
|
|
3410
|
+
/** Printable ASCII without spaces: what an HTTP header can carry unchanged. */
|
|
3411
|
+
const LEGAL_KEY = /^[\x21-\x7E]+$/u;
|
|
3412
|
+
const RECORD_VERSION = 1;
|
|
3413
|
+
const MAX_KEYS = 32;
|
|
3414
|
+
const RECORD_KEY = typeof credentialKey === "function" ? credentialKey(PLUGIN_ID, KEY_RECORD_ID) : void 0;
|
|
3415
|
+
const text$1 = (value) => typeof value === "string" ? value : "";
|
|
3416
|
+
/** Validate a pasted key before it is stored; the gateway never sees a bad one from us. */
|
|
3417
|
+
function assertApiKey(value) {
|
|
3418
|
+
const key = text$1(value).trim();
|
|
3419
|
+
if (key.length < 8 || key.length > 512 || !LEGAL_KEY.test(key)) throw new Error("PackyCode API key must be 8-512 printable ASCII characters without spaces");
|
|
3420
|
+
return key;
|
|
3421
|
+
}
|
|
3422
|
+
function entry(value) {
|
|
3423
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
3424
|
+
const key = text$1(value.key);
|
|
3425
|
+
if (key === "" || !LEGAL_KEY.test(key)) return void 0;
|
|
3426
|
+
const id = text$1(value.id);
|
|
3427
|
+
return {
|
|
3428
|
+
id: id === "" ? randomUUID().slice(0, 8) : id,
|
|
3429
|
+
label: text$1(value.label),
|
|
3430
|
+
key,
|
|
3431
|
+
addedAt: Number.isFinite(value.addedAt) ? value.addedAt : 0,
|
|
3432
|
+
...Number.isFinite(value.disabledAt) ? { disabledAt: value.disabledAt } : {},
|
|
3433
|
+
...text$1(value.disabledReason) === "" ? {} : { disabledReason: text$1(value.disabledReason) }
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
/** Tolerate a hand-edited or older record instead of failing the whole route. */
|
|
3437
|
+
function normalizeState(record) {
|
|
3438
|
+
const payload = record !== null && typeof record === "object" && record.kind === "grant" && record.payload !== null && typeof record.payload === "object" ? record.payload : void 0;
|
|
3439
|
+
const keys = (Array.isArray(payload?.keys) ? payload.keys : []).map(entry).filter((candidate) => candidate !== void 0);
|
|
3440
|
+
const activeId = text$1(payload?.activeId);
|
|
3441
|
+
return {
|
|
3442
|
+
activeId: keys.some((candidate) => candidate.id === activeId) ? activeId : keys[0]?.id,
|
|
3443
|
+
keys
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* The next free `Key N` name. The ring shrinks on removal, so numbering follows
|
|
3448
|
+
* the highest name in use instead of the current key count — otherwise removing
|
|
3449
|
+
* the first key and adding another produces a second "Key 2".
|
|
3450
|
+
* @param keys - the stored keys.
|
|
3451
|
+
* @returns an unused default label.
|
|
3452
|
+
*/
|
|
3453
|
+
function nextDefaultLabel(keys) {
|
|
3454
|
+
const used = new Set(keys.map((candidate) => candidate.label));
|
|
3455
|
+
let index = keys.length + 1;
|
|
3456
|
+
while (used.has(`Key ${index}`)) index += 1;
|
|
3457
|
+
return `Key ${index}`;
|
|
3458
|
+
}
|
|
3459
|
+
/** The public shape: what a browser or a log may see, never key material. */
|
|
3460
|
+
function projection(state) {
|
|
3461
|
+
return {
|
|
3462
|
+
activeId: state.activeId,
|
|
2424
3463
|
keys: state.keys.map((candidate) => ({
|
|
2425
3464
|
id: candidate.id,
|
|
2426
3465
|
label: candidate.label,
|
|
@@ -2501,7 +3540,7 @@ var PackyCodeKeyRing = class {
|
|
|
2501
3540
|
}
|
|
2502
3541
|
async add(key, label) {
|
|
2503
3542
|
const value = assertApiKey(key);
|
|
2504
|
-
const name = text(label).trim().slice(0, 60);
|
|
3543
|
+
const name = text$1(label).trim().slice(0, 60);
|
|
2505
3544
|
return projection(await this.#write((state) => {
|
|
2506
3545
|
if (state.keys.length >= MAX_KEYS) throw new Error(`PackyCode accepts at most ${MAX_KEYS} keys`);
|
|
2507
3546
|
if (state.keys.some((candidate) => candidate.key === value)) throw new Error("That PackyCode API key is already stored");
|
|
@@ -2518,7 +3557,7 @@ var PackyCodeKeyRing = class {
|
|
|
2518
3557
|
}));
|
|
2519
3558
|
}
|
|
2520
3559
|
async rename(id, label) {
|
|
2521
|
-
const name = text(label).trim().slice(0, 60);
|
|
3560
|
+
const name = text$1(label).trim().slice(0, 60);
|
|
2522
3561
|
if (name === "") throw new Error("That PackyCode key name cannot be empty");
|
|
2523
3562
|
return projection(await this.#write((state) => {
|
|
2524
3563
|
if (!state.keys.some((candidate) => candidate.id === id)) throw new Error("Unknown PackyCode key");
|
|
@@ -2581,7 +3620,7 @@ var PackyCodeKeyRing = class {
|
|
|
2581
3620
|
const keys = state.keys.map((candidate) => candidate.id === id ? {
|
|
2582
3621
|
...candidate,
|
|
2583
3622
|
disabledAt: now,
|
|
2584
|
-
disabledReason: text(reason) === "" ? "unusable" : text(reason)
|
|
3623
|
+
disabledReason: text$1(reason) === "" ? "unusable" : text$1(reason)
|
|
2585
3624
|
} : candidate);
|
|
2586
3625
|
if (!keys.some((candidate) => candidate.id === id)) return {
|
|
2587
3626
|
activeId: state.activeId,
|
|
@@ -2595,69 +3634,6 @@ var PackyCodeKeyRing = class {
|
|
|
2595
3634
|
}
|
|
2596
3635
|
};
|
|
2597
3636
|
//#endregion
|
|
2598
|
-
//#region src/permission.js
|
|
2599
|
-
/**
|
|
2600
|
-
* The Claude Code CLI mode a session's own permissions allow.
|
|
2601
|
-
*
|
|
2602
|
-
* A CLI-backed turn runs the CLI's own tool loop, outside the harness sandbox
|
|
2603
|
-
* and approval flow. Deriving its mode from the session's permission preset
|
|
2604
|
-
* means the user grants it once, in the place they already grant the harness
|
|
2605
|
-
* itself, instead of in a separate plugin setting.
|
|
2606
|
-
*
|
|
2607
|
-
* @module dsh-packycode-api/permission
|
|
2608
|
-
*/
|
|
2609
|
-
/** Configured value meaning "follow the session's permission preset". */
|
|
2610
|
-
const AUTO_CLAUDE_MODE = "auto";
|
|
2611
|
-
const RANK = Object.freeze({
|
|
2612
|
-
"read-only": 0,
|
|
2613
|
-
edit: 1,
|
|
2614
|
-
full: 2
|
|
2615
|
-
});
|
|
2616
|
-
/**
|
|
2617
|
-
* Map the harness's two permission knobs onto a CLI mode.
|
|
2618
|
-
*
|
|
2619
|
-
* The CLI cannot ask the harness user for approval, so it skips its own
|
|
2620
|
-
* permission checks only when the session itself neither sandboxes nor asks.
|
|
2621
|
-
* @param sandbox - the session's resolved sandbox mode.
|
|
2622
|
-
* @param approval - the session's effective approval policy.
|
|
2623
|
-
* @returns a CLI mode, or `undefined` for an unknown sandbox mode.
|
|
2624
|
-
*/
|
|
2625
|
-
function claudeModeFor(sandbox, approval) {
|
|
2626
|
-
switch (sandbox) {
|
|
2627
|
-
case "read-only": return "read-only";
|
|
2628
|
-
case "workspace-write": return "edit";
|
|
2629
|
-
case "danger-full-access": return approval === "never" ? "full" : "edit";
|
|
2630
|
-
default: return;
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
/**
|
|
2634
|
-
* Read a session's CLI mode from the host's permission services.
|
|
2635
|
-
* @param ctx - the plugin context; services are read without an inject requirement.
|
|
2636
|
-
* @param session - the session whose permission state is read.
|
|
2637
|
-
* @returns a CLI mode, or `undefined` when the session or the services are absent.
|
|
2638
|
-
*/
|
|
2639
|
-
function claudeModeForSession(ctx, session) {
|
|
2640
|
-
if (session === void 0 || session === null) return void 0;
|
|
2641
|
-
try {
|
|
2642
|
-
const sandbox = ctx.get?.("sandboxPolicy")?.resolve?.({ session })?.mode;
|
|
2643
|
-
const approval = ctx.get?.("approval")?.effectivePolicy?.(session);
|
|
2644
|
-
return claudeModeFor(sandbox, approval);
|
|
2645
|
-
} catch {
|
|
2646
|
-
return;
|
|
2647
|
-
}
|
|
2648
|
-
}
|
|
2649
|
-
/**
|
|
2650
|
-
* The narrower of two modes; an unknown ceiling leaves the request as it is.
|
|
2651
|
-
* @param requested - the mode a caller asked for.
|
|
2652
|
-
* @param ceiling - the most the session allows.
|
|
2653
|
-
* @returns the mode the CLI runs with.
|
|
2654
|
-
*/
|
|
2655
|
-
function capClaudeMode(requested, ceiling) {
|
|
2656
|
-
if (ceiling === void 0 || RANK[ceiling] === void 0) return requested;
|
|
2657
|
-
if (requested === void 0 || RANK[requested] === void 0) return ceiling;
|
|
2658
|
-
return RANK[requested] > RANK[ceiling] ? ceiling : requested;
|
|
2659
|
-
}
|
|
2660
|
-
//#endregion
|
|
2661
3637
|
//#region src/plugin-version.js
|
|
2662
3638
|
/**
|
|
2663
3639
|
* Version self-knowledge for the settings panel.
|
|
@@ -2675,36 +3651,6 @@ const NPM_REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/late
|
|
|
2675
3651
|
const DEFAULT_VERSION_TTL_MS = 5 * 6e4;
|
|
2676
3652
|
const DEFAULT_VERSION_TIMEOUT_MS = 1e4;
|
|
2677
3653
|
const DEFAULT_UPDATE_TIMEOUT_MS = 18e4;
|
|
2678
|
-
const clone = (value) => structuredClone(value);
|
|
2679
|
-
/**
|
|
2680
|
-
* Resolve fetch at call time. A sibling plugin may wrap `globalThis.fetch`
|
|
2681
|
-
* and later dismantle that wrapper; a binding captured during `apply()` would
|
|
2682
|
-
* keep the dead wrapper for the process lifetime.
|
|
2683
|
-
*/
|
|
2684
|
-
function ambientFetch(input, init) {
|
|
2685
|
-
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
2686
|
-
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
2687
|
-
return globalThis.fetch(input, {
|
|
2688
|
-
...init,
|
|
2689
|
-
headers
|
|
2690
|
-
});
|
|
2691
|
-
}
|
|
2692
|
-
/** One caller's cancellation must not abort the shared registry read. */
|
|
2693
|
-
function waitForSharedRequest(promise, signal) {
|
|
2694
|
-
if (signal === void 0) return promise;
|
|
2695
|
-
signal.throwIfAborted();
|
|
2696
|
-
return new Promise((resolve, reject) => {
|
|
2697
|
-
const onAbort = () => reject(signal.reason);
|
|
2698
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2699
|
-
promise.then((value) => {
|
|
2700
|
-
signal.removeEventListener("abort", onAbort);
|
|
2701
|
-
resolve(value);
|
|
2702
|
-
}, (error) => {
|
|
2703
|
-
signal.removeEventListener("abort", onAbort);
|
|
2704
|
-
reject(error);
|
|
2705
|
-
});
|
|
2706
|
-
});
|
|
2707
|
-
}
|
|
2708
3654
|
/** Parse a strict `x.y.z[-pre][+build]` version; anything else is undefined. */
|
|
2709
3655
|
function parseSemver(value) {
|
|
2710
3656
|
if (typeof value !== "string") return void 0;
|
|
@@ -2906,22 +3852,17 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
2906
3852
|
argv,
|
|
2907
3853
|
execPath
|
|
2908
3854
|
});
|
|
2909
|
-
let cached;
|
|
2910
|
-
let inFlight;
|
|
2911
|
-
let generation = 0;
|
|
2912
3855
|
let updating = false;
|
|
2913
3856
|
const fetchLatest = async (signal) => {
|
|
2914
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
2915
|
-
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
2916
3857
|
let response;
|
|
2917
3858
|
try {
|
|
2918
|
-
response = await
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
signal
|
|
3859
|
+
response = await boundedFetch(registryUrl, { headers: { Accept: "application/json" } }, {
|
|
3860
|
+
fetchImpl,
|
|
3861
|
+
timeoutMs,
|
|
3862
|
+
signal
|
|
2922
3863
|
});
|
|
2923
3864
|
} catch (error) {
|
|
2924
|
-
if (
|
|
3865
|
+
if (error.aborted) throw error.cause;
|
|
2925
3866
|
throw new Error("PackyCode plugin version check failed");
|
|
2926
3867
|
}
|
|
2927
3868
|
if (!response.ok) throw new Error("PackyCode plugin version check failed");
|
|
@@ -2957,36 +3898,20 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
2957
3898
|
fetchedAt: now()
|
|
2958
3899
|
};
|
|
2959
3900
|
};
|
|
3901
|
+
const cache = createCachedRead(load, {
|
|
3902
|
+
ttlMs,
|
|
3903
|
+
now
|
|
3904
|
+
});
|
|
2960
3905
|
return Object.freeze({
|
|
2961
|
-
|
|
3906
|
+
read: (options) => cache.read(options),
|
|
3907
|
+
async update({ signal } = {}) {
|
|
2962
3908
|
signal?.throwIfAborted();
|
|
2963
|
-
if (
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
});
|
|
2970
|
-
};
|
|
2971
|
-
if (force) return clone(await createPending(signal));
|
|
2972
|
-
const pending = inFlight ?? createPending();
|
|
2973
|
-
if (inFlight === void 0) {
|
|
2974
|
-
inFlight = pending;
|
|
2975
|
-
const clear = () => {
|
|
2976
|
-
if (inFlight === pending) inFlight = void 0;
|
|
2977
|
-
};
|
|
2978
|
-
pending.then(clear, clear);
|
|
2979
|
-
}
|
|
2980
|
-
return clone(await waitForSharedRequest(pending, signal));
|
|
2981
|
-
},
|
|
2982
|
-
async update({ signal } = {}) {
|
|
2983
|
-
signal?.throwIfAborted();
|
|
2984
|
-
if (updating) throw new Error("PackyCode plugin update is already running");
|
|
2985
|
-
updating = true;
|
|
2986
|
-
try {
|
|
2987
|
-
const install = await findInstall({
|
|
2988
|
-
dshHome,
|
|
2989
|
-
ownPackageJsonUrl
|
|
3909
|
+
if (updating) throw new Error("PackyCode plugin update is already running");
|
|
3910
|
+
updating = true;
|
|
3911
|
+
try {
|
|
3912
|
+
const install = await findInstall({
|
|
3913
|
+
dshHome,
|
|
3914
|
+
ownPackageJsonUrl
|
|
2990
3915
|
});
|
|
2991
3916
|
if (install.kind === "link") throw new Error("PackyCode plugin is installed from a local checkout");
|
|
2992
3917
|
if (install.kind !== "npm" || install.profile === void 0) throw new Error("PackyCode plugin update could not find the owning profile");
|
|
@@ -3018,8 +3943,7 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
3018
3943
|
expected: latest,
|
|
3019
3944
|
settleDelayMs
|
|
3020
3945
|
});
|
|
3021
|
-
|
|
3022
|
-
cached = void 0;
|
|
3946
|
+
cache.invalidate();
|
|
3023
3947
|
return {
|
|
3024
3948
|
version: latest,
|
|
3025
3949
|
profile: install.profile
|
|
@@ -3028,172 +3952,88 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
3028
3952
|
updating = false;
|
|
3029
3953
|
}
|
|
3030
3954
|
},
|
|
3031
|
-
invalidate()
|
|
3032
|
-
generation += 1;
|
|
3033
|
-
cached = void 0;
|
|
3034
|
-
}
|
|
3955
|
+
invalidate: () => cache.invalidate()
|
|
3035
3956
|
});
|
|
3036
3957
|
}
|
|
3037
3958
|
//#endregion
|
|
3038
|
-
//#region src/
|
|
3959
|
+
//#region src/preferences.js
|
|
3039
3960
|
/**
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
* The gateway's own guidance for these routes, which the base URLs and pinned
|
|
3043
|
-
* protocols below exist to satisfy:
|
|
3044
|
-
* - OpenAI/Codex traffic takes `/v1` in the base URL and never an endpoint
|
|
3045
|
-
* path — the SDK appends `/responses` or `/chat/completions` itself.
|
|
3046
|
-
* - Anthropic traffic takes `https://cf.api.fan` with **no** `/v1`: the
|
|
3047
|
-
* Anthropic client appends `/v1/messages` itself.
|
|
3048
|
-
* - A model asked over the wrong protocol does not fail cleanly; the stream
|
|
3049
|
-
* ends early. So every model pins the protocol its vendor serves here.
|
|
3961
|
+
* Where the plugin's preferences are read from and written to, on both
|
|
3962
|
+
* settings generations DSH has shipped.
|
|
3050
3963
|
*
|
|
3051
|
-
*
|
|
3052
|
-
*
|
|
3053
|
-
*
|
|
3054
|
-
*
|
|
3055
|
-
*
|
|
3964
|
+
* - DSH 0.1.5 installs a settings *section*: the section hands back a live
|
|
3965
|
+
* source and writes go through `settings.update(namespace, patch)`.
|
|
3966
|
+
* - DSH 0.2 has no sections: a preference is a *volatile* Config field of the
|
|
3967
|
+
* plugin's own row, read through a live reference, written through
|
|
3968
|
+
* `settings.update(rowId, patch)`, and announced by `loader/volatile-update`.
|
|
3969
|
+
* Only volatile fields are writable there, so the host overrides stay
|
|
3970
|
+
* read-only while the web search preference is editable.
|
|
3056
3971
|
*
|
|
3057
|
-
* @module dsh-packycode-api/
|
|
3972
|
+
* @module dsh-packycode-api/preferences
|
|
3058
3973
|
*/
|
|
3059
|
-
/** pi-ai never reads this gateway's pricing; the harness zeroes cost anyway. */
|
|
3060
|
-
const NO_COST = Object.freeze({
|
|
3061
|
-
input: 0,
|
|
3062
|
-
output: 0,
|
|
3063
|
-
cacheRead: 0,
|
|
3064
|
-
cacheWrite: 0
|
|
3065
|
-
});
|
|
3066
|
-
const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
3067
|
-
const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
3068
|
-
const MESSAGES_BASE = DEFAULT_API_HOST;
|
|
3069
3974
|
/**
|
|
3070
|
-
*
|
|
3071
|
-
*
|
|
3072
|
-
*
|
|
3073
|
-
* then emits `thinking: disabled`).
|
|
3975
|
+
* Mark a preference editable without restarting the plugin. DSH 0.2 stores
|
|
3976
|
+
* plugin preferences as volatile Config fields and writes only those; an older
|
|
3977
|
+
* schema library has no `volatile()` and keeps a plain field.
|
|
3074
3978
|
*/
|
|
3075
|
-
const
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3979
|
+
const live = (schema) => typeof schema.volatile === "function" ? schema.volatile() : schema;
|
|
3980
|
+
/** Read a Config field that may arrive as a volatile reference. */
|
|
3981
|
+
const fieldValue = (value) => value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
|
|
3982
|
+
/** A preference target nothing can write to yet. */
|
|
3983
|
+
function unwritable(message) {
|
|
3984
|
+
return {
|
|
3985
|
+
writable: false,
|
|
3986
|
+
set: async () => {
|
|
3987
|
+
throw new Error(message);
|
|
3988
|
+
}
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3083
3991
|
/**
|
|
3084
|
-
*
|
|
3085
|
-
*
|
|
3086
|
-
*
|
|
3992
|
+
* Connect the preferences to the settings service the host provides.
|
|
3993
|
+
* @param settingsCtx - the scope the `settings` inject handed over.
|
|
3994
|
+
* @param options - the plugin context and Config, the section namespace, the
|
|
3995
|
+
* current source and how to replace it, what to do when the section changes,
|
|
3996
|
+
* and the two preference targets this fills in (host overrides, search).
|
|
3087
3997
|
*/
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
const
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
maxTokens: 128e3,
|
|
3128
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
3129
|
-
}),
|
|
3130
|
-
Object.freeze({
|
|
3131
|
-
id: "gpt-6-astra",
|
|
3132
|
-
name: "GPT-6 Astra",
|
|
3133
|
-
api: "openai-responses",
|
|
3134
|
-
provider: PROVIDER,
|
|
3135
|
-
baseUrl: RESPONSES_BASE,
|
|
3136
|
-
reasoning: true,
|
|
3137
|
-
input: Object.freeze(["text", "image"]),
|
|
3138
|
-
cost: NO_COST,
|
|
3139
|
-
contextWindow: 105e4,
|
|
3140
|
-
maxTokens: 128e3,
|
|
3141
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
3142
|
-
}),
|
|
3143
|
-
Object.freeze({
|
|
3144
|
-
id: "claude-opus-5-5",
|
|
3145
|
-
name: "Claude Opus 5.5",
|
|
3146
|
-
api: "anthropic-messages",
|
|
3147
|
-
provider: PROVIDER,
|
|
3148
|
-
baseUrl: MESSAGES_BASE,
|
|
3149
|
-
reasoning: true,
|
|
3150
|
-
input: Object.freeze(["text", "image"]),
|
|
3151
|
-
cost: NO_COST,
|
|
3152
|
-
contextWindow: 1e6,
|
|
3153
|
-
maxTokens: 128e3,
|
|
3154
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
3155
|
-
compat: Object.freeze({
|
|
3156
|
-
forceAdaptiveThinking: true,
|
|
3157
|
-
supportsTemperature: false
|
|
3158
|
-
})
|
|
3159
|
-
}),
|
|
3160
|
-
Object.freeze({
|
|
3161
|
-
id: "claude-sonnet-5-5",
|
|
3162
|
-
name: "Claude Sonnet 5.5",
|
|
3163
|
-
api: "anthropic-messages",
|
|
3164
|
-
provider: PROVIDER,
|
|
3165
|
-
baseUrl: MESSAGES_BASE,
|
|
3166
|
-
reasoning: true,
|
|
3167
|
-
input: Object.freeze(["text", "image"]),
|
|
3168
|
-
cost: NO_COST,
|
|
3169
|
-
contextWindow: 1e6,
|
|
3170
|
-
maxTokens: 128e3,
|
|
3171
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
3172
|
-
compat: Object.freeze({
|
|
3173
|
-
forceAdaptiveThinking: true,
|
|
3174
|
-
supportsTemperature: false
|
|
3175
|
-
})
|
|
3176
|
-
}),
|
|
3177
|
-
Object.freeze({
|
|
3178
|
-
id: "claude-haiku-5-5",
|
|
3179
|
-
name: "Claude Haiku 5.5",
|
|
3180
|
-
api: "anthropic-messages",
|
|
3181
|
-
provider: PROVIDER,
|
|
3182
|
-
baseUrl: MESSAGES_BASE,
|
|
3183
|
-
reasoning: true,
|
|
3184
|
-
input: Object.freeze(["text", "image"]),
|
|
3185
|
-
cost: NO_COST,
|
|
3186
|
-
contextWindow: 1e6,
|
|
3187
|
-
maxTokens: 128e3,
|
|
3188
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
3189
|
-
compat: Object.freeze({
|
|
3190
|
-
forceAdaptiveThinking: true,
|
|
3191
|
-
supportsTemperature: false
|
|
3192
|
-
})
|
|
3193
|
-
})
|
|
3194
|
-
]);
|
|
3195
|
-
/** Every model id this catalog advertises, in picker order. */
|
|
3196
|
-
const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
|
|
3998
|
+
function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSource, onChange, hostPreference, searchPreference }) {
|
|
3999
|
+
const settings = settingsCtx.settings;
|
|
4000
|
+
if (typeof settings?.installSection === "function") {
|
|
4001
|
+
try {
|
|
4002
|
+
settings.installSection(ctx, namespace, Config, source(), {
|
|
4003
|
+
setSource: (current) => setSource(typeof current === "function" ? () => current() ?? {} : () => current ?? {}),
|
|
4004
|
+
onChange
|
|
4005
|
+
});
|
|
4006
|
+
if (typeof settings.update === "function") {
|
|
4007
|
+
const write = (patch) => settings.update(namespace, patch);
|
|
4008
|
+
Object.assign(hostPreference, {
|
|
4009
|
+
writable: true,
|
|
4010
|
+
set: write
|
|
4011
|
+
});
|
|
4012
|
+
Object.assign(searchPreference, {
|
|
4013
|
+
writable: true,
|
|
4014
|
+
set: write
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
} catch (error) {
|
|
4018
|
+
ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
|
|
4019
|
+
}
|
|
4020
|
+
return;
|
|
4021
|
+
}
|
|
4022
|
+
if (typeof settings?.update !== "function") return;
|
|
4023
|
+
const rowId = ctx.fiber?.entry?.options?.id ?? namespace;
|
|
4024
|
+
Object.assign(searchPreference, {
|
|
4025
|
+
writable: settings.writable !== false,
|
|
4026
|
+
set: (patch) => settings.update(rowId, patch)
|
|
4027
|
+
});
|
|
4028
|
+
if (typeof settings.configure === "function") {
|
|
4029
|
+
const scope = typeof settingsCtx.effect === "function" ? settingsCtx : ctx;
|
|
4030
|
+
try {
|
|
4031
|
+
scope.effect(() => settings.configure({ auto: false }, ctx.fiber), "packycode-api: settings page policy");
|
|
4032
|
+
} catch (error) {
|
|
4033
|
+
ctx.logger?.warn?.("packycode-api: could not hide the generated settings page: %s", error.message);
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
3197
4037
|
//#endregion
|
|
3198
4038
|
//#region src/provider.js
|
|
3199
4039
|
/**
|
|
@@ -3313,190 +4153,36 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
|
|
|
3313
4153
|
} });
|
|
3314
4154
|
}
|
|
3315
4155
|
//#endregion
|
|
3316
|
-
//#region src/
|
|
4156
|
+
//#region src/route.js
|
|
3317
4157
|
/**
|
|
3318
|
-
* The
|
|
3319
|
-
*
|
|
3320
|
-
* Every reply carries only what this plugin owns: key labels and state, the
|
|
3321
|
-
* balance projection the host built, host overrides, and whether this device
|
|
3322
|
-
* has the Claude Code CLI. Key material never crosses this boundary.
|
|
4158
|
+
* The HTTP model route: the pi-ai provider profile and the adapter that
|
|
4159
|
+
* serves the catalog's HTTP models (DeepSeek and GPT) with the ring's keys.
|
|
3323
4160
|
*
|
|
3324
|
-
* @module dsh-packycode-api/
|
|
4161
|
+
* @module dsh-packycode-api/route
|
|
3325
4162
|
*/
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
4163
|
+
/**
|
|
4164
|
+
* Request-level bounds from the shipped pi-ai adapter: a bounded image payload
|
|
4165
|
+
* keeps a long session completing requests instead of being refused by a
|
|
4166
|
+
* gateway size cap.
|
|
4167
|
+
*/
|
|
4168
|
+
const REQUEST_LIMITS = Object.freeze({
|
|
4169
|
+
streamIdleTimeoutMs: 600 * 1e3,
|
|
4170
|
+
maxRequestImageBytes: 20 * 1024 * 1024,
|
|
4171
|
+
requestImagePixelBudget: 2048 * 2048,
|
|
4172
|
+
requestImageMaxBytes: 1024 * 1024
|
|
3333
4173
|
});
|
|
3334
|
-
/** Only sentences this plugin authored may reach the browser. */
|
|
3335
|
-
const SAFE_MESSAGE = /^(?:PackyCode|That |Unknown |PackyCode's |add |the )[\s\S]{0,180}$/u;
|
|
3336
|
-
/** Accept an absolute http(s) origin and drop a trailing slash. */
|
|
3337
|
-
function normalizeHost(value, fallback) {
|
|
3338
|
-
const text = typeof value === "string" ? value.trim() : "";
|
|
3339
|
-
if (text === "") return fallback;
|
|
3340
|
-
let url;
|
|
3341
|
-
try {
|
|
3342
|
-
url = new URL(text);
|
|
3343
|
-
} catch {
|
|
3344
|
-
return fallback;
|
|
3345
|
-
}
|
|
3346
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") return fallback;
|
|
3347
|
-
return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
|
|
3348
|
-
}
|
|
3349
4174
|
/**
|
|
3350
|
-
*
|
|
3351
|
-
*
|
|
3352
|
-
*
|
|
3353
|
-
*
|
|
4175
|
+
* pi-ai's credential store over the key ring.
|
|
4176
|
+
*
|
|
4177
|
+
* Reads answer with the active key so pi-ai's own auth reporting stays honest,
|
|
4178
|
+
* while writes are refused: the ring owns them, because only the ring can move
|
|
4179
|
+
* the active pointer past a key the gateway rejected.
|
|
4180
|
+
* @param ring - the plugin's key ring.
|
|
4181
|
+
* @returns a pi-ai `CredentialStore`.
|
|
3354
4182
|
*/
|
|
3355
|
-
function
|
|
3356
|
-
const
|
|
3357
|
-
|
|
3358
|
-
return claudeCli;
|
|
3359
|
-
};
|
|
3360
|
-
const requireManager = () => {
|
|
3361
|
-
if (pluginManager === void 0) throw new Error("PackyCode plugin version is unavailable");
|
|
3362
|
-
return pluginManager;
|
|
3363
|
-
};
|
|
3364
|
-
const status = async () => ({
|
|
3365
|
-
...await ring.list(),
|
|
3366
|
-
hosts: hosts(),
|
|
3367
|
-
writable: preference.writable === true,
|
|
3368
|
-
defaults: {
|
|
3369
|
-
apiHost: DEFAULT_API_HOST,
|
|
3370
|
-
statusHost: DEFAULT_STATUS_HOST
|
|
3371
|
-
}
|
|
3372
|
-
});
|
|
3373
|
-
return async (endpoint, payload, signal) => {
|
|
3374
|
-
try {
|
|
3375
|
-
switch (endpoint) {
|
|
3376
|
-
case "status": return ok(await status());
|
|
3377
|
-
case "keys/add":
|
|
3378
|
-
await ring.add(payload?.key, payload?.label);
|
|
3379
|
-
balance.invalidate();
|
|
3380
|
-
return ok(await status());
|
|
3381
|
-
case "keys/rename":
|
|
3382
|
-
await ring.rename(String(payload?.id ?? ""), payload?.label);
|
|
3383
|
-
balance.invalidate();
|
|
3384
|
-
return ok(await status());
|
|
3385
|
-
case "keys/remove":
|
|
3386
|
-
await ring.remove(String(payload?.id ?? ""));
|
|
3387
|
-
balance.invalidate();
|
|
3388
|
-
return ok(await status());
|
|
3389
|
-
case "keys/remove-all":
|
|
3390
|
-
await ring.removeAll();
|
|
3391
|
-
balance.invalidate();
|
|
3392
|
-
return ok(await status());
|
|
3393
|
-
case "keys/select":
|
|
3394
|
-
await ring.select(String(payload?.id ?? ""));
|
|
3395
|
-
balance.invalidate();
|
|
3396
|
-
return ok(await status());
|
|
3397
|
-
case "keys/enable":
|
|
3398
|
-
await ring.enable(String(payload?.id ?? ""));
|
|
3399
|
-
balance.invalidate();
|
|
3400
|
-
return ok(await status());
|
|
3401
|
-
case "balance": {
|
|
3402
|
-
const [value, state] = await Promise.all([balance.read({ force: payload?.force === true }), ring.list()]);
|
|
3403
|
-
return ok({
|
|
3404
|
-
...value,
|
|
3405
|
-
activeId: state.activeId
|
|
3406
|
-
});
|
|
3407
|
-
}
|
|
3408
|
-
case "hosts/update":
|
|
3409
|
-
if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
|
|
3410
|
-
await preference.set({
|
|
3411
|
-
apiHost: normalizeHost(payload?.apiHost, hosts().apiHost),
|
|
3412
|
-
statusHost: normalizeHost(payload?.statusHost, hosts().statusHost)
|
|
3413
|
-
});
|
|
3414
|
-
balance.invalidate();
|
|
3415
|
-
return ok(await status());
|
|
3416
|
-
case "claude-cli/status": return ok(await requireCli().status({ force: payload?.force === true }));
|
|
3417
|
-
case "claude-cli/install": {
|
|
3418
|
-
const cli = requireCli();
|
|
3419
|
-
cli.install();
|
|
3420
|
-
return ok(await cli.status());
|
|
3421
|
-
}
|
|
3422
|
-
case "plugin/version": return ok(await requireManager().read({
|
|
3423
|
-
force: payload?.force === true,
|
|
3424
|
-
signal
|
|
3425
|
-
}));
|
|
3426
|
-
case "plugin/update": return ok(await requireManager().update({ signal }));
|
|
3427
|
-
default: return failed("Unknown PackyCode endpoint");
|
|
3428
|
-
}
|
|
3429
|
-
} catch (error) {
|
|
3430
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3431
|
-
return failed(SAFE_MESSAGE.test(message) ? message : "PackyCode request failed");
|
|
3432
|
-
}
|
|
3433
|
-
};
|
|
3434
|
-
}
|
|
3435
|
-
//#endregion
|
|
3436
|
-
//#region src/index.js
|
|
3437
|
-
/**
|
|
3438
|
-
* dsh-packycode-api — a self-contained PackyCode route for DeepSeek Harness.
|
|
3439
|
-
*
|
|
3440
|
-
* The plugin builds its own pi-ai provider, model catalog, key ring, and
|
|
3441
|
-
* balance reader, then registers the route through the same public
|
|
3442
|
-
* `llm.registerAdapter` API the shipped adapters use. Nothing here reads the
|
|
3443
|
-
* profile's composition config, so installing the package is the whole setup:
|
|
3444
|
-
* the picker shows the models and the user only pastes API keys.
|
|
3445
|
-
*
|
|
3446
|
-
* @module dsh-packycode-api
|
|
3447
|
-
*/
|
|
3448
|
-
const name = PLUGIN_ID;
|
|
3449
|
-
/**
|
|
3450
|
-
* Only the model route is mandatory. `connection` carries the browser RPC
|
|
3451
|
-
* surface, and `settings` the host overrides: a profile without them (a
|
|
3452
|
-
* headless run, for one) must still boot with a working route instead of
|
|
3453
|
-
* blocking on a service it will never see.
|
|
3454
|
-
*/
|
|
3455
|
-
const inject = ["llm", "credentials"];
|
|
3456
|
-
/** What a `claude_code` tool run is told about where its task came from. */
|
|
3457
|
-
const CLAUDE_TOOL_INSTRUCTIONS = "This task was delegated to Claude Code by another agent in DeepSeek Harness through its claude_code tool. The task text is self-contained; complete it with your own tools and reply with the final result only.";
|
|
3458
|
-
/** Host overrides; a profile that never installs this section keeps the defaults. */
|
|
3459
|
-
const Config = z.object({
|
|
3460
|
-
apiHost: z.string().default(DEFAULT_API_HOST),
|
|
3461
|
-
statusHost: z.string().default(DEFAULT_STATUS_HOST),
|
|
3462
|
-
/** Path to the Claude Code CLI binary; empty means "look in the usual places, then PATH". */
|
|
3463
|
-
claudeCliPath: z.string().default(""),
|
|
3464
|
-
/**
|
|
3465
|
-
* What the CLI may do on its own during a CLI-backed turn. `auto` follows the
|
|
3466
|
-
* session's permission preset, so nothing needs configuring here.
|
|
3467
|
-
*/
|
|
3468
|
-
claudeCliMode: z.union([
|
|
3469
|
-
AUTO_CLAUDE_MODE,
|
|
3470
|
-
"read-only",
|
|
3471
|
-
"edit",
|
|
3472
|
-
"full"
|
|
3473
|
-
]).default(AUTO_CLAUDE_MODE),
|
|
3474
|
-
/** Working directory for CLI-backed turns; empty means "the session's workspace". */
|
|
3475
|
-
claudeCliCwd: z.string().default("")
|
|
3476
|
-
});
|
|
3477
|
-
/**
|
|
3478
|
-
* Request-level bounds from the shipped pi-ai adapter: a bounded image payload
|
|
3479
|
-
* keeps a long session completing requests instead of being refused by a
|
|
3480
|
-
* gateway size cap.
|
|
3481
|
-
*/
|
|
3482
|
-
const REQUEST_LIMITS = Object.freeze({
|
|
3483
|
-
streamIdleTimeoutMs: 600 * 1e3,
|
|
3484
|
-
maxRequestImageBytes: 20 * 1024 * 1024,
|
|
3485
|
-
requestImagePixelBudget: 2048 * 2048,
|
|
3486
|
-
requestImageMaxBytes: 1024 * 1024
|
|
3487
|
-
});
|
|
3488
|
-
/**
|
|
3489
|
-
* pi-ai's credential store over the key ring.
|
|
3490
|
-
*
|
|
3491
|
-
* Reads answer with the active key so pi-ai's own auth reporting stays honest,
|
|
3492
|
-
* while writes are refused: the ring owns them, because only the ring can move
|
|
3493
|
-
* the active pointer past a key the gateway rejected.
|
|
3494
|
-
* @param ring - the plugin's key ring.
|
|
3495
|
-
* @returns a pi-ai `CredentialStore`.
|
|
3496
|
-
*/
|
|
3497
|
-
function ringCredentialStore(ring) {
|
|
3498
|
-
const managed = () => {
|
|
3499
|
-
throw new Error("PackyCode API keys are managed in the PackyCode settings section");
|
|
4183
|
+
function ringCredentialStore(ring) {
|
|
4184
|
+
const managed = () => {
|
|
4185
|
+
throw new Error("PackyCode API keys are managed in the PackyCode settings section");
|
|
3500
4186
|
};
|
|
3501
4187
|
return {
|
|
3502
4188
|
async read() {
|
|
@@ -3551,21 +4237,1108 @@ function createPackyCodeProfile(provider) {
|
|
|
3551
4237
|
}, `${PLUGIN_ID}.retryPolicy`)
|
|
3552
4238
|
});
|
|
3553
4239
|
}
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
4240
|
+
/**
|
|
4241
|
+
* The HTTP route's adapter.
|
|
4242
|
+
*
|
|
4243
|
+
* Each request resolves the active key, then runs on the key the chooser picks
|
|
4244
|
+
* for its model; a key-level failure in the stream moves that key aside.
|
|
4245
|
+
* @param options - the key ring and chooser, the session id used when a caller
|
|
4246
|
+
* supplies none, and the attachment store image input is read from.
|
|
4247
|
+
* @returns a `PiAiAdapter` serving the `packycode` route.
|
|
4248
|
+
*/
|
|
4249
|
+
function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments }) {
|
|
4250
|
+
const raw = createPackyCodeProvider();
|
|
3557
4251
|
/**
|
|
3558
|
-
*
|
|
3559
|
-
*
|
|
3560
|
-
*
|
|
4252
|
+
* The gateway's Codex-group check refuses a Responses request that carries
|
|
4253
|
+
* no `prompt_cache_key` with a 403, which pi-ai derives from the session id.
|
|
4254
|
+
* The harness supplies one for every agent turn; the fallback covers a
|
|
4255
|
+
* caller that does not, so the route never fails for a missing cache key.
|
|
3561
4256
|
*/
|
|
3562
|
-
const
|
|
3563
|
-
|
|
3564
|
-
|
|
4257
|
+
const requestOptions = (model, options) => {
|
|
4258
|
+
const withId = options === void 0 || options.sessionId !== void 0 ? options : {
|
|
4259
|
+
...options,
|
|
4260
|
+
sessionId: fallbackSessionId
|
|
4261
|
+
};
|
|
4262
|
+
if (typeof withId?.apiKey !== "string") return withId;
|
|
4263
|
+
const key = keys.forRequest(model?.id, withId.apiKey);
|
|
4264
|
+
return key === withId.apiKey ? withId : {
|
|
4265
|
+
...withId,
|
|
4266
|
+
apiKey: key
|
|
4267
|
+
};
|
|
4268
|
+
};
|
|
4269
|
+
const guarded = (method) => (model, context, options) => {
|
|
4270
|
+
const routed = requestOptions(model, options);
|
|
4271
|
+
return guardKeyRotation(raw[method](model, context, routed), {
|
|
4272
|
+
key: routed?.apiKey,
|
|
4273
|
+
onKeyFailure: keys.onKeyFailure
|
|
4274
|
+
});
|
|
4275
|
+
};
|
|
4276
|
+
const provider = Object.freeze({
|
|
4277
|
+
...raw,
|
|
4278
|
+
stream: guarded("stream"),
|
|
4279
|
+
streamSimple: guarded("streamSimple")
|
|
4280
|
+
});
|
|
4281
|
+
const profiles = /* @__PURE__ */ new Map([[PROVIDER, createPackyCodeProfile(provider)]]);
|
|
4282
|
+
return new PiAiAdapter({
|
|
4283
|
+
profiles: () => profiles,
|
|
4284
|
+
resolveApiKey: async () => {
|
|
4285
|
+
const active = await keys.active();
|
|
4286
|
+
if (active === void 0) throw new LlmError("No usable PackyCode API key: add one under Settings → PackyCode, or re-enable the keys this plugin moved past.", "MISSING_CREDENTIAL");
|
|
4287
|
+
return active.key;
|
|
4288
|
+
},
|
|
4289
|
+
auth: Object.freeze({
|
|
4290
|
+
credentials: ringCredentialStore(ring),
|
|
4291
|
+
authContext: Object.freeze({
|
|
4292
|
+
env: async () => void 0,
|
|
4293
|
+
fileExists: async () => false
|
|
4294
|
+
})
|
|
4295
|
+
}),
|
|
4296
|
+
resolveAttachments
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
4299
|
+
//#endregion
|
|
4300
|
+
//#region src/rpc.js
|
|
4301
|
+
/**
|
|
4302
|
+
* The loopback RPC surface behind the settings panel and the composer chip.
|
|
4303
|
+
*
|
|
4304
|
+
* Every reply carries only what this plugin owns: key labels and state, the
|
|
4305
|
+
* balance and group projections the host built, host overrides, whether this
|
|
4306
|
+
* device has the Claude Code CLI, the plugin version, and the web search
|
|
4307
|
+
* preference. Key material never crosses this boundary.
|
|
4308
|
+
*
|
|
4309
|
+
* @module dsh-packycode-api/rpc
|
|
4310
|
+
*/
|
|
4311
|
+
const ok = (value) => ({
|
|
4312
|
+
ok: true,
|
|
4313
|
+
value
|
|
4314
|
+
});
|
|
4315
|
+
const failed = (message) => ({
|
|
4316
|
+
ok: false,
|
|
4317
|
+
error: { message }
|
|
4318
|
+
});
|
|
4319
|
+
/** Only sentences this plugin authored may reach the browser. */
|
|
4320
|
+
const SAFE_MESSAGE = /^(?:PackyCode|That |Unknown |PackyCode's |add |the )[\s\S]{0,180}$/u;
|
|
4321
|
+
const keyId = (payload) => String(payload?.id ?? "");
|
|
4322
|
+
/**
|
|
4323
|
+
* Build the RPC handler.
|
|
4324
|
+
* @param options - the key ring, the balance and group readers, the host
|
|
4325
|
+
* preference, and the optional Claude Code CLI manager, plugin manager, and
|
|
4326
|
+
* web search controller (without one, its endpoints refuse).
|
|
4327
|
+
* @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
|
|
4328
|
+
*/
|
|
4329
|
+
function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCli, pluginManager, search }) {
|
|
4330
|
+
const need = (service, message) => {
|
|
4331
|
+
if (service === void 0) throw new Error(message);
|
|
4332
|
+
return service;
|
|
4333
|
+
};
|
|
4334
|
+
const cli = () => need(claudeCli, "PackyCode Claude Code CLI support is unavailable in this runtime");
|
|
4335
|
+
const manager = () => need(pluginManager, "PackyCode plugin version is unavailable");
|
|
4336
|
+
const webSearch = () => need(search, "PackyCode web search is unavailable in this runtime");
|
|
4337
|
+
const groupReader = () => need(groups, "PackyCode key groups are unavailable in this runtime");
|
|
4338
|
+
const status = async () => ({
|
|
4339
|
+
...await ring.list(),
|
|
4340
|
+
hosts: hosts(),
|
|
4341
|
+
writable: preference.writable === true,
|
|
4342
|
+
defaults: {
|
|
4343
|
+
apiHost: DEFAULT_API_HOST,
|
|
4344
|
+
statusHost: DEFAULT_STATUS_HOST
|
|
4345
|
+
}
|
|
4346
|
+
});
|
|
4347
|
+
/**
|
|
4348
|
+
* Key mutations, each answered with the new status. Every one drops the
|
|
4349
|
+
* cached balance; one that changes which keys exist also drops the groups.
|
|
4350
|
+
*/
|
|
4351
|
+
const keyMutations = {
|
|
4352
|
+
"keys/add": {
|
|
4353
|
+
run: (payload) => ring.add(payload?.key, payload?.label),
|
|
4354
|
+
changesKeys: true
|
|
4355
|
+
},
|
|
4356
|
+
"keys/rename": { run: (payload) => ring.rename(keyId(payload), payload?.label) },
|
|
4357
|
+
"keys/remove": {
|
|
4358
|
+
run: (payload) => ring.remove(keyId(payload)),
|
|
4359
|
+
changesKeys: true
|
|
4360
|
+
},
|
|
4361
|
+
"keys/remove-all": {
|
|
4362
|
+
run: () => ring.removeAll(),
|
|
4363
|
+
changesKeys: true
|
|
4364
|
+
},
|
|
4365
|
+
"keys/select": { run: (payload) => ring.select(keyId(payload)) },
|
|
4366
|
+
"keys/enable": { run: (payload) => ring.enable(keyId(payload)) }
|
|
3565
4367
|
};
|
|
4368
|
+
const endpoints = {
|
|
4369
|
+
status: () => status(),
|
|
4370
|
+
balance: async (payload) => {
|
|
4371
|
+
const [value, state] = await Promise.all([balance.read({ force: payload?.force === true }), ring.list()]);
|
|
4372
|
+
return {
|
|
4373
|
+
...value,
|
|
4374
|
+
activeId: state.activeId
|
|
4375
|
+
};
|
|
4376
|
+
},
|
|
4377
|
+
groups: (payload) => groupReader().read({ force: payload?.force === true }),
|
|
4378
|
+
"hosts/update": async (payload) => {
|
|
4379
|
+
if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
|
|
4380
|
+
await preference.set({
|
|
4381
|
+
apiHost: normalizeHost(payload?.apiHost, hosts().apiHost),
|
|
4382
|
+
statusHost: normalizeHost(payload?.statusHost, hosts().statusHost)
|
|
4383
|
+
});
|
|
4384
|
+
balance.invalidate();
|
|
4385
|
+
groups?.invalidate();
|
|
4386
|
+
return status();
|
|
4387
|
+
},
|
|
4388
|
+
"claude-cli/status": (payload) => cli().status({ force: payload?.force === true }),
|
|
4389
|
+
"claude-cli/install": () => {
|
|
4390
|
+
cli().install();
|
|
4391
|
+
return cli().status();
|
|
4392
|
+
},
|
|
4393
|
+
"plugin/version": (payload, signal) => manager().read({
|
|
4394
|
+
force: payload?.force === true,
|
|
4395
|
+
signal
|
|
4396
|
+
}),
|
|
4397
|
+
"plugin/update": (_payload, signal) => manager().update({ signal }),
|
|
4398
|
+
"search/status": () => webSearch().status(),
|
|
4399
|
+
"search/update": (payload) => webSearch().update({
|
|
4400
|
+
searchProvider: payload?.searchProvider,
|
|
4401
|
+
searchBackend: payload?.searchBackend
|
|
4402
|
+
})
|
|
4403
|
+
};
|
|
4404
|
+
return async (endpoint, payload, signal) => {
|
|
4405
|
+
try {
|
|
4406
|
+
const mutation = Object.hasOwn(keyMutations, endpoint) ? keyMutations[endpoint] : void 0;
|
|
4407
|
+
if (mutation !== void 0) {
|
|
4408
|
+
await mutation.run(payload);
|
|
4409
|
+
balance.invalidate();
|
|
4410
|
+
if (mutation.changesKeys) groups?.invalidate();
|
|
4411
|
+
return ok(await status());
|
|
4412
|
+
}
|
|
4413
|
+
const handle = Object.hasOwn(endpoints, endpoint) ? endpoints[endpoint] : void 0;
|
|
4414
|
+
if (handle === void 0) return failed("Unknown PackyCode endpoint");
|
|
4415
|
+
return ok(await handle(payload, signal));
|
|
4416
|
+
} catch (error) {
|
|
4417
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4418
|
+
return failed(SAFE_MESSAGE.test(message) ? message : "PackyCode request failed");
|
|
4419
|
+
}
|
|
4420
|
+
};
|
|
4421
|
+
}
|
|
4422
|
+
//#endregion
|
|
4423
|
+
//#region src/search.js
|
|
4424
|
+
/**
|
|
4425
|
+
* PackyCode web search for the DSH `web` seam.
|
|
4426
|
+
*
|
|
4427
|
+
* Two search providers register with `ctx.web`:
|
|
4428
|
+
* - `packycode-api` searches through PackyCode for every request.
|
|
4429
|
+
* - `packycode-api-auto` searches through PackyCode only when the initiating
|
|
4430
|
+
* session runs on a PackyCode model, and hands every other request to the
|
|
4431
|
+
* provider DSH would have used without this plugin.
|
|
4432
|
+
*
|
|
4433
|
+
* PackyCode has no dedicated search endpoint; a search is one model call with
|
|
4434
|
+
* the vendor's server-side search tool. The user picks the backend:
|
|
4435
|
+
* - `deepseek`: Anthropic Messages at `/v1/messages`, `deepseek-flash`, with
|
|
4436
|
+
* the `web_search_20250305` server tool. Results come from its structured
|
|
4437
|
+
* `web_search_tool_result` blocks, never from prose.
|
|
4438
|
+
* - `gpt`: OpenAI Responses at `/v1/responses`, `gpt-6.1-sol`, with the
|
|
4439
|
+
* hosted `web_search` tool. Results come from `url_citation` annotations and
|
|
4440
|
+
* the `web_search_call` actions.
|
|
4441
|
+
*
|
|
4442
|
+
* Only keys stored in this plugin's key ring authenticate a search; there is no
|
|
4443
|
+
* fallback to another credential. A key the gateway reports as exhausted or
|
|
4444
|
+
* rejected is moved aside exactly as on the model route, and the search tries
|
|
4445
|
+
* the next usable key.
|
|
4446
|
+
*
|
|
4447
|
+
* @module dsh-packycode-api/search
|
|
4448
|
+
*/
|
|
4449
|
+
/** Search provider that always searches through PackyCode. */
|
|
4450
|
+
const PACKYCODE_SEARCH_PROVIDER_ID = "packycode-api";
|
|
4451
|
+
/** Search provider that routes by the initiating session's model provider. */
|
|
4452
|
+
const PACKYCODE_AUTO_SEARCH_PROVIDER_ID = "packycode-api-auto";
|
|
4453
|
+
/** Both ids this plugin registers; neither may serve as its own fallback. */
|
|
4454
|
+
const OWN_SEARCH_PROVIDER_IDS = Object.freeze([PACKYCODE_SEARCH_PROVIDER_ID, PACKYCODE_AUTO_SEARCH_PROVIDER_ID]);
|
|
4455
|
+
/** DSH's built-in search provider, the fallback when nothing else is known. */
|
|
4456
|
+
const DSH_SEARCH_PROVIDER_FALLBACK = "deepseek-official";
|
|
4457
|
+
/** Search routing the user picks. */
|
|
4458
|
+
const SEARCH_MODE_DEFAULT = "default";
|
|
4459
|
+
const SEARCH_MODES = Object.freeze([
|
|
4460
|
+
SEARCH_MODE_DEFAULT,
|
|
4461
|
+
"auto",
|
|
4462
|
+
"packycode"
|
|
4463
|
+
]);
|
|
4464
|
+
/** Search backends the user picks between. */
|
|
4465
|
+
const SEARCH_BACKEND_DEEPSEEK = "deepseek";
|
|
4466
|
+
const SEARCH_BACKENDS = Object.freeze([SEARCH_BACKEND_DEEPSEEK, "gpt"]);
|
|
4467
|
+
/**
|
|
4468
|
+
* DeepSeek is the default: a search costs a few thousand tokens there, and its
|
|
4469
|
+
* group answered the server tool in the live check. GPT search reads far more
|
|
4470
|
+
* (about 26K input tokens per search) and needs a key whose group the gateway
|
|
4471
|
+
* currently routes GPT models to.
|
|
4472
|
+
*/
|
|
4473
|
+
const DEFAULT_SEARCH_BACKEND = SEARCH_BACKEND_DEEPSEEK;
|
|
4474
|
+
/** The model each backend searches with. */
|
|
4475
|
+
const SEARCH_BACKEND_MODELS = Object.freeze({
|
|
4476
|
+
[SEARCH_BACKEND_DEEPSEEK]: "deepseek-flash",
|
|
4477
|
+
["gpt"]: "gpt-6.1-sol"
|
|
4478
|
+
});
|
|
4479
|
+
/** Bound on one search request, below the `web_search` tool's 60 s budget. */
|
|
4480
|
+
const DEFAULT_SEARCH_TIMEOUT_MS = 55e3;
|
|
4481
|
+
/** Upper bound on server-side searches inside one DeepSeek request. */
|
|
4482
|
+
const DEEPSEEK_MAX_USES = 3;
|
|
4483
|
+
const MAX_OUTPUT_TOKENS = 4096;
|
|
4484
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
4485
|
+
/** Distinct keys one search may try before reporting the last failure. */
|
|
4486
|
+
const MAX_KEY_ATTEMPTS = 4;
|
|
4487
|
+
const MAX_CONTENT_CHARS = 4e3;
|
|
4488
|
+
const MAX_SNIPPET_CHARS = 500;
|
|
4489
|
+
const MAX_TITLE_CHARS = 200;
|
|
4490
|
+
const MAX_SOURCES = 20;
|
|
4491
|
+
const MAX_ERROR_DETAIL_CHARS = 400;
|
|
4492
|
+
/**
|
|
4493
|
+
* Error shape of the DSH web seam. `@deepseek-ai/dsh-web` defines `WebError`
|
|
4494
|
+
* as a bare `HarnessError` subclass with a machine-routable `code`; consumers
|
|
4495
|
+
* route on that code, so this mirror avoids a peer dependency on the package.
|
|
4496
|
+
*/
|
|
4497
|
+
var WebError = class extends HarnessError {};
|
|
4498
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
4499
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
4500
|
+
function bounded(value, maximum) {
|
|
4501
|
+
const clean = text(value).replace(/\s+/gu, " ").trim();
|
|
4502
|
+
if (clean === "") return void 0;
|
|
4503
|
+
return clean.length <= maximum ? clean : `${clean.slice(0, maximum - 1)}…`;
|
|
4504
|
+
}
|
|
4505
|
+
function boundedBlock(value, maximum) {
|
|
4506
|
+
const clean = text(value).trim();
|
|
4507
|
+
if (clean === "") return void 0;
|
|
4508
|
+
return clean.length <= maximum ? clean : `${clean.slice(0, maximum - 1)}…`;
|
|
4509
|
+
}
|
|
4510
|
+
function httpUrl(value) {
|
|
4511
|
+
const url = text(value);
|
|
4512
|
+
if (url === "") return void 0;
|
|
4513
|
+
try {
|
|
4514
|
+
const parsed = new URL(url);
|
|
4515
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : void 0;
|
|
4516
|
+
} catch {
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
/** Accumulates sources by URL, first sighting wins, missing fields filled later. */
|
|
4521
|
+
function sourceList() {
|
|
4522
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
4523
|
+
return {
|
|
4524
|
+
add({ url, title, snippet, publishedAt }) {
|
|
4525
|
+
const parsed = httpUrl(url);
|
|
4526
|
+
if (parsed === void 0) return;
|
|
4527
|
+
const key = parsed.href;
|
|
4528
|
+
const existing = byUrl.get(key);
|
|
4529
|
+
if (existing === void 0) {
|
|
4530
|
+
if (byUrl.size >= MAX_SOURCES) return;
|
|
4531
|
+
byUrl.set(key, {
|
|
4532
|
+
url: text(url),
|
|
4533
|
+
title: bounded(title, MAX_TITLE_CHARS) ?? parsed.hostname,
|
|
4534
|
+
...bounded(snippet, MAX_SNIPPET_CHARS) === void 0 ? {} : { snippet: bounded(snippet, MAX_SNIPPET_CHARS) },
|
|
4535
|
+
...bounded(publishedAt, 64) === void 0 ? {} : { publishedAt: bounded(publishedAt, 64) }
|
|
4536
|
+
});
|
|
4537
|
+
return;
|
|
4538
|
+
}
|
|
4539
|
+
if (existing.title === parsed.hostname && bounded(title, MAX_TITLE_CHARS) !== void 0) existing.title = bounded(title, MAX_TITLE_CHARS);
|
|
4540
|
+
if (existing.snippet === void 0 && bounded(snippet, MAX_SNIPPET_CHARS) !== void 0) existing.snippet = bounded(snippet, MAX_SNIPPET_CHARS);
|
|
4541
|
+
if (existing.publishedAt === void 0 && bounded(publishedAt, 64) !== void 0) existing.publishedAt = bounded(publishedAt, 64);
|
|
4542
|
+
},
|
|
4543
|
+
values: () => [...byUrl.values()]
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
function result(sources, content) {
|
|
4547
|
+
return {
|
|
4548
|
+
...content === void 0 ? {} : { content },
|
|
4549
|
+
sources,
|
|
4550
|
+
truncated: false
|
|
4551
|
+
};
|
|
4552
|
+
}
|
|
4553
|
+
/**
|
|
4554
|
+
* Map an Anthropic Messages response with native search to a search result.
|
|
4555
|
+
* Sources come from `web_search_tool_result` blocks; a text block's citation
|
|
4556
|
+
* excerpt becomes its source's snippet; the answer text becomes `content`.
|
|
4557
|
+
* @param response - parsed Messages response body.
|
|
4558
|
+
* @returns the normalized search result.
|
|
4559
|
+
* @throws {WebError} when the response carries no search result block.
|
|
4560
|
+
*/
|
|
4561
|
+
function parseMessagesSearchResponse(response) {
|
|
4562
|
+
const blocks = Array.isArray(response?.content) ? response.content : [];
|
|
4563
|
+
const resultBlocks = blocks.filter((block) => block?.type === "web_search_tool_result");
|
|
4564
|
+
if (resultBlocks.length === 0) throw new WebError("PackyCode search returned no web_search_tool_result block; the model did not run a web search", "WEB_PROVIDER_ERROR");
|
|
4565
|
+
const sources = sourceList();
|
|
4566
|
+
const errors = [];
|
|
4567
|
+
for (const block of resultBlocks) {
|
|
4568
|
+
if (!Array.isArray(block.content)) {
|
|
4569
|
+
if (record(block.content) && text(block.content.error_code) !== "") errors.push(block.content.error_code);
|
|
4570
|
+
continue;
|
|
4571
|
+
}
|
|
4572
|
+
for (const item of block.content) {
|
|
4573
|
+
if (item?.type !== "web_search_result") continue;
|
|
4574
|
+
sources.add({
|
|
4575
|
+
url: item.url,
|
|
4576
|
+
title: item.title,
|
|
4577
|
+
publishedAt: item.page_age
|
|
4578
|
+
});
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
for (const block of blocks) {
|
|
4582
|
+
if (block?.type !== "text" || !Array.isArray(block.citations)) continue;
|
|
4583
|
+
for (const cite of block.citations) sources.add({
|
|
4584
|
+
url: cite?.url,
|
|
4585
|
+
title: cite?.title,
|
|
4586
|
+
snippet: cite?.cited_text
|
|
4587
|
+
});
|
|
4588
|
+
}
|
|
4589
|
+
const found = sources.values();
|
|
4590
|
+
if (found.length === 0 && errors.length > 0) throw new WebError(`PackyCode search failed upstream (${errors.join(", ")})`, "WEB_PROVIDER_ERROR");
|
|
4591
|
+
return result(found, boundedBlock(blocks.filter((block) => block?.type === "text").map((block) => text(block.text)).join(""), MAX_CONTENT_CHARS));
|
|
4592
|
+
}
|
|
4593
|
+
/**
|
|
4594
|
+
* Map an OpenAI Responses response with the hosted search tool to a search
|
|
4595
|
+
* result. Cited URLs come first with their titles, then every other URL the
|
|
4596
|
+
* search actions surfaced; the final message becomes `content`.
|
|
4597
|
+
* @param response - parsed Responses body.
|
|
4598
|
+
* @returns the normalized search result.
|
|
4599
|
+
* @throws {WebError} when the model ran no web search.
|
|
4600
|
+
*/
|
|
4601
|
+
function parseResponsesSearchResponse(response) {
|
|
4602
|
+
const output = Array.isArray(response?.output) ? response.output : [];
|
|
4603
|
+
const calls = output.filter((item) => item?.type === "web_search_call");
|
|
4604
|
+
if (calls.length === 0) throw new WebError("PackyCode search returned no web_search_call; the model did not run a web search", "WEB_PROVIDER_ERROR");
|
|
4605
|
+
const sources = sourceList();
|
|
4606
|
+
let answer;
|
|
4607
|
+
for (const item of output) {
|
|
4608
|
+
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
|
4609
|
+
const parts = [];
|
|
4610
|
+
for (const part of item.content) {
|
|
4611
|
+
if (part?.type !== "output_text") continue;
|
|
4612
|
+
parts.push(text(part.text));
|
|
4613
|
+
for (const annotation of Array.isArray(part.annotations) ? part.annotations : []) if (annotation?.type === "url_citation") sources.add({
|
|
4614
|
+
url: annotation.url,
|
|
4615
|
+
title: annotation.title
|
|
4616
|
+
});
|
|
4617
|
+
}
|
|
4618
|
+
const joined = parts.join("");
|
|
4619
|
+
if (joined.trim() !== "") answer = joined;
|
|
4620
|
+
}
|
|
4621
|
+
for (const call of calls) {
|
|
4622
|
+
const action = record(call.action) ? call.action : {};
|
|
4623
|
+
if (text(action.url) !== "") sources.add({ url: action.url });
|
|
4624
|
+
for (const source of Array.isArray(action.sources) ? action.sources : []) sources.add({
|
|
4625
|
+
url: source?.url,
|
|
4626
|
+
title: source?.title
|
|
4627
|
+
});
|
|
4628
|
+
}
|
|
4629
|
+
return result(sources.values(), boundedBlock(answer, MAX_CONTENT_CHARS));
|
|
4630
|
+
}
|
|
4631
|
+
/** Build one backend's request; the key is attached by the caller. */
|
|
4632
|
+
function buildSearchRequest(backend, { apiHost, query, sessionId }) {
|
|
4633
|
+
const model = SEARCH_BACKEND_MODELS[backend];
|
|
4634
|
+
if (backend === "gpt") return {
|
|
4635
|
+
url: `${apiHost}/v1/responses`,
|
|
4636
|
+
model,
|
|
4637
|
+
headers: (key) => ({ authorization: `Bearer ${key}` }),
|
|
4638
|
+
body: {
|
|
4639
|
+
model,
|
|
4640
|
+
input: [{
|
|
4641
|
+
role: "user",
|
|
4642
|
+
content: [{
|
|
4643
|
+
type: "input_text",
|
|
4644
|
+
text: `Search the web for: ${query}\n\nReply with a concise factual summary of what the sources say, citing them.`
|
|
4645
|
+
}]
|
|
4646
|
+
}],
|
|
4647
|
+
tools: [{ type: "web_search" }],
|
|
4648
|
+
tool_choice: "required",
|
|
4649
|
+
include: ["web_search_call.action.sources"],
|
|
4650
|
+
reasoning: { effort: "low" },
|
|
4651
|
+
max_output_tokens: MAX_OUTPUT_TOKENS,
|
|
4652
|
+
store: false,
|
|
4653
|
+
...typeof sessionId === "string" && sessionId !== "" ? { prompt_cache_key: sessionId } : {}
|
|
4654
|
+
},
|
|
4655
|
+
parse: parseResponsesSearchResponse
|
|
4656
|
+
};
|
|
4657
|
+
if (backend === "deepseek") return {
|
|
4658
|
+
url: `${apiHost}/v1/messages`,
|
|
4659
|
+
model,
|
|
4660
|
+
headers: (key) => ({
|
|
4661
|
+
"x-api-key": key,
|
|
4662
|
+
"anthropic-version": ANTHROPIC_VERSION
|
|
4663
|
+
}),
|
|
4664
|
+
body: {
|
|
4665
|
+
model,
|
|
4666
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
4667
|
+
messages: [{
|
|
4668
|
+
role: "user",
|
|
4669
|
+
content: `Perform a web search for the query: ${query}\n\nThen reply with a concise factual summary of what the results say.`
|
|
4670
|
+
}],
|
|
4671
|
+
tools: [{
|
|
4672
|
+
type: "web_search_20250305",
|
|
4673
|
+
name: "web_search",
|
|
4674
|
+
max_uses: DEEPSEEK_MAX_USES
|
|
4675
|
+
}]
|
|
4676
|
+
},
|
|
4677
|
+
parse: parseMessagesSearchResponse
|
|
4678
|
+
};
|
|
4679
|
+
throw new WebError(`PackyCode search backend "${String(backend)}" is not supported`, "WEB_PROVIDER_UNAVAILABLE");
|
|
4680
|
+
}
|
|
4681
|
+
/** Read a failure body as bounded text, preferring the gateway's own message. */
|
|
4682
|
+
async function failureText(response) {
|
|
4683
|
+
let raw = "";
|
|
4684
|
+
try {
|
|
4685
|
+
raw = await response.text();
|
|
4686
|
+
} catch {
|
|
4687
|
+
return "";
|
|
4688
|
+
}
|
|
4689
|
+
try {
|
|
4690
|
+
const parsed = JSON.parse(raw);
|
|
4691
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message ?? parsed?.message;
|
|
4692
|
+
if (typeof message === "string" && message !== "") return `${typeof parsed?.error?.code === "string" && parsed.error.code !== "" ? `${parsed.error.code}: ` : ""}${message}`.slice(0, MAX_ERROR_DETAIL_CHARS);
|
|
4693
|
+
} catch {}
|
|
4694
|
+
return raw.replace(/\s+/gu, " ").trim().slice(0, MAX_ERROR_DETAIL_CHARS);
|
|
4695
|
+
}
|
|
4696
|
+
const mask = (message, key) => typeof key === "string" && key.length >= 8 ? message.split(key).join(`…${key.slice(-4)}`) : message;
|
|
4697
|
+
function isAbort(error, signal) {
|
|
4698
|
+
return signal?.aborted === true || error?.name === "AbortError";
|
|
4699
|
+
}
|
|
4700
|
+
/**
|
|
4701
|
+
* Create the provider that searches through PackyCode on every request.
|
|
4702
|
+
* @param options - the key ring, the host resolution, the backend preference,
|
|
4703
|
+
* the key-failure sink shared with the model route, and test seams.
|
|
4704
|
+
* @returns a DSH web search provider.
|
|
4705
|
+
*/
|
|
4706
|
+
function createPackyCodeSearchProvider({ ring, hosts, backend = () => DEFAULT_SEARCH_BACKEND, onKeyFailure, covers, resolveSessionId, fetchImpl = ambientFetch, timeoutMs = DEFAULT_SEARCH_TIMEOUT_MS } = {}) {
|
|
4707
|
+
if (ring === void 0 || typeof ring.activeKey !== "function") throw new Error("PackyCode search requires the key ring");
|
|
4708
|
+
if (typeof hosts !== "function") throw new Error("PackyCode search requires the host resolution");
|
|
4709
|
+
/** One request with one key; classified failures come back as values. */
|
|
4710
|
+
const attempt = async (request, key, signal) => {
|
|
4711
|
+
if (signal?.aborted === true) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: signal.reason });
|
|
4712
|
+
let response;
|
|
4713
|
+
try {
|
|
4714
|
+
response = await boundedFetch(request.url, {
|
|
4715
|
+
method: "POST",
|
|
4716
|
+
headers: {
|
|
4717
|
+
...request.headers(key),
|
|
4718
|
+
accept: "application/json",
|
|
4719
|
+
"content-type": "application/json"
|
|
4720
|
+
},
|
|
4721
|
+
body: JSON.stringify(request.body)
|
|
4722
|
+
}, {
|
|
4723
|
+
fetchImpl,
|
|
4724
|
+
timeoutMs,
|
|
4725
|
+
signal
|
|
4726
|
+
});
|
|
4727
|
+
} catch (error) {
|
|
4728
|
+
if (error.aborted) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: error });
|
|
4729
|
+
if (error.timedOut) throw new WebError(`PackyCode search got no answer within ${Math.round(timeoutMs / 1e3)}s`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
4730
|
+
throw new WebError(`PackyCode search request failed (${mask(error.code ?? error.message, key)})`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
4731
|
+
}
|
|
4732
|
+
if (!response.ok) return {
|
|
4733
|
+
failure: mask(await failureText(response), key),
|
|
4734
|
+
status: response.status
|
|
4735
|
+
};
|
|
4736
|
+
let body;
|
|
4737
|
+
try {
|
|
4738
|
+
body = await response.json();
|
|
4739
|
+
} catch (error) {
|
|
4740
|
+
if (isAbort(error, signal)) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: error });
|
|
4741
|
+
throw new WebError("PackyCode search returned an unreadable response", "WEB_PROVIDER_ERROR", { cause: error });
|
|
4742
|
+
}
|
|
4743
|
+
if (record(body?.error)) return {
|
|
4744
|
+
failure: mask(text(body.error.message) || JSON.stringify(body.error).slice(0, MAX_ERROR_DETAIL_CHARS), key),
|
|
4745
|
+
status: response.status
|
|
4746
|
+
};
|
|
4747
|
+
return { value: request.parse(body) };
|
|
4748
|
+
};
|
|
4749
|
+
return Object.freeze({
|
|
4750
|
+
id: PACKYCODE_SEARCH_PROVIDER_ID,
|
|
4751
|
+
available: () => true,
|
|
4752
|
+
async search(searchRequest, signal) {
|
|
4753
|
+
if (signal?.aborted === true) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: signal.reason });
|
|
4754
|
+
const query = text(searchRequest?.query).trim();
|
|
4755
|
+
if (query === "") throw new WebError("PackyCode search needs a non-empty query", "WEB_PROVIDER_ERROR");
|
|
4756
|
+
const chosen = SEARCH_BACKENDS.includes(backend()) ? backend() : DEFAULT_SEARCH_BACKEND;
|
|
4757
|
+
const request = buildSearchRequest(chosen, {
|
|
4758
|
+
apiHost: hosts().apiHost,
|
|
4759
|
+
query,
|
|
4760
|
+
sessionId: resolveSessionId?.()
|
|
4761
|
+
});
|
|
4762
|
+
const candidates = await orderedKeys(ring, request.model, covers);
|
|
4763
|
+
let lastFailure;
|
|
4764
|
+
let uncovered = false;
|
|
4765
|
+
for (const key of candidates.slice(0, MAX_KEY_ATTEMPTS)) {
|
|
4766
|
+
const outcome = await attempt(request, key.key, signal);
|
|
4767
|
+
if (outcome.value !== void 0) return outcome.value;
|
|
4768
|
+
const failure = outcome.failure === "" ? `HTTP ${outcome.status}` : outcome.failure;
|
|
4769
|
+
lastFailure = failure;
|
|
4770
|
+
if (classifyClientPolicy(failure)) throw new WebError(explainClientPolicyFailure(), "WEB_PROVIDER_ERROR");
|
|
4771
|
+
const kind = classifyGatewayFailure(failure) ?? (outcome.status === 401 ? "invalid-key" : void 0);
|
|
4772
|
+
if (kind !== void 0) {
|
|
4773
|
+
onKeyFailure?.(kind, failure, key.key, key.id);
|
|
4774
|
+
continue;
|
|
4775
|
+
}
|
|
4776
|
+
if (classifyCoverage(failure)) {
|
|
4777
|
+
uncovered = true;
|
|
4778
|
+
continue;
|
|
4779
|
+
}
|
|
4780
|
+
throw new WebError(`PackyCode search failed (HTTP ${outcome.status}): ${failure}`, "WEB_PROVIDER_ERROR");
|
|
4781
|
+
}
|
|
4782
|
+
if (candidates.length === 0) throw new WebError("PackyCode search has no usable API key; add one under Settings → PackyCode", "WEB_PROVIDER_CREDENTIAL_MISSING");
|
|
4783
|
+
if (uncovered) {
|
|
4784
|
+
const other = chosen === "gpt" ? SEARCH_BACKEND_DEEPSEEK : "gpt";
|
|
4785
|
+
throw new WebError(`PackyCode search: no stored API key's group covers ${request.model} (${chosen} backend). Switch the search backend to "${other}" under Settings → PackyCode → Web search, or add a key whose group includes it. Gateway said: ${lastFailure}`, "WEB_PROVIDER_ERROR");
|
|
4786
|
+
}
|
|
4787
|
+
throw new WebError(`PackyCode search: every usable key was refused (${lastFailure ?? "no detail"})`, "WEB_PROVIDER_CREDENTIAL_MISSING");
|
|
4788
|
+
}
|
|
4789
|
+
});
|
|
4790
|
+
}
|
|
4791
|
+
/**
|
|
4792
|
+
* The keys one search tries, in order: the active key, then the other usable
|
|
4793
|
+
* keys in ring order — except that a key the group reading shows cannot reach
|
|
4794
|
+
* the backend's model goes last, behind every key that can or may.
|
|
4795
|
+
* @param ring - the key ring.
|
|
4796
|
+
* @param modelId - the backend's model.
|
|
4797
|
+
* @param covers - `(keyId, modelId) => true | false | undefined`, optional.
|
|
4798
|
+
* @returns the candidates, without key material beyond what the request needs.
|
|
4799
|
+
*/
|
|
4800
|
+
async function orderedKeys(ring, modelId, covers) {
|
|
4801
|
+
const active = await ring.activeKey();
|
|
4802
|
+
if (active === void 0) return [];
|
|
4803
|
+
const usable = typeof ring.secrets === "function" ? (await ring.secrets()).filter((entry) => !entry.disabled) : [];
|
|
4804
|
+
const start = usable.findIndex((entry) => entry.id === active.id);
|
|
4805
|
+
const rest = start < 0 ? usable.filter((entry) => entry.id !== active.id) : [...usable.slice(start + 1), ...usable.slice(0, start)];
|
|
4806
|
+
const rank = (entry) => {
|
|
4807
|
+
const value = covers?.(entry.id, modelId);
|
|
4808
|
+
return value === true ? 0 : value === false ? 2 : 1;
|
|
4809
|
+
};
|
|
4810
|
+
return [active, ...rest].map((entry, index) => ({
|
|
4811
|
+
entry,
|
|
4812
|
+
index,
|
|
4813
|
+
rank: index === 0 && rank(entry) !== 2 ? -1 : rank(entry)
|
|
4814
|
+
})).sort((left, right) => left.rank - right.rank || left.index - right.index).map(({ entry }) => ({
|
|
4815
|
+
id: entry.id,
|
|
4816
|
+
label: entry.label,
|
|
4817
|
+
key: entry.key
|
|
4818
|
+
}));
|
|
4819
|
+
}
|
|
4820
|
+
/** Marks a request already passing through the auto provider's fallback. */
|
|
4821
|
+
const delegating = new AsyncLocalStorage();
|
|
4822
|
+
/**
|
|
4823
|
+
* Create the provider that searches through PackyCode for PackyCode sessions
|
|
4824
|
+
* only. Every other request goes to the fallback provider: the search provider
|
|
4825
|
+
* the web row selected before this plugin's patch, or DSH's built-in search.
|
|
4826
|
+
*
|
|
4827
|
+
* Another plugin's router may delegate back to this one (its own fallback is
|
|
4828
|
+
* the web row's base, which this plugin's patch points here). A request that
|
|
4829
|
+
* re-enters while already delegating goes straight to DSH's built-in search
|
|
4830
|
+
* instead of looping.
|
|
4831
|
+
* @param options - the PackyCode provider, the initiating model provider, a
|
|
4832
|
+
* lookup of registered search providers, and the fallback id.
|
|
4833
|
+
* @returns a DSH web search provider.
|
|
4834
|
+
*/
|
|
4835
|
+
function createPackyCodeAutoSearchProvider({ packycode, resolveModelProvider, resolveProvider, resolveFallbackId }) {
|
|
4836
|
+
return Object.freeze({
|
|
4837
|
+
id: PACKYCODE_AUTO_SEARCH_PROVIDER_ID,
|
|
4838
|
+
available: () => true,
|
|
4839
|
+
async search(request, signal) {
|
|
4840
|
+
if (resolveModelProvider?.() === "packycode") return packycode.search(request, signal);
|
|
4841
|
+
const wanted = delegating.getStore() === true ? DSH_SEARCH_PROVIDER_FALLBACK : resolveFallbackId?.() ?? "deepseek-official";
|
|
4842
|
+
const id = OWN_SEARCH_PROVIDER_IDS.includes(wanted) ? DSH_SEARCH_PROVIDER_FALLBACK : wanted;
|
|
4843
|
+
const provider = resolveProvider?.(id);
|
|
4844
|
+
if (provider === void 0 || OWN_SEARCH_PROVIDER_IDS.includes(provider.id) || provider.available?.() !== true) throw new WebError(`PackyCode auto search: the fallback search provider "${id}" is unavailable`, "WEB_PROVIDER_UNAVAILABLE");
|
|
4845
|
+
return delegating.run(true, () => provider.search(request, signal));
|
|
4846
|
+
}
|
|
4847
|
+
});
|
|
4848
|
+
}
|
|
4849
|
+
//#endregion
|
|
4850
|
+
//#region src/search-composition.js
|
|
4851
|
+
/**
|
|
4852
|
+
* Point the DSH web row's base search provider at this plugin.
|
|
4853
|
+
*
|
|
4854
|
+
* The web runtime has one search slot. Subscription plugins (Codex, Kimi)
|
|
4855
|
+
* rewrite that slot at runtime whenever the web row restarts, and fall back to
|
|
4856
|
+
* the row's *base* config for models they do not own. Writing the runtime slot
|
|
4857
|
+
* here as well would make two switchers restart each other forever. Instead
|
|
4858
|
+
* this plugin keeps one marked `- id: web` entry in the owning profile's
|
|
4859
|
+
* `cordis.patch.yml`: the boot layer hot-applies the file, the base config then
|
|
4860
|
+
* names a PackyCode provider, and every router — or the bare slot, when no
|
|
4861
|
+
* router holds it — reaches PackyCode for the requests it does not own.
|
|
4862
|
+
*
|
|
4863
|
+
* Only the marked entry is ever touched. It is rewritten in place, so a
|
|
4864
|
+
* sibling plugin that appends its own entry after it never sees this file
|
|
4865
|
+
* reorder; an unchanged entry is never rewritten. A profile patch replaces the
|
|
4866
|
+
* whole matched config, so the entry restates the web row's other scalar
|
|
4867
|
+
* fields. It also records, as a comment, the provider the base named before
|
|
4868
|
+
* the entry existed, which is where the auto provider sends requests it does
|
|
4869
|
+
* not serve.
|
|
4870
|
+
*
|
|
4871
|
+
* @module dsh-packycode-api/search-composition
|
|
4872
|
+
*/
|
|
4873
|
+
const SEARCH_PATCH_FILE = "cordis.patch.yml";
|
|
4874
|
+
const BLOCK_BEGIN = "# >>> dsh-packycode-api: web search provider (managed in Settings → PackyCode → Web search)";
|
|
4875
|
+
const BLOCK_END = "# <<< dsh-packycode-api: web search provider";
|
|
4876
|
+
const BEGIN_PREFIX = "# >>> dsh-packycode-api: web search provider";
|
|
4877
|
+
const FALLBACK_LINE = /^#\s*fallback:\s*(\S+)\s*$/u;
|
|
4878
|
+
const PROVIDER_ID = /^[A-Za-z0-9._-]+$/u;
|
|
4879
|
+
const EMPTY_LIST = /^\[\]\s*$/u;
|
|
4880
|
+
const LIST_ITEM = /^-(?:\s|$)/u;
|
|
4881
|
+
const ITEM_HEAD = /^- id: web\s*$/u;
|
|
4882
|
+
const CONTINUATION = /^\s+\S/u;
|
|
4883
|
+
const isBegin = (line) => line.trim().startsWith(BEGIN_PREFIX);
|
|
4884
|
+
const isEnd = (line) => line.trim() === BLOCK_END;
|
|
4885
|
+
/**
|
|
4886
|
+
* Find this plugin's entry: the begin marker, its comment lines, the single
|
|
4887
|
+
* `- id: web` item with its indented lines, and an end marker directly after.
|
|
4888
|
+
* Anything else — including another entry a YAML rewrite moved inside the
|
|
4889
|
+
* markers — is not part of it.
|
|
4890
|
+
* @param lines - the file split into lines.
|
|
4891
|
+
* @returns the half-open line range and the recorded fallback, or undefined.
|
|
4892
|
+
*/
|
|
4893
|
+
function locate(lines) {
|
|
4894
|
+
const start = lines.findIndex(isBegin);
|
|
4895
|
+
if (start < 0) return void 0;
|
|
4896
|
+
let index = start + 1;
|
|
4897
|
+
let fallback;
|
|
4898
|
+
while (index < lines.length && lines[index].startsWith("#") && !isEnd(lines[index]) && !isBegin(lines[index])) {
|
|
4899
|
+
const match = FALLBACK_LINE.exec(lines[index]);
|
|
4900
|
+
if (match !== null) fallback = match[1];
|
|
4901
|
+
index += 1;
|
|
4902
|
+
}
|
|
4903
|
+
let provider;
|
|
4904
|
+
if (index < lines.length && ITEM_HEAD.test(lines[index])) {
|
|
4905
|
+
index += 1;
|
|
4906
|
+
while (index < lines.length && CONTINUATION.test(lines[index])) {
|
|
4907
|
+
const match = /^\s+searchProvider:\s*['"]?([A-Za-z0-9._-]+)['"]?\s*$/u.exec(lines[index]);
|
|
4908
|
+
if (match !== null) provider = match[1];
|
|
4909
|
+
index += 1;
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
if (index < lines.length && isEnd(lines[index])) index += 1;
|
|
4913
|
+
return {
|
|
4914
|
+
start,
|
|
4915
|
+
end: index,
|
|
4916
|
+
fallback,
|
|
4917
|
+
provider
|
|
4918
|
+
};
|
|
4919
|
+
}
|
|
4920
|
+
/** Drop the located entry and any stray end marker left elsewhere. */
|
|
4921
|
+
function strip(lines) {
|
|
4922
|
+
const found = locate(lines);
|
|
4923
|
+
return (found === void 0 ? [...lines] : [...lines.slice(0, found.start), ...lines.slice(found.end)]).filter((line) => !isEnd(line));
|
|
4924
|
+
}
|
|
4925
|
+
function yamlScalar(value) {
|
|
4926
|
+
if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
4927
|
+
return /^[A-Za-z0-9._-]+$/u.test(value) ? value : JSON.stringify(value);
|
|
4928
|
+
}
|
|
4929
|
+
/** The entry's lines; the web row's other scalar fields are restated, sorted. */
|
|
4930
|
+
function entryLines(providerId, fallback, baseConfig) {
|
|
4931
|
+
const fields = {};
|
|
4932
|
+
for (const [key, value] of Object.entries(baseConfig ?? {})) {
|
|
4933
|
+
if (key === "searchProvider" || !/^[A-Za-z0-9_]+$/u.test(key)) continue;
|
|
4934
|
+
if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) fields[key] = value;
|
|
4935
|
+
}
|
|
4936
|
+
fields.searchProvider = providerId;
|
|
4937
|
+
return [
|
|
4938
|
+
BLOCK_BEGIN,
|
|
4939
|
+
`# fallback: ${fallback}`,
|
|
4940
|
+
"- id: web",
|
|
4941
|
+
" config:",
|
|
4942
|
+
...Object.keys(fields).sort().map((key) => ` ${key}: ${yamlScalar(fields[key])}`),
|
|
4943
|
+
BLOCK_END
|
|
4944
|
+
];
|
|
4945
|
+
}
|
|
4946
|
+
/** A provider id the auto provider may fall back to. */
|
|
4947
|
+
function usableFallback(id) {
|
|
4948
|
+
return typeof id === "string" && PROVIDER_ID.test(id) && !OWN_SEARCH_PROVIDER_IDS.includes(id) ? id : DSH_SEARCH_PROVIDER_FALLBACK;
|
|
4949
|
+
}
|
|
4950
|
+
/**
|
|
4951
|
+
* Refuse a file this line-level editor cannot extend safely: a flow sequence
|
|
4952
|
+
* with entries, or a top-level line that is not a list item or comment.
|
|
4953
|
+
*/
|
|
4954
|
+
function assertBlockSequence(lines) {
|
|
4955
|
+
for (const line of lines) {
|
|
4956
|
+
if (line.trim() === "" || line.trimStart().startsWith("#") || CONTINUATION.test(line) || LIST_ITEM.test(line) || EMPTY_LIST.test(line)) continue;
|
|
4957
|
+
throw new Error("PackyCode web search could not edit the profile patch: it is not a plain YAML list; set the web search provider by hand");
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
/**
|
|
4961
|
+
* Maintain this plugin's entry in the owning profile's patch file.
|
|
4962
|
+
* @param options - where the file is, the web row's current base config, an
|
|
4963
|
+
* optional exclusive section (DSH's reload lock), and file-system seams.
|
|
4964
|
+
* @returns the composition: read the entry, apply a provider, remove it.
|
|
4965
|
+
*/
|
|
4966
|
+
function createSearchComposition({ resolvePatchPath, readBaseConfig, runExclusive = (run) => run(), fs = {
|
|
4967
|
+
readFile,
|
|
4968
|
+
writeFile,
|
|
4969
|
+
rename
|
|
4970
|
+
} } = {}) {
|
|
4971
|
+
if (typeof resolvePatchPath !== "function") throw new Error("PackyCode search composition requires resolvePatchPath");
|
|
4972
|
+
if (typeof readBaseConfig !== "function") throw new Error("PackyCode search composition requires readBaseConfig");
|
|
4973
|
+
let chain = Promise.resolve();
|
|
4974
|
+
const serialized = (run) => {
|
|
4975
|
+
const next = chain.then(() => runExclusive(run));
|
|
4976
|
+
chain = next.then(() => void 0, () => void 0);
|
|
4977
|
+
return next;
|
|
4978
|
+
};
|
|
4979
|
+
const load = async () => {
|
|
4980
|
+
const path = await resolvePatchPath();
|
|
4981
|
+
if (typeof path !== "string" || path === "") throw new Error("PackyCode web search could not find the profile patch file");
|
|
4982
|
+
let content = "";
|
|
4983
|
+
try {
|
|
4984
|
+
content = await fs.readFile(path, "utf8");
|
|
4985
|
+
} catch (error) {
|
|
4986
|
+
if (error?.code !== "ENOENT") throw error;
|
|
4987
|
+
}
|
|
4988
|
+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
4989
|
+
const lines = content === "" ? [] : content.split(/\r?\n/u);
|
|
4990
|
+
if (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
4991
|
+
return {
|
|
4992
|
+
path,
|
|
4993
|
+
content,
|
|
4994
|
+
eol,
|
|
4995
|
+
lines
|
|
4996
|
+
};
|
|
4997
|
+
};
|
|
4998
|
+
const render = ({ eol }, lines) => lines.length === 0 ? "" : `${lines.join(eol)}${eol}`;
|
|
4999
|
+
const save = async (file, lines) => {
|
|
5000
|
+
const next = render(file, lines);
|
|
5001
|
+
if (next === file.content) return false;
|
|
5002
|
+
const temporary = `${file.path}.packycode-api.tmp`;
|
|
5003
|
+
await fs.writeFile(temporary, next, { mode: 384 });
|
|
5004
|
+
await fs.rename(temporary, file.path);
|
|
5005
|
+
return true;
|
|
5006
|
+
};
|
|
5007
|
+
/** The file with this plugin's entry pointing at `providerId`. */
|
|
5008
|
+
const applied = (file, providerId) => {
|
|
5009
|
+
const found = locate(file.lines);
|
|
5010
|
+
assertBlockSequence(found === void 0 ? file.lines : [...file.lines.slice(0, found.start), ...file.lines.slice(found.end)]);
|
|
5011
|
+
const base = readBaseConfig() ?? {};
|
|
5012
|
+
const fallback = found?.fallback !== void 0 ? usableFallback(found.fallback) : usableFallback(base.searchProvider);
|
|
5013
|
+
const entry = entryLines(providerId, fallback, base);
|
|
5014
|
+
if (found !== void 0) {
|
|
5015
|
+
const before = file.lines.slice(0, found.start).filter((line) => !isEnd(line));
|
|
5016
|
+
const after = file.lines.slice(found.end).filter((line) => !isEnd(line));
|
|
5017
|
+
while (after.length > 0 && after.at(-1).trim() === "") after.pop();
|
|
5018
|
+
return {
|
|
5019
|
+
fallback,
|
|
5020
|
+
lines: [
|
|
5021
|
+
...before,
|
|
5022
|
+
...entry,
|
|
5023
|
+
...after
|
|
5024
|
+
]
|
|
5025
|
+
};
|
|
5026
|
+
}
|
|
5027
|
+
const lines = file.lines.filter((line) => !EMPTY_LIST.test(line) && !isEnd(line));
|
|
5028
|
+
while (lines.length > 0 && lines.at(-1).trim() === "") lines.pop();
|
|
5029
|
+
return {
|
|
5030
|
+
fallback,
|
|
5031
|
+
lines: [...lines, ...entry]
|
|
5032
|
+
};
|
|
5033
|
+
};
|
|
5034
|
+
/** The file without this plugin's entry, or undefined when it holds none. */
|
|
5035
|
+
const removed = (file) => {
|
|
5036
|
+
if (locate(file.lines) === void 0 && !file.lines.some(isEnd)) return void 0;
|
|
5037
|
+
const lines = strip(file.lines);
|
|
5038
|
+
while (lines.length > 0 && lines.at(-1).trim() === "") lines.pop();
|
|
5039
|
+
if (!lines.some((line) => LIST_ITEM.test(line)) && !lines.some((line) => EMPTY_LIST.test(line))) lines.push("[]");
|
|
5040
|
+
return lines;
|
|
5041
|
+
};
|
|
5042
|
+
return Object.freeze({
|
|
5043
|
+
/** The entry as the file holds it now. */
|
|
5044
|
+
async read() {
|
|
5045
|
+
const { lines } = await load();
|
|
5046
|
+
const found = locate(lines);
|
|
5047
|
+
return found === void 0 ? { present: false } : {
|
|
5048
|
+
present: true,
|
|
5049
|
+
provider: found.provider,
|
|
5050
|
+
fallback: usableFallback(found.fallback)
|
|
5051
|
+
};
|
|
5052
|
+
},
|
|
5053
|
+
/**
|
|
5054
|
+
* Point the base search provider at `providerId`, keeping the recorded
|
|
5055
|
+
* fallback when the entry already exists.
|
|
5056
|
+
* @returns whether the file changed, and the fallback the entry records.
|
|
5057
|
+
*/
|
|
5058
|
+
async apply(providerId) {
|
|
5059
|
+
if (!OWN_SEARCH_PROVIDER_IDS.includes(providerId)) throw new Error(`PackyCode web search cannot select "${String(providerId)}"`);
|
|
5060
|
+
const current = await load();
|
|
5061
|
+
const planned = applied(current, providerId);
|
|
5062
|
+
if (render(current, planned.lines) === current.content) return {
|
|
5063
|
+
changed: false,
|
|
5064
|
+
fallback: planned.fallback
|
|
5065
|
+
};
|
|
5066
|
+
return serialized(async () => {
|
|
5067
|
+
const file = await load();
|
|
5068
|
+
const next = applied(file, providerId);
|
|
5069
|
+
return {
|
|
5070
|
+
changed: await save(file, next.lines),
|
|
5071
|
+
fallback: next.fallback
|
|
5072
|
+
};
|
|
5073
|
+
});
|
|
5074
|
+
},
|
|
5075
|
+
/** Remove the entry, leaving an empty list when nothing else remains. */
|
|
5076
|
+
async remove() {
|
|
5077
|
+
if (removed(await load()) === void 0) return { changed: false };
|
|
5078
|
+
return serialized(async () => {
|
|
5079
|
+
const file = await load();
|
|
5080
|
+
const lines = removed(file);
|
|
5081
|
+
return { changed: lines === void 0 ? false : await save(file, lines) };
|
|
5082
|
+
});
|
|
5083
|
+
}
|
|
5084
|
+
});
|
|
5085
|
+
}
|
|
5086
|
+
/**
|
|
5087
|
+
* Run `operation` inside DSH's reload transaction, so a patch write never
|
|
5088
|
+
* overlaps a profile reload or a settings write. A reconcile started from
|
|
5089
|
+
* inside one of those (a web restart during a reload, a volatile update
|
|
5090
|
+
* during a settings write) inherits its context, and DSH refuses to nest the
|
|
5091
|
+
* transaction; that one runs unlocked, after the transaction that started it.
|
|
5092
|
+
* @param hmr - DSH's `hmr` service, when the host has one.
|
|
5093
|
+
* @param operation - the write.
|
|
5094
|
+
* @returns the operation's result.
|
|
5095
|
+
*/
|
|
5096
|
+
async function runInReloadTransaction(hmr, operation) {
|
|
5097
|
+
if (typeof hmr?.runExclusive !== "function") return operation();
|
|
5098
|
+
let started = false;
|
|
5099
|
+
try {
|
|
5100
|
+
return await hmr.runExclusive(() => {
|
|
5101
|
+
started = true;
|
|
5102
|
+
return operation();
|
|
5103
|
+
});
|
|
5104
|
+
} catch (error) {
|
|
5105
|
+
if (!started && /cannot be nested/u.test(error?.message ?? "")) return operation();
|
|
5106
|
+
throw error;
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
/**
|
|
5110
|
+
* The composition for the profile this plugin runs in.
|
|
5111
|
+
* @param options - the plugin context (for `profileContext` and `hmr`), the
|
|
5112
|
+
* web row's Loader entry, and where to look for the owning profile when the
|
|
5113
|
+
* host does not name its patch file.
|
|
5114
|
+
* @returns the composition.
|
|
5115
|
+
*/
|
|
5116
|
+
function createProfileSearchComposition({ ctx, webEntry, dshHome, ownPackageJsonUrl }) {
|
|
5117
|
+
return createSearchComposition({
|
|
5118
|
+
resolvePatchPath: async () => {
|
|
5119
|
+
const profile = ctx.get?.("profileContext");
|
|
5120
|
+
if (typeof profile?.patchPath === "string" && profile.patchPath !== "") return profile.patchPath;
|
|
5121
|
+
const install = await findInstall({
|
|
5122
|
+
dshHome,
|
|
5123
|
+
ownPackageJsonUrl
|
|
5124
|
+
});
|
|
5125
|
+
return install.profile === void 0 ? void 0 : join(dshHome, "profiles", install.profile, SEARCH_PATCH_FILE);
|
|
5126
|
+
},
|
|
5127
|
+
readBaseConfig: () => webEntry()?.options?.config ?? {},
|
|
5128
|
+
runExclusive: (operation) => runInReloadTransaction(ctx.get?.("hmr"), operation)
|
|
5129
|
+
});
|
|
5130
|
+
}
|
|
5131
|
+
//#endregion
|
|
5132
|
+
//#region src/search-controller.js
|
|
5133
|
+
/**
|
|
5134
|
+
* Web search wiring: the two search providers, the user's routing and backend
|
|
5135
|
+
* preference, the profile-patch entry that makes DSH reach them, and the
|
|
5136
|
+
* projection the settings panel reads.
|
|
5137
|
+
*
|
|
5138
|
+
* @module dsh-packycode-api/search-controller
|
|
5139
|
+
*/
|
|
5140
|
+
/** Routers other subscription plugins register; shown so the user knows who holds the slot. */
|
|
5141
|
+
const FOREIGN_ROUTERS = Object.freeze({
|
|
5142
|
+
codex: "codex-subscription-auto",
|
|
5143
|
+
kimi: "kimi-subscription-auto"
|
|
5144
|
+
});
|
|
5145
|
+
/** Normalize a stored preference; anything unknown reads as the default. */
|
|
5146
|
+
function readSearchChoice(value) {
|
|
5147
|
+
return {
|
|
5148
|
+
mode: SEARCH_MODES.includes(value?.searchProvider) ? value.searchProvider : SEARCH_MODE_DEFAULT,
|
|
5149
|
+
backend: SEARCH_BACKENDS.includes(value?.searchBackend) ? value.searchBackend : DEFAULT_SEARCH_BACKEND
|
|
5150
|
+
};
|
|
5151
|
+
}
|
|
5152
|
+
/** Only sentences this plugin wrote reach the browser. */
|
|
5153
|
+
function safeMessage(error) {
|
|
5154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5155
|
+
return /^PackyCode /u.test(message) ? message.slice(0, 240) : "PackyCode web search could not update the profile patch";
|
|
5156
|
+
}
|
|
5157
|
+
/**
|
|
5158
|
+
* @param options - the key ring and hosts the search runs with, the key-failure
|
|
5159
|
+
* sink and group coverage shared with the model route, the preference reader
|
|
5160
|
+
* and writer, the initiating session's provider and id, the web row's Loader
|
|
5161
|
+
* entry, the profile-patch composition, a logger, and an optional fetch.
|
|
5162
|
+
*/
|
|
5163
|
+
function createSearchController({ ring, hosts, onKeyFailure, covers, readPreference, writePreference, isWritable = () => typeof writePreference === "function", resolveModelProvider, resolveSessionId, webEntry = () => void 0, composition, logger, fetchImpl }) {
|
|
5164
|
+
let web;
|
|
5165
|
+
let fallbackId;
|
|
5166
|
+
let applied = { state: "idle" };
|
|
5167
|
+
let chain = Promise.resolve();
|
|
5168
|
+
const preference = () => readSearchChoice(readPreference?.());
|
|
5169
|
+
const packycode = createPackyCodeSearchProvider({
|
|
5170
|
+
ring,
|
|
5171
|
+
hosts,
|
|
5172
|
+
backend: () => preference().backend,
|
|
5173
|
+
onKeyFailure,
|
|
5174
|
+
covers,
|
|
5175
|
+
resolveSessionId,
|
|
5176
|
+
...fetchImpl === void 0 ? {} : { fetchImpl }
|
|
5177
|
+
});
|
|
5178
|
+
const auto = createPackyCodeAutoSearchProvider({
|
|
5179
|
+
packycode,
|
|
5180
|
+
resolveModelProvider,
|
|
5181
|
+
resolveProvider: (id) => web?.searchProviders?.get?.(id),
|
|
5182
|
+
resolveFallbackId: () => fallbackId ?? usableFallback(webEntry()?.options?.config?.searchProvider)
|
|
5183
|
+
});
|
|
5184
|
+
/** Make the profile patch match the preference; serialized, idempotent. */
|
|
5185
|
+
const reconcile = () => {
|
|
5186
|
+
const run = async () => {
|
|
5187
|
+
if (web === void 0 || composition === void 0) {
|
|
5188
|
+
applied = { state: "unavailable" };
|
|
5189
|
+
return;
|
|
5190
|
+
}
|
|
5191
|
+
const { mode } = preference();
|
|
5192
|
+
try {
|
|
5193
|
+
if (mode === "default") {
|
|
5194
|
+
await composition.remove();
|
|
5195
|
+
fallbackId = void 0;
|
|
5196
|
+
applied = { state: "removed" };
|
|
5197
|
+
} else {
|
|
5198
|
+
const outcome = await composition.apply(mode === "auto" ? PACKYCODE_AUTO_SEARCH_PROVIDER_ID : PACKYCODE_SEARCH_PROVIDER_ID);
|
|
5199
|
+
fallbackId = outcome.fallback;
|
|
5200
|
+
applied = {
|
|
5201
|
+
state: "applied",
|
|
5202
|
+
fallback: outcome.fallback
|
|
5203
|
+
};
|
|
5204
|
+
}
|
|
5205
|
+
} catch (error) {
|
|
5206
|
+
applied = {
|
|
5207
|
+
state: "failed",
|
|
5208
|
+
error: safeMessage(error)
|
|
5209
|
+
};
|
|
5210
|
+
logger?.warn?.("packycode-api: could not apply the web search selection: %s", error?.message ?? String(error));
|
|
5211
|
+
}
|
|
5212
|
+
};
|
|
5213
|
+
const next = chain.then(run);
|
|
5214
|
+
chain = next.then(() => void 0, () => void 0);
|
|
5215
|
+
return next;
|
|
5216
|
+
};
|
|
5217
|
+
const status = () => {
|
|
5218
|
+
const { mode, backend } = preference();
|
|
5219
|
+
const entry = webEntry();
|
|
5220
|
+
const base = entry?.options?.config?.searchProvider;
|
|
5221
|
+
const runtime = entry?.fiber?.config?.searchProvider;
|
|
5222
|
+
return {
|
|
5223
|
+
searchProvider: mode,
|
|
5224
|
+
searchBackend: backend,
|
|
5225
|
+
writable: isWritable() === true,
|
|
5226
|
+
available: web !== void 0,
|
|
5227
|
+
models: { ...SEARCH_BACKEND_MODELS },
|
|
5228
|
+
effective: {
|
|
5229
|
+
...typeof base === "string" ? { base } : {},
|
|
5230
|
+
...typeof runtime === "string" ? { runtime } : {}
|
|
5231
|
+
},
|
|
5232
|
+
applied: { ...applied },
|
|
5233
|
+
foreign: Object.fromEntries(Object.entries(FOREIGN_ROUTERS).map(([name, id]) => [name, web?.searchProviders?.has?.(id) === true]))
|
|
5234
|
+
};
|
|
5235
|
+
};
|
|
5236
|
+
return Object.freeze({
|
|
5237
|
+
packycode,
|
|
5238
|
+
auto,
|
|
5239
|
+
/**
|
|
5240
|
+
* Register both providers with the web service and reconcile once.
|
|
5241
|
+
* @param service - the `web` service, as the inject scope hands it over.
|
|
5242
|
+
* @param effect - that scope's effect registrar.
|
|
5243
|
+
*/
|
|
5244
|
+
register(service, effect) {
|
|
5245
|
+
effect(() => {
|
|
5246
|
+
web = service;
|
|
5247
|
+
const disposePackycode = service.registerSearchProvider(packycode);
|
|
5248
|
+
const disposeAuto = service.registerSearchProvider(auto);
|
|
5249
|
+
return () => {
|
|
5250
|
+
disposeAuto?.();
|
|
5251
|
+
disposePackycode?.();
|
|
5252
|
+
if (web === service) web = void 0;
|
|
5253
|
+
};
|
|
5254
|
+
}, "packycode-api: web search providers");
|
|
5255
|
+
reconcile();
|
|
5256
|
+
},
|
|
5257
|
+
reconcile,
|
|
5258
|
+
status,
|
|
5259
|
+
/** Validate, persist, and apply a preference change. */
|
|
5260
|
+
async update(patch) {
|
|
5261
|
+
const next = {};
|
|
5262
|
+
if (patch?.searchProvider !== void 0) {
|
|
5263
|
+
if (!SEARCH_MODES.includes(patch.searchProvider)) throw new Error("That PackyCode search option is not supported");
|
|
5264
|
+
next.searchProvider = patch.searchProvider;
|
|
5265
|
+
}
|
|
5266
|
+
if (patch?.searchBackend !== void 0) {
|
|
5267
|
+
if (!SEARCH_BACKENDS.includes(patch.searchBackend)) throw new Error("That PackyCode search backend is not supported");
|
|
5268
|
+
next.searchBackend = patch.searchBackend;
|
|
5269
|
+
}
|
|
5270
|
+
if (Object.keys(next).length === 0) return status();
|
|
5271
|
+
if (isWritable() !== true || typeof writePreference !== "function") throw new Error("PackyCode settings are not writable in this profile");
|
|
5272
|
+
try {
|
|
5273
|
+
await writePreference(next);
|
|
5274
|
+
} catch (error) {
|
|
5275
|
+
logger?.warn?.("packycode-api: could not save the web search preference: %s", error?.message ?? String(error));
|
|
5276
|
+
throw new Error("PackyCode could not save the web search preference", { cause: error });
|
|
5277
|
+
}
|
|
5278
|
+
await reconcile();
|
|
5279
|
+
return status();
|
|
5280
|
+
}
|
|
5281
|
+
});
|
|
5282
|
+
}
|
|
5283
|
+
//#endregion
|
|
5284
|
+
//#region src/index.js
|
|
5285
|
+
/**
|
|
5286
|
+
* dsh-packycode-api — a self-contained PackyCode route for DeepSeek Harness.
|
|
5287
|
+
*
|
|
5288
|
+
* The plugin builds its own pi-ai provider, model catalog, key ring, balance
|
|
5289
|
+
* and group readers, then registers the route through the same public
|
|
5290
|
+
* `llm.registerAdapter` API the shipped adapters use. Nothing here reads the
|
|
5291
|
+
* profile's composition config, so installing the package is the whole setup:
|
|
5292
|
+
* the picker shows the models and the user only pastes API keys.
|
|
5293
|
+
*
|
|
5294
|
+
* This module only composes; each concern lives in its own module.
|
|
5295
|
+
*
|
|
5296
|
+
* @module dsh-packycode-api
|
|
5297
|
+
*/
|
|
5298
|
+
const name = PLUGIN_ID;
|
|
5299
|
+
/**
|
|
5300
|
+
* Only the model route is mandatory. `settings`, `web`, `connection`, and
|
|
5301
|
+
* `tools` are requested through `ctx.inject`: a profile without them (a
|
|
5302
|
+
* headless run, for one) must still boot with a working route instead of
|
|
5303
|
+
* blocking on a service it will never see.
|
|
5304
|
+
*/
|
|
5305
|
+
const inject = ["llm", "credentials"];
|
|
5306
|
+
/** The model a CLI run without one is matched against for its key's groups. */
|
|
5307
|
+
const CLI_GROUP_MODEL = "claude-opus-5-5";
|
|
5308
|
+
/** Host overrides and preferences; a profile that never sets them keeps the defaults. */
|
|
5309
|
+
const Config = z.object({
|
|
5310
|
+
apiHost: z.string().default(DEFAULT_API_HOST),
|
|
5311
|
+
statusHost: z.string().default(DEFAULT_STATUS_HOST),
|
|
5312
|
+
/** Path to the Claude Code CLI binary; empty means "look in the usual places, then PATH". */
|
|
5313
|
+
claudeCliPath: z.string().default(""),
|
|
5314
|
+
/**
|
|
5315
|
+
* What the CLI may do on its own during a CLI-backed turn. `auto` follows the
|
|
5316
|
+
* session's permission preset, so nothing needs configuring here.
|
|
5317
|
+
*/
|
|
5318
|
+
claudeCliMode: z.union([
|
|
5319
|
+
AUTO_CLAUDE_MODE,
|
|
5320
|
+
"read-only",
|
|
5321
|
+
"edit",
|
|
5322
|
+
"full"
|
|
5323
|
+
]).default(AUTO_CLAUDE_MODE),
|
|
5324
|
+
/** Working directory for CLI-backed turns; empty means "the session's workspace". */
|
|
5325
|
+
claudeCliCwd: z.string().default(""),
|
|
5326
|
+
/**
|
|
5327
|
+
* Web search routing: `default` leaves DSH's search alone, `auto` searches
|
|
5328
|
+
* through PackyCode for PackyCode sessions only, `packycode` for every
|
|
5329
|
+
* request DSH's own search would have served.
|
|
5330
|
+
*/
|
|
5331
|
+
searchProvider: live(z.union([...SEARCH_MODES]).default(SEARCH_MODE_DEFAULT)),
|
|
5332
|
+
/** Which PackyCode model runs a search: `deepseek` or `gpt`. */
|
|
5333
|
+
searchBackend: live(z.union([...SEARCH_BACKENDS]).default(DEFAULT_SEARCH_BACKEND))
|
|
5334
|
+
});
|
|
5335
|
+
function apply(ctx, config) {
|
|
5336
|
+
const ring = new PackyCodeKeyRing(ctx.credentials);
|
|
3566
5337
|
let source = () => ({
|
|
3567
5338
|
apiHost: normalizeHost(config?.apiHost, DEFAULT_API_HOST),
|
|
3568
|
-
statusHost: normalizeHost(config?.statusHost, DEFAULT_STATUS_HOST)
|
|
5339
|
+
statusHost: normalizeHost(config?.statusHost, DEFAULT_STATUS_HOST),
|
|
5340
|
+
searchProvider: fieldValue(config?.searchProvider),
|
|
5341
|
+
searchBackend: fieldValue(config?.searchBackend)
|
|
3569
5342
|
});
|
|
3570
5343
|
const hosts = () => {
|
|
3571
5344
|
const current = source() ?? {};
|
|
@@ -3575,69 +5348,63 @@ function apply(ctx, config) {
|
|
|
3575
5348
|
};
|
|
3576
5349
|
};
|
|
3577
5350
|
const balance = createBalanceReader({
|
|
3578
|
-
...balanceReaderOptions,
|
|
3579
5351
|
listKeys: () => ring.secrets(),
|
|
3580
5352
|
getHosts: hosts
|
|
3581
5353
|
});
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
5354
|
+
/**
|
|
5355
|
+
* Which token groups each key is in, and so which models it can reach.
|
|
5356
|
+
* Nothing reads it at boot: the settings panel asks for it, and a request
|
|
5357
|
+
* with a choice of keys waits briefly for the first reading.
|
|
5358
|
+
*/
|
|
5359
|
+
const groups = createGroupReader({
|
|
5360
|
+
listKeys: () => ring.secrets(),
|
|
5361
|
+
getHosts: hosts
|
|
5362
|
+
});
|
|
5363
|
+
const keys = createKeyChooser({
|
|
5364
|
+
ring,
|
|
5365
|
+
groups,
|
|
5366
|
+
logger: ctx.logger,
|
|
5367
|
+
onRotated: () => balance.invalidate()
|
|
5368
|
+
});
|
|
5369
|
+
/** The session of the agent this turn runs for, when one is in scope. */
|
|
5370
|
+
const currentSession = () => {
|
|
5371
|
+
try {
|
|
5372
|
+
return ctx.get?.("agents")?.currentInitiator?.()?.session;
|
|
5373
|
+
} catch {
|
|
3586
5374
|
return;
|
|
3587
5375
|
}
|
|
3588
|
-
ctx.logger?.warn?.("packycode-api: %s on key %s; the next key serves the retry", kind, id);
|
|
3589
|
-
ring.disable(id, kind).then(() => balance.invalidate()).catch((error) => ctx.logger?.warn?.("packycode-api: could not move past key %s: %s", id, error.message));
|
|
3590
5376
|
};
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
|
|
3599
|
-
const withSessionId = (options) => options === void 0 || options.sessionId !== void 0 ? options : {
|
|
3600
|
-
...options,
|
|
3601
|
-
sessionId: fallbackSessionId
|
|
5377
|
+
/** One Loader entry by row id, or undefined outside a Loader composition. */
|
|
5378
|
+
const loaderEntry = (id) => {
|
|
5379
|
+
try {
|
|
5380
|
+
return [...ctx.get?.("loader")?.entries?.() ?? []].find((entry) => entry?.options?.id === id);
|
|
5381
|
+
} catch {
|
|
5382
|
+
return;
|
|
5383
|
+
}
|
|
3602
5384
|
};
|
|
3603
|
-
const
|
|
3604
|
-
|
|
3605
|
-
stream: (model, context, options) => guardKeyRotation(raw.stream(model, context, withSessionId(options)), {
|
|
3606
|
-
key: options?.apiKey,
|
|
3607
|
-
onKeyFailure
|
|
3608
|
-
}),
|
|
3609
|
-
streamSimple: (model, context, options) => guardKeyRotation(raw.streamSimple(model, context, withSessionId(options)), {
|
|
3610
|
-
key: options?.apiKey,
|
|
3611
|
-
onKeyFailure
|
|
3612
|
-
})
|
|
3613
|
-
}));
|
|
3614
|
-
const profiles = /* @__PURE__ */ new Map([[PROVIDER, profile]]);
|
|
5385
|
+
const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
|
|
5386
|
+
const attachments = () => ctx.get?.("attachments") ?? ctx.attachments;
|
|
3615
5387
|
/**
|
|
3616
|
-
* The Claude Code CLI runner. PackyCode
|
|
3617
|
-
*
|
|
3618
|
-
*
|
|
3619
|
-
*
|
|
5388
|
+
* The Claude Code CLI runner. PackyCode serves its Claude models only to the
|
|
5389
|
+
* official CLI, so CLI-backed models are run by that CLI itself — with this
|
|
5390
|
+
* plugin's endpoint and a ring key, in an isolated config home. Each run
|
|
5391
|
+
* uses the active key unless its groups cannot reach the run's Claude model
|
|
5392
|
+
* and another stored key's can, and reports which key it used by label.
|
|
3620
5393
|
*/
|
|
3621
5394
|
const claudeCli = createClaudeCliManager({ explicitPath: () => config?.claudeCliPath });
|
|
3622
5395
|
const claudeRunner = createClaudeRunner({
|
|
3623
5396
|
binary: () => claudeCli.binary(),
|
|
3624
|
-
resolveCredential: async () => {
|
|
3625
|
-
const
|
|
3626
|
-
if (
|
|
5397
|
+
resolveCredential: async ({ model } = {}) => {
|
|
5398
|
+
const choice = await keys.forRun(PACKYCODE_MODEL_IDS.includes(model) ? model : CLI_GROUP_MODEL);
|
|
5399
|
+
if (choice === void 0) throw new Error("Claude Code CLI needs a usable PackyCode API key; add one under Settings → PackyCode");
|
|
5400
|
+
const { key, ...described } = choice;
|
|
3627
5401
|
return {
|
|
5402
|
+
...described,
|
|
3628
5403
|
baseUrl: hosts().apiHost,
|
|
3629
|
-
token:
|
|
5404
|
+
token: key
|
|
3630
5405
|
};
|
|
3631
5406
|
}
|
|
3632
5407
|
});
|
|
3633
|
-
/** The session of the agent this turn runs for, when one is in scope. */
|
|
3634
|
-
const currentSession = () => {
|
|
3635
|
-
try {
|
|
3636
|
-
return ctx.get?.("agents")?.currentInitiator?.()?.session;
|
|
3637
|
-
} catch {
|
|
3638
|
-
return;
|
|
3639
|
-
}
|
|
3640
|
-
};
|
|
3641
5408
|
/** The session's own workspace, so a CLI turn edits the project in view. */
|
|
3642
5409
|
const resolveCliCwd = () => {
|
|
3643
5410
|
const configured = typeof config?.claudeCliCwd === "string" ? config.claudeCliCwd.trim() : "";
|
|
@@ -3649,76 +5416,102 @@ function apply(ctx, config) {
|
|
|
3649
5416
|
* The most a CLI turn may do: an explicit setting when the profile names
|
|
3650
5417
|
* one, otherwise what the session's permission preset allows.
|
|
3651
5418
|
*/
|
|
3652
|
-
const
|
|
5419
|
+
const cliModeCeiling = (session) => {
|
|
3653
5420
|
const configured = config?.claudeCliMode;
|
|
3654
|
-
return configured === void 0 || configured === "auto" ?
|
|
5421
|
+
return configured === void 0 || configured === "auto" ? claudeModeForSession(ctx, session) : configured;
|
|
3655
5422
|
};
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
const adapter = createClaudeCliAdapter({
|
|
3663
|
-
inner: new PiAiAdapter({
|
|
3664
|
-
profiles: () => profiles,
|
|
3665
|
-
resolveApiKey: async () => {
|
|
3666
|
-
await rememberKeys();
|
|
3667
|
-
const active = await ring.activeKey();
|
|
3668
|
-
if (active === void 0) throw new LlmError("No usable PackyCode API key: add one under Settings → PackyCode, or re-enable the keys this plugin moved past.", "MISSING_CREDENTIAL");
|
|
3669
|
-
return active.key;
|
|
3670
|
-
},
|
|
3671
|
-
auth: Object.freeze({
|
|
3672
|
-
credentials: ringCredentialStore(ring),
|
|
3673
|
-
authContext: Object.freeze({
|
|
3674
|
-
env: async () => void 0,
|
|
3675
|
-
fileExists: async () => false
|
|
3676
|
-
})
|
|
3677
|
-
}),
|
|
3678
|
-
resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
|
|
5423
|
+
ctx.llm.registerAdapter([PROVIDER], createClaudeCliAdapter({
|
|
5424
|
+
inner: createRouteAdapter({
|
|
5425
|
+
ring,
|
|
5426
|
+
keys,
|
|
5427
|
+
fallbackSessionId,
|
|
5428
|
+
resolveAttachments: attachments
|
|
3679
5429
|
}),
|
|
3680
5430
|
runner: claudeRunner,
|
|
3681
|
-
resolveMode:
|
|
5431
|
+
resolveMode: () => cliModeCeiling(currentSession()) ?? "edit",
|
|
3682
5432
|
resolveCwd: resolveCliCwd,
|
|
3683
|
-
resolveAttachments:
|
|
3684
|
-
|
|
3685
|
-
|
|
5433
|
+
resolveAttachments: attachments,
|
|
5434
|
+
explainKey: (runKey) => keys.describe(runKey)
|
|
5435
|
+
}));
|
|
3686
5436
|
ctx.llm.registerConfigurableProviders([{
|
|
3687
5437
|
provider: PROVIDER,
|
|
3688
5438
|
displayName: DISPLAY_NAME,
|
|
3689
5439
|
settingsNs: SETTINGS_NAMESPACE,
|
|
3690
5440
|
settingsPath: []
|
|
3691
5441
|
}]);
|
|
3692
|
-
const
|
|
5442
|
+
const hostPreference = unwritable("PackyCode host overrides are not writable in this profile");
|
|
5443
|
+
const searchPreference = {
|
|
3693
5444
|
writable: false,
|
|
3694
|
-
set:
|
|
3695
|
-
throw new Error("PackyCode host overrides are not writable in this profile");
|
|
3696
|
-
}
|
|
5445
|
+
set: void 0
|
|
3697
5446
|
};
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
5447
|
+
const dshHome = typeof process.env.DSH_HOME === "string" && process.env.DSH_HOME !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
|
|
5448
|
+
const search = createSearchController({
|
|
5449
|
+
ring,
|
|
5450
|
+
hosts,
|
|
5451
|
+
onKeyFailure: keys.onKeyFailure,
|
|
5452
|
+
covers: keys.covers,
|
|
5453
|
+
readPreference: () => source() ?? {},
|
|
5454
|
+
writePreference: (patch) => searchPreference.set(patch),
|
|
5455
|
+
isWritable: () => searchPreference.writable === true && typeof searchPreference.set === "function",
|
|
5456
|
+
resolveModelProvider: () => {
|
|
5457
|
+
try {
|
|
5458
|
+
return currentSession()?.requestContext?.()?.provider;
|
|
5459
|
+
} catch {
|
|
5460
|
+
return;
|
|
3709
5461
|
}
|
|
3710
|
-
}
|
|
3711
|
-
|
|
3712
|
-
|
|
5462
|
+
},
|
|
5463
|
+
resolveSessionId: () => {
|
|
5464
|
+
const id = currentSession()?.id;
|
|
5465
|
+
return typeof id === "string" && id !== "" ? id : fallbackSessionId;
|
|
5466
|
+
},
|
|
5467
|
+
webEntry: () => loaderEntry("web"),
|
|
5468
|
+
composition: createProfileSearchComposition({
|
|
5469
|
+
ctx,
|
|
5470
|
+
webEntry: () => loaderEntry("web"),
|
|
5471
|
+
dshHome,
|
|
5472
|
+
ownPackageJsonUrl: new URL("../package.json", import.meta.url)
|
|
5473
|
+
}),
|
|
5474
|
+
logger: ctx.logger
|
|
5475
|
+
});
|
|
5476
|
+
ctx.inject(["settings"], (settingsCtx) => connectSettings(settingsCtx, {
|
|
5477
|
+
ctx,
|
|
5478
|
+
Config,
|
|
5479
|
+
namespace: SETTINGS_NAMESPACE,
|
|
5480
|
+
source: () => source(),
|
|
5481
|
+
setSource: (next) => {
|
|
5482
|
+
source = next;
|
|
5483
|
+
},
|
|
5484
|
+
onChange: () => {
|
|
5485
|
+
balance.invalidate();
|
|
5486
|
+
groups.invalidate();
|
|
5487
|
+
search.reconcile();
|
|
5488
|
+
},
|
|
5489
|
+
hostPreference,
|
|
5490
|
+
searchPreference
|
|
5491
|
+
}));
|
|
5492
|
+
ctx.on?.("loader/volatile-update", () => {
|
|
5493
|
+
search.reconcile();
|
|
5494
|
+
});
|
|
5495
|
+
/**
|
|
5496
|
+
* Both search providers register with the web service whenever one exists.
|
|
5497
|
+
* Registration alone changes nothing; the preference decides whether the
|
|
5498
|
+
* profile patch points DSH at them.
|
|
5499
|
+
*/
|
|
5500
|
+
ctx.inject(["web"], (webCtx) => {
|
|
5501
|
+
if (typeof webCtx.web?.registerSearchProvider !== "function") return;
|
|
5502
|
+
const scope = typeof webCtx.effect === "function" ? webCtx : ctx;
|
|
5503
|
+
search.register(webCtx.web, (callback, label) => scope.effect(callback, label));
|
|
3713
5504
|
});
|
|
3714
|
-
|
|
5505
|
+
keys.remember().catch(() => void 0);
|
|
3715
5506
|
const handler = createPackyCodeRpc({
|
|
3716
5507
|
ring,
|
|
3717
5508
|
balance,
|
|
5509
|
+
groups,
|
|
3718
5510
|
hosts,
|
|
3719
|
-
preference,
|
|
5511
|
+
preference: hostPreference,
|
|
3720
5512
|
claudeCli,
|
|
3721
|
-
pluginManager: createPackyCodePluginManager()
|
|
5513
|
+
pluginManager: createPackyCodePluginManager(),
|
|
5514
|
+
search
|
|
3722
5515
|
});
|
|
3723
5516
|
/**
|
|
3724
5517
|
* Connection registers a channel through `owner.webServer`, read on its own
|
|
@@ -3731,82 +5524,12 @@ function apply(ctx, config) {
|
|
|
3731
5524
|
ctx.inject(["connection", "webServer"], (connectionCtx) => {
|
|
3732
5525
|
ctx.effect(() => connectionCtx.connection.rpc.handle(CHANNEL, handler), "packycode-api: loopback account RPC");
|
|
3733
5526
|
});
|
|
3734
|
-
/**
|
|
3735
|
-
* The explicit delegation tool, for asking Claude Code for something while
|
|
3736
|
-
* another model drives the session. It shares the runner the CLI-backed
|
|
3737
|
-
* models use.
|
|
3738
|
-
*/
|
|
3739
5527
|
ctx.inject(["tools"], (toolsCtx) => {
|
|
3740
|
-
ctx.effect(() => toolsCtx.tools.register(
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
parameters: {
|
|
3744
|
-
prompt: {
|
|
3745
|
-
type: "string",
|
|
3746
|
-
required: true,
|
|
3747
|
-
description: "The task for Claude Code, written as a self-contained instruction (it does not see this conversation)."
|
|
3748
|
-
},
|
|
3749
|
-
mode: {
|
|
3750
|
-
type: "string",
|
|
3751
|
-
enum: Object.keys(CLAUDE_CLI_MODES),
|
|
3752
|
-
description: "What the CLI may do on its own. Defaults to the most this session's permission preset allows; a wider mode is capped to it. Ask for \"full\" only when the task genuinely needs shell commands."
|
|
3753
|
-
},
|
|
3754
|
-
model: {
|
|
3755
|
-
type: "string",
|
|
3756
|
-
description: "Model id passed to the CLI (for example \"claude-opus-5-5\", \"claude-sonnet-5-5\", or \"claude-haiku-5-5\"). Omit to use the CLI's own default."
|
|
3757
|
-
},
|
|
3758
|
-
cwd: {
|
|
3759
|
-
type: "string",
|
|
3760
|
-
description: "Working directory for the CLI. Defaults to this session's workspace; a relative path resolves against it."
|
|
3761
|
-
}
|
|
3762
|
-
},
|
|
3763
|
-
output: {
|
|
3764
|
-
schema: {
|
|
3765
|
-
type: "object",
|
|
3766
|
-
additionalProperties: false,
|
|
3767
|
-
properties: {
|
|
3768
|
-
result: {
|
|
3769
|
-
type: "string",
|
|
3770
|
-
required: true
|
|
3771
|
-
},
|
|
3772
|
-
model: {
|
|
3773
|
-
type: "string",
|
|
3774
|
-
required: true
|
|
3775
|
-
},
|
|
3776
|
-
mode: {
|
|
3777
|
-
type: "string",
|
|
3778
|
-
required: true
|
|
3779
|
-
},
|
|
3780
|
-
costUsd: {
|
|
3781
|
-
type: "number",
|
|
3782
|
-
required: true
|
|
3783
|
-
},
|
|
3784
|
-
durationMs: {
|
|
3785
|
-
type: "number",
|
|
3786
|
-
required: true
|
|
3787
|
-
}
|
|
3788
|
-
}
|
|
3789
|
-
},
|
|
3790
|
-
render: (_args, value) => [{
|
|
3791
|
-
type: "text",
|
|
3792
|
-
text: `${value.result}\n\n— Claude Code CLI · ${value.model || "default model"} · ${value.mode} · ${Math.round(value.durationMs / 1e3)}s · $${value.costUsd.toFixed(4)}`
|
|
3793
|
-
}]
|
|
3794
|
-
},
|
|
3795
|
-
async execute(args, exec) {
|
|
3796
|
-
const sessionCwd = exec?.agent?.session?.header?.cwd;
|
|
3797
|
-
const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
|
|
3798
|
-
const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
|
|
3799
|
-
return await claudeRunner.run({
|
|
3800
|
-
prompt: args.prompt,
|
|
3801
|
-
mode: capClaudeMode(args.mode, cliModeCeiling(exec?.agent?.session)) ?? "edit",
|
|
3802
|
-
model: args.model,
|
|
3803
|
-
appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
|
|
3804
|
-
cwd,
|
|
3805
|
-
signal: exec?.signal
|
|
3806
|
-
});
|
|
3807
|
-
}
|
|
5528
|
+
ctx.effect(() => toolsCtx.tools.register(createClaudeCodeTool({
|
|
5529
|
+
runner: claudeRunner,
|
|
5530
|
+
modeCeiling: cliModeCeiling
|
|
3808
5531
|
})), "packycode-api: claude_code tool");
|
|
3809
5532
|
});
|
|
3810
5533
|
}
|
|
3811
5534
|
//#endregion
|
|
3812
|
-
export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, capClaudeMode, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeRunner, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, delegationInstructions, explainCliClientPolicyFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseTokenUsage, readClaudeStreamEvents, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
|
|
5535
|
+
export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TOOL_INSTRUCTIONS, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, buildSearchRequest, capClaudeMode, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeCodeTool, createClaudeRunner, createGroupReader, createKeyChooser, createPackyCodeAutoSearchProvider, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, delegationInstructions, describeRunKey, explainCliClientPolicyFailure, explainCliGroupFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, keyNote, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, readClaudeStreamEvents, readSearchChoice, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
|