dsh-packycode-api 1.0.4 → 1.0.5
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 +8 -0
- package/README.en.md +10 -3
- package/README.md +10 -3
- package/lib/client.js +56 -22
- package/lib/index.js +781 -214
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -41,8 +41,17 @@ const DEFAULT_API_HOST = "https://cf.api.fan";
|
|
|
41
41
|
/**
|
|
42
42
|
* Host carrying `/api/usage/token/`. `cf.api.fan` answers 404 for the whole
|
|
43
43
|
* `/api/*` family, so per-key quota is read from the PackyAPI host instead.
|
|
44
|
+
* PackyAPI now runs on `.ai` (its site and docs moved there); the `.com` name
|
|
45
|
+
* is DNS-poisoned on mainland networks, so a host whose fetch does not go
|
|
46
|
+
* through a proxy reads `fetch failed` from it.
|
|
44
47
|
*/
|
|
45
|
-
const DEFAULT_STATUS_HOST = "https://www.packyapi.
|
|
48
|
+
const DEFAULT_STATUS_HOST = "https://www.packyapi.ai";
|
|
49
|
+
/**
|
|
50
|
+
* Earlier default status hosts. A profile that saved one keeps working, but
|
|
51
|
+
* the current default is tried before it, so an unreachable legacy name
|
|
52
|
+
* cannot hold a read up for its whole timeout first.
|
|
53
|
+
*/
|
|
54
|
+
const LEGACY_STATUS_HOSTS = Object.freeze(["https://www.packyapi.com"]);
|
|
46
55
|
/** PackyAPI quotes balances in quota units of 1/500000 USD (new-api convention). */
|
|
47
56
|
const QUOTA_PER_USD = 5e5;
|
|
48
57
|
/** Balance cache lifetime; a forced read bypasses it. */
|
|
@@ -61,7 +70,8 @@ const DEFAULT_REASONING_LEVEL = "high";
|
|
|
61
70
|
* the account. Only the second family exists on the model host (`cf.api.fan`
|
|
62
71
|
* answers 404 for `/api/*`), so a read tries the configured host first and
|
|
63
72
|
* falls back to the PackyAPI host — the same fallback the PackyCode status
|
|
64
|
-
* tooling uses.
|
|
73
|
+
* tooling uses. A saved legacy status host (`www.packyapi.com`, DNS-poisoned
|
|
74
|
+
* on mainland networks) is tried after the current default, not before it.
|
|
65
75
|
*
|
|
66
76
|
* Credentials and response bodies stay host-side; the browser receives only
|
|
67
77
|
* the projection built here.
|
|
@@ -139,22 +149,71 @@ function parseBillingUsage(payload) {
|
|
|
139
149
|
function statusMessage(status) {
|
|
140
150
|
if (status === 401) return "this API key was rejected (401)";
|
|
141
151
|
if (status === 402 || status === 403) return "the account cannot be read with this key (402/403)";
|
|
152
|
+
if (status === 429) return "the gateway is rate limiting balance reads (429)";
|
|
142
153
|
return `the gateway answered HTTP ${status}`;
|
|
143
154
|
}
|
|
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
|
+
}
|
|
144
167
|
async function getJson(url, key, { fetchImpl, timeoutMs, signal }) {
|
|
145
168
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
146
169
|
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await fetchImpl(url, {
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${key}`,
|
|
175
|
+
Accept: "application/json"
|
|
176
|
+
},
|
|
177
|
+
redirect: "error",
|
|
178
|
+
signal: requestSignal
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (signal?.aborted === true) throw error;
|
|
182
|
+
throw new Error(transportMessage(error, timeoutSignal, timeoutMs), { cause: error });
|
|
183
|
+
}
|
|
155
184
|
if (!response.ok) throw new Error(statusMessage(response.status));
|
|
156
185
|
return response.json();
|
|
157
186
|
}
|
|
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
|
+
}
|
|
158
217
|
async function withFallback(hosts, path, key, options) {
|
|
159
218
|
const tried = [];
|
|
160
219
|
for (const host of hosts) try {
|
|
@@ -169,6 +228,12 @@ async function withFallback(hosts, path, key, options) {
|
|
|
169
228
|
}
|
|
170
229
|
/**
|
|
171
230
|
* Cached, single-flight balance reader.
|
|
231
|
+
*
|
|
232
|
+
* A read that fails for one key — the gateway throttled it, a host was
|
|
233
|
+
* unreachable — keeps showing the last balance that key did report, marked
|
|
234
|
+
* `stale` with when it was read and why this read failed, instead of blanking
|
|
235
|
+
* the row. The same holds for the account figures. These last-good values
|
|
236
|
+
* live in the host process: a page refresh keeps them, a restart does not.
|
|
172
237
|
* @param options - key listing, host resolution, and transport knobs.
|
|
173
238
|
* @returns `{ read({force, signal}), invalidate() }`.
|
|
174
239
|
*/
|
|
@@ -178,13 +243,11 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
178
243
|
let cached;
|
|
179
244
|
let inFlight;
|
|
180
245
|
let generation = 0;
|
|
246
|
+
/** Last successful per-key usage, by key id, and the last account figures. */
|
|
247
|
+
const lastGood = /* @__PURE__ */ new Map();
|
|
248
|
+
let lastAccount;
|
|
181
249
|
const loadKey = async (entry, hosts, signal) => {
|
|
182
|
-
const { payload } = await withFallback(
|
|
183
|
-
hosts.statusHost,
|
|
184
|
-
DEFAULT_STATUS_HOST,
|
|
185
|
-
hosts.apiHost,
|
|
186
|
-
DEFAULT_API_HOST
|
|
187
|
-
])], "/api/usage/token/", entry.key, {
|
|
250
|
+
const { payload } = await withFallback(statusHostOrder(hosts), "/api/usage/token/", entry.key, {
|
|
188
251
|
fetchImpl,
|
|
189
252
|
timeoutMs,
|
|
190
253
|
signal
|
|
@@ -207,12 +270,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
207
270
|
...keys.filter((entry) => entry.disabled !== true).map((entry) => entry.id)
|
|
208
271
|
])].map((id) => keys.find((entry) => entry.id === id)).filter((entry) => entry !== void 0);
|
|
209
272
|
if (candidates.length === 0) throw new Error("add a PackyCode API key to read the account balance");
|
|
210
|
-
const hostsToTry =
|
|
211
|
-
hosts.apiHost,
|
|
212
|
-
DEFAULT_API_HOST,
|
|
213
|
-
hosts.statusHost,
|
|
214
|
-
DEFAULT_STATUS_HOST
|
|
215
|
-
])];
|
|
273
|
+
const hostsToTry = accountHostOrder(hosts);
|
|
216
274
|
const options = {
|
|
217
275
|
fetchImpl,
|
|
218
276
|
timeoutMs,
|
|
@@ -239,6 +297,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
239
297
|
signal?.throwIfAborted();
|
|
240
298
|
const hosts = getHosts();
|
|
241
299
|
const keys = await listKeys();
|
|
300
|
+
for (const id of lastGood.keys()) if (!keys.some((entry) => entry.id === id)) lastGood.delete(id);
|
|
242
301
|
const entries = [];
|
|
243
302
|
const usableIds = [];
|
|
244
303
|
for (const entry of keys) {
|
|
@@ -248,23 +307,50 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
248
307
|
disabled: entry.disabled === true
|
|
249
308
|
};
|
|
250
309
|
try {
|
|
310
|
+
const usage = await loadKey(entry, hosts, signal);
|
|
311
|
+
lastGood.set(entry.id, {
|
|
312
|
+
usage,
|
|
313
|
+
fetchedAt: now()
|
|
314
|
+
});
|
|
251
315
|
entries.push({
|
|
252
316
|
...base,
|
|
253
|
-
usage
|
|
317
|
+
usage
|
|
254
318
|
});
|
|
255
319
|
usableIds.push(entry.id);
|
|
256
320
|
} catch (error) {
|
|
257
|
-
|
|
321
|
+
signal?.throwIfAborted();
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
const previous = lastGood.get(entry.id);
|
|
324
|
+
entries.push(previous === void 0 ? {
|
|
325
|
+
...base,
|
|
326
|
+
error: message
|
|
327
|
+
} : {
|
|
258
328
|
...base,
|
|
259
|
-
|
|
329
|
+
usage: previous.usage,
|
|
330
|
+
stale: {
|
|
331
|
+
fetchedAt: previous.fetchedAt,
|
|
332
|
+
error: message
|
|
333
|
+
}
|
|
260
334
|
});
|
|
261
335
|
}
|
|
262
336
|
}
|
|
263
337
|
let account;
|
|
264
338
|
try {
|
|
265
339
|
account = await loadAccount(keys, hosts, signal, usableIds);
|
|
340
|
+
lastAccount = {
|
|
341
|
+
account,
|
|
342
|
+
fetchedAt: now()
|
|
343
|
+
};
|
|
266
344
|
} catch (error) {
|
|
267
|
-
|
|
345
|
+
signal?.throwIfAborted();
|
|
346
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
347
|
+
account = lastAccount === void 0 || !keys.some((entry) => entry.id === lastAccount.account.keyId) ? { error: message } : {
|
|
348
|
+
...lastAccount.account,
|
|
349
|
+
stale: {
|
|
350
|
+
fetchedAt: lastAccount.fetchedAt,
|
|
351
|
+
error: message
|
|
352
|
+
}
|
|
353
|
+
};
|
|
268
354
|
}
|
|
269
355
|
return {
|
|
270
356
|
keys: entries,
|
|
@@ -302,6 +388,147 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
302
388
|
});
|
|
303
389
|
}
|
|
304
390
|
//#endregion
|
|
391
|
+
//#region src/failures.js
|
|
392
|
+
/**
|
|
393
|
+
* Failure classification for the two conditions that move the key ring along.
|
|
394
|
+
*
|
|
395
|
+
* The gateway reports an exhausted key and a rejected key in Chinese, and the
|
|
396
|
+
* harness classifies failures by fixed English/status wording it recognizes
|
|
397
|
+
* (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
|
|
398
|
+
* harness's retry policy re-run the step — and therefore what lets the retry
|
|
399
|
+
* pick up the next key. Rewriting is honest about what happened: this plugin
|
|
400
|
+
* knows the key's condition from the gateway's own words plus its balance
|
|
401
|
+
* reader, so the sentence it substitutes says no more than that.
|
|
402
|
+
*
|
|
403
|
+
* @module dsh-packycode-api/failures
|
|
404
|
+
*/
|
|
405
|
+
/** Terminal quota, balance, or credit wording, in the gateway's own languages. */
|
|
406
|
+
const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
|
|
407
|
+
/** A rejected key names itself; a bare 403 does not, and must not disable one. */
|
|
408
|
+
const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
|
|
409
|
+
const INVALID_KEY_STATUS = /\b401\b/;
|
|
410
|
+
/**
|
|
411
|
+
* A gateway group that only serves its vendor's official client. These arrive
|
|
412
|
+
* as 403, which the harness reads as an authentication failure and renders as
|
|
413
|
+
* an invalid-API-key problem — the opposite of what happened, and a condition
|
|
414
|
+
* no retry can clear.
|
|
415
|
+
*/
|
|
416
|
+
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;
|
|
417
|
+
/** The key's quota is exhausted; move to the next key. */
|
|
418
|
+
const QUOTA_FAILURE = "quota";
|
|
419
|
+
/** The gateway rejected the key itself; move to the next key. */
|
|
420
|
+
const INVALID_KEY_FAILURE = "invalid-key";
|
|
421
|
+
/**
|
|
422
|
+
* Whether the gateway is refusing this client rather than this credential.
|
|
423
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
424
|
+
* @returns true when the route is gated to a vendor's official client.
|
|
425
|
+
*/
|
|
426
|
+
function classifyClientPolicy(message) {
|
|
427
|
+
return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Classify a gateway failure message.
|
|
431
|
+
*
|
|
432
|
+
* Quota wording wins over a status token: an exhausted balance is often
|
|
433
|
+
* reported as 402/403, and only the key's own rejection may read as 401. A
|
|
434
|
+
* client-policy refusal is not a key fault and is classified separately.
|
|
435
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
436
|
+
* @returns the failure kind, or undefined when the key is not at fault.
|
|
437
|
+
*/
|
|
438
|
+
function classifyGatewayFailure(message) {
|
|
439
|
+
const text = typeof message === "string" ? message : "";
|
|
440
|
+
if (text === "") return void 0;
|
|
441
|
+
if (classifyClientPolicy(text)) return void 0;
|
|
442
|
+
if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
|
|
443
|
+
if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
|
|
444
|
+
if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
448
|
+
*
|
|
449
|
+
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
450
|
+
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
451
|
+
* 401/403 before it reads any wording, which would send the failure into a
|
|
452
|
+
* category the retry policy does not re-run.
|
|
453
|
+
* @param message - the original provider failure text.
|
|
454
|
+
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
455
|
+
* @returns text the harness routes to QUOTA or AUTH.
|
|
456
|
+
*/
|
|
457
|
+
/**
|
|
458
|
+
* Explain a client-policy refusal so the user reads the real cause.
|
|
459
|
+
*
|
|
460
|
+
* The sentence must not read as any other failure the harness knows: it
|
|
461
|
+
* deliberately carries no HTTP status and none of the words the classifier
|
|
462
|
+
* matches for auth, quota, rate limits, timeouts, or transport faults. That
|
|
463
|
+
* keeps the failure out of the retry policy — no retry can talk a group into
|
|
464
|
+
* serving an API client — and lets the sentence reach the user verbatim, in
|
|
465
|
+
* place of the "API key is invalid" the bare 403 would have produced.
|
|
466
|
+
* @param keyName - the account name the balance reader saw, when known.
|
|
467
|
+
* @returns the replacement sentence.
|
|
468
|
+
*/
|
|
469
|
+
function explainClientPolicyFailure() {
|
|
470
|
+
return "PackyCode 拒绝了这个客户端:所选分组只允许厂商官方客户端调用(Claude 分组要求官方 Claude CLI,Codex 分组要求标准 Codex 客户端)。这不是 API Key 失效,重试也不会成功。请改用允许 API 调用的分组(例如 cc-sale 或 claude-officially),或改用 PackyCode 的 gpt-6.1-sol / deepseek-flash。 / This group only serves the vendor’s official client; the API key is fine and retrying cannot help.";
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The gateway is out of capacity for the moment: its Claude pool's
|
|
474
|
+
* per-account wait queue is full, or it throttled or shed the request. The
|
|
475
|
+
* CLI retries these on its own; this matches what is left when it gave up.
|
|
476
|
+
*/
|
|
477
|
+
const CAPACITY_WORDING = /concurrency_queue_full|并发排队已满|wait queues? (?:are|is) full|负载已饱和|overloaded|rate[_\s-]?limit|API Error:\s*(?:429|502|503|504|529)\b/iu;
|
|
478
|
+
/**
|
|
479
|
+
* Whether a CLI failure is the gateway being busy rather than a fault of the
|
|
480
|
+
* key, the client, or the request.
|
|
481
|
+
* @param message - the CLI failure text.
|
|
482
|
+
* @returns true for throttling and capacity refusals.
|
|
483
|
+
*/
|
|
484
|
+
function classifyCapacity(message) {
|
|
485
|
+
return CAPACITY_WORDING.test(typeof message === "string" ? message : "");
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Explain a CLI turn that failed because the gateway stayed busy.
|
|
489
|
+
* @param message - the CLI failure text.
|
|
490
|
+
* @param retries - how many retries the CLI reported before giving up.
|
|
491
|
+
* @returns the replacement sentence, with the gateway's own words kept.
|
|
492
|
+
*/
|
|
493
|
+
function explainCliCapacityFailure(message, retries) {
|
|
494
|
+
const detail = (typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").slice(0, 600);
|
|
495
|
+
return `PackyCode 的 Claude 上游暂时繁忙(并发排队已满或被限流)。${typeof retries === "number" && retries > 0 ? `Claude Code CLI 已自动重试 ${retries} 次仍未成功。` : ""}这不是 API Key 或插件的问题,稍后再发一次即可;着急的话可以先切到 gpt-6.1-sol 或 deepseek-flash。 / PackyCode's Claude upstream is busy right now; retry shortly. Gateway said: ${detail}`;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Explain a client-policy refusal of a request the local Claude Code CLI made.
|
|
499
|
+
*
|
|
500
|
+
* The request did come from the official CLI, so the HTTP-route advice above
|
|
501
|
+
* (switch to a CLI) does not apply. The gateway's own words are kept, without
|
|
502
|
+
* their HTTP status, so a support request can quote the request ids.
|
|
503
|
+
* @param message - the CLI failure text.
|
|
504
|
+
* @returns the replacement sentence.
|
|
505
|
+
*/
|
|
506
|
+
function explainCliClientPolicyFailure(message) {
|
|
507
|
+
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
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
511
|
+
*
|
|
512
|
+
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
513
|
+
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
514
|
+
* 401/403 before it reads any wording, which would send the failure into a
|
|
515
|
+
* category the retry policy does not re-run.
|
|
516
|
+
* @param message - the original provider failure text.
|
|
517
|
+
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
518
|
+
* @param options - optional account name for the policy explanation.
|
|
519
|
+
* @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
|
|
520
|
+
*/
|
|
521
|
+
function normalizeFailureMessage(message, kind, options = {}) {
|
|
522
|
+
const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
|
|
523
|
+
if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
|
|
524
|
+
if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
|
|
525
|
+
if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
|
|
526
|
+
return text;
|
|
527
|
+
}
|
|
528
|
+
function neutralizeAuthStatus(text) {
|
|
529
|
+
return text.replace(/\b(?:401|403)\b/gu, "HTTP");
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
305
532
|
//#region src/claude-adapter.js
|
|
306
533
|
/**
|
|
307
534
|
* Route Claude models through the local Claude Code CLI.
|
|
@@ -314,9 +541,12 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
314
541
|
* A CLI-backed turn is a *delegation*: the CLI runs its own agent loop with
|
|
315
542
|
* its own tools and returns one answer. The CLI cannot call the harness's
|
|
316
543
|
* tools, so `options.tools` is deliberately not forwarded, and the harness's
|
|
317
|
-
* system prompt is dropped in favour of the CLI's own.
|
|
318
|
-
*
|
|
319
|
-
*
|
|
544
|
+
* system prompt is dropped in favour of the CLI's own. The delegation
|
|
545
|
+
* instructions travel as `--append-system-prompt`; the recent conversation
|
|
546
|
+
* travels as one user message whose images are real image blocks, read from
|
|
547
|
+
* the attachment store, so the CLI's model sees them as images. While it
|
|
548
|
+
* works, its thinking and tool activity stream into a reasoning block and its
|
|
549
|
+
* answer streams as text.
|
|
320
550
|
*
|
|
321
551
|
* A session-title request is not a delegation: it runs on the small model,
|
|
322
552
|
* read-only and outside the workspace, with the title instructions appended,
|
|
@@ -335,15 +565,344 @@ const TITLE_MODEL_ID = "claude-haiku-5-5";
|
|
|
335
565
|
/** How much of the conversation a delegated prompt carries. */
|
|
336
566
|
const MAX_PROMPT_CHARS$1 = 48e3;
|
|
337
567
|
const MAX_BLOCK_CHARS = 8e3;
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
568
|
+
const TRUNCATED_MARK = "…(较早对话已截断)";
|
|
569
|
+
/**
|
|
570
|
+
* Image bounds for one delegated turn. Each image is re-encoded by the
|
|
571
|
+
* attachment store to the same request version the HTTP route sends (at most
|
|
572
|
+
* 2048×2048 pixels, about 1 MiB); the oldest images are left out once the turn
|
|
573
|
+
* would carry more than 20 of them or 20 MiB of base64.
|
|
574
|
+
*/
|
|
575
|
+
const CLI_IMAGE_LIMITS = Object.freeze({
|
|
576
|
+
maxPixels: 2048 * 2048,
|
|
577
|
+
maxBytesPerImage: 1024 * 1024,
|
|
578
|
+
maxTotalBase64Bytes: 20 * 1024 * 1024,
|
|
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);
|
|
589
|
+
}
|
|
590
|
+
/** The host path of a stored attachment, when the store is file-backed. */
|
|
591
|
+
function hostPath(lookup) {
|
|
592
|
+
try {
|
|
593
|
+
const value = lookup();
|
|
594
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
595
|
+
} catch {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Flatten one message's content into ordered parts: text, image, and file.
|
|
601
|
+
* Tool calls and results become short text so a turn that started on another
|
|
602
|
+
* model still reads coherently; reasoning is not carried.
|
|
603
|
+
*/
|
|
604
|
+
function collectParts(blocks, parts) {
|
|
605
|
+
for (const block of Array.isArray(blocks) ? blocks : []) {
|
|
606
|
+
if (block === null || typeof block !== "object") continue;
|
|
607
|
+
switch (block.type) {
|
|
608
|
+
case "text":
|
|
609
|
+
if (typeof block.text === "string" && block.text !== "") parts.push({
|
|
610
|
+
kind: "text",
|
|
611
|
+
text: block.text
|
|
612
|
+
});
|
|
613
|
+
break;
|
|
614
|
+
case "image":
|
|
615
|
+
if (block.attachment !== void 0) parts.push({
|
|
616
|
+
kind: "image",
|
|
617
|
+
ref: block.attachment
|
|
618
|
+
});
|
|
619
|
+
else parts.push({
|
|
620
|
+
kind: "text",
|
|
621
|
+
text: "[图片]"
|
|
622
|
+
});
|
|
623
|
+
break;
|
|
624
|
+
case "file":
|
|
625
|
+
if (block.attachment !== void 0) parts.push({
|
|
626
|
+
kind: "file",
|
|
627
|
+
ref: block.attachment
|
|
628
|
+
});
|
|
629
|
+
break;
|
|
630
|
+
case "tool-call":
|
|
631
|
+
case "tool-call-delta":
|
|
632
|
+
parts.push({
|
|
633
|
+
kind: "text",
|
|
634
|
+
text: `[调用工具 ${block.name ?? block.id ?? ""}]`
|
|
635
|
+
});
|
|
636
|
+
break;
|
|
637
|
+
case "tool-result": {
|
|
638
|
+
if (typeof block.content === "string") {
|
|
639
|
+
parts.push({
|
|
640
|
+
kind: "text",
|
|
641
|
+
text: `[工具结果] ${block.content.slice(0, MAX_BLOCK_CHARS)}`
|
|
642
|
+
});
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
const nested = [];
|
|
646
|
+
collectParts(block.content, nested);
|
|
647
|
+
let budget = MAX_BLOCK_CHARS;
|
|
648
|
+
parts.push({
|
|
649
|
+
kind: "text",
|
|
650
|
+
text: "[工具结果]"
|
|
651
|
+
});
|
|
652
|
+
for (const part of nested) {
|
|
653
|
+
if (part.kind !== "text") {
|
|
654
|
+
parts.push(part);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (budget <= 0) continue;
|
|
658
|
+
parts.push({
|
|
659
|
+
kind: "text",
|
|
660
|
+
text: part.text.slice(0, budget)
|
|
661
|
+
});
|
|
662
|
+
budget -= part.text.length;
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
default: break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return parts;
|
|
670
|
+
}
|
|
671
|
+
const textLength = (parts) => parts.reduce((sum, part) => sum + (part.kind === "text" ? part.text.length : 0), 0);
|
|
672
|
+
/**
|
|
673
|
+
* The conversation as labelled turns, newest last, trimmed to the prompt
|
|
674
|
+
* budget by dropping whole older turns; a single newest turn that is larger
|
|
675
|
+
* than the budget keeps its tail.
|
|
676
|
+
*/
|
|
677
|
+
function delegatedTurns(options) {
|
|
678
|
+
const messages = Array.isArray(options?.messages) ? options.messages : [];
|
|
679
|
+
const turns = [];
|
|
680
|
+
for (const message of messages) {
|
|
681
|
+
if (message === null || typeof message !== "object" || message.role === "system") continue;
|
|
682
|
+
const parts = collectParts(message.content, []);
|
|
683
|
+
if (parts.length === 0 || textLength(parts) === 0 && !parts.some((part) => part.kind !== "text")) continue;
|
|
684
|
+
const label = message.role === "user" ? "用户" : message.role === "assistant" ? "助手" : String(message.role);
|
|
685
|
+
turns.push({
|
|
686
|
+
label,
|
|
687
|
+
parts
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
const kept = [];
|
|
691
|
+
let used = 0;
|
|
692
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
693
|
+
const turn = turns[index];
|
|
694
|
+
const size = textLength(turn.parts) + turn.label.length + 3;
|
|
695
|
+
if (used + size <= MAX_PROMPT_CHARS$1) {
|
|
696
|
+
kept.unshift(turn);
|
|
697
|
+
used += size;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (kept.length === 0) kept.unshift({
|
|
701
|
+
label: turn.label,
|
|
702
|
+
parts: tailOf(turn.parts, MAX_PROMPT_CHARS$1 - turn.label.length - 3),
|
|
703
|
+
clipped: true
|
|
704
|
+
});
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
turns: kept,
|
|
709
|
+
truncated: kept.length < turns.length || kept[0]?.clipped === true
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/** Keep the last `budget` characters of a turn's text, and every part after them. */
|
|
713
|
+
function tailOf(parts, budget) {
|
|
714
|
+
const result = [];
|
|
715
|
+
let remaining = budget;
|
|
716
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
717
|
+
const part = parts[index];
|
|
718
|
+
if (part.kind !== "text") {
|
|
719
|
+
result.unshift(part);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (remaining <= 0) continue;
|
|
723
|
+
result.unshift({
|
|
724
|
+
kind: "text",
|
|
725
|
+
text: part.text.length > remaining ? part.text.slice(-remaining) : part.text
|
|
726
|
+
});
|
|
727
|
+
remaining -= part.text.length;
|
|
728
|
+
}
|
|
729
|
+
return result;
|
|
730
|
+
}
|
|
731
|
+
/** How a part reads when it is carried as text only. */
|
|
732
|
+
function partText(part, describe) {
|
|
733
|
+
if (part.kind === "text") return part.text;
|
|
734
|
+
return describe(part);
|
|
735
|
+
}
|
|
736
|
+
/** Render turns as one transcript, with non-text parts described by `describe`. */
|
|
737
|
+
function renderTurns({ turns, truncated }, describe) {
|
|
738
|
+
const transcript = turns.map((turn) => `${turn.label}:${turn.parts.map((part) => partText(part, describe)).join("\n").trim()}`).join("\n\n");
|
|
739
|
+
return truncated ? `${TRUNCATED_MARK}\n\n${transcript}` : transcript;
|
|
740
|
+
}
|
|
741
|
+
const plainDescription = (part) => part.kind === "image" ? `[图片 ${attachmentName(part.ref)}]` : `[文件 ${attachmentName(part.ref)}]`;
|
|
742
|
+
/**
|
|
743
|
+
* The delegation instructions, appended to the CLI's own system prompt.
|
|
744
|
+
*
|
|
745
|
+
* The harness system prompt describes tools this model cannot call, so only
|
|
746
|
+
* its opening is passed on, marked as context.
|
|
747
|
+
* @param options - the assembled request.
|
|
748
|
+
* @param cwd - the working directory the CLI will run in, when known.
|
|
749
|
+
* @returns the instructions text.
|
|
750
|
+
*/
|
|
751
|
+
function delegationInstructions(options, cwd) {
|
|
752
|
+
const system = typeof options?.system === "string" && options.system.trim() !== "" ? options.system.trim() : "";
|
|
753
|
+
return [
|
|
754
|
+
"本回合由 DeepSeek Harness 委派给 Claude Code 执行。用户消息是 DeepSeek Harness 会话的近期对话记录,最后一条是当前请求;其中的图片已作为图片随消息附上,「只读副本」路径也可以用 Read 工具读取。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。",
|
|
755
|
+
cwd === void 0 ? void 0 : `工作目录:${cwd}`,
|
|
756
|
+
system === "" ? void 0 : `补充说明(来自 DeepSeek Harness,其中提到的工具你未必拥有):${system.slice(0, 2e3)}`
|
|
757
|
+
].filter((part) => part !== void 0).join("\n");
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The recent conversation as one text transcript. Images and files are named
|
|
761
|
+
* in place; {@link buildDelegatedContent} is what carries the images.
|
|
762
|
+
* @param options - the assembled request.
|
|
763
|
+
* @returns the transcript text.
|
|
764
|
+
*/
|
|
765
|
+
function renderDelegatedPrompt(options) {
|
|
766
|
+
return renderTurns(delegatedTurns(options), plainDescription).trim();
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* The recent conversation as content blocks for the CLI's stream-json input:
|
|
770
|
+
* the transcript, with every image that fits the turn's bounds as an image
|
|
771
|
+
* block at the place it was sent, labelled with its read-only host copy.
|
|
772
|
+
* An image that is left out, or that cannot be read, is described in text
|
|
773
|
+
* instead; it never fails the turn.
|
|
774
|
+
* @param options - the assembled request.
|
|
775
|
+
* @param context - the attachment store (`ctx.attachments`), image bounds,
|
|
776
|
+
* and cancellation.
|
|
777
|
+
* @returns text and image blocks, in conversation order.
|
|
778
|
+
*/
|
|
779
|
+
async function buildDelegatedContent(options, { attachments, limits = CLI_IMAGE_LIMITS, signal } = {}) {
|
|
780
|
+
const shape = delegatedTurns(options);
|
|
781
|
+
const images = shape.turns.flatMap((turn) => turn.parts.filter((part) => part.kind === "image"));
|
|
782
|
+
const pathOf = (ref) => hostPath(() => attachments?.imageHostPath?.(ref));
|
|
783
|
+
const filePathOf = (ref) => hostPath(() => attachments?.fileHostPath?.(ref));
|
|
784
|
+
const carried = /* @__PURE__ */ new Map();
|
|
785
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
786
|
+
const reads = /* @__PURE__ */ new Map();
|
|
787
|
+
let total = 0;
|
|
788
|
+
let count = 0;
|
|
789
|
+
let full = false;
|
|
790
|
+
for (const part of [...images].reverse()) {
|
|
791
|
+
if (attachments === void 0 || attachments === null) {
|
|
792
|
+
reasons.set(part, "unavailable");
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (full) {
|
|
796
|
+
reasons.set(part, "limit");
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const id = String(part.ref.attachmentId);
|
|
800
|
+
try {
|
|
801
|
+
if (!reads.has(id)) reads.set(id, readRequestImage(attachments, part.ref, limits, signal));
|
|
802
|
+
const image = await reads.get(id);
|
|
803
|
+
const size = base64Length(image.data.byteLength);
|
|
804
|
+
if (size > MAX_IMAGE_BASE64_BYTES) {
|
|
805
|
+
reasons.set(part, "too-large");
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (count + 1 > limits.maxImages || total + size > limits.maxTotalBase64Bytes) {
|
|
809
|
+
full = true;
|
|
810
|
+
reasons.set(part, "limit");
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
count += 1;
|
|
814
|
+
total += size;
|
|
815
|
+
carried.set(part, image);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
if (signal?.aborted === true) throw error;
|
|
818
|
+
reasons.set(part, "unreadable");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const blocks = [];
|
|
822
|
+
let text = shape.truncated ? `${TRUNCATED_MARK}\n\n` : "";
|
|
823
|
+
const flush = () => {
|
|
824
|
+
if (text !== "") blocks.push({
|
|
825
|
+
type: "text",
|
|
826
|
+
text
|
|
827
|
+
});
|
|
828
|
+
text = "";
|
|
829
|
+
};
|
|
830
|
+
for (const [turnIndex, turn] of shape.turns.entries()) {
|
|
831
|
+
text += `${turnIndex === 0 ? "" : "\n\n"}${turn.label}:`;
|
|
832
|
+
for (const [partIndex, part] of turn.parts.entries()) {
|
|
833
|
+
const separator = partIndex === 0 ? "" : "\n";
|
|
834
|
+
if (part.kind === "text") {
|
|
835
|
+
text += separator + part.text;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (part.kind === "file") {
|
|
839
|
+
const path = filePathOf(part.ref);
|
|
840
|
+
text += `${separator}[文件 ${attachmentName(part.ref)}${path === void 0 ? "" : `;只读副本:${path}`}]`;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const path = pathOf(part.ref);
|
|
844
|
+
const copy = path === void 0 ? "" : `;只读副本:${path}`;
|
|
845
|
+
const image = carried.get(part);
|
|
846
|
+
if (image === void 0) {
|
|
847
|
+
text += `${separator}[图片 ${attachmentName(part.ref)}:${imageOmission(reasons.get(part))}${copy}]`;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
text += `${separator}[图片 ${attachmentName(part.ref)}(${image.width}x${image.height}px)${copy}]`;
|
|
851
|
+
flush();
|
|
852
|
+
blocks.push({
|
|
853
|
+
type: "image",
|
|
854
|
+
source: {
|
|
855
|
+
type: "base64",
|
|
856
|
+
media_type: image.mediaType,
|
|
857
|
+
data: Buffer.from(image.data).toString("base64")
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
flush();
|
|
863
|
+
return blocks;
|
|
864
|
+
}
|
|
865
|
+
function imageOmission(reason) {
|
|
866
|
+
switch (reason) {
|
|
867
|
+
case "limit": return "超出本回合的图片上限,未随消息附上,需要时请用 Read 工具读取";
|
|
868
|
+
case "too-large": return "图片过大,未随消息附上,需要时请用 Read 工具读取";
|
|
869
|
+
case "unavailable": return "当前运行环境无法读取附件";
|
|
870
|
+
default: return "读取失败,未随消息附上";
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* One request-sized version of a stored image: the store's own request
|
|
875
|
+
* encoder when it has one, otherwise the stored normalized bytes.
|
|
876
|
+
*/
|
|
877
|
+
async function readRequestImage(attachments, ref, limits, signal) {
|
|
878
|
+
if (typeof attachments.readImageRequest === "function") {
|
|
879
|
+
const version = await attachments.readImageRequest(ref, {
|
|
880
|
+
maxPixels: limits.maxPixels,
|
|
881
|
+
maxBytes: limits.maxBytesPerImage
|
|
882
|
+
}, signal);
|
|
883
|
+
return {
|
|
884
|
+
data: version.data,
|
|
885
|
+
mediaType: version.mediaType ?? ref.mediaType,
|
|
886
|
+
width: version.width ?? ref.width,
|
|
887
|
+
height: version.height ?? ref.height
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const stored = await attachments.readImage(ref, signal);
|
|
891
|
+
return {
|
|
892
|
+
data: stored.data,
|
|
893
|
+
mediaType: stored.mediaType ?? ref.mediaType,
|
|
894
|
+
width: ref.width,
|
|
895
|
+
height: ref.height
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* The prompt of a one-shot auxiliary request: its messages' text, verbatim.
|
|
900
|
+
* The caller frames its own input, so nothing is added around it.
|
|
901
|
+
* @param options - the assembled request.
|
|
902
|
+
* @returns the prompt text.
|
|
903
|
+
*/
|
|
904
|
+
function renderAuxiliaryPrompt(options) {
|
|
905
|
+
return (Array.isArray(options?.messages) ? options.messages : []).filter((message) => message !== null && typeof message === "object" && message.role !== "system").flatMap((message) => Array.isArray(message.content) ? message.content : []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n").trim();
|
|
347
906
|
}
|
|
348
907
|
/**
|
|
349
908
|
* Emit text and reasoning as well-formed harness blocks.
|
|
@@ -430,43 +989,9 @@ function turnUsage(lastRequest, finished) {
|
|
|
430
989
|
cacheWriteTokens: finished?.cacheWriteTokens ?? 0
|
|
431
990
|
};
|
|
432
991
|
}
|
|
433
|
-
/**
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
* The leading system message carries the harness's system prompt, which
|
|
437
|
-
* describes tools this model cannot call; the CLI brings its own instead.
|
|
438
|
-
* @param options - the assembled request.
|
|
439
|
-
* @param cwd - the working directory the CLI will run in, when known.
|
|
440
|
-
* @returns the prompt text.
|
|
441
|
-
*/
|
|
442
|
-
function renderDelegatedPrompt(options, cwd) {
|
|
443
|
-
const messages = Array.isArray(options?.messages) ? options.messages : [];
|
|
444
|
-
const turns = [];
|
|
445
|
-
for (const message of messages) {
|
|
446
|
-
if (message === null || typeof message !== "object") continue;
|
|
447
|
-
if (message.role === "system") continue;
|
|
448
|
-
const text = (Array.isArray(message.content) ? message.content : []).map(renderBlock).filter((part) => part !== "").join("\n").trim();
|
|
449
|
-
if (text === "") continue;
|
|
450
|
-
const label = message.role === "user" ? "用户" : message.role === "assistant" ? "助手" : String(message.role);
|
|
451
|
-
turns.push(`${label}:${text}`);
|
|
452
|
-
}
|
|
453
|
-
let transcript = turns.join("\n\n");
|
|
454
|
-
if (transcript.length > MAX_PROMPT_CHARS$1) transcript = `…(较早对话已截断)\n\n${transcript.slice(-48e3)}`;
|
|
455
|
-
const system = typeof options?.system === "string" && options.system.trim() !== "" ? options.system.trim() : "";
|
|
456
|
-
return `${[
|
|
457
|
-
"[本回合由 DeepSeek Harness 委派给 Claude Code CLI 执行。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。]",
|
|
458
|
-
cwd === void 0 ? void 0 : `[工作目录:${cwd}]`,
|
|
459
|
-
system === "" ? void 0 : `[补充说明:${system.slice(0, 2e3)}]`
|
|
460
|
-
].filter((part) => part !== void 0).join("\n")}\n\n${transcript}`.trim();
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* The prompt of a one-shot auxiliary request: its messages' text, verbatim.
|
|
464
|
-
* The caller frames its own input, so nothing is added around it.
|
|
465
|
-
* @param options - the assembled request.
|
|
466
|
-
* @returns the prompt text.
|
|
467
|
-
*/
|
|
468
|
-
function renderAuxiliaryPrompt(options) {
|
|
469
|
-
return (Array.isArray(options?.messages) ? options.messages : []).filter((message) => message !== null && typeof message === "object" && message.role !== "system").flatMap((message) => Array.isArray(message.content) ? message.content : []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n").trim();
|
|
992
|
+
/** One line describing a retry the CLI is making on its own. */
|
|
993
|
+
function retryNote(event) {
|
|
994
|
+
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)} 秒后`}`;
|
|
470
995
|
}
|
|
471
996
|
/**
|
|
472
997
|
* How one request runs on the CLI: a title request on the small model with
|
|
@@ -475,7 +1000,7 @@ function renderAuxiliaryPrompt(options) {
|
|
|
475
1000
|
* The CLI's own system prompt and tool set stay in place even for a title:
|
|
476
1001
|
* they are part of what the gateway sees from the official client.
|
|
477
1002
|
*/
|
|
478
|
-
function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
1003
|
+
async function cliCall(options, { titleModel, cwdOf, modeOf, attachmentsOf, imageLimits }) {
|
|
479
1004
|
if (options?.purpose === "session-title") return {
|
|
480
1005
|
prompt: renderAuxiliaryPrompt(options),
|
|
481
1006
|
model: titleModel,
|
|
@@ -485,7 +1010,12 @@ function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
|
485
1010
|
};
|
|
486
1011
|
const cwd = cwdOf();
|
|
487
1012
|
return {
|
|
488
|
-
|
|
1013
|
+
content: await buildDelegatedContent(options, {
|
|
1014
|
+
attachments: attachmentsOf(),
|
|
1015
|
+
limits: imageLimits,
|
|
1016
|
+
signal: options?.signal
|
|
1017
|
+
}),
|
|
1018
|
+
appendSystemPrompt: delegationInstructions(options, cwd),
|
|
489
1019
|
model: options.model,
|
|
490
1020
|
mode: modeOf(),
|
|
491
1021
|
cwd
|
|
@@ -495,26 +1025,39 @@ function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
|
495
1025
|
* Wrap an adapter so the CLI-backed models run through the local CLI.
|
|
496
1026
|
*
|
|
497
1027
|
* @param options - the inner adapter, the CLI runner, the CLI-backed model ids,
|
|
498
|
-
* the model session titles run on,
|
|
499
|
-
* turn runs with.
|
|
1028
|
+
* the model session titles run on, the mode and working directory each
|
|
1029
|
+
* turn runs with, and the attachment store images are read from.
|
|
500
1030
|
* @returns an adapter with the same provider surface.
|
|
501
1031
|
*/
|
|
502
|
-
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, resolveMode, resolveCwd }) {
|
|
1032
|
+
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, resolveMode, resolveCwd, resolveAttachments, imageLimits = CLI_IMAGE_LIMITS }) {
|
|
503
1033
|
if (inner === void 0 || inner === null) throw new Error("the CLI adapter needs an inner adapter");
|
|
504
1034
|
if (runner === void 0 || runner === null) throw new Error("the CLI adapter needs a runner");
|
|
505
1035
|
const cliModels = new Set(modelIds);
|
|
506
1036
|
/** The mode is read per turn so a settings change applies without a reload. */
|
|
507
1037
|
const modeOf = () => typeof resolveMode === "function" ? resolveMode() : void 0;
|
|
508
1038
|
const cwdOf = () => typeof resolveCwd === "function" ? resolveCwd() : void 0;
|
|
1039
|
+
const attachmentsOf = () => {
|
|
1040
|
+
try {
|
|
1041
|
+
return typeof resolveAttachments === "function" ? resolveAttachments() ?? void 0 : void 0;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
509
1046
|
async function* cliStream(options) {
|
|
1047
|
+
const quiet = options?.purpose === "session-title";
|
|
1048
|
+
const blocks = createBlockWriter();
|
|
1049
|
+
if (!quiet) yield* blocks.note("▸ 启动 Claude Code CLI…\n");
|
|
510
1050
|
let call;
|
|
511
1051
|
try {
|
|
512
|
-
call = cliCall(options, {
|
|
1052
|
+
call = await cliCall(options, {
|
|
513
1053
|
titleModel,
|
|
514
1054
|
cwdOf,
|
|
515
|
-
modeOf
|
|
1055
|
+
modeOf,
|
|
1056
|
+
attachmentsOf,
|
|
1057
|
+
imageLimits
|
|
516
1058
|
});
|
|
517
1059
|
} catch (error) {
|
|
1060
|
+
yield* blocks.close();
|
|
518
1061
|
yield {
|
|
519
1062
|
type: "finish",
|
|
520
1063
|
reason: {
|
|
@@ -527,7 +1070,6 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
527
1070
|
};
|
|
528
1071
|
return;
|
|
529
1072
|
}
|
|
530
|
-
const blocks = createBlockWriter();
|
|
531
1073
|
const seen = /* @__PURE__ */ new Set();
|
|
532
1074
|
const firstSighting = (key) => {
|
|
533
1075
|
if (key.endsWith(":")) return true;
|
|
@@ -535,6 +1077,7 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
535
1077
|
seen.add(key);
|
|
536
1078
|
return true;
|
|
537
1079
|
};
|
|
1080
|
+
let retries = 0;
|
|
538
1081
|
try {
|
|
539
1082
|
let finished;
|
|
540
1083
|
let lastRequest;
|
|
@@ -542,6 +1085,16 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
542
1085
|
...call,
|
|
543
1086
|
signal: options.signal
|
|
544
1087
|
})) switch (event.type) {
|
|
1088
|
+
case "started":
|
|
1089
|
+
if (!quiet) {
|
|
1090
|
+
const detail = [event.version, event.model].filter((part) => typeof part === "string" && part !== "").join(" · ");
|
|
1091
|
+
yield* blocks.note(`▸ Claude Code CLI 已启动${detail === "" ? "" : `(${detail})`}\n`);
|
|
1092
|
+
}
|
|
1093
|
+
break;
|
|
1094
|
+
case "retry":
|
|
1095
|
+
retries = Math.max(retries, event.attempt ?? retries + 1);
|
|
1096
|
+
if (!quiet) yield* blocks.note(` ↻ ${retryNote(event)}\n`);
|
|
1097
|
+
break;
|
|
545
1098
|
case "block":
|
|
546
1099
|
blocks.boundary(event.kind);
|
|
547
1100
|
break;
|
|
@@ -575,13 +1128,15 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
575
1128
|
reason: { kind: "stop" }
|
|
576
1129
|
};
|
|
577
1130
|
} catch (error) {
|
|
1131
|
+
yield* blocks.close();
|
|
1132
|
+
const raw = String(error?.message ?? error);
|
|
578
1133
|
yield {
|
|
579
1134
|
type: "finish",
|
|
580
1135
|
reason: {
|
|
581
1136
|
kind: "error",
|
|
582
1137
|
failure: {
|
|
583
1138
|
code: "PI_AI_ERROR",
|
|
584
|
-
message:
|
|
1139
|
+
message: classifyClientPolicy(raw) ? explainCliClientPolicyFailure(raw) : classifyCapacity(raw) ? explainCliCapacityFailure(raw, retries) : raw
|
|
585
1140
|
}
|
|
586
1141
|
}
|
|
587
1142
|
};
|
|
@@ -745,8 +1300,32 @@ const DEFAULT_CLAUDE_MODE = "edit";
|
|
|
745
1300
|
/** Isolated CLI home; the plugin owns the endpoint and key it runs with. */
|
|
746
1301
|
const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), ".dsh", "packycode-claude-cli");
|
|
747
1302
|
const DEFAULT_CLAUDE_TIMEOUT_MS = 1800 * 1e3;
|
|
748
|
-
/**
|
|
1303
|
+
/**
|
|
1304
|
+
* How long a streaming run may stay silent after starting. The CLI prints its
|
|
1305
|
+
* `system/init` line within a second or two once it runs; one that prints
|
|
1306
|
+
* nothing for this long is stuck before its first request and is stopped, so
|
|
1307
|
+
* the turn reports it instead of spinning until the overall timeout.
|
|
1308
|
+
*/
|
|
1309
|
+
const DEFAULT_CLAUDE_STARTUP_TIMEOUT_MS = 120 * 1e3;
|
|
1310
|
+
/**
|
|
1311
|
+
* Upper bound on one run's prompt text. The prompt travels over stdin, so this
|
|
1312
|
+
* is a sanity cap rather than an OS limit.
|
|
1313
|
+
*/
|
|
749
1314
|
const MAX_PROMPT_CHARS = 1e5;
|
|
1315
|
+
/** Upper bound on the images one run carries. */
|
|
1316
|
+
const MAX_INPUT_IMAGES = 20;
|
|
1317
|
+
/**
|
|
1318
|
+
* Instructions a run carries when its caller supplies none.
|
|
1319
|
+
*
|
|
1320
|
+
* In print mode the official CLI chooses its own identity line from whether
|
|
1321
|
+
* it was given appended instructions: with them it introduces itself to the
|
|
1322
|
+
* model as "Claude Code, Anthropic's official CLI for Claude, running within
|
|
1323
|
+
* the Claude Agent SDK"; without them as "a Claude agent, built on Anthropic's
|
|
1324
|
+
* Claude Agent SDK". PackyCode routes Claude only to Claude Code, and a run
|
|
1325
|
+
* without instructions was refused upstream as not coming from it, so every
|
|
1326
|
+
* run states what it was delegated to do.
|
|
1327
|
+
*/
|
|
1328
|
+
const DEFAULT_APPEND_SYSTEM_PROMPT = "This task was delegated to Claude Code by DeepSeek Harness. Complete it with your own tools and reply with the final result.";
|
|
750
1329
|
const KILL_GRACE_MS = 3e3;
|
|
751
1330
|
/** The npm package the official CLI ships as. */
|
|
752
1331
|
const CLAUDE_NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
@@ -862,34 +1441,74 @@ function resolveSpawnCommand(binary, { platform = process.platform, exists = exi
|
|
|
862
1441
|
}
|
|
863
1442
|
/**
|
|
864
1443
|
* Build the CLI arguments for one delegated task.
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
1444
|
+
*
|
|
1445
|
+
* The prompt is not among them: it travels over stdin (see
|
|
1446
|
+
* {@link buildClaudeInput}). An argv is bounded — about 32K UTF-16 units on
|
|
1447
|
+
* Windows — and cannot carry an image; stdin carries both.
|
|
1448
|
+
* @param options - the permission mode, an optional model, whether the caller
|
|
1449
|
+
* consumes the streaming protocol, and instructions appended to the CLI's
|
|
1450
|
+
* own system prompt ({@link DEFAULT_APPEND_SYSTEM_PROMPT} when blank). The
|
|
1451
|
+
* CLI's system prompt and tool set are never replaced: they are part of
|
|
1452
|
+
* what the official client sends.
|
|
869
1453
|
* @returns argv after the binary name.
|
|
870
1454
|
*/
|
|
871
|
-
function buildClaudeArgs({
|
|
872
|
-
const text = typeof prompt === "string" ? prompt : "";
|
|
873
|
-
if (text.trim() === "") throw new Error("claude_code needs a non-empty prompt");
|
|
874
|
-
if (text.length > MAX_PROMPT_CHARS) throw new Error(`claude_code prompt is too long (${text.length} > ${MAX_PROMPT_CHARS} characters)`);
|
|
1455
|
+
function buildClaudeArgs({ mode = DEFAULT_CLAUDE_MODE, model, stream = false, appendSystemPrompt } = {}) {
|
|
875
1456
|
const spec = CLAUDE_CLI_MODES[mode];
|
|
876
1457
|
if (spec === void 0) throw new Error(`claude_code mode must be one of ${Object.keys(CLAUDE_CLI_MODES).join(", ")}`);
|
|
877
|
-
const args = [
|
|
1458
|
+
const args = stream ? [
|
|
1459
|
+
"-p",
|
|
1460
|
+
"--input-format",
|
|
1461
|
+
"stream-json",
|
|
1462
|
+
"--output-format",
|
|
1463
|
+
"stream-json",
|
|
1464
|
+
"--include-partial-messages",
|
|
1465
|
+
"--verbose"
|
|
1466
|
+
] : [
|
|
878
1467
|
"-p",
|
|
879
|
-
text,
|
|
880
1468
|
"--output-format",
|
|
881
|
-
|
|
1469
|
+
"json"
|
|
882
1470
|
];
|
|
883
|
-
if (stream) args.push("--include-partial-messages", "--verbose");
|
|
884
1471
|
const named = typeof model === "string" ? model.trim() : "";
|
|
885
1472
|
if (named !== "") args.push("--model", named);
|
|
886
1473
|
const appended = typeof appendSystemPrompt === "string" ? appendSystemPrompt.trim() : "";
|
|
887
|
-
|
|
1474
|
+
args.push("--append-system-prompt", appended === "" ? DEFAULT_APPEND_SYSTEM_PROMPT : appended);
|
|
888
1475
|
if (spec.skipPermissions === true) args.push("--dangerously-skip-permissions");
|
|
889
1476
|
else args.push("--allowedTools", ...spec.allowedTools);
|
|
890
1477
|
return args;
|
|
891
1478
|
}
|
|
892
1479
|
/**
|
|
1480
|
+
* Build what the CLI reads from stdin for one run.
|
|
1481
|
+
*
|
|
1482
|
+
* A streaming run reads one `stream-json` user message, whose content may mix
|
|
1483
|
+
* text and Anthropic image blocks; the CLI forwards those images to the model
|
|
1484
|
+
* as images. A non-streaming run reads plain prompt text.
|
|
1485
|
+
* @param options - the prompt text, or `content` blocks (`text` and
|
|
1486
|
+
* `image` with a base64 `source`), and whether the run streams.
|
|
1487
|
+
* @returns the stdin payload.
|
|
1488
|
+
*/
|
|
1489
|
+
function buildClaudeInput({ prompt, content, stream = false } = {}) {
|
|
1490
|
+
const blocks = Array.isArray(content) ? content.filter((block) => block?.type === "text" && typeof block.text === "string" && block.text !== "" || block?.type === "image") : [{
|
|
1491
|
+
type: "text",
|
|
1492
|
+
text: typeof prompt === "string" ? prompt : ""
|
|
1493
|
+
}];
|
|
1494
|
+
const text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1495
|
+
const images = blocks.filter((block) => block.type === "image");
|
|
1496
|
+
if (text.trim() === "" && images.length === 0) throw new Error("claude_code needs a non-empty prompt");
|
|
1497
|
+
if (text.length > MAX_PROMPT_CHARS) throw new Error(`claude_code prompt is too long (${text.length} > ${MAX_PROMPT_CHARS} characters)`);
|
|
1498
|
+
if (images.length > MAX_INPUT_IMAGES) throw new Error(`claude_code carries at most ${MAX_INPUT_IMAGES} images per run (got ${images.length})`);
|
|
1499
|
+
if (!stream) {
|
|
1500
|
+
if (images.length > 0) throw new Error("claude_code images need the streaming CLI input");
|
|
1501
|
+
return text;
|
|
1502
|
+
}
|
|
1503
|
+
return `${JSON.stringify({
|
|
1504
|
+
type: "user",
|
|
1505
|
+
message: {
|
|
1506
|
+
role: "user",
|
|
1507
|
+
content: blocks
|
|
1508
|
+
}
|
|
1509
|
+
})}\n`;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
893
1512
|
* The child's environment: this plugin's endpoint and key, an isolated config
|
|
894
1513
|
* home, and the CLI's own network-noise switches off.
|
|
895
1514
|
* @param options - credential, config home, and the parent environment.
|
|
@@ -1048,6 +1667,25 @@ function readClaudeStreamEvents(event) {
|
|
|
1048
1667
|
id: String(block.tool_use_id ?? ""),
|
|
1049
1668
|
text: oneLine(toolResultText(block.content))
|
|
1050
1669
|
}));
|
|
1670
|
+
if (event.type === "system") {
|
|
1671
|
+
if (event.subtype === "init") return [{
|
|
1672
|
+
type: "started",
|
|
1673
|
+
version: typeof event.claude_code_version === "string" ? event.claude_code_version : "",
|
|
1674
|
+
model: typeof event.model === "string" ? event.model : ""
|
|
1675
|
+
}];
|
|
1676
|
+
if (event.subtype === "api_retry") {
|
|
1677
|
+
const count = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1678
|
+
return [{
|
|
1679
|
+
type: "retry",
|
|
1680
|
+
attempt: count(event.attempt),
|
|
1681
|
+
maxRetries: count(event.max_retries),
|
|
1682
|
+
delayMs: count(event.retry_delay_ms),
|
|
1683
|
+
status: count(event.error_status),
|
|
1684
|
+
error: typeof event.error === "string" ? event.error : ""
|
|
1685
|
+
}];
|
|
1686
|
+
}
|
|
1687
|
+
return [];
|
|
1688
|
+
}
|
|
1051
1689
|
if (event.type === "result") return [{
|
|
1052
1690
|
type: "result",
|
|
1053
1691
|
result: event
|
|
@@ -1058,24 +1696,29 @@ function readClaudeStreamEvents(event) {
|
|
|
1058
1696
|
* A Claude Code CLI runner.
|
|
1059
1697
|
* @param options - binary (a path, or a function resolving one per run so a
|
|
1060
1698
|
* CLI installed after startup is picked up), config home, credential
|
|
1061
|
-
* resolution, and process
|
|
1062
|
-
*
|
|
1699
|
+
* resolution, the overall and (streaming) startup time limits, and process
|
|
1700
|
+
* seams.
|
|
1701
|
+
* @returns `{ run(options), stream(options) }`.
|
|
1063
1702
|
*/
|
|
1064
|
-
function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, platform = process.platform } = {}) {
|
|
1703
|
+
function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, startupTimeoutMs = DEFAULT_CLAUDE_STARTUP_TIMEOUT_MS, platform = process.platform } = {}) {
|
|
1065
1704
|
if (typeof resolveCredential !== "function") throw new Error("the Claude Code CLI runner needs resolveCredential");
|
|
1066
1705
|
/**
|
|
1067
1706
|
* Spawn one CLI process and own its lifetime: capture both pipes, enforce the
|
|
1068
1707
|
* timeout, and kill the process — with a grace period — when the caller
|
|
1069
1708
|
* aborts. Both public entry points share it.
|
|
1070
1709
|
*/
|
|
1071
|
-
const start = async ({ prompt, mode, model, appendSystemPrompt, cwd, signal, stream, onStdout }) => {
|
|
1710
|
+
const start = async ({ prompt, content, mode, model, appendSystemPrompt, cwd, signal, stream, onStdout }) => {
|
|
1072
1711
|
const args = buildClaudeArgs({
|
|
1073
|
-
prompt,
|
|
1074
1712
|
mode,
|
|
1075
1713
|
model,
|
|
1076
1714
|
stream,
|
|
1077
1715
|
appendSystemPrompt
|
|
1078
1716
|
});
|
|
1717
|
+
const input = buildClaudeInput({
|
|
1718
|
+
prompt,
|
|
1719
|
+
content,
|
|
1720
|
+
stream
|
|
1721
|
+
});
|
|
1079
1722
|
const { baseUrl, token } = await resolveCredential();
|
|
1080
1723
|
const program = typeof binary === "function" ? await binary() : binary;
|
|
1081
1724
|
const command = resolveSpawnCommand(program, { platform });
|
|
@@ -1088,16 +1731,20 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1088
1731
|
cwd,
|
|
1089
1732
|
env,
|
|
1090
1733
|
stdio: [
|
|
1091
|
-
"
|
|
1734
|
+
"pipe",
|
|
1092
1735
|
"pipe",
|
|
1093
1736
|
"pipe"
|
|
1094
1737
|
],
|
|
1095
1738
|
windowsHide: true
|
|
1096
1739
|
});
|
|
1740
|
+
child.stdin?.on?.("error", () => void 0);
|
|
1741
|
+
child.stdin?.end?.(input);
|
|
1097
1742
|
const state = {
|
|
1098
1743
|
stdout: "",
|
|
1099
1744
|
stderr: "",
|
|
1100
1745
|
timedOut: false,
|
|
1746
|
+
silent: false,
|
|
1747
|
+
sawOutput: false,
|
|
1101
1748
|
settled: false
|
|
1102
1749
|
};
|
|
1103
1750
|
const kill = () => {
|
|
@@ -1114,17 +1761,30 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1114
1761
|
kill();
|
|
1115
1762
|
}, timeoutMs) : void 0;
|
|
1116
1763
|
timer?.unref?.();
|
|
1764
|
+
let startupTimer = stream && startupTimeoutMs > 0 ? setTimeout(() => {
|
|
1765
|
+
if (state.sawOutput) return;
|
|
1766
|
+
state.silent = true;
|
|
1767
|
+
kill();
|
|
1768
|
+
}, startupTimeoutMs) : void 0;
|
|
1769
|
+
startupTimer?.unref?.();
|
|
1770
|
+
const clearStartup = () => {
|
|
1771
|
+
if (startupTimer !== void 0) clearTimeout(startupTimer);
|
|
1772
|
+
startupTimer = void 0;
|
|
1773
|
+
};
|
|
1117
1774
|
const onAbort = () => kill();
|
|
1118
1775
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1119
1776
|
const release = () => {
|
|
1120
1777
|
if (state.settled) return;
|
|
1121
1778
|
state.settled = true;
|
|
1122
1779
|
if (timer !== void 0) clearTimeout(timer);
|
|
1780
|
+
clearStartup();
|
|
1123
1781
|
signal?.removeEventListener("abort", onAbort);
|
|
1124
1782
|
};
|
|
1125
1783
|
child.stdout?.setEncoding?.("utf8");
|
|
1126
1784
|
child.stderr?.setEncoding?.("utf8");
|
|
1127
1785
|
child.stdout?.on("data", (chunk) => {
|
|
1786
|
+
state.sawOutput = true;
|
|
1787
|
+
clearStartup();
|
|
1128
1788
|
const text = String(chunk);
|
|
1129
1789
|
state.stdout = (state.stdout + text).slice(-4194304);
|
|
1130
1790
|
onStdout?.(text);
|
|
@@ -1143,6 +1803,11 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1143
1803
|
reject(/* @__PURE__ */ new Error(`claude_code timed out after ${Math.round(timeoutMs / 1e3)}s`));
|
|
1144
1804
|
return;
|
|
1145
1805
|
}
|
|
1806
|
+
if (state.silent) {
|
|
1807
|
+
const detail = state.stderr.trim().split("\n").slice(-4).join(" ").slice(0, 400);
|
|
1808
|
+
reject(/* @__PURE__ */ new Error(`Claude Code CLI printed nothing within ${Math.round(startupTimeoutMs / 1e3)}s of starting and was stopped${detail === "" ? "" : `: ${detail}`}`));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1146
1811
|
if (signal?.aborted === true) {
|
|
1147
1812
|
reject(/* @__PURE__ */ new Error("claude_code was aborted"));
|
|
1148
1813
|
return;
|
|
@@ -1167,12 +1832,13 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1167
1832
|
* @param options - prompt, mode, model, working directory, and cancellation.
|
|
1168
1833
|
* @returns the CLI's answer plus its own accounting.
|
|
1169
1834
|
*/
|
|
1170
|
-
async run({ prompt, mode = DEFAULT_CLAUDE_MODE, model, cwd, signal } = {}) {
|
|
1835
|
+
async run({ prompt, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1171
1836
|
const startedAt = Date.now();
|
|
1172
1837
|
const { state, closed } = await start({
|
|
1173
1838
|
prompt,
|
|
1174
1839
|
mode,
|
|
1175
1840
|
model,
|
|
1841
|
+
appendSystemPrompt,
|
|
1176
1842
|
cwd,
|
|
1177
1843
|
signal,
|
|
1178
1844
|
stream: false
|
|
@@ -1196,18 +1862,19 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1196
1862
|
* Run one delegated task, yielding its answer as the CLI produces it.
|
|
1197
1863
|
* The final `result` event carries the CLI's own accounting, summed over
|
|
1198
1864
|
* every request the CLI made.
|
|
1199
|
-
* @param options - as {@link run}, plus optional `
|
|
1200
|
-
*
|
|
1865
|
+
* @param options - as {@link run}, plus optional `content` blocks (text
|
|
1866
|
+
* and base64 images) that replace `prompt`.
|
|
1201
1867
|
* @returns an async generator of {@link readClaudeStreamEvents} events and
|
|
1202
1868
|
* one closing `result` event.
|
|
1203
1869
|
*/
|
|
1204
|
-
async *stream({ prompt, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1870
|
+
async *stream({ prompt, content, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1205
1871
|
const startedAt = Date.now();
|
|
1206
1872
|
let buffer = "";
|
|
1207
1873
|
let sawResult;
|
|
1208
1874
|
const lines = [];
|
|
1209
1875
|
const { state, closed, kill } = await start({
|
|
1210
1876
|
prompt,
|
|
1877
|
+
content,
|
|
1211
1878
|
mode,
|
|
1212
1879
|
model,
|
|
1213
1880
|
appendSystemPrompt,
|
|
@@ -2486,110 +3153,6 @@ const PACKYCODE_MODELS = Object.freeze([
|
|
|
2486
3153
|
/** Every model id this catalog advertises, in picker order. */
|
|
2487
3154
|
const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
|
|
2488
3155
|
//#endregion
|
|
2489
|
-
//#region src/failures.js
|
|
2490
|
-
/**
|
|
2491
|
-
* Failure classification for the two conditions that move the key ring along.
|
|
2492
|
-
*
|
|
2493
|
-
* The gateway reports an exhausted key and a rejected key in Chinese, and the
|
|
2494
|
-
* harness classifies failures by fixed English/status wording it recognizes
|
|
2495
|
-
* (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
|
|
2496
|
-
* harness's retry policy re-run the step — and therefore what lets the retry
|
|
2497
|
-
* pick up the next key. Rewriting is honest about what happened: this plugin
|
|
2498
|
-
* knows the key's condition from the gateway's own words plus its balance
|
|
2499
|
-
* reader, so the sentence it substitutes says no more than that.
|
|
2500
|
-
*
|
|
2501
|
-
* @module dsh-packycode-api/failures
|
|
2502
|
-
*/
|
|
2503
|
-
/** Terminal quota, balance, or credit wording, in the gateway's own languages. */
|
|
2504
|
-
const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
|
|
2505
|
-
/** A rejected key names itself; a bare 403 does not, and must not disable one. */
|
|
2506
|
-
const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
|
|
2507
|
-
const INVALID_KEY_STATUS = /\b401\b/;
|
|
2508
|
-
/**
|
|
2509
|
-
* A gateway group that only serves its vendor's official client. These arrive
|
|
2510
|
-
* as 403, which the harness reads as an authentication failure and renders as
|
|
2511
|
-
* an invalid-API-key problem — the opposite of what happened, and a condition
|
|
2512
|
-
* no retry can clear.
|
|
2513
|
-
*/
|
|
2514
|
-
const CLIENT_POLICY_WORDING = /only accessible via the official|official claude cli|官方\s*claude\s*cli|请使用标准\s*codex\s*客户端|客户端存在异常|二次分发|API\s*转接/iu;
|
|
2515
|
-
/** The key's quota is exhausted; move to the next key. */
|
|
2516
|
-
const QUOTA_FAILURE = "quota";
|
|
2517
|
-
/** The gateway rejected the key itself; move to the next key. */
|
|
2518
|
-
const INVALID_KEY_FAILURE = "invalid-key";
|
|
2519
|
-
/**
|
|
2520
|
-
* Whether the gateway is refusing this client rather than this credential.
|
|
2521
|
-
* @param message - provider failure text, as pi-ai carries it.
|
|
2522
|
-
* @returns true when the route is gated to a vendor's official client.
|
|
2523
|
-
*/
|
|
2524
|
-
function classifyClientPolicy(message) {
|
|
2525
|
-
return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
|
|
2526
|
-
}
|
|
2527
|
-
/**
|
|
2528
|
-
* Classify a gateway failure message.
|
|
2529
|
-
*
|
|
2530
|
-
* Quota wording wins over a status token: an exhausted balance is often
|
|
2531
|
-
* reported as 402/403, and only the key's own rejection may read as 401. A
|
|
2532
|
-
* client-policy refusal is not a key fault and is classified separately.
|
|
2533
|
-
* @param message - provider failure text, as pi-ai carries it.
|
|
2534
|
-
* @returns the failure kind, or undefined when the key is not at fault.
|
|
2535
|
-
*/
|
|
2536
|
-
function classifyGatewayFailure(message) {
|
|
2537
|
-
const text = typeof message === "string" ? message : "";
|
|
2538
|
-
if (text === "") return void 0;
|
|
2539
|
-
if (classifyClientPolicy(text)) return void 0;
|
|
2540
|
-
if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
|
|
2541
|
-
if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
|
|
2542
|
-
if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
|
|
2543
|
-
}
|
|
2544
|
-
/**
|
|
2545
|
-
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
2546
|
-
*
|
|
2547
|
-
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
2548
|
-
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
2549
|
-
* 401/403 before it reads any wording, which would send the failure into a
|
|
2550
|
-
* category the retry policy does not re-run.
|
|
2551
|
-
* @param message - the original provider failure text.
|
|
2552
|
-
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
2553
|
-
* @returns text the harness routes to QUOTA or AUTH.
|
|
2554
|
-
*/
|
|
2555
|
-
/**
|
|
2556
|
-
* Explain a client-policy refusal so the user reads the real cause.
|
|
2557
|
-
*
|
|
2558
|
-
* The sentence must not read as any other failure the harness knows: it
|
|
2559
|
-
* deliberately carries no HTTP status and none of the words the classifier
|
|
2560
|
-
* matches for auth, quota, rate limits, timeouts, or transport faults. That
|
|
2561
|
-
* keeps the failure out of the retry policy — no retry can talk a group into
|
|
2562
|
-
* serving an API client — and lets the sentence reach the user verbatim, in
|
|
2563
|
-
* place of the "API key is invalid" the bare 403 would have produced.
|
|
2564
|
-
* @param keyName - the account name the balance reader saw, when known.
|
|
2565
|
-
* @returns the replacement sentence.
|
|
2566
|
-
*/
|
|
2567
|
-
function explainClientPolicyFailure() {
|
|
2568
|
-
return "PackyCode 拒绝了这个客户端:所选分组只允许厂商官方客户端调用(Claude 分组要求官方 Claude CLI,Codex 分组要求标准 Codex 客户端)。这不是 API Key 失效,重试也不会成功。请改用允许 API 调用的分组(例如 cc-sale 或 claude-officially),或改用 PackyCode 的 gpt-6.1-sol / deepseek-flash。 / This group only serves the vendor’s official client; the API key is fine and retrying cannot help.";
|
|
2569
|
-
}
|
|
2570
|
-
/**
|
|
2571
|
-
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
2572
|
-
*
|
|
2573
|
-
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
2574
|
-
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
2575
|
-
* 401/403 before it reads any wording, which would send the failure into a
|
|
2576
|
-
* category the retry policy does not re-run.
|
|
2577
|
-
* @param message - the original provider failure text.
|
|
2578
|
-
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
2579
|
-
* @param options - optional account name for the policy explanation.
|
|
2580
|
-
* @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
|
|
2581
|
-
*/
|
|
2582
|
-
function normalizeFailureMessage(message, kind, options = {}) {
|
|
2583
|
-
const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
|
|
2584
|
-
if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
|
|
2585
|
-
if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
|
|
2586
|
-
if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
|
|
2587
|
-
return text;
|
|
2588
|
-
}
|
|
2589
|
-
function neutralizeAuthStatus(text) {
|
|
2590
|
-
return text.replace(/\b(?:401|403)\b/gu, "HTTP");
|
|
2591
|
-
}
|
|
2592
|
-
//#endregion
|
|
2593
3156
|
//#region src/provider.js
|
|
2594
3157
|
/**
|
|
2595
3158
|
* The PackyCode pi-ai provider, built in code so the user configures no route.
|
|
@@ -2848,6 +3411,8 @@ const name = PLUGIN_ID;
|
|
|
2848
3411
|
* blocking on a service it will never see.
|
|
2849
3412
|
*/
|
|
2850
3413
|
const inject = ["llm", "credentials"];
|
|
3414
|
+
/** What a `claude_code` tool run is told about where its task came from. */
|
|
3415
|
+
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.";
|
|
2851
3416
|
/** Host overrides; a profile that never installs this section keeps the defaults. */
|
|
2852
3417
|
const Config = z.object({
|
|
2853
3418
|
apiHost: z.string().default(DEFAULT_API_HOST),
|
|
@@ -3072,7 +3637,8 @@ function apply(ctx, config) {
|
|
|
3072
3637
|
}),
|
|
3073
3638
|
runner: claudeRunner,
|
|
3074
3639
|
resolveMode: resolveCliMode,
|
|
3075
|
-
resolveCwd: resolveCliCwd
|
|
3640
|
+
resolveCwd: resolveCliCwd,
|
|
3641
|
+
resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
|
|
3076
3642
|
});
|
|
3077
3643
|
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
3078
3644
|
ctx.llm.registerConfigurableProviders([{
|
|
@@ -3192,6 +3758,7 @@ function apply(ctx, config) {
|
|
|
3192
3758
|
prompt: args.prompt,
|
|
3193
3759
|
mode: capClaudeMode(args.mode, cliModeCeiling(exec?.agent?.session)) ?? "edit",
|
|
3194
3760
|
model: args.model,
|
|
3761
|
+
appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
|
|
3195
3762
|
cwd,
|
|
3196
3763
|
signal: exec?.signal
|
|
3197
3764
|
});
|
|
@@ -3200,4 +3767,4 @@ function apply(ctx, config) {
|
|
|
3200
3767
|
});
|
|
3201
3768
|
}
|
|
3202
3769
|
//#endregion
|
|
3203
|
-
export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLI_BACKED_MODEL_IDS, Config, DEFAULT_API_HOST, DEFAULT_STATUS_HOST, DISPLAY_NAME, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SETTINGS_NAMESPACE, TITLE_MODEL_ID, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, capClaudeMode, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeRunner, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseTokenUsage, readClaudeStreamEvents, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, summarizeToolUse };
|
|
3770
|
+
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 };
|