dsh-packycode-api 1.0.6 → 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 +27 -0
- package/README.en.md +47 -9
- package/README.md +52 -10
- package/compatibility.json +1 -1
- package/lib/client.js +1155 -726
- package/lib/index.js +2670 -918
- 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,96 +859,444 @@ 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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
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
|
+
};
|
|
569
918
|
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* would carry more than 20 of them or 20 MiB of base64.
|
|
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.
|
|
574
922
|
*/
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
maxImages: 20
|
|
580
|
-
});
|
|
581
|
-
/** The vendor's per-image cap on base64 data; a fallback read above it is not sent. */
|
|
582
|
-
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
|
|
583
|
-
const base64Length = (bytes) => Math.ceil(bytes / 3) * 4;
|
|
584
|
-
/** A short name for an image or file a reader can match to the chat. */
|
|
585
|
-
function attachmentName(ref) {
|
|
586
|
-
if (typeof ref?.name === "string" && ref.name !== "") return ref.name;
|
|
587
|
-
const id = String(ref?.attachmentId ?? "");
|
|
588
|
-
return id.startsWith("sha256:") ? id.slice(7, 15) : id.slice(0, 8);
|
|
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);
|
|
589
927
|
}
|
|
590
|
-
/**
|
|
591
|
-
|
|
928
|
+
/**
|
|
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.
|
|
933
|
+
*/
|
|
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
|
+
};
|
|
951
|
+
}
|
|
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) {
|
|
592
971
|
try {
|
|
593
|
-
|
|
594
|
-
return typeof value === "string" && value !== "" ? value : void 0;
|
|
972
|
+
return await response.text();
|
|
595
973
|
} catch {
|
|
596
|
-
return;
|
|
974
|
+
return "";
|
|
597
975
|
}
|
|
598
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
|
+
}
|
|
599
985
|
/**
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
986
|
+
* Cached, single-flight group reader.
|
|
987
|
+
* @param options - key listing, host resolution, and transport knobs.
|
|
988
|
+
* @returns `{ read, snapshot, covers, requiredGroups, invalidate }`.
|
|
603
989
|
*/
|
|
604
|
-
function
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
+
});
|
|
1297
|
+
else parts.push({
|
|
1298
|
+
kind: "text",
|
|
1299
|
+
text: "[图片]"
|
|
622
1300
|
});
|
|
623
1301
|
break;
|
|
624
1302
|
case "file":
|
|
@@ -989,6 +1667,23 @@ function turnUsage(lastRequest, finished) {
|
|
|
989
1667
|
cacheWriteTokens: finished?.cacheWriteTokens ?? 0
|
|
990
1668
|
};
|
|
991
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
|
+
}
|
|
992
1687
|
/** One line describing a retry the CLI is making on its own. */
|
|
993
1688
|
function retryNote(event) {
|
|
994
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)} 秒后`}`;
|
|
@@ -1025,14 +1720,25 @@ async function cliCall(options, { titleModel, cwdOf, modeOf, attachmentsOf, imag
|
|
|
1025
1720
|
* Wrap an adapter so the CLI-backed models run through the local CLI.
|
|
1026
1721
|
*
|
|
1027
1722
|
* @param options - the inner adapter, the CLI runner, the CLI-backed model ids,
|
|
1028
|
-
* the model session titles run on, the
|
|
1029
|
-
*
|
|
1723
|
+
* the model session titles run on, the HTTP models whose titles run on
|
|
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.
|
|
1030
1727
|
* @returns an adapter with the same provider surface.
|
|
1031
1728
|
*/
|
|
1032
|
-
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, 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 }) {
|
|
1033
1730
|
if (inner === void 0 || inner === null) throw new Error("the CLI adapter needs an inner adapter");
|
|
1034
1731
|
if (runner === void 0 || runner === null) throw new Error("the CLI adapter needs a runner");
|
|
1035
1732
|
const cliModels = new Set(modelIds);
|
|
1733
|
+
/** The model a title request for `model` runs on, when it is rerouted. */
|
|
1734
|
+
const titleRouteOf = (options) => options?.purpose === "session-title" && !cliModels.has(options.model) ? Object.hasOwn(titleRoutes, options.model) ? titleRoutes[options.model] : void 0 : void 0;
|
|
1735
|
+
/** Run a rerouted title on its own model, bound to that model's generation. */
|
|
1736
|
+
async function* reroutedTitle(options, model) {
|
|
1737
|
+
yield* (await inner.prepareCall(options.provider, model, options.signal)).stream({
|
|
1738
|
+
...options,
|
|
1739
|
+
model
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1036
1742
|
/** The mode is read per turn so a settings change applies without a reload. */
|
|
1037
1743
|
const modeOf = () => typeof resolveMode === "function" ? resolveMode() : void 0;
|
|
1038
1744
|
const cwdOf = () => typeof resolveCwd === "function" ? resolveCwd() : void 0;
|
|
@@ -1043,6 +1749,34 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1043
1749
|
return;
|
|
1044
1750
|
}
|
|
1045
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
|
+
}
|
|
1046
1780
|
async function* cliStream(options) {
|
|
1047
1781
|
const quiet = options?.purpose === "session-title";
|
|
1048
1782
|
const blocks = createBlockWriter();
|
|
@@ -1078,6 +1812,8 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1078
1812
|
return true;
|
|
1079
1813
|
};
|
|
1080
1814
|
let retries = 0;
|
|
1815
|
+
/** The key this run uses, as the runner described it (never the token). */
|
|
1816
|
+
let runKey;
|
|
1081
1817
|
try {
|
|
1082
1818
|
let finished;
|
|
1083
1819
|
let lastRequest;
|
|
@@ -1085,6 +1821,10 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1085
1821
|
...call,
|
|
1086
1822
|
signal: options.signal
|
|
1087
1823
|
})) switch (event.type) {
|
|
1824
|
+
case "key":
|
|
1825
|
+
runKey = event;
|
|
1826
|
+
if (!quiet) yield* blocks.note(keyNote(event));
|
|
1827
|
+
break;
|
|
1088
1828
|
case "started":
|
|
1089
1829
|
if (!quiet) {
|
|
1090
1830
|
const detail = [event.version, event.model].filter((part) => typeof part === "string" && part !== "").join(" · ");
|
|
@@ -1129,14 +1869,16 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1129
1869
|
};
|
|
1130
1870
|
} catch (error) {
|
|
1131
1871
|
yield* blocks.close();
|
|
1132
|
-
const raw = String(error?.message ?? error);
|
|
1133
1872
|
yield {
|
|
1134
1873
|
type: "finish",
|
|
1135
1874
|
reason: {
|
|
1136
1875
|
kind: "error",
|
|
1137
1876
|
failure: {
|
|
1138
1877
|
code: "PI_AI_ERROR",
|
|
1139
|
-
message:
|
|
1878
|
+
message: await explainFailure(String(error?.message ?? error), runKey, {
|
|
1879
|
+
model: options.model,
|
|
1880
|
+
retries
|
|
1881
|
+
})
|
|
1140
1882
|
}
|
|
1141
1883
|
}
|
|
1142
1884
|
};
|
|
@@ -1156,14 +1898,26 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
1156
1898
|
return inner.resolveModel(provider, model, signal);
|
|
1157
1899
|
}
|
|
1158
1900
|
async prepareCall(provider, model, signal) {
|
|
1159
|
-
if (!cliModels.has(model))
|
|
1901
|
+
if (!cliModels.has(model)) {
|
|
1902
|
+
const prepared = await inner.prepareCall(provider, model, signal);
|
|
1903
|
+
if (!Object.hasOwn(titleRoutes, model)) return prepared;
|
|
1904
|
+
return {
|
|
1905
|
+
...prepared,
|
|
1906
|
+
stream: (options) => {
|
|
1907
|
+
const routed = titleRouteOf(options);
|
|
1908
|
+
return routed === void 0 ? prepared.stream(options) : reroutedTitle(options, routed);
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1160
1912
|
return {
|
|
1161
1913
|
model: await inner.resolveModel(provider, model, signal),
|
|
1162
1914
|
stream: (options) => cliStream(options)
|
|
1163
1915
|
};
|
|
1164
1916
|
}
|
|
1165
1917
|
stream(options) {
|
|
1166
|
-
|
|
1918
|
+
if (cliModels.has(options.model)) return cliStream(options);
|
|
1919
|
+
const routed = titleRouteOf(options);
|
|
1920
|
+
return routed === void 0 ? inner.stream(options) : reroutedTitle(options, routed);
|
|
1167
1921
|
}
|
|
1168
1922
|
}();
|
|
1169
1923
|
}
|
|
@@ -1692,6 +2446,31 @@ function readClaudeStreamEvents(event) {
|
|
|
1692
2446
|
}];
|
|
1693
2447
|
return [];
|
|
1694
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
|
+
}
|
|
1695
2474
|
/**
|
|
1696
2475
|
* A Claude Code CLI runner.
|
|
1697
2476
|
* @param options - binary (a path, or a function resolving one per run so a
|
|
@@ -1719,7 +2498,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1719
2498
|
content,
|
|
1720
2499
|
stream
|
|
1721
2500
|
});
|
|
1722
|
-
const
|
|
2501
|
+
const credential = await resolveCredential({ model });
|
|
2502
|
+
const { baseUrl, token } = credential;
|
|
1723
2503
|
const program = typeof binary === "function" ? await binary() : binary;
|
|
1724
2504
|
const command = resolveSpawnCommand(program, { platform });
|
|
1725
2505
|
const env = withProgramDirOnPath(buildClaudeEnvironment({
|
|
@@ -1823,7 +2603,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1823
2603
|
child,
|
|
1824
2604
|
state,
|
|
1825
2605
|
closed,
|
|
1826
|
-
kill
|
|
2606
|
+
kill,
|
|
2607
|
+
key: describeRunKey(credential)
|
|
1827
2608
|
};
|
|
1828
2609
|
};
|
|
1829
2610
|
return Object.freeze({
|
|
@@ -1872,7 +2653,7 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1872
2653
|
let buffer = "";
|
|
1873
2654
|
let sawResult;
|
|
1874
2655
|
const lines = [];
|
|
1875
|
-
const { state, closed, kill } = await start({
|
|
2656
|
+
const { state, closed, kill, key } = await start({
|
|
1876
2657
|
prompt,
|
|
1877
2658
|
content,
|
|
1878
2659
|
mode,
|
|
@@ -1891,6 +2672,10 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1891
2672
|
}
|
|
1892
2673
|
}
|
|
1893
2674
|
});
|
|
2675
|
+
if (key !== void 0) yield {
|
|
2676
|
+
type: "key",
|
|
2677
|
+
...key
|
|
2678
|
+
};
|
|
1894
2679
|
const drain = function* () {
|
|
1895
2680
|
while (lines.length > 0) {
|
|
1896
2681
|
const line = lines.shift().trim();
|
|
@@ -2326,67 +3111,350 @@ function createClaudeCliManager({ explicitPath = () => "", platform = process.pl
|
|
|
2326
3111
|
});
|
|
2327
3112
|
}
|
|
2328
3113
|
//#endregion
|
|
2329
|
-
//#region src/
|
|
3114
|
+
//#region src/permission.js
|
|
2330
3115
|
/**
|
|
2331
|
-
* The
|
|
3116
|
+
* The Claude Code CLI mode a session's own permissions allow.
|
|
2332
3117
|
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
* attempt — the harness retry of the failed step — resolves a different key
|
|
2338
|
-
* 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.
|
|
2339
3122
|
*
|
|
2340
|
-
* @module dsh-packycode-api/
|
|
3123
|
+
* @module dsh-packycode-api/permission
|
|
2341
3124
|
*/
|
|
2342
|
-
/**
|
|
2343
|
-
const
|
|
2344
|
-
const
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
function assertApiKey(value) {
|
|
2350
|
-
const key = text(value).trim();
|
|
2351
|
-
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");
|
|
2352
|
-
return key;
|
|
2353
|
-
}
|
|
2354
|
-
function entry(value) {
|
|
2355
|
-
if (value === null || typeof value !== "object") return void 0;
|
|
2356
|
-
const key = text(value.key);
|
|
2357
|
-
if (key === "" || !LEGAL_KEY.test(key)) return void 0;
|
|
2358
|
-
const id = text(value.id);
|
|
2359
|
-
return {
|
|
2360
|
-
id: id === "" ? randomUUID().slice(0, 8) : id,
|
|
2361
|
-
label: text(value.label),
|
|
2362
|
-
key,
|
|
2363
|
-
addedAt: Number.isFinite(value.addedAt) ? value.addedAt : 0,
|
|
2364
|
-
...Number.isFinite(value.disabledAt) ? { disabledAt: value.disabledAt } : {},
|
|
2365
|
-
...text(value.disabledReason) === "" ? {} : { disabledReason: text(value.disabledReason) }
|
|
2366
|
-
};
|
|
2367
|
-
}
|
|
2368
|
-
/** Tolerate a hand-edited or older record instead of failing the whole route. */
|
|
2369
|
-
function normalizeState(record) {
|
|
2370
|
-
const payload = record !== null && typeof record === "object" && record.kind === "grant" && record.payload !== null && typeof record.payload === "object" ? record.payload : void 0;
|
|
2371
|
-
const keys = (Array.isArray(payload?.keys) ? payload.keys : []).map(entry).filter((candidate) => candidate !== void 0);
|
|
2372
|
-
const activeId = text(payload?.activeId);
|
|
2373
|
-
return {
|
|
2374
|
-
activeId: keys.some((candidate) => candidate.id === activeId) ? activeId : keys[0]?.id,
|
|
2375
|
-
keys
|
|
2376
|
-
};
|
|
2377
|
-
}
|
|
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
|
+
});
|
|
2378
3132
|
/**
|
|
2379
|
-
*
|
|
2380
|
-
*
|
|
2381
|
-
* the
|
|
2382
|
-
*
|
|
2383
|
-
* @
|
|
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.
|
|
2384
3140
|
*/
|
|
2385
|
-
function
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
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
|
+
}
|
|
3148
|
+
}
|
|
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}`;
|
|
2390
3458
|
}
|
|
2391
3459
|
/** The public shape: what a browser or a log may see, never key material. */
|
|
2392
3460
|
function projection(state) {
|
|
@@ -2472,7 +3540,7 @@ var PackyCodeKeyRing = class {
|
|
|
2472
3540
|
}
|
|
2473
3541
|
async add(key, label) {
|
|
2474
3542
|
const value = assertApiKey(key);
|
|
2475
|
-
const name = text(label).trim().slice(0, 60);
|
|
3543
|
+
const name = text$1(label).trim().slice(0, 60);
|
|
2476
3544
|
return projection(await this.#write((state) => {
|
|
2477
3545
|
if (state.keys.length >= MAX_KEYS) throw new Error(`PackyCode accepts at most ${MAX_KEYS} keys`);
|
|
2478
3546
|
if (state.keys.some((candidate) => candidate.key === value)) throw new Error("That PackyCode API key is already stored");
|
|
@@ -2489,7 +3557,7 @@ var PackyCodeKeyRing = class {
|
|
|
2489
3557
|
}));
|
|
2490
3558
|
}
|
|
2491
3559
|
async rename(id, label) {
|
|
2492
|
-
const name = text(label).trim().slice(0, 60);
|
|
3560
|
+
const name = text$1(label).trim().slice(0, 60);
|
|
2493
3561
|
if (name === "") throw new Error("That PackyCode key name cannot be empty");
|
|
2494
3562
|
return projection(await this.#write((state) => {
|
|
2495
3563
|
if (!state.keys.some((candidate) => candidate.id === id)) throw new Error("Unknown PackyCode key");
|
|
@@ -2552,7 +3620,7 @@ var PackyCodeKeyRing = class {
|
|
|
2552
3620
|
const keys = state.keys.map((candidate) => candidate.id === id ? {
|
|
2553
3621
|
...candidate,
|
|
2554
3622
|
disabledAt: now,
|
|
2555
|
-
disabledReason: text(reason) === "" ? "unusable" : text(reason)
|
|
3623
|
+
disabledReason: text$1(reason) === "" ? "unusable" : text$1(reason)
|
|
2556
3624
|
} : candidate);
|
|
2557
3625
|
if (!keys.some((candidate) => candidate.id === id)) return {
|
|
2558
3626
|
activeId: state.activeId,
|
|
@@ -2566,69 +3634,6 @@ var PackyCodeKeyRing = class {
|
|
|
2566
3634
|
}
|
|
2567
3635
|
};
|
|
2568
3636
|
//#endregion
|
|
2569
|
-
//#region src/permission.js
|
|
2570
|
-
/**
|
|
2571
|
-
* The Claude Code CLI mode a session's own permissions allow.
|
|
2572
|
-
*
|
|
2573
|
-
* A CLI-backed turn runs the CLI's own tool loop, outside the harness sandbox
|
|
2574
|
-
* and approval flow. Deriving its mode from the session's permission preset
|
|
2575
|
-
* means the user grants it once, in the place they already grant the harness
|
|
2576
|
-
* itself, instead of in a separate plugin setting.
|
|
2577
|
-
*
|
|
2578
|
-
* @module dsh-packycode-api/permission
|
|
2579
|
-
*/
|
|
2580
|
-
/** Configured value meaning "follow the session's permission preset". */
|
|
2581
|
-
const AUTO_CLAUDE_MODE = "auto";
|
|
2582
|
-
const RANK = Object.freeze({
|
|
2583
|
-
"read-only": 0,
|
|
2584
|
-
edit: 1,
|
|
2585
|
-
full: 2
|
|
2586
|
-
});
|
|
2587
|
-
/**
|
|
2588
|
-
* Map the harness's two permission knobs onto a CLI mode.
|
|
2589
|
-
*
|
|
2590
|
-
* The CLI cannot ask the harness user for approval, so it skips its own
|
|
2591
|
-
* permission checks only when the session itself neither sandboxes nor asks.
|
|
2592
|
-
* @param sandbox - the session's resolved sandbox mode.
|
|
2593
|
-
* @param approval - the session's effective approval policy.
|
|
2594
|
-
* @returns a CLI mode, or `undefined` for an unknown sandbox mode.
|
|
2595
|
-
*/
|
|
2596
|
-
function claudeModeFor(sandbox, approval) {
|
|
2597
|
-
switch (sandbox) {
|
|
2598
|
-
case "read-only": return "read-only";
|
|
2599
|
-
case "workspace-write": return "edit";
|
|
2600
|
-
case "danger-full-access": return approval === "never" ? "full" : "edit";
|
|
2601
|
-
default: return;
|
|
2602
|
-
}
|
|
2603
|
-
}
|
|
2604
|
-
/**
|
|
2605
|
-
* Read a session's CLI mode from the host's permission services.
|
|
2606
|
-
* @param ctx - the plugin context; services are read without an inject requirement.
|
|
2607
|
-
* @param session - the session whose permission state is read.
|
|
2608
|
-
* @returns a CLI mode, or `undefined` when the session or the services are absent.
|
|
2609
|
-
*/
|
|
2610
|
-
function claudeModeForSession(ctx, session) {
|
|
2611
|
-
if (session === void 0 || session === null) return void 0;
|
|
2612
|
-
try {
|
|
2613
|
-
const sandbox = ctx.get?.("sandboxPolicy")?.resolve?.({ session })?.mode;
|
|
2614
|
-
const approval = ctx.get?.("approval")?.effectivePolicy?.(session);
|
|
2615
|
-
return claudeModeFor(sandbox, approval);
|
|
2616
|
-
} catch {
|
|
2617
|
-
return;
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
/**
|
|
2621
|
-
* The narrower of two modes; an unknown ceiling leaves the request as it is.
|
|
2622
|
-
* @param requested - the mode a caller asked for.
|
|
2623
|
-
* @param ceiling - the most the session allows.
|
|
2624
|
-
* @returns the mode the CLI runs with.
|
|
2625
|
-
*/
|
|
2626
|
-
function capClaudeMode(requested, ceiling) {
|
|
2627
|
-
if (ceiling === void 0 || RANK[ceiling] === void 0) return requested;
|
|
2628
|
-
if (requested === void 0 || RANK[requested] === void 0) return ceiling;
|
|
2629
|
-
return RANK[requested] > RANK[ceiling] ? ceiling : requested;
|
|
2630
|
-
}
|
|
2631
|
-
//#endregion
|
|
2632
3637
|
//#region src/plugin-version.js
|
|
2633
3638
|
/**
|
|
2634
3639
|
* Version self-knowledge for the settings panel.
|
|
@@ -2646,36 +3651,6 @@ const NPM_REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/late
|
|
|
2646
3651
|
const DEFAULT_VERSION_TTL_MS = 5 * 6e4;
|
|
2647
3652
|
const DEFAULT_VERSION_TIMEOUT_MS = 1e4;
|
|
2648
3653
|
const DEFAULT_UPDATE_TIMEOUT_MS = 18e4;
|
|
2649
|
-
const clone = (value) => structuredClone(value);
|
|
2650
|
-
/**
|
|
2651
|
-
* Resolve fetch at call time. A sibling plugin may wrap `globalThis.fetch`
|
|
2652
|
-
* and later dismantle that wrapper; a binding captured during `apply()` would
|
|
2653
|
-
* keep the dead wrapper for the process lifetime.
|
|
2654
|
-
*/
|
|
2655
|
-
function ambientFetch(input, init) {
|
|
2656
|
-
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
2657
|
-
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
2658
|
-
return globalThis.fetch(input, {
|
|
2659
|
-
...init,
|
|
2660
|
-
headers
|
|
2661
|
-
});
|
|
2662
|
-
}
|
|
2663
|
-
/** One caller's cancellation must not abort the shared registry read. */
|
|
2664
|
-
function waitForSharedRequest(promise, signal) {
|
|
2665
|
-
if (signal === void 0) return promise;
|
|
2666
|
-
signal.throwIfAborted();
|
|
2667
|
-
return new Promise((resolve, reject) => {
|
|
2668
|
-
const onAbort = () => reject(signal.reason);
|
|
2669
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2670
|
-
promise.then((value) => {
|
|
2671
|
-
signal.removeEventListener("abort", onAbort);
|
|
2672
|
-
resolve(value);
|
|
2673
|
-
}, (error) => {
|
|
2674
|
-
signal.removeEventListener("abort", onAbort);
|
|
2675
|
-
reject(error);
|
|
2676
|
-
});
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
3654
|
/** Parse a strict `x.y.z[-pre][+build]` version; anything else is undefined. */
|
|
2680
3655
|
function parseSemver(value) {
|
|
2681
3656
|
if (typeof value !== "string") return void 0;
|
|
@@ -2877,22 +3852,17 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
2877
3852
|
argv,
|
|
2878
3853
|
execPath
|
|
2879
3854
|
});
|
|
2880
|
-
let cached;
|
|
2881
|
-
let inFlight;
|
|
2882
|
-
let generation = 0;
|
|
2883
3855
|
let updating = false;
|
|
2884
3856
|
const fetchLatest = async (signal) => {
|
|
2885
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
2886
|
-
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
2887
3857
|
let response;
|
|
2888
3858
|
try {
|
|
2889
|
-
response = await
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
signal
|
|
3859
|
+
response = await boundedFetch(registryUrl, { headers: { Accept: "application/json" } }, {
|
|
3860
|
+
fetchImpl,
|
|
3861
|
+
timeoutMs,
|
|
3862
|
+
signal
|
|
2893
3863
|
});
|
|
2894
3864
|
} catch (error) {
|
|
2895
|
-
if (
|
|
3865
|
+
if (error.aborted) throw error.cause;
|
|
2896
3866
|
throw new Error("PackyCode plugin version check failed");
|
|
2897
3867
|
}
|
|
2898
3868
|
if (!response.ok) throw new Error("PackyCode plugin version check failed");
|
|
@@ -2928,28 +3898,12 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
2928
3898
|
fetchedAt: now()
|
|
2929
3899
|
};
|
|
2930
3900
|
};
|
|
3901
|
+
const cache = createCachedRead(load, {
|
|
3902
|
+
ttlMs,
|
|
3903
|
+
now
|
|
3904
|
+
});
|
|
2931
3905
|
return Object.freeze({
|
|
2932
|
-
|
|
2933
|
-
signal?.throwIfAborted();
|
|
2934
|
-
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
|
|
2935
|
-
const createPending = (requestSignal) => {
|
|
2936
|
-
const observedGeneration = generation;
|
|
2937
|
-
return load(requestSignal).then((value) => {
|
|
2938
|
-
if (generation === observedGeneration) cached = value;
|
|
2939
|
-
return value;
|
|
2940
|
-
});
|
|
2941
|
-
};
|
|
2942
|
-
if (force) return clone(await createPending(signal));
|
|
2943
|
-
const pending = inFlight ?? createPending();
|
|
2944
|
-
if (inFlight === void 0) {
|
|
2945
|
-
inFlight = pending;
|
|
2946
|
-
const clear = () => {
|
|
2947
|
-
if (inFlight === pending) inFlight = void 0;
|
|
2948
|
-
};
|
|
2949
|
-
pending.then(clear, clear);
|
|
2950
|
-
}
|
|
2951
|
-
return clone(await waitForSharedRequest(pending, signal));
|
|
2952
|
-
},
|
|
3906
|
+
read: (options) => cache.read(options),
|
|
2953
3907
|
async update({ signal } = {}) {
|
|
2954
3908
|
signal?.throwIfAborted();
|
|
2955
3909
|
if (updating) throw new Error("PackyCode plugin update is already running");
|
|
@@ -2989,8 +3943,7 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
2989
3943
|
expected: latest,
|
|
2990
3944
|
settleDelayMs
|
|
2991
3945
|
});
|
|
2992
|
-
|
|
2993
|
-
cached = void 0;
|
|
3946
|
+
cache.invalidate();
|
|
2994
3947
|
return {
|
|
2995
3948
|
version: latest,
|
|
2996
3949
|
profile: install.profile
|
|
@@ -2999,172 +3952,88 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
|
|
|
2999
3952
|
updating = false;
|
|
3000
3953
|
}
|
|
3001
3954
|
},
|
|
3002
|
-
invalidate()
|
|
3003
|
-
generation += 1;
|
|
3004
|
-
cached = void 0;
|
|
3005
|
-
}
|
|
3955
|
+
invalidate: () => cache.invalidate()
|
|
3006
3956
|
});
|
|
3007
3957
|
}
|
|
3008
3958
|
//#endregion
|
|
3009
|
-
//#region src/
|
|
3959
|
+
//#region src/preferences.js
|
|
3010
3960
|
/**
|
|
3011
|
-
*
|
|
3961
|
+
* Where the plugin's preferences are read from and written to, on both
|
|
3962
|
+
* settings generations DSH has shipped.
|
|
3012
3963
|
*
|
|
3013
|
-
*
|
|
3014
|
-
*
|
|
3015
|
-
* -
|
|
3016
|
-
*
|
|
3017
|
-
*
|
|
3018
|
-
*
|
|
3019
|
-
*
|
|
3020
|
-
* ends early. So every model pins the protocol its vendor serves here.
|
|
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.
|
|
3021
3971
|
*
|
|
3022
|
-
*
|
|
3023
|
-
* model does not support is refused before the request is sent: Claude Opus
|
|
3024
|
-
* 5.5 (platform.claude.com effort docs), GPT-6.1 Sol and GPT-6 Astra
|
|
3025
|
-
* `reasoning.effort` (developers.openai.com model pages), DeepSeek thinking
|
|
3026
|
-
* mode (`thinking` toggle plus `low|high|max` effort).
|
|
3027
|
-
*
|
|
3028
|
-
* @module dsh-packycode-api/catalog
|
|
3972
|
+
* @module dsh-packycode-api/preferences
|
|
3029
3973
|
*/
|
|
3030
|
-
/** pi-ai never reads this gateway's pricing; the harness zeroes cost anyway. */
|
|
3031
|
-
const NO_COST = Object.freeze({
|
|
3032
|
-
input: 0,
|
|
3033
|
-
output: 0,
|
|
3034
|
-
cacheRead: 0,
|
|
3035
|
-
cacheWrite: 0
|
|
3036
|
-
});
|
|
3037
|
-
const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
3038
|
-
const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
3039
|
-
const MESSAGES_BASE = DEFAULT_API_HOST;
|
|
3040
3974
|
/**
|
|
3041
|
-
*
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
* 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.
|
|
3045
3978
|
*/
|
|
3046
|
-
const
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
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
|
+
}
|
|
3054
3991
|
/**
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
3057
|
-
*
|
|
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).
|
|
3058
3997
|
*/
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
const
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
maxTokens: 128e3,
|
|
3099
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
3100
|
-
}),
|
|
3101
|
-
Object.freeze({
|
|
3102
|
-
id: "gpt-6-astra",
|
|
3103
|
-
name: "GPT-6 Astra",
|
|
3104
|
-
api: "openai-responses",
|
|
3105
|
-
provider: PROVIDER,
|
|
3106
|
-
baseUrl: RESPONSES_BASE,
|
|
3107
|
-
reasoning: true,
|
|
3108
|
-
input: Object.freeze(["text", "image"]),
|
|
3109
|
-
cost: NO_COST,
|
|
3110
|
-
contextWindow: 105e4,
|
|
3111
|
-
maxTokens: 128e3,
|
|
3112
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
3113
|
-
}),
|
|
3114
|
-
Object.freeze({
|
|
3115
|
-
id: "claude-opus-5-5",
|
|
3116
|
-
name: "Claude Opus 5.5",
|
|
3117
|
-
api: "anthropic-messages",
|
|
3118
|
-
provider: PROVIDER,
|
|
3119
|
-
baseUrl: MESSAGES_BASE,
|
|
3120
|
-
reasoning: true,
|
|
3121
|
-
input: Object.freeze(["text", "image"]),
|
|
3122
|
-
cost: NO_COST,
|
|
3123
|
-
contextWindow: 1e6,
|
|
3124
|
-
maxTokens: 128e3,
|
|
3125
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
3126
|
-
compat: Object.freeze({
|
|
3127
|
-
forceAdaptiveThinking: true,
|
|
3128
|
-
supportsTemperature: false
|
|
3129
|
-
})
|
|
3130
|
-
}),
|
|
3131
|
-
Object.freeze({
|
|
3132
|
-
id: "claude-sonnet-5-5",
|
|
3133
|
-
name: "Claude Sonnet 5.5",
|
|
3134
|
-
api: "anthropic-messages",
|
|
3135
|
-
provider: PROVIDER,
|
|
3136
|
-
baseUrl: MESSAGES_BASE,
|
|
3137
|
-
reasoning: true,
|
|
3138
|
-
input: Object.freeze(["text", "image"]),
|
|
3139
|
-
cost: NO_COST,
|
|
3140
|
-
contextWindow: 1e6,
|
|
3141
|
-
maxTokens: 128e3,
|
|
3142
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
3143
|
-
compat: Object.freeze({
|
|
3144
|
-
forceAdaptiveThinking: true,
|
|
3145
|
-
supportsTemperature: false
|
|
3146
|
-
})
|
|
3147
|
-
}),
|
|
3148
|
-
Object.freeze({
|
|
3149
|
-
id: "claude-haiku-5-5",
|
|
3150
|
-
name: "Claude Haiku 5.5",
|
|
3151
|
-
api: "anthropic-messages",
|
|
3152
|
-
provider: PROVIDER,
|
|
3153
|
-
baseUrl: MESSAGES_BASE,
|
|
3154
|
-
reasoning: true,
|
|
3155
|
-
input: Object.freeze(["text", "image"]),
|
|
3156
|
-
cost: NO_COST,
|
|
3157
|
-
contextWindow: 2e5,
|
|
3158
|
-
maxTokens: 64e3,
|
|
3159
|
-
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
3160
|
-
compat: Object.freeze({
|
|
3161
|
-
forceAdaptiveThinking: true,
|
|
3162
|
-
supportsTemperature: false
|
|
3163
|
-
})
|
|
3164
|
-
})
|
|
3165
|
-
]);
|
|
3166
|
-
/** Every model id this catalog advertises, in picker order. */
|
|
3167
|
-
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
|
+
}
|
|
3168
4037
|
//#endregion
|
|
3169
4038
|
//#region src/provider.js
|
|
3170
4039
|
/**
|
|
@@ -3284,149 +4153,1159 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
|
|
|
3284
4153
|
} });
|
|
3285
4154
|
}
|
|
3286
4155
|
//#endregion
|
|
3287
|
-
//#region src/
|
|
4156
|
+
//#region src/route.js
|
|
4157
|
+
/**
|
|
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.
|
|
4160
|
+
*
|
|
4161
|
+
* @module dsh-packycode-api/route
|
|
4162
|
+
*/
|
|
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
|
|
4173
|
+
});
|
|
4174
|
+
/**
|
|
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`.
|
|
4182
|
+
*/
|
|
4183
|
+
function ringCredentialStore(ring) {
|
|
4184
|
+
const managed = () => {
|
|
4185
|
+
throw new Error("PackyCode API keys are managed in the PackyCode settings section");
|
|
4186
|
+
};
|
|
4187
|
+
return {
|
|
4188
|
+
async read() {
|
|
4189
|
+
const active = await ring.activeKey();
|
|
4190
|
+
return active === void 0 ? void 0 : {
|
|
4191
|
+
type: "api_key",
|
|
4192
|
+
key: active.key
|
|
4193
|
+
};
|
|
4194
|
+
},
|
|
4195
|
+
async list() {
|
|
4196
|
+
return await ring.activeKey() === void 0 ? [] : [{
|
|
4197
|
+
providerId: PROVIDER,
|
|
4198
|
+
type: "api_key"
|
|
4199
|
+
}];
|
|
4200
|
+
},
|
|
4201
|
+
modify: managed,
|
|
4202
|
+
delete: managed
|
|
4203
|
+
};
|
|
4204
|
+
}
|
|
4205
|
+
/**
|
|
4206
|
+
* The provider profile this route runs on.
|
|
4207
|
+
*
|
|
4208
|
+
* `modelErrors` and `configuredMaxTokens` are required by the host adapter;
|
|
4209
|
+
* they stay empty because the catalog is fixed and no model carries a
|
|
4210
|
+
* per-request output cap of its own.
|
|
4211
|
+
* @param provider - the route's pi-ai provider.
|
|
4212
|
+
* @returns the frozen profile handed to `PiAiAdapter`.
|
|
4213
|
+
*/
|
|
4214
|
+
function createPackyCodeProfile(provider) {
|
|
4215
|
+
return Object.freeze({
|
|
4216
|
+
provider: PROVIDER,
|
|
4217
|
+
displayName: DISPLAY_NAME,
|
|
4218
|
+
piProvider: provider,
|
|
4219
|
+
reasoning: DEFAULT_REASONING_LEVEL,
|
|
4220
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
4221
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
4222
|
+
...REQUEST_LIMITS,
|
|
4223
|
+
cacheRetention: "short",
|
|
4224
|
+
retryPolicy: resolveRetryPolicy({
|
|
4225
|
+
mode: "normal",
|
|
4226
|
+
maxRetries: 20,
|
|
4227
|
+
retryableCodes: [
|
|
4228
|
+
"EMPTY_RESPONSE",
|
|
4229
|
+
"RATE_LIMIT",
|
|
4230
|
+
"SERVER",
|
|
4231
|
+
"TIMEOUT",
|
|
4232
|
+
"TRANSPORT",
|
|
4233
|
+
QUOTA_EXCEEDED_CODE,
|
|
4234
|
+
"AUTH",
|
|
4235
|
+
"INVALID_REQUEST"
|
|
4236
|
+
]
|
|
4237
|
+
}, `${PLUGIN_ID}.retryPolicy`)
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
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();
|
|
4251
|
+
/**
|
|
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.
|
|
4256
|
+
*/
|
|
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)) }
|
|
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
|
|
3288
5133
|
/**
|
|
3289
|
-
*
|
|
3290
|
-
*
|
|
3291
|
-
*
|
|
3292
|
-
* balance projection the host built, host overrides, and whether this device
|
|
3293
|
-
* has the Claude Code CLI. Key material never crosses this boundary.
|
|
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.
|
|
3294
5137
|
*
|
|
3295
|
-
* @module dsh-packycode-api/
|
|
5138
|
+
* @module dsh-packycode-api/search-controller
|
|
3296
5139
|
*/
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
const failed = (message) => ({
|
|
3302
|
-
ok: false,
|
|
3303
|
-
error: { message }
|
|
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"
|
|
3304
5144
|
});
|
|
3305
|
-
/**
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
}
|
|
3317
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") return fallback;
|
|
3318
|
-
return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
|
|
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";
|
|
3319
5156
|
}
|
|
3320
5157
|
/**
|
|
3321
|
-
*
|
|
3322
|
-
*
|
|
3323
|
-
*
|
|
3324
|
-
*
|
|
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.
|
|
3325
5162
|
*/
|
|
3326
|
-
function
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
const
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
apiHost: DEFAULT_API_HOST,
|
|
3341
|
-
statusHost: DEFAULT_STATUS_HOST
|
|
3342
|
-
}
|
|
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 }
|
|
3343
5177
|
});
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
balance.invalidate();
|
|
3371
|
-
return ok(await status());
|
|
3372
|
-
case "balance": {
|
|
3373
|
-
const [value, state] = await Promise.all([balance.read({ force: payload?.force === true }), ring.list()]);
|
|
3374
|
-
return ok({
|
|
3375
|
-
...value,
|
|
3376
|
-
activeId: state.activeId
|
|
3377
|
-
});
|
|
3378
|
-
}
|
|
3379
|
-
case "hosts/update":
|
|
3380
|
-
if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
|
|
3381
|
-
await preference.set({
|
|
3382
|
-
apiHost: normalizeHost(payload?.apiHost, hosts().apiHost),
|
|
3383
|
-
statusHost: normalizeHost(payload?.statusHost, hosts().statusHost)
|
|
3384
|
-
});
|
|
3385
|
-
balance.invalidate();
|
|
3386
|
-
return ok(await status());
|
|
3387
|
-
case "claude-cli/status": return ok(await requireCli().status({ force: payload?.force === true }));
|
|
3388
|
-
case "claude-cli/install": {
|
|
3389
|
-
const cli = requireCli();
|
|
3390
|
-
cli.install();
|
|
3391
|
-
return ok(await cli.status());
|
|
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
|
+
};
|
|
3392
5204
|
}
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
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));
|
|
3399
5211
|
}
|
|
3400
|
-
}
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
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
|
+
};
|
|
3404
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
|
+
});
|
|
3405
5282
|
}
|
|
3406
5283
|
//#endregion
|
|
3407
5284
|
//#region src/index.js
|
|
3408
5285
|
/**
|
|
3409
5286
|
* dsh-packycode-api — a self-contained PackyCode route for DeepSeek Harness.
|
|
3410
5287
|
*
|
|
3411
|
-
* The plugin builds its own pi-ai provider, model catalog, key ring,
|
|
3412
|
-
*
|
|
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
|
|
3413
5290
|
* `llm.registerAdapter` API the shipped adapters use. Nothing here reads the
|
|
3414
5291
|
* profile's composition config, so installing the package is the whole setup:
|
|
3415
5292
|
* the picker shows the models and the user only pastes API keys.
|
|
3416
5293
|
*
|
|
5294
|
+
* This module only composes; each concern lives in its own module.
|
|
5295
|
+
*
|
|
3417
5296
|
* @module dsh-packycode-api
|
|
3418
5297
|
*/
|
|
3419
5298
|
const name = PLUGIN_ID;
|
|
3420
5299
|
/**
|
|
3421
|
-
* Only the model route is mandatory. `
|
|
3422
|
-
*
|
|
5300
|
+
* Only the model route is mandatory. `settings`, `web`, `connection`, and
|
|
5301
|
+
* `tools` are requested through `ctx.inject`: a profile without them (a
|
|
3423
5302
|
* headless run, for one) must still boot with a working route instead of
|
|
3424
5303
|
* blocking on a service it will never see.
|
|
3425
5304
|
*/
|
|
3426
5305
|
const inject = ["llm", "credentials"];
|
|
3427
|
-
/**
|
|
3428
|
-
const
|
|
3429
|
-
/** Host overrides; a profile that never
|
|
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. */
|
|
3430
5309
|
const Config = z.object({
|
|
3431
5310
|
apiHost: z.string().default(DEFAULT_API_HOST),
|
|
3432
5311
|
statusHost: z.string().default(DEFAULT_STATUS_HOST),
|
|
@@ -3443,100 +5322,23 @@ const Config = z.object({
|
|
|
3443
5322
|
"full"
|
|
3444
5323
|
]).default(AUTO_CLAUDE_MODE),
|
|
3445
5324
|
/** Working directory for CLI-backed turns; empty means "the session's workspace". */
|
|
3446
|
-
claudeCliCwd: z.string().default("")
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
*
|
|
3450
|
-
*
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
maxRequestImageBytes: 20 * 1024 * 1024,
|
|
3456
|
-
requestImagePixelBudget: 2048 * 2048,
|
|
3457
|
-
requestImageMaxBytes: 1024 * 1024
|
|
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))
|
|
3458
5334
|
});
|
|
3459
|
-
/**
|
|
3460
|
-
* pi-ai's credential store over the key ring.
|
|
3461
|
-
*
|
|
3462
|
-
* Reads answer with the active key so pi-ai's own auth reporting stays honest,
|
|
3463
|
-
* while writes are refused: the ring owns them, because only the ring can move
|
|
3464
|
-
* the active pointer past a key the gateway rejected.
|
|
3465
|
-
* @param ring - the plugin's key ring.
|
|
3466
|
-
* @returns a pi-ai `CredentialStore`.
|
|
3467
|
-
*/
|
|
3468
|
-
function ringCredentialStore(ring) {
|
|
3469
|
-
const managed = () => {
|
|
3470
|
-
throw new Error("PackyCode API keys are managed in the PackyCode settings section");
|
|
3471
|
-
};
|
|
3472
|
-
return {
|
|
3473
|
-
async read() {
|
|
3474
|
-
const active = await ring.activeKey();
|
|
3475
|
-
return active === void 0 ? void 0 : {
|
|
3476
|
-
type: "api_key",
|
|
3477
|
-
key: active.key
|
|
3478
|
-
};
|
|
3479
|
-
},
|
|
3480
|
-
async list() {
|
|
3481
|
-
return await ring.activeKey() === void 0 ? [] : [{
|
|
3482
|
-
providerId: PROVIDER,
|
|
3483
|
-
type: "api_key"
|
|
3484
|
-
}];
|
|
3485
|
-
},
|
|
3486
|
-
modify: managed,
|
|
3487
|
-
delete: managed
|
|
3488
|
-
};
|
|
3489
|
-
}
|
|
3490
|
-
/**
|
|
3491
|
-
* The provider profile this route runs on.
|
|
3492
|
-
*
|
|
3493
|
-
* `modelErrors` and `configuredMaxTokens` are required by the host adapter;
|
|
3494
|
-
* they stay empty because the catalog is fixed and no model carries a
|
|
3495
|
-
* per-request output cap of its own.
|
|
3496
|
-
* @param provider - the route's pi-ai provider.
|
|
3497
|
-
* @returns the frozen profile handed to `PiAiAdapter`.
|
|
3498
|
-
*/
|
|
3499
|
-
function createPackyCodeProfile(provider) {
|
|
3500
|
-
return Object.freeze({
|
|
3501
|
-
provider: PROVIDER,
|
|
3502
|
-
displayName: DISPLAY_NAME,
|
|
3503
|
-
piProvider: provider,
|
|
3504
|
-
reasoning: DEFAULT_REASONING_LEVEL,
|
|
3505
|
-
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
3506
|
-
modelErrors: /* @__PURE__ */ new Map(),
|
|
3507
|
-
...REQUEST_LIMITS,
|
|
3508
|
-
cacheRetention: "short",
|
|
3509
|
-
retryPolicy: resolveRetryPolicy({
|
|
3510
|
-
mode: "normal",
|
|
3511
|
-
maxRetries: 20,
|
|
3512
|
-
retryableCodes: [
|
|
3513
|
-
"EMPTY_RESPONSE",
|
|
3514
|
-
"RATE_LIMIT",
|
|
3515
|
-
"SERVER",
|
|
3516
|
-
"TIMEOUT",
|
|
3517
|
-
"TRANSPORT",
|
|
3518
|
-
QUOTA_EXCEEDED_CODE,
|
|
3519
|
-
"AUTH",
|
|
3520
|
-
"INVALID_REQUEST"
|
|
3521
|
-
]
|
|
3522
|
-
}, `${PLUGIN_ID}.retryPolicy`)
|
|
3523
|
-
});
|
|
3524
|
-
}
|
|
3525
5335
|
function apply(ctx, config) {
|
|
3526
|
-
const balanceReaderOptions = {};
|
|
3527
5336
|
const ring = new PackyCodeKeyRing(ctx.credentials);
|
|
3528
|
-
/**
|
|
3529
|
-
* Key material to ring id. A failure is reported from inside a stream, which
|
|
3530
|
-
* carries the request's key but not its id; this index is refreshed on every
|
|
3531
|
-
* resolution so the right key is the one that moves aside.
|
|
3532
|
-
*/
|
|
3533
|
-
const idByKey = /* @__PURE__ */ new Map();
|
|
3534
|
-
const rememberKeys = async () => {
|
|
3535
|
-
for (const entry of await ring.secrets()) idByKey.set(entry.key, entry.id);
|
|
3536
|
-
};
|
|
3537
5337
|
let source = () => ({
|
|
3538
5338
|
apiHost: normalizeHost(config?.apiHost, DEFAULT_API_HOST),
|
|
3539
|
-
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)
|
|
3540
5342
|
});
|
|
3541
5343
|
const hosts = () => {
|
|
3542
5344
|
const current = source() ?? {};
|
|
@@ -3546,69 +5348,63 @@ function apply(ctx, config) {
|
|
|
3546
5348
|
};
|
|
3547
5349
|
};
|
|
3548
5350
|
const balance = createBalanceReader({
|
|
3549
|
-
...balanceReaderOptions,
|
|
3550
5351
|
listKeys: () => ring.secrets(),
|
|
3551
5352
|
getHosts: hosts
|
|
3552
5353
|
});
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
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 {
|
|
3557
5374
|
return;
|
|
3558
5375
|
}
|
|
3559
|
-
ctx.logger?.warn?.("packycode-api: %s on key %s; the next key serves the retry", kind, id);
|
|
3560
|
-
ring.disable(id, kind).then(() => balance.invalidate()).catch((error) => ctx.logger?.warn?.("packycode-api: could not move past key %s: %s", id, error.message));
|
|
3561
5376
|
};
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
|
|
3570
|
-
const withSessionId = (options) => options === void 0 || options.sessionId !== void 0 ? options : {
|
|
3571
|
-
...options,
|
|
3572
|
-
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
|
+
}
|
|
3573
5384
|
};
|
|
3574
|
-
const
|
|
3575
|
-
|
|
3576
|
-
stream: (model, context, options) => guardKeyRotation(raw.stream(model, context, withSessionId(options)), {
|
|
3577
|
-
key: options?.apiKey,
|
|
3578
|
-
onKeyFailure
|
|
3579
|
-
}),
|
|
3580
|
-
streamSimple: (model, context, options) => guardKeyRotation(raw.streamSimple(model, context, withSessionId(options)), {
|
|
3581
|
-
key: options?.apiKey,
|
|
3582
|
-
onKeyFailure
|
|
3583
|
-
})
|
|
3584
|
-
}));
|
|
3585
|
-
const profiles = /* @__PURE__ */ new Map([[PROVIDER, profile]]);
|
|
5385
|
+
const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
|
|
5386
|
+
const attachments = () => ctx.get?.("attachments") ?? ctx.attachments;
|
|
3586
5387
|
/**
|
|
3587
|
-
* The Claude Code CLI runner. PackyCode
|
|
3588
|
-
*
|
|
3589
|
-
*
|
|
3590
|
-
*
|
|
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.
|
|
3591
5393
|
*/
|
|
3592
5394
|
const claudeCli = createClaudeCliManager({ explicitPath: () => config?.claudeCliPath });
|
|
3593
5395
|
const claudeRunner = createClaudeRunner({
|
|
3594
5396
|
binary: () => claudeCli.binary(),
|
|
3595
|
-
resolveCredential: async () => {
|
|
3596
|
-
const
|
|
3597
|
-
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;
|
|
3598
5401
|
return {
|
|
5402
|
+
...described,
|
|
3599
5403
|
baseUrl: hosts().apiHost,
|
|
3600
|
-
token:
|
|
5404
|
+
token: key
|
|
3601
5405
|
};
|
|
3602
5406
|
}
|
|
3603
5407
|
});
|
|
3604
|
-
/** The session of the agent this turn runs for, when one is in scope. */
|
|
3605
|
-
const currentSession = () => {
|
|
3606
|
-
try {
|
|
3607
|
-
return ctx.get?.("agents")?.currentInitiator?.()?.session;
|
|
3608
|
-
} catch {
|
|
3609
|
-
return;
|
|
3610
|
-
}
|
|
3611
|
-
};
|
|
3612
5408
|
/** The session's own workspace, so a CLI turn edits the project in view. */
|
|
3613
5409
|
const resolveCliCwd = () => {
|
|
3614
5410
|
const configured = typeof config?.claudeCliCwd === "string" ? config.claudeCliCwd.trim() : "";
|
|
@@ -3620,76 +5416,102 @@ function apply(ctx, config) {
|
|
|
3620
5416
|
* The most a CLI turn may do: an explicit setting when the profile names
|
|
3621
5417
|
* one, otherwise what the session's permission preset allows.
|
|
3622
5418
|
*/
|
|
3623
|
-
const
|
|
5419
|
+
const cliModeCeiling = (session) => {
|
|
3624
5420
|
const configured = config?.claudeCliMode;
|
|
3625
|
-
return configured === void 0 || configured === "auto" ?
|
|
5421
|
+
return configured === void 0 || configured === "auto" ? claudeModeForSession(ctx, session) : configured;
|
|
3626
5422
|
};
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
const adapter = createClaudeCliAdapter({
|
|
3634
|
-
inner: new PiAiAdapter({
|
|
3635
|
-
profiles: () => profiles,
|
|
3636
|
-
resolveApiKey: async () => {
|
|
3637
|
-
await rememberKeys();
|
|
3638
|
-
const active = await ring.activeKey();
|
|
3639
|
-
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");
|
|
3640
|
-
return active.key;
|
|
3641
|
-
},
|
|
3642
|
-
auth: Object.freeze({
|
|
3643
|
-
credentials: ringCredentialStore(ring),
|
|
3644
|
-
authContext: Object.freeze({
|
|
3645
|
-
env: async () => void 0,
|
|
3646
|
-
fileExists: async () => false
|
|
3647
|
-
})
|
|
3648
|
-
}),
|
|
3649
|
-
resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
|
|
5423
|
+
ctx.llm.registerAdapter([PROVIDER], createClaudeCliAdapter({
|
|
5424
|
+
inner: createRouteAdapter({
|
|
5425
|
+
ring,
|
|
5426
|
+
keys,
|
|
5427
|
+
fallbackSessionId,
|
|
5428
|
+
resolveAttachments: attachments
|
|
3650
5429
|
}),
|
|
3651
5430
|
runner: claudeRunner,
|
|
3652
|
-
resolveMode:
|
|
5431
|
+
resolveMode: () => cliModeCeiling(currentSession()) ?? "edit",
|
|
3653
5432
|
resolveCwd: resolveCliCwd,
|
|
3654
|
-
resolveAttachments:
|
|
3655
|
-
|
|
3656
|
-
|
|
5433
|
+
resolveAttachments: attachments,
|
|
5434
|
+
explainKey: (runKey) => keys.describe(runKey)
|
|
5435
|
+
}));
|
|
3657
5436
|
ctx.llm.registerConfigurableProviders([{
|
|
3658
5437
|
provider: PROVIDER,
|
|
3659
5438
|
displayName: DISPLAY_NAME,
|
|
3660
5439
|
settingsNs: SETTINGS_NAMESPACE,
|
|
3661
5440
|
settingsPath: []
|
|
3662
5441
|
}]);
|
|
3663
|
-
const
|
|
5442
|
+
const hostPreference = unwritable("PackyCode host overrides are not writable in this profile");
|
|
5443
|
+
const searchPreference = {
|
|
3664
5444
|
writable: false,
|
|
3665
|
-
set:
|
|
3666
|
-
throw new Error("PackyCode host overrides are not writable in this profile");
|
|
3667
|
-
}
|
|
5445
|
+
set: void 0
|
|
3668
5446
|
};
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
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;
|
|
3680
5461
|
}
|
|
3681
|
-
}
|
|
3682
|
-
|
|
3683
|
-
|
|
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));
|
|
3684
5504
|
});
|
|
3685
|
-
|
|
5505
|
+
keys.remember().catch(() => void 0);
|
|
3686
5506
|
const handler = createPackyCodeRpc({
|
|
3687
5507
|
ring,
|
|
3688
5508
|
balance,
|
|
5509
|
+
groups,
|
|
3689
5510
|
hosts,
|
|
3690
|
-
preference,
|
|
5511
|
+
preference: hostPreference,
|
|
3691
5512
|
claudeCli,
|
|
3692
|
-
pluginManager: createPackyCodePluginManager()
|
|
5513
|
+
pluginManager: createPackyCodePluginManager(),
|
|
5514
|
+
search
|
|
3693
5515
|
});
|
|
3694
5516
|
/**
|
|
3695
5517
|
* Connection registers a channel through `owner.webServer`, read on its own
|
|
@@ -3702,82 +5524,12 @@ function apply(ctx, config) {
|
|
|
3702
5524
|
ctx.inject(["connection", "webServer"], (connectionCtx) => {
|
|
3703
5525
|
ctx.effect(() => connectionCtx.connection.rpc.handle(CHANNEL, handler), "packycode-api: loopback account RPC");
|
|
3704
5526
|
});
|
|
3705
|
-
/**
|
|
3706
|
-
* The explicit delegation tool, for asking Claude Code for something while
|
|
3707
|
-
* another model drives the session. It shares the runner the CLI-backed
|
|
3708
|
-
* models use.
|
|
3709
|
-
*/
|
|
3710
5527
|
ctx.inject(["tools"], (toolsCtx) => {
|
|
3711
|
-
ctx.effect(() => toolsCtx.tools.register(
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
parameters: {
|
|
3715
|
-
prompt: {
|
|
3716
|
-
type: "string",
|
|
3717
|
-
required: true,
|
|
3718
|
-
description: "The task for Claude Code, written as a self-contained instruction (it does not see this conversation)."
|
|
3719
|
-
},
|
|
3720
|
-
mode: {
|
|
3721
|
-
type: "string",
|
|
3722
|
-
enum: Object.keys(CLAUDE_CLI_MODES),
|
|
3723
|
-
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."
|
|
3724
|
-
},
|
|
3725
|
-
model: {
|
|
3726
|
-
type: "string",
|
|
3727
|
-
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."
|
|
3728
|
-
},
|
|
3729
|
-
cwd: {
|
|
3730
|
-
type: "string",
|
|
3731
|
-
description: "Working directory for the CLI. Defaults to this session's workspace; a relative path resolves against it."
|
|
3732
|
-
}
|
|
3733
|
-
},
|
|
3734
|
-
output: {
|
|
3735
|
-
schema: {
|
|
3736
|
-
type: "object",
|
|
3737
|
-
additionalProperties: false,
|
|
3738
|
-
properties: {
|
|
3739
|
-
result: {
|
|
3740
|
-
type: "string",
|
|
3741
|
-
required: true
|
|
3742
|
-
},
|
|
3743
|
-
model: {
|
|
3744
|
-
type: "string",
|
|
3745
|
-
required: true
|
|
3746
|
-
},
|
|
3747
|
-
mode: {
|
|
3748
|
-
type: "string",
|
|
3749
|
-
required: true
|
|
3750
|
-
},
|
|
3751
|
-
costUsd: {
|
|
3752
|
-
type: "number",
|
|
3753
|
-
required: true
|
|
3754
|
-
},
|
|
3755
|
-
durationMs: {
|
|
3756
|
-
type: "number",
|
|
3757
|
-
required: true
|
|
3758
|
-
}
|
|
3759
|
-
}
|
|
3760
|
-
},
|
|
3761
|
-
render: (_args, value) => [{
|
|
3762
|
-
type: "text",
|
|
3763
|
-
text: `${value.result}\n\n— Claude Code CLI · ${value.model || "default model"} · ${value.mode} · ${Math.round(value.durationMs / 1e3)}s · $${value.costUsd.toFixed(4)}`
|
|
3764
|
-
}]
|
|
3765
|
-
},
|
|
3766
|
-
async execute(args, exec) {
|
|
3767
|
-
const sessionCwd = exec?.agent?.session?.header?.cwd;
|
|
3768
|
-
const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
|
|
3769
|
-
const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
|
|
3770
|
-
return await claudeRunner.run({
|
|
3771
|
-
prompt: args.prompt,
|
|
3772
|
-
mode: capClaudeMode(args.mode, cliModeCeiling(exec?.agent?.session)) ?? "edit",
|
|
3773
|
-
model: args.model,
|
|
3774
|
-
appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
|
|
3775
|
-
cwd,
|
|
3776
|
-
signal: exec?.signal
|
|
3777
|
-
});
|
|
3778
|
-
}
|
|
5528
|
+
ctx.effect(() => toolsCtx.tools.register(createClaudeCodeTool({
|
|
5529
|
+
runner: claudeRunner,
|
|
5530
|
+
modeCeiling: cliModeCeiling
|
|
3779
5531
|
})), "packycode-api: claude_code tool");
|
|
3780
5532
|
});
|
|
3781
5533
|
}
|
|
3782
5534
|
//#endregion
|
|
3783
|
-
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, 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 };
|