@ljwei-stak/dsh-model-router 0.13.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.
Files changed (37) hide show
  1. package/.dsh-plugin/client.js +3092 -0
  2. package/.dsh-plugin/index.mjs +1651 -0
  3. package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
  4. package/.dsh-plugin/shared/harness-plan.mjs +179 -0
  5. package/.dsh-plugin/shared/livebench.mjs +264 -0
  6. package/.dsh-plugin/shared/model-profiles.mjs +142 -0
  7. package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
  8. package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
  9. package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
  10. package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
  11. package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
  12. package/.dsh-plugin/shared/router-state.mjs +207 -0
  13. package/.dsh-plugin/shared/router.mjs +1134 -0
  14. package/.dsh-plugin/shared/routing-presets.mjs +49 -0
  15. package/.dsh-plugin/shared/run-ledger.mjs +348 -0
  16. package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
  17. package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
  18. package/.dsh-plugin/shared/task-executors.mjs +1154 -0
  19. package/.dsh-plugin/shared/tool-health.mjs +311 -0
  20. package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
  21. package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
  22. package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
  23. package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
  24. package/CHANGELOG.md +36 -0
  25. package/INSTALLATION_GUIDE.zh.md +134 -0
  26. package/LICENSE +21 -0
  27. package/MIGRATION.md +53 -0
  28. package/README.i18n.yaml +3 -0
  29. package/README.md +424 -0
  30. package/README.zh.md +413 -0
  31. package/cordis.patch.yml +12 -0
  32. package/docs/assets/candidate-pruning.svg +80 -0
  33. package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
  34. package/docs/assets/router-only-0.12.0.png +0 -0
  35. package/docs/assets/routing-workflow.svg +96 -0
  36. package/docs/assets/workbench-usage.svg +119 -0
  37. package/package.json +161 -0
@@ -0,0 +1,1154 @@
1
+ /**
2
+ * Pluggable headless adapters for official vendor agent CLIs.
3
+ *
4
+ * A caller names a configured provider/model and a task. This module picks the
5
+ * vendor adapter, detects the fixed executable, passes a credential only into
6
+ * that process, runs in the given directory, and captures output under a
7
+ * timeout. The API fallback stays outside this file: the caller supplies it.
8
+ * No caller-supplied command, package, or argument list is accepted.
9
+ *
10
+ * Claude: https://code.claude.com/docs/en/headless
11
+ * Codex: https://learn.chatgpt.com/docs/non-interactive-mode
12
+ * Gemini: https://google-gemini.github.io/gemini-cli/docs/cli/headless.html
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+ import { StringDecoder } from 'node:string_decoder'
16
+ import { realpath, stat } from 'node:fs/promises'
17
+ import { isAbsolute, win32 } from 'node:path'
18
+ import { toolForProvider } from './official-tool-registry.mjs'
19
+ import { normalizeExecutionPreference } from './model-profiles.mjs'
20
+ import { BILLING_MODE_LABEL, billingPlan, detectQuotaExhaustion, vendorKey } from './subscription-billing.mjs'
21
+
22
+ export const MAX_TASK_BYTES = 64_000
23
+ const MAX_ANSWER_CHARS = 48_000
24
+ const MAX_OUTPUT_BYTES = 2_000_000
25
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000
26
+ const MAX_TIMEOUT_MS = 45 * 60_000
27
+ const PROBE_TIMEOUT_MS = 8_000
28
+ const STOP_GRACE_MS = 5_000
29
+ const CANCEL_GRACE_MS = 1_500
30
+ const IS_WINDOWS = process.platform === 'win32'
31
+
32
+ const BASE_ENV_KEYS = Object.freeze([
33
+ 'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'windir', 'ComSpec',
34
+ 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA',
35
+ 'PROGRAMDATA', 'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TERM',
36
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
37
+ 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS',
38
+ ])
39
+
40
+ const READ_ONLY_INSTRUCTION = 'Read the task supplied on standard input. Answer in the task language. Do not modify files.'
41
+
42
+ /** Portable headless adapters. Registry tools without an entry here use the verified runner. */
43
+ export const TASK_ADAPTERS = Object.freeze([
44
+ Object.freeze({
45
+ id: 'claude-code',
46
+ label: 'Claude Code',
47
+ executable: 'claude',
48
+ portable: true,
49
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
50
+ sessionEnv: Object.freeze(['CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR']),
51
+ format: 'claude-json',
52
+ buildArgs(modelId) {
53
+ return [
54
+ '-p', '--output-format', 'json', '--no-session-persistence',
55
+ '--permission-mode', 'dontAsk',
56
+ // `=` keeps these variadic flags from consuming the positional instruction.
57
+ '--tools=Read,Glob,Grep', '--disallowedTools=mcp__*',
58
+ ...(modelId ? ['--model', modelId] : []),
59
+ READ_ONLY_INSTRUCTION,
60
+ ]
61
+ },
62
+ }),
63
+ Object.freeze({
64
+ id: 'codex',
65
+ label: 'Codex CLI',
66
+ executable: 'codex',
67
+ portable: true,
68
+ apiKeyEnv: 'OPENAI_API_KEY',
69
+ sessionEnv: Object.freeze(['CODEX_API_KEY', 'CODEX_HOME']),
70
+ format: 'codex-jsonl',
71
+ buildArgs(modelId) {
72
+ return [
73
+ 'exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only',
74
+ ...(modelId ? ['--model', modelId] : []),
75
+ '-',
76
+ ]
77
+ },
78
+ }),
79
+ Object.freeze({
80
+ id: 'gemini',
81
+ label: 'Gemini CLI',
82
+ executable: 'gemini',
83
+ portable: true,
84
+ portableOnly: true,
85
+ apiKeyEnv: 'GEMINI_API_KEY',
86
+ sessionEnv: Object.freeze(['GOOGLE_API_KEY', 'GEMINI_CONFIG_DIR']),
87
+ format: 'gemini-json',
88
+ buildArgs(modelId) {
89
+ return [
90
+ '-p', READ_ONLY_INSTRUCTION, '--output-format', 'json',
91
+ ...(modelId ? ['-m', modelId] : []),
92
+ ]
93
+ },
94
+ }),
95
+ ])
96
+
97
+ const ADAPTER_BY_ID = new Map(TASK_ADAPTERS.map(adapter => [adapter.id, adapter]))
98
+
99
+ export function executionPreference(route) {
100
+ return normalizeExecutionPreference(route?.execution)
101
+ }
102
+
103
+ export function adapterForProvider(provider) {
104
+ const tool = toolForProvider(provider)
105
+ if (!tool) return null
106
+ return ADAPTER_BY_ID.get(tool.id) ?? Object.freeze({
107
+ id: tool.id,
108
+ label: tool.label,
109
+ executable: null,
110
+ portable: false,
111
+ apiKeyEnv: null,
112
+ sessionEnv: Object.freeze([]),
113
+ format: null,
114
+ buildArgs() { return [] },
115
+ })
116
+ }
117
+
118
+ export function adapterForToolId(toolId) {
119
+ return ADAPTER_BY_ID.get(String(toolId ?? '').trim()) ?? null
120
+ }
121
+
122
+ /**
123
+ * Why a task text cannot be sent to a CLI or the catalog API, in Chinese, or
124
+ * null. The limit is in UTF-8 bytes (CLI standard input), so the message also
125
+ * gives the character count: about 21,000 Chinese or 64,000 ASCII characters.
126
+ */
127
+ export function taskTextProblem(task) {
128
+ if (typeof task !== 'string' || !task.trim()) return '任务内容为空,请先描述任务。'
129
+ if (task.includes('\0')) return '任务内容包含不可见的空字符(\\0),请删除后重试。'
130
+ const bytes = Buffer.byteLength(task, 'utf8')
131
+ if (bytes > MAX_TASK_BYTES) {
132
+ return `任务内容过长:${[...task].length} 个字符(UTF-8 ${bytes} 字节),上限 ${MAX_TASK_BYTES} 字节(约 ${Math.floor(MAX_TASK_BYTES / 3)} 个中文字符或 ${MAX_TASK_BYTES} 个英文字符)。请精简任务,或把长资料放进工作区文件并在任务中引用路径。`
133
+ }
134
+ return null
135
+ }
136
+
137
+ function checkedTask(task) {
138
+ const problem = taskTextProblem(task)
139
+ if (problem) throw new TypeError(problem)
140
+ return task
141
+ }
142
+
143
+ /** Longest prefix of `value` within `maxBytes` UTF-8 bytes, never splitting a character. */
144
+ export function sliceUtf8(value, maxBytes) {
145
+ const textValue = String(value ?? '')
146
+ if (maxBytes <= 0) return ''
147
+ if (Buffer.byteLength(textValue, 'utf8') <= maxBytes) return textValue
148
+ let low = 0
149
+ let high = textValue.length
150
+ while (low < high) {
151
+ const mid = Math.ceil((low + high) / 2)
152
+ if (Buffer.byteLength(textValue.slice(0, mid), 'utf8') <= maxBytes) low = mid
153
+ else high = mid - 1
154
+ }
155
+ let end = low
156
+ const code = textValue.charCodeAt(end - 1)
157
+ if (code >= 0xd800 && code <= 0xdbff) end -= 1
158
+ return textValue.slice(0, end)
159
+ }
160
+
161
+ const TRUNCATED = '…(已截断)'
162
+
163
+ function truncatedTo(value, maxBytes) {
164
+ const textValue = String(value ?? '')
165
+ if (Buffer.byteLength(textValue, 'utf8') <= maxBytes) return textValue
166
+ return `${sliceUtf8(textValue, Math.max(0, maxBytes - Buffer.byteLength(TRUNCATED, 'utf8')))}${TRUNCATED}`
167
+ }
168
+
169
+ function checkedModel(modelId) {
170
+ if (modelId === undefined || modelId === null || modelId === '') return null
171
+ if (typeof modelId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/.test(modelId)) return null
172
+ return modelId
173
+ }
174
+
175
+ function checkedTimeout(timeoutMs) {
176
+ const value = timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : timeoutMs
177
+ if (!Number.isSafeInteger(value) || value < 1_000 || value > MAX_TIMEOUT_MS) {
178
+ throw new TypeError(`timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
179
+ }
180
+ return value
181
+ }
182
+
183
+ async function checkedWorkspace(workspace) {
184
+ if (typeof workspace !== 'string' || !isAbsolute(workspace) || workspace.includes('\0')) {
185
+ throw new TypeError('workspace must be an absolute directory path')
186
+ }
187
+ const canonical = await realpath(workspace)
188
+ if (!(await stat(canonical)).isDirectory()) throw new TypeError('workspace must be a directory')
189
+ return canonical
190
+ }
191
+
192
+ function cliModelFor(route, adapter) {
193
+ const explicit = checkedModel(route?.cliModel)
194
+ if (explicit) return explicit
195
+ if (adapter?.id === 'claude-code' || adapter?.id === 'codex' || adapter?.id === 'gemini') {
196
+ return checkedModel(route?.model)
197
+ }
198
+ return null
199
+ }
200
+
201
+ /** Variables that switch a CLI from its account login to API-key billing. */
202
+ const API_KEY_VARIABLES = Object.freeze({
203
+ 'claude-code': ['ANTHROPIC_API_KEY'],
204
+ codex: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
205
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
206
+ })
207
+
208
+ /**
209
+ * `credentialMode`: `default` injects a configured or inherited key;
210
+ * `session-only` strips every API-key variable so the CLI uses its own
211
+ * subscription login; `api-only` is `default` (the caller checks a key exists).
212
+ */
213
+ function childEnvironment(adapter, credentials, credentialMode = 'default') {
214
+ const env = {}
215
+ const sessionOnly = credentialMode === 'session-only'
216
+ const stripped = new Set(sessionOnly ? [...(API_KEY_VARIABLES[adapter?.id] ?? []), adapter?.apiKeyEnv].filter(Boolean) : [])
217
+ for (const key of BASE_ENV_KEYS) {
218
+ if (process.env[key] !== undefined) env[key] = process.env[key]
219
+ }
220
+ for (const key of adapter?.sessionEnv ?? []) {
221
+ if (process.env[key] !== undefined && !stripped.has(key)) env[key] = process.env[key]
222
+ }
223
+ if (sessionOnly) return { env, secret: '', credentialSource: 'cli-session' }
224
+ const supplied = typeof credentials?.apiKey === 'string' ? credentials.apiKey : ''
225
+ if (supplied && (supplied.includes('\0') || supplied.length > 4_096)) {
226
+ throw new TypeError('apiKey is not a usable credential')
227
+ }
228
+ const inherited = adapter?.apiKeyEnv ? process.env[adapter.apiKeyEnv] : ''
229
+ const apiKey = supplied || (typeof inherited === 'string' ? inherited : '')
230
+ if (apiKey && adapter?.apiKeyEnv) env[adapter.apiKeyEnv] = apiKey
231
+ return {
232
+ env,
233
+ secret: apiKey && adapter?.apiKeyEnv ? apiKey : '',
234
+ credentialSource: supplied ? 'configured-api-key' : apiKey ? 'process-environment' : 'cli-session',
235
+ }
236
+ }
237
+
238
+ function redact(value, secret) {
239
+ const text = typeof value === 'string' ? value : ''
240
+ if (!secret || !text.includes(secret)) return text
241
+ return text.split(secret).join('[redacted]')
242
+ }
243
+
244
+ function versionFromBanner(value) {
245
+ const match = /(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/.exec(String(value ?? ''))
246
+ return match ? match[1] : null
247
+ }
248
+
249
+ function parseClaude(stdout) {
250
+ const result = JSON.parse(stdout)
251
+ if (result.type !== 'result' || result.is_error === true || result.subtype !== 'success' || typeof result.result !== 'string') {
252
+ return { ok: false, error: 'Claude 未返回成功终态。' }
253
+ }
254
+ return { ok: true, answer: result.result }
255
+ }
256
+
257
+ function parseCodex(stdout) {
258
+ let answer = ''
259
+ let completed = false
260
+ let failed = false
261
+ for (const line of stdout.split(/\r?\n/u)) {
262
+ const trimmed = line.trim()
263
+ if (!trimmed) continue
264
+ const event = JSON.parse(trimmed)
265
+ // Top-level `error` events include transient "Reconnecting... n/5" notices; the turn event decides.
266
+ if (event.type === 'turn.failed') failed = true
267
+ if (event.type === 'turn.completed' && !failed) completed = true
268
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') {
269
+ answer = event.item.text
270
+ }
271
+ }
272
+ if (failed || !completed || !answer.trim()) return { ok: false, error: 'Codex 未返回完整成功终态和回答。' }
273
+ return { ok: true, answer }
274
+ }
275
+
276
+ function parseGemini(stdout) {
277
+ const result = JSON.parse(stdout)
278
+ if (result?.error) return { ok: false, error: String(result.error.message ?? result.error.type ?? 'Gemini CLI 返回错误。') }
279
+ if (typeof result?.response !== 'string' || !result.response.trim()) return { ok: false, error: 'Gemini CLI 未返回 response 文本。' }
280
+ return { ok: true, answer: result.response }
281
+ }
282
+
283
+ function parseAdapterOutput(format, stdout) {
284
+ try {
285
+ if (format === 'claude-json') return parseClaude(stdout)
286
+ if (format === 'codex-jsonl') return parseCodex(stdout)
287
+ if (format === 'gemini-json') return parseGemini(stdout)
288
+ } catch {
289
+ return { ok: false, error: '官方 CLI 输出无法解析。' }
290
+ }
291
+ return { ok: false, error: '该官方工具没有无界面输出解析器。' }
292
+ }
293
+
294
+ const DIAGNOSTIC_CHARS = 400
295
+
296
+ /** Shorten and scrub a CLI diagnostic before it reaches the user or the run ledger. */
297
+ export function redactDiagnostic(value, secret = '') {
298
+ const scrubbed = redact(String(value ?? ''), secret)
299
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-[redacted]')
300
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi, '$1[redacted]')
301
+ .replace(/AIza[0-9A-Za-z_-]{20,}/g, '[redacted]')
302
+ .replace(/\b(api[_-]?key|token|secret|password)(["'\s:=]+)[^\s"',}]{6,}/gi, '$1$2[redacted]')
303
+ .replace(/\s+/g, ' ')
304
+ .trim()
305
+ return scrubbed.length > DIAGNOSTIC_CHARS ? `${scrubbed.slice(0, DIAGNOSTIC_CHARS)}…` : scrubbed
306
+ }
307
+
308
+ function jsonLines(stdout) {
309
+ const events = []
310
+ for (const line of String(stdout ?? '').split(/\r?\n/u)) {
311
+ const trimmed = line.trim()
312
+ if (!trimmed) continue
313
+ try { events.push(JSON.parse(trimmed)) } catch { /* not a protocol line */ }
314
+ }
315
+ return events
316
+ }
317
+
318
+ /**
319
+ * The vendor's own failure text: Claude's JSON `result`, Codex's
320
+ * `turn.failed.error.message`, Gemini's `error.message`, else a stderr tail.
321
+ */
322
+ export function failureDetail(format, stdout = '', stderr = '', secret = '') {
323
+ let detail = ''
324
+ try {
325
+ if (format === 'claude-json') {
326
+ const result = JSON.parse(stdout)
327
+ if (typeof result?.result === 'string' && result.result.trim()) detail = result.result
328
+ else if (typeof result?.error === 'string') detail = result.error
329
+ } else if (format === 'codex-jsonl') {
330
+ let failed = ''
331
+ let lastError = ''
332
+ for (const event of jsonLines(stdout)) {
333
+ if (event.type === 'turn.failed' && typeof event.error?.message === 'string') failed = event.error.message
334
+ if (event.type === 'error' && typeof event.message === 'string' && !/^Reconnecting\.\.\./u.test(event.message)) lastError = event.message
335
+ }
336
+ detail = failed || lastError
337
+ } else if (format === 'gemini-json') {
338
+ const result = JSON.parse(stdout)
339
+ detail = String(result?.error?.message ?? '')
340
+ }
341
+ } catch { /* fall through to stderr */ }
342
+ if (!detail.trim()) {
343
+ detail = String(stderr ?? '').split(/\r?\n/u).map(line => line.trim()).filter(Boolean).slice(-3).join(' | ')
344
+ }
345
+ return redactDiagnostic(detail, secret)
346
+ }
347
+
348
+ /** True when a diagnostic says the CLI has no usable account session or key. */
349
+ export function looksLikeLoginFailure(detail) {
350
+ return /not logged in|please run \/login|log ?in required|unauthori[sz]ed|\b401\b|missing bearer|invalid api key|authentication (?:failed|required)/i.test(String(detail ?? ''))
351
+ }
352
+
353
+ const count = value => Number.isFinite(value) && value >= 0 ? Math.round(value) : 0
354
+
355
+ /** Token usage reported by the CLI, in the Harness TokenUsage shape (disjoint cache counts). */
356
+ export function usageFromOutput(format, stdout = '') {
357
+ try {
358
+ if (format === 'claude-json') {
359
+ const result = JSON.parse(stdout)
360
+ const usage = result?.usage
361
+ if (!usage || typeof usage !== 'object') return null
362
+ const reported = Number(result.total_cost_usd)
363
+ return {
364
+ usage: {
365
+ inputTokens: count(usage.input_tokens),
366
+ outputTokens: count(usage.output_tokens),
367
+ cacheReadTokens: count(usage.cache_read_input_tokens),
368
+ cacheWriteTokens: count(usage.cache_creation_input_tokens),
369
+ },
370
+ ...(Number.isFinite(reported) && reported > 0 ? { reportedCostUsd: reported } : {}),
371
+ }
372
+ }
373
+ if (format === 'codex-jsonl') {
374
+ const completed = jsonLines(stdout).filter(event => event.type === 'turn.completed' && event.usage).at(-1)
375
+ if (!completed) return null
376
+ const cached = count(completed.usage.cached_input_tokens)
377
+ return { usage: {
378
+ inputTokens: Math.max(0, count(completed.usage.input_tokens) - cached),
379
+ outputTokens: count(completed.usage.output_tokens),
380
+ cacheReadTokens: cached,
381
+ cacheWriteTokens: 0,
382
+ } }
383
+ }
384
+ if (format === 'gemini-json') {
385
+ const models = JSON.parse(stdout)?.stats?.models
386
+ if (!models || typeof models !== 'object') return null
387
+ let input = 0
388
+ let output = 0
389
+ let cached = 0
390
+ for (const entry of Object.values(models)) {
391
+ input += count(entry?.tokens?.prompt)
392
+ output += count(entry?.tokens?.candidates)
393
+ cached += count(entry?.tokens?.cached)
394
+ }
395
+ return { usage: { inputTokens: Math.max(0, input - cached), outputTokens: output, cacheReadTokens: cached, cacheWriteTokens: 0 } }
396
+ }
397
+ } catch { /* usage is optional */ }
398
+ return null
399
+ }
400
+
401
+ /**
402
+ * Usage already parsed from vendor events (no JSON text):
403
+ * - 'grok-end': the `end`/`result` spend fields of `grok -p --output-format
404
+ * streaming-json` (input_tokens is uncached; total_cost_usd only when the
405
+ * server reported a complete cost, never with cost_is_partial);
406
+ * - 'mimo-steps': the `step-finish` parts of `mimo run --format json`
407
+ * (tokens.input/output/cache.read/cache.write, cost in USD), summed;
408
+ * - 'minimax-result': `exec.result.usage` of `mcode exec` (camelCase counts),
409
+ * ignored when usageSource is 'unavailable'.
410
+ */
411
+ export function usageFromEvents(format, value) {
412
+ if (format === 'grok-end') {
413
+ const usage = value?.usage
414
+ if (!usage || typeof usage !== 'object') return null
415
+ const reported = Number(value.total_cost_usd)
416
+ return {
417
+ usage: {
418
+ inputTokens: count(usage.input_tokens), outputTokens: count(usage.output_tokens),
419
+ cacheReadTokens: count(usage.cache_read_input_tokens), cacheWriteTokens: count(usage.cache_creation_input_tokens),
420
+ },
421
+ ...(value.cost_is_partial !== true && Number.isFinite(reported) && reported > 0 ? { reportedCostUsd: reported } : {}),
422
+ }
423
+ }
424
+ if (format === 'mimo-steps') {
425
+ const parts = (Array.isArray(value) ? value : []).filter(part => part?.tokens && typeof part.tokens === 'object')
426
+ if (!parts.length) return null
427
+ const sum = pick => parts.reduce((total, part) => total + count(pick(part)), 0)
428
+ const cost = parts.reduce((total, part) => total + (Number.isFinite(part.cost) && part.cost > 0 ? part.cost : 0), 0)
429
+ return {
430
+ usage: {
431
+ inputTokens: sum(part => part.tokens.input), outputTokens: sum(part => part.tokens.output) + sum(part => part.tokens.reasoning),
432
+ cacheReadTokens: sum(part => part.tokens.cache?.read), cacheWriteTokens: sum(part => part.tokens.cache?.write),
433
+ },
434
+ ...(cost > 0 ? { reportedCostUsd: Number(cost.toFixed(8)) } : {}),
435
+ }
436
+ }
437
+ if (format === 'minimax-result') {
438
+ if (!value?.usage || typeof value.usage !== 'object' || value.usageSource === 'unavailable') return null
439
+ const usage = value.usage
440
+ return { usage: {
441
+ inputTokens: count(usage.inputTokens), outputTokens: count(usage.outputTokens) + count(usage.reasoningTokens),
442
+ cacheReadTokens: count(usage.cacheReadTokens), cacheWriteTokens: count(usage.cacheWriteTokens),
443
+ }, ...(value.usageIncomplete === true ? { usageIncomplete: true } : {}) }
444
+ }
445
+ return null
446
+ }
447
+
448
+ /**
449
+ * Signal a CLI and every process it started. POSIX: the child leads its own
450
+ * process group (spawned detached), so the group is signalled. Windows:
451
+ * `taskkill /T /F` ends the process tree. Falls back to the child alone.
452
+ */
453
+ export function killProcessTree(child, signal = 'SIGTERM', { tree = true, platform = process.platform, killImpl = process.kill.bind(process), spawnTaskkill = spawn } = {}) {
454
+ const pid = tree && Number.isSafeInteger(child?.pid) && child.pid > 0 ? child.pid : null
455
+ if (pid && platform === 'win32') {
456
+ try {
457
+ // Absolute System32 path, like the signed runner: a PATH entry cannot shadow taskkill.
458
+ const taskkill = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe')
459
+ const killer = spawnTaskkill(taskkill, ['/PID', String(pid), '/T', '/F'], { windowsHide: true, shell: false, stdio: 'ignore' })
460
+ killer?.on?.('error', () => { try { child.kill(signal) } catch { /* already gone */ } })
461
+ return true
462
+ } catch { /* fall through */ }
463
+ } else if (pid) {
464
+ try { killImpl(-pid, signal); return true } catch { /* not a group leader, or already gone */ }
465
+ }
466
+ try { child?.kill?.(signal) } catch { /* already gone */ }
467
+ return false
468
+ }
469
+
470
+ function captureProcess(spawnImpl, file, args, { cwd, env, stdin, timeoutMs, signal, onChunk, secret, stopGraceMs = STOP_GRACE_MS }) {
471
+ return new Promise(resolve => {
472
+ let child
473
+ // Only a real child process has a process group / tree to signal (tests inject fakes).
474
+ const tree = spawnImpl === spawn
475
+ try {
476
+ // Own process group on POSIX so a stop also reaches the CLI's tool subprocesses.
477
+ child = spawnImpl(file, args, {
478
+ cwd, env, windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'], detached: tree && !IS_WINDOWS,
479
+ })
480
+ } catch (error) {
481
+ resolve({ ok: false, exitCode: null, stdout: '', stderr: '', spawnError: error?.code ?? error?.message ?? 'spawn-failed', timedOut: false, cancelled: false })
482
+ return
483
+ }
484
+ const stdoutDecoder = new StringDecoder('utf8')
485
+ const stderrDecoder = new StringDecoder('utf8')
486
+ let stdout = ''
487
+ let stderr = ''
488
+ let bytes = 0
489
+ let finished = false
490
+ let stopReason = null
491
+ let exitCode = null
492
+ let spawnError = null
493
+ const settle = () => {
494
+ if (finished) return
495
+ finished = true
496
+ clearTimeout(timer)
497
+ clearTimeout(graceTimer)
498
+ signal?.removeEventListener('abort', onAbort)
499
+ resolve({
500
+ ok: !stopReason && !spawnError && exitCode === 0,
501
+ exitCode, stdout, stderr, spawnError, timedOut: stopReason === 'timed-out',
502
+ cancelled: stopReason === 'cancelled', outputLimit: stopReason === 'output-limit',
503
+ })
504
+ }
505
+ let graceTimer
506
+ const stop = reason => {
507
+ if (finished || stopReason) return
508
+ stopReason = reason
509
+ killProcessTree(child, 'SIGTERM', { tree })
510
+ // A CLI that ignores SIGTERM is force-killed so it cannot linger after the fallback.
511
+ // A user cancel waits less than a timeout or output-limit stop.
512
+ graceTimer = setTimeout(() => {
513
+ killProcessTree(child, 'SIGKILL', { tree })
514
+ settle()
515
+ }, reason === 'cancelled' ? Math.min(stopGraceMs, CANCEL_GRACE_MS) : stopGraceMs)
516
+ graceTimer.unref?.()
517
+ }
518
+ const onAbort = () => stop('cancelled')
519
+ const timer = setTimeout(() => stop('timed-out'), timeoutMs)
520
+ signal?.addEventListener('abort', onAbort, { once: true })
521
+ if (signal?.aborted) onAbort()
522
+ child.stdout?.on('data', bytesIn => {
523
+ if (finished || stopReason) return
524
+ bytes += bytesIn.length
525
+ const chunk = redact(stdoutDecoder.write(bytesIn), secret)
526
+ stdout += chunk
527
+ if (chunk && typeof onChunk === 'function') onChunk(chunk)
528
+ if (bytes > MAX_OUTPUT_BYTES) stop('output-limit')
529
+ })
530
+ child.stderr?.on('data', bytesIn => {
531
+ if (finished || stopReason) return
532
+ bytes += bytesIn.length
533
+ stderr += redact(stderrDecoder.write(bytesIn), secret)
534
+ if (bytes > MAX_OUTPUT_BYTES) stop('output-limit')
535
+ })
536
+ if (typeof child.stdin?.on === 'function') child.stdin.on('error', () => { /* process may exit before reading */ })
537
+ child.on('error', error => {
538
+ spawnError = error?.code ?? error?.message ?? 'spawn-failed'
539
+ settle()
540
+ })
541
+ child.on('exit', () => {
542
+ // The CLI is gone: stop whatever it left behind in its group after a stop.
543
+ if (stopReason) killProcessTree(child, 'SIGKILL', { tree })
544
+ })
545
+ child.on('close', code => {
546
+ exitCode = code
547
+ const restOut = redact(stdoutDecoder.end(), secret)
548
+ const restErr = redact(stderrDecoder.end(), secret)
549
+ if (restOut) {
550
+ stdout += restOut
551
+ if (typeof onChunk === 'function') onChunk(restOut)
552
+ }
553
+ stderr += restErr
554
+ settle()
555
+ })
556
+ if (!stopReason) {
557
+ try { if (typeof child.stdin?.end === 'function') child.stdin.end(stdin ?? '', 'utf8') } catch { /* stdin may already be closed */ }
558
+ }
559
+ })
560
+ }
561
+
562
+ async function useApi(apiFallback, { route, task, signal, reason, adapter, preference, exitCode = null, timedOut = false, detail = '', raw = '', skipped = false, attempted = false }) {
563
+ const provider = String(route?.provider ?? '')
564
+ const model = String(route?.model ?? '')
565
+ const fallback = {
566
+ reason,
567
+ ...(detail ? { error: detail } : {}),
568
+ ...(detail && looksLikeLoginFailure(detail) ? { loginRequired: true } : {}),
569
+ ...(skipped ? { skipped: true } : {}),
570
+ }
571
+ if (typeof apiFallback !== 'function') {
572
+ return {
573
+ ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
574
+ answer: '', fallback, timedOut, exitCode, error: reason,
575
+ }
576
+ }
577
+ try {
578
+ const api = await apiFallback({ route, task, signal, reason, detail, raw: String(raw ?? '').slice(-40_000), toolId: adapter?.id ?? null, attempted, timedOut, exitCode })
579
+ // A billing-aware fallback can pause the step for the user's decision.
580
+ if (api?.paused) {
581
+ return {
582
+ ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter?.id ?? null, answer: '',
583
+ fallback: null, timedOut, exitCode, paused: true,
584
+ pause: { ...api.pause, ...(detail ? { detail } : {}), ...(fallback.loginRequired ? { loginRequired: true } : {}) },
585
+ error: api.error,
586
+ }
587
+ }
588
+ const answer = String(api?.answer ?? '').slice(0, MAX_ANSWER_CHARS)
589
+ if (signal?.aborted && !(api?.ok === true && answer.trim())) {
590
+ return {
591
+ ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
592
+ answer: '', fallback, timedOut, exitCode, error: '执行已取消。', cancelled: true,
593
+ ...(api?.usage ? { usage: api.usage } : {}),
594
+ }
595
+ }
596
+ // A billing-aware fallback can refuse (subscription-only) or explain a switch.
597
+ const note = api?.billingNote ?? null
598
+ return {
599
+ ok: api?.ok === true && answer.trim().length > 0,
600
+ provider, model, preference, channel: api?.refused ? 'official-cli' : 'harness-llm', toolId: adapter?.id ?? null,
601
+ answer, fallback: note?.reason ? { ...fallback, reason: note.reason } : api?.refused ? { ...fallback, reason: api.error } : fallback, timedOut, exitCode,
602
+ ...(api?.usage ? { usage: api.usage } : {}),
603
+ ...(note ? { billingSwitch: note } : {}),
604
+ ...(api?.billing ? { billing: api.billing } : {}),
605
+ error: api?.ok === true && answer.trim() ? undefined : (api?.error || reason),
606
+ }
607
+ } catch (error) {
608
+ if (signal?.aborted) {
609
+ return {
610
+ ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
611
+ answer: '', fallback, timedOut, exitCode, error: '执行已取消。', cancelled: true,
612
+ }
613
+ }
614
+ return {
615
+ ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
616
+ answer: '', fallback, timedOut, exitCode, error: String(error?.message ?? error),
617
+ }
618
+ }
619
+ }
620
+
621
+ function officialSuccess({ route, adapter, preference, answer, exitCode, version, credentialSource, usage = null }) {
622
+ return {
623
+ ok: true,
624
+ provider: route.provider,
625
+ model: route.model,
626
+ preference,
627
+ channel: 'official-cli',
628
+ toolId: adapter.id,
629
+ toolLabel: adapter.label,
630
+ answer: answer.slice(0, MAX_ANSWER_CHARS),
631
+ truncated: answer.length > MAX_ANSWER_CHARS,
632
+ fallback: null,
633
+ timedOut: false,
634
+ exitCode,
635
+ version: version ?? null,
636
+ credentialSource,
637
+ ...(usage?.usage ? { usage: usage.usage } : {}),
638
+ ...(usage?.reportedCostUsd ? { reportedCostUsd: usage.reportedCostUsd } : {}),
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Run one assigned model task. `auto` and `official` try the vendor CLI first.
644
+ * `api` skips the CLI. A missing tool or a failed process uses `apiFallback`.
645
+ */
646
+ export async function executeAssignedTask({
647
+ route, task, workspace, timeoutMs, signal, credentials = null, spawnImpl = spawn,
648
+ apiFallback, runVerified = null, onChunk = null, skipOfficial = null, stopGraceMs, credentialMode = 'default',
649
+ } = {}) {
650
+ const prompt = checkedTask(task)
651
+ const preference = executionPreference(route)
652
+ const provider = String(route?.provider ?? '')
653
+ const model = String(route?.model ?? '')
654
+ if (!provider || !model) throw new TypeError('a configured provider and model are required')
655
+ if (preference === 'api') {
656
+ return useApi(apiFallback, { route, task: prompt, signal, reason: '该模型配置为只使用模型目录 API。', preference })
657
+ }
658
+ const adapter = adapterForProvider(provider)
659
+ if (!adapter) {
660
+ return useApi(apiFallback, {
661
+ route, task: prompt, signal, preference,
662
+ reason: '该供应商没有官方代理工具,已使用模型目录 API。',
663
+ })
664
+ }
665
+ if (credentialMode === 'api-only' && !childEnvironment(adapter, credentials).secret) {
666
+ return useApi(apiFallback, {
667
+ route, task: prompt, signal, adapter, preference, skipped: true,
668
+ reason: `按设置只用 API Key:${adapter.label} 没有可注入的 API Key,已使用模型目录 API,未使用订阅登录。`,
669
+ })
670
+ }
671
+ // A cached health check (for example "not logged in") skips the CLI at once.
672
+ const skipReason = typeof skipOfficial === 'function'
673
+ ? skipOfficial({ toolId: adapter.id, hasApiKey: Boolean(childEnvironment(adapter, credentials, credentialMode).secret) })
674
+ : null
675
+ if (typeof skipReason === 'string' && skipReason) {
676
+ return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, reason: skipReason, skipped: true })
677
+ }
678
+ const cwd = await checkedWorkspace(workspace)
679
+ const timeout = checkedTimeout(timeoutMs)
680
+ const modelId = cliModelFor(route, adapter)
681
+ if (typeof runVerified === 'function' && adapter.portableOnly !== true) {
682
+ const verified = await runVerified({
683
+ toolId: adapter.id, task: prompt, workspace: cwd, modelId, mode: 'read-only', signal, timeoutMs: timeout,
684
+ ...(credentialMode === 'session-only' ? { sessionOnly: true } : {}),
685
+ })
686
+ if (verified?.status === 'succeeded' && typeof verified.finalText === 'string' && verified.finalText.trim()) {
687
+ const { secret, credentialSource } = childEnvironment(adapter, credentials, credentialMode)
688
+ return officialSuccess({
689
+ route, adapter, preference, answer: redact(verified.finalText, secret),
690
+ exitCode: verified.exitCode ?? 0, version: null, credentialSource,
691
+ })
692
+ }
693
+ if (verified && verified.status !== 'unsupported') {
694
+ return useApi(apiFallback, {
695
+ route, task: prompt, signal, adapter, preference, exitCode: verified.exitCode ?? null,
696
+ timedOut: verified.status === 'timed-out', attempted: true,
697
+ reason: verified.error || verified.reason || '官方 CLI 执行失败,已回退模型目录 API。',
698
+ detail: redactDiagnostic(verified.detail ?? verified.stderrTail ?? verified.stdoutTail ?? '', childEnvironment(adapter, credentials, credentialMode).secret),
699
+ raw: [verified.error, verified.reason, verified.detail, verified.stderrTail, verified.stdoutTail].filter(item => typeof item === 'string').join('\n'),
700
+ })
701
+ }
702
+ }
703
+ if (!adapter.portable || !adapter.executable) {
704
+ return useApi(apiFallback, {
705
+ route, task: prompt, signal, adapter, preference,
706
+ reason: `${adapter.label} 没有可用的无界面适配器,已使用模型目录 API。`,
707
+ })
708
+ }
709
+ const { env, secret, credentialSource } = childEnvironment(adapter, credentials, credentialMode)
710
+ const probe = await captureProcess(spawnImpl, adapter.executable, ['--version'], {
711
+ cwd, env, stdin: '', timeoutMs: Math.min(PROBE_TIMEOUT_MS, timeout), signal, secret,
712
+ })
713
+ if (probe.spawnError || probe.cancelled || probe.timedOut || probe.exitCode !== 0) {
714
+ const reason = probe.cancelled ? '执行已取消。'
715
+ : probe.spawnError ? `${adapter.label} 未安装或无法启动,已回退模型目录 API。`
716
+ : probe.timedOut ? `${adapter.label} 检测超时,已回退模型目录 API。`
717
+ : `${adapter.label} 无法运行,已回退模型目录 API。`
718
+ if (probe.cancelled) {
719
+ return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: null, error: reason, cancelled: true }
720
+ }
721
+ return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, reason })
722
+ }
723
+ const args = adapter.buildArgs(modelId)
724
+ const run = await captureProcess(spawnImpl, adapter.executable, args, {
725
+ cwd, env, stdin: prompt, timeoutMs: timeout, signal, onChunk, secret,
726
+ ...(Number.isSafeInteger(stopGraceMs) && stopGraceMs >= 10 && stopGraceMs <= STOP_GRACE_MS ? { stopGraceMs } : {}),
727
+ })
728
+ if (run.cancelled) {
729
+ return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: run.exitCode, error: '执行已取消。', cancelled: true }
730
+ }
731
+ if (!run.ok) {
732
+ return useApi(apiFallback, {
733
+ route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, timedOut: run.timedOut, attempted: true,
734
+ reason: run.timedOut ? `${adapter.label} 执行超时,已回退模型目录 API。`
735
+ : run.outputLimit ? `${adapter.label} 输出超过上限,已回退模型目录 API。`
736
+ : `${adapter.label} 执行失败,已回退模型目录 API。`,
737
+ detail: run.timedOut ? '' : failureDetail(adapter.format, run.stdout, run.stderr, secret),
738
+ raw: `${run.stdout.slice(-20_000)}\n${run.stderr.slice(-20_000)}`,
739
+ })
740
+ }
741
+ const parsed = parseAdapterOutput(adapter.format, run.stdout)
742
+ if (!parsed.ok) {
743
+ return useApi(apiFallback, {
744
+ route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true,
745
+ reason: `${parsed.error} 已回退模型目录 API。`,
746
+ detail: failureDetail(adapter.format, run.stdout, run.stderr, secret),
747
+ raw: `${run.stdout.slice(-20_000)}\n${run.stderr.slice(-20_000)}`,
748
+ })
749
+ }
750
+ const answer = redact(parsed.answer, secret)
751
+ if (!answer.trim()) {
752
+ return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true, reason: '官方 CLI 没有返回文本,已回退模型目录 API。' })
753
+ }
754
+ return officialSuccess({
755
+ route, adapter, preference, answer, exitCode: run.exitCode,
756
+ version: versionFromBanner(`${probe.stdout}\n${probe.stderr}`),
757
+ credentialSource,
758
+ usage: usageFromOutput(adapter.format, run.stdout),
759
+ })
760
+ }
761
+
762
+ const formatReset = at => {
763
+ if (!Number.isFinite(at)) return ''
764
+ const date = new Date(at)
765
+ const pad = value => String(value).padStart(2, '0')
766
+ return `${date.getMonth() + 1}-${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`
767
+ }
768
+
769
+ function switchReason(info, until) {
770
+ const reset = until ? `(预计 ${formatReset(until)} 恢复)` : ''
771
+ return info?.kind === 'rate-limit' ? `订阅通道触发限流${reset},已切换 API Key。` : `订阅额度已用尽${reset},已切换 API Key。`
772
+ }
773
+
774
+ /** What a subscription-first step does after a failure that is not quota / rate-limit exhaustion. */
775
+ export const SUBSCRIPTION_FAILURE_ACTIONS = Object.freeze(['ask', 'api', 'fail'])
776
+ export const DEFAULT_SUBSCRIPTION_FAILURE_ACTION = 'ask'
777
+ export const SUBSCRIPTION_CHOICES = Object.freeze(['api', 'subscription', 'cancel'])
778
+
779
+ const failureAction = billing => SUBSCRIPTION_FAILURE_ACTIONS.includes(billing?.onFailure) ? billing.onFailure : DEFAULT_SUBSCRIPTION_FAILURE_ACTION
780
+
781
+ function pauseFor(summary, detail, extra = {}) {
782
+ return {
783
+ kind: 'subscription-failure',
784
+ reason: `${summary} 这不是额度用尽或限流,已暂停此步骤等待你决定:改用 API 重试、重试订阅或取消。`,
785
+ detail: String(detail ?? '').slice(0, 2_000),
786
+ choices: [...SUBSCRIPTION_CHOICES],
787
+ ...extra,
788
+ }
789
+ }
790
+
791
+ const failureSummary = text => `${String(text ?? '').replace(/,?已(?:回退模型目录 API|改用 API Key)。?$/u, '').replace(/[。.]$/u, '') || '订阅调用失败'}。`
792
+
793
+ /**
794
+ * Subscription-first execution of one route. `billing` supplies:
795
+ * `quota` (tracker), `cooldownMinutes`, `extraPatterns`, `routes`,
796
+ * `loginBillingFor(toolId)` ('subscription' | 'api-key' | 'logged-out' |
797
+ * 'unknown'), and `now`. Without `billing` this is `executeAssignedTask`.
798
+ *
799
+ * - subscription-first: use the subscription (CLI account login without any
800
+ * API key, or a coding-plan key route). On quota / rate limit, mark it
801
+ * exhausted and retry the same step on the API-key route at once.
802
+ * - A failure that is not exhaustion (timeout, crash, parse error, non-zero
803
+ * exit, auth error) follows `billing.onFailure`: 'ask' (default) pauses the
804
+ * step for the user, 'api' falls back to the API key, 'fail' stops.
805
+ * `billing.choice` 'api' is the user's confirmation to use the API key.
806
+ * - subscription-only: never fall back to an API key.
807
+ * - api-only: never use the subscription.
808
+ */
809
+ export async function executeRouteWithBilling({ route, billing = null, apiFallback, ...options } = {}) {
810
+ const plan = billingPlan(route, billing?.routes ?? [])
811
+ // Every result names the billing mode it ran under, whichever branch produced it.
812
+ const result = await routeWithBilling(plan, { route, billing, apiFallback, ...options })
813
+ return { ...result, billingMode: result?.billingMode ?? plan.mode }
814
+ }
815
+
816
+ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...options } = {}) {
817
+ if (!billing) return executeAssignedTask({ ...options, route, apiFallback })
818
+ const now = typeof billing.now === 'function' ? billing.now : Date.now
819
+ const sub = plan.subscription
820
+ const apiRoute = plan.apiRoute
821
+ const label = BILLING_MODE_LABEL[plan.mode]
822
+ const provider = String(route?.provider ?? '')
823
+ const model = String(route?.model ?? '')
824
+ const base = { provider, model, billingMode: plan.mode }
825
+ const refuse = (error, extra = {}) => ({
826
+ ok: false, ...base, preference: executionPreference(route), channel: sub?.kind === 'plan-key' ? 'harness-llm' : 'official-cli',
827
+ toolId: sub?.toolId ?? null, answer: '', fallback: null, error, ...extra,
828
+ })
829
+ const viaApi = async (reason, note, skipped = false) => {
830
+ if (!apiRoute || (sub?.kind === 'plan-key' && apiRoute.provider === sub.route.provider && apiRoute.model === sub.route.model)) {
831
+ return refuse(`${reason.replace(/,?已切换 API Key。$/u, '。')}没有配置可回退的 API 路线(apiRoute)。`, note ? { billingSwitch: { ...note, to: null } } : {})
832
+ }
833
+ const result = await useApiRoute(apiFallback, { route: apiRoute, task: options.task, signal: options.signal, reason, skipped })
834
+ // Keep the API route that actually ran (pricing); the plan route is recorded separately.
835
+ return {
836
+ ...base, ...result, billingMode: plan.mode, billing: 'api',
837
+ ...(note ? { billingSwitch: { ...note, to: 'api' } } : {}),
838
+ ...(sub?.kind === 'plan-key' ? { subscriptionRoute: { ...sub.route } } : {}),
839
+ }
840
+ }
841
+ const classify = (text, vendor) => detectQuotaExhaustion(text, { vendor, provider: sub?.kind === 'plan-key' ? sub.route.provider : provider, extraPatterns: billing.extraPatterns ?? {}, now: now() })
842
+ const markExhausted = (info, detail) => billing.quota?.mark?.(sub.key, { ...info, detail }, { cooldownMinutes: billing.cooldownMinutes }) ?? null
843
+
844
+ if (!sub) {
845
+ if (plan.mode === 'subscription-only') return refuse(`${label}:该路线没有可用的订阅(CLI 账号登录或编程套餐路线)。`)
846
+ return executeAssignedTask({ ...options, route, apiFallback })
847
+ }
848
+ if (billing.choice === 'api') {
849
+ const reason = '已按你的确认改用 API Key 重试此步骤。'
850
+ if (plan.mode === 'subscription-only') return refuse('按设置只用订阅,不能改用 API Key。')
851
+ return viaApi(reason, { from: 'subscription', reason, kind: 'user-confirmed', until: null, detail: '' }, true)
852
+ }
853
+ if (plan.mode === 'api-only') {
854
+ if (sub.kind === 'plan-key') return viaApi('按设置只用 API Key,未使用编程套餐。', null, true)
855
+ return executeAssignedTask({ ...options, route, apiFallback, credentialMode: 'api-only' })
856
+ }
857
+ const exhausted = billing.quota?.status?.(sub.key) ?? null
858
+ if (exhausted) {
859
+ const reason = `订阅额度已用尽(预计 ${formatReset(exhausted.until)} 恢复),已直接使用 API Key。`
860
+ const note = { from: 'subscription', reason, kind: exhausted.kind, until: exhausted.until, detail: exhausted.detail ?? '', skippedSubscription: true }
861
+ if (plan.mode === 'subscription-only') return refuse(`订阅额度已用尽(预计 ${formatReset(exhausted.until)} 恢复);按设置只用订阅,未使用 API Key。`, { billingSwitch: { ...note, to: null } })
862
+ return viaApi(reason, note, true)
863
+ }
864
+
865
+ if (sub.kind === 'plan-key') {
866
+ let attempt
867
+ try { attempt = await apiFallback({ route: sub.route, task: options.task, signal: options.signal, reason: '编程套餐(订阅)', subscription: true }) }
868
+ catch (error) { attempt = { ok: false, error: String(error?.message ?? error) } }
869
+ if (attempt?.ok === true && String(attempt.answer ?? '').trim()) {
870
+ return {
871
+ ok: true, ...base, preference: executionPreference(route), channel: 'harness-llm', toolId: null,
872
+ answer: String(attempt.answer).slice(0, MAX_ANSWER_CHARS), fallback: null,
873
+ billing: 'subscription', subscriptionRoute: { ...sub.route },
874
+ ...(attempt.usage ? { usage: attempt.usage } : {}),
875
+ }
876
+ }
877
+ const detail = redactDiagnostic(attempt?.error ?? '')
878
+ const info = classify(String(attempt?.error ?? ''), vendorKey({ provider: sub.route.provider }))
879
+ if (options.signal?.aborted) return refuse('执行已取消。', { cancelled: true })
880
+ if (info) {
881
+ const entry = markExhausted(info, detail)
882
+ const reason = switchReason(info, entry?.until)
883
+ const note = { from: 'subscription', reason, kind: info.kind, until: entry?.until ?? null, detail, source: info.source }
884
+ if (plan.mode === 'subscription-only') return refuse(reason.replace('已切换 API Key。', '按设置只用订阅,未使用 API Key。'), { billingSwitch: { ...note, to: null }, subscriptionRoute: { ...sub.route } })
885
+ return viaApi(reason, note)
886
+ }
887
+ if (plan.mode === 'subscription-only') return refuse(`编程套餐路线调用失败:${detail || '未知错误'}`, { subscriptionRoute: { ...sub.route } })
888
+ const action = failureAction(billing)
889
+ if (action === 'fail') return refuse(`编程套餐路线调用失败:${detail || '未知错误'}。按设置不自动改用 API Key。`, { subscriptionRoute: { ...sub.route } })
890
+ if (action === 'ask') {
891
+ const pause = pauseFor(`编程套餐路线调用失败:${detail || '未知错误'}。`, detail)
892
+ return refuse(pause.reason, { paused: true, pause, subscriptionRoute: { ...sub.route } })
893
+ }
894
+ return viaApi('编程套餐路线调用失败,已改用 API Key。', { from: 'subscription', reason: '编程套餐路线调用失败,已改用 API Key。', kind: 'error', until: null, detail })
895
+ }
896
+
897
+ // CLI account login. "Retry subscription" means the user signed in again: ignore a cached logged-out state.
898
+ const retrySubscription = billing.choice === 'subscription'
899
+ const login = retrySubscription ? 'unknown'
900
+ : typeof billing.loginBillingFor === 'function' ? billing.loginBillingFor(sub.toolId) : 'unknown'
901
+ if (login === 'api-key' || login === 'logged-out') {
902
+ if (plan.mode === 'subscription-only') {
903
+ return refuse(login === 'logged-out' ? `${label}:官方 CLI 未登录订阅账号。` : `${label}:官方 CLI 当前以 API Key 计费,没有订阅登录。`)
904
+ }
905
+ // The user has this subscription (the CLI failed authentication while running on it, or
906
+ // the profile names a subscription billing mode): ask instead of silently paying by API key.
907
+ const loginInfo = typeof billing.loginDetailFor === 'function' ? billing.loginDetailFor(sub.toolId) : null
908
+ const action = failureAction(billing)
909
+ if (login === 'logged-out' && action !== 'api' && (loginInfo?.source === 'runtime-auth' || subscriptionDeclared(route, billing.routes))) {
910
+ const name = adapterForProvider(provider)?.label ?? sub.toolId
911
+ const why = loginInfo?.source === 'runtime-auth'
912
+ ? `${name} 的订阅登录已失效(上次运行报认证错误)`
913
+ : `${name} 未登录订阅账号(该路线配置为“${label}”)`
914
+ if (action === 'fail') return refuse(`${why},按设置不自动改用 API Key。请在终端登录后点“重新体检”。`, { loginRequired: true })
915
+ const pause = {
916
+ kind: 'subscription-login',
917
+ reason: `${why},未自动改用 API Key,已暂停此步骤:请先在终端登录再选“重试订阅”,或选“改用 API 重试”、“取消”。`,
918
+ detail: String(loginInfo?.detail ?? '').slice(0, 2_000),
919
+ choices: [...SUBSCRIPTION_CHOICES],
920
+ loginRequired: true,
921
+ }
922
+ return refuse(pause.reason, { paused: true, pause })
923
+ }
924
+ // No subscription login: the existing CLI-with-key / catalog API behaviour.
925
+ return executeAssignedTask({ ...options, route, apiFallback })
926
+ }
927
+ if (retrySubscription) options = { ...options, skipOfficial: null }
928
+ const guarded = async args => {
929
+ const vendor = vendorKey({ toolId: args?.toolId ?? sub.toolId })
930
+ const info = classify(`${args?.detail ?? ''}
931
+ ${args?.raw ?? ''}`, vendor)
932
+ if (info) {
933
+ const entry = markExhausted(info, args?.detail ?? '')
934
+ const reason = switchReason(info, entry?.until)
935
+ const note = { from: 'subscription', reason, kind: info.kind, until: entry?.until ?? null, detail: args?.detail ?? '', source: info.source }
936
+ if (plan.mode === 'subscription-only') {
937
+ return { ok: false, refused: true, error: reason.replace('已切换 API Key。', '按设置只用订阅,未使用 API Key。'), billingNote: { ...note, to: null } }
938
+ }
939
+ const api = await apiFallback({ ...args, route: apiRoute ?? args.route })
940
+ return { ...api, billing: 'api', billingNote: { ...note, to: 'api' } }
941
+ }
942
+ if (plan.mode === 'subscription-only') return { ok: false, refused: true, error: `${args?.reason ?? '官方 CLI 执行失败。'}(按设置只用订阅,未回退 API)` }
943
+ // The subscription really ran and failed for another reason: do not spend API money silently.
944
+ const action = failureAction(billing)
945
+ if (args?.attempted === true && action !== 'api') {
946
+ const summary = failureSummary(args?.reason)
947
+ if (action === 'fail') return { ok: false, refused: true, error: `${summary}按设置不自动改用 API Key。` }
948
+ const pause = pauseFor(summary, args?.detail ?? '', { timedOut: args?.timedOut === true, exitCode: args?.exitCode ?? null })
949
+ return { ok: false, paused: true, pause, error: pause.reason }
950
+ }
951
+ return apiFallback(args)
952
+ }
953
+ const result = await executeAssignedTask({ ...options, route, apiFallback: guarded, credentialMode: 'session-only' })
954
+ return {
955
+ ...result,
956
+ billingMode: plan.mode,
957
+ ...(result.ok && result.channel === 'official-cli' ? { billing: 'subscription' } : {}),
958
+ }
959
+ }
960
+
961
+ /** The profile names a subscription billing mode for this route (not just the default). */
962
+ function subscriptionDeclared(route, routes = []) {
963
+ const key = `${route?.provider}\u0000${route?.model}`
964
+ const self = (Array.isArray(routes) ? routes : []).find(item => `${item.provider}\u0000${item.model}` === key) ?? route
965
+ return self?.billing === 'subscription-first' || self?.billing === 'subscription-only'
966
+ }
967
+
968
+ /** API-key run of `route` on the catalog API, in executor result shape. */
969
+ function useApiRoute(apiFallback, { route, task, signal: abort, reason, skipped }) {
970
+ return useApi(apiFallback, { route, task, signal: abort, reason, skipped, preference: executionPreference(route) })
971
+ }
972
+
973
+ const PROMPT_HEADROOM_BYTES = 512
974
+ const MIN_TASK_SHARE_BYTES = 8_000
975
+ const DEPENDENCY_ANSWER_BYTES = 7_500
976
+
977
+ /**
978
+ * The prompt for one work package, always within MAX_TASK_BYTES: the objective
979
+ * is capped first, dependency answers share what the task leaves (each at most
980
+ * DEPENDENCY_ANSWER_BYTES, the remainder split evenly), and the total task is
981
+ * truncated last. Truncated parts end with “(已截断)”.
982
+ */
983
+ export function packagePrompt(task, item, completed, maxBytes = MAX_TASK_BYTES) {
984
+ const dependencies = (item.dependsOn ?? []).map(id => completed.find(result => result.id === id)).filter(Boolean)
985
+ const name = truncatedTo(item.name, 300)
986
+ const closing = '只完成当前工作包,并给出可汇总的结果。'
987
+ const budget = maxBytes - PROMPT_HEADROOM_BYTES
988
+ const bytes = value => Buffer.byteLength(value, 'utf8')
989
+ const objective = item.objective ? truncatedTo(item.objective, Math.floor(budget / 4)) : ''
990
+ const fixed = bytes(`总任务:\n\n\n当前工作包:${name}\n\n${objective ? `具体目标:\n${objective}\n\n` : ''}已完成的依赖结果:\n\n\n${closing}`)
991
+ + dependencies.reduce((sum, dep) => sum + bytes(`${truncatedTo(dep.name, 200)}:\n\n\n`), 0)
992
+ const taskBytes = bytes(String(task))
993
+ const free = Math.max(0, budget - fixed)
994
+ // Dependencies get what they need up to their cap, but never squeeze the task below its share.
995
+ const wantDeps = dependencies.reduce((sum, dep) => sum + Math.min(bytes(String(dep.answer ?? '')), DEPENDENCY_ANSWER_BYTES), 0)
996
+ const depBudget = Math.max(0, Math.min(wantDeps, free - Math.min(taskBytes, MIN_TASK_SHARE_BYTES)))
997
+ let remaining = depBudget
998
+ const depTexts = dependencies.map((dep, index) => {
999
+ const share = Math.floor(remaining / (dependencies.length - index))
1000
+ const answer = truncatedTo(String(dep.answer ?? ''), Math.min(DEPENDENCY_ANSWER_BYTES, share))
1001
+ remaining -= bytes(answer)
1002
+ return `${truncatedTo(dep.name, 200)}:\n${answer}`
1003
+ })
1004
+ const taskText = truncatedTo(String(task), Math.max(0, free - (depBudget - remaining)))
1005
+ const prompt = [
1006
+ `总任务:\n${taskText}`,
1007
+ `当前工作包:${name}`,
1008
+ objective ? `具体目标:\n${objective}` : '',
1009
+ dependencies.length ? `已完成的依赖结果:\n${depTexts.join('\n\n')}` : '当前工作包无前置依赖。',
1010
+ closing,
1011
+ ].filter(Boolean).join('\n\n')
1012
+ return bytes(prompt) <= maxBytes ? prompt : sliceUtf8(prompt, maxBytes)
1013
+ }
1014
+
1015
+ /** One forced model receives the whole task. Routed plans keep their packages. */
1016
+ export function assignmentPackages(plan, task) {
1017
+ if (plan?.routingBypassed) {
1018
+ const route = plan.directRoute
1019
+ if (!route?.provider || !route?.model) throw new TypeError('direct plan is missing its model')
1020
+ return [{
1021
+ id: 'direct',
1022
+ name: '指定模型',
1023
+ objective: String(task ?? ''),
1024
+ dependsOn: [],
1025
+ recommendedProvider: route.provider,
1026
+ recommendedModel: route.model,
1027
+ }]
1028
+ }
1029
+ const packages = plan?.team?.workPackages
1030
+ if (!Array.isArray(packages) || packages.length === 0) throw new TypeError('plan has no work packages')
1031
+ return packages.map(item => ({
1032
+ id: item.id,
1033
+ name: item.name,
1034
+ objective: item.objective ?? item.name,
1035
+ dependsOn: [...(item.dependsOn ?? [])],
1036
+ recommendedProvider: item.recommendedProvider,
1037
+ recommendedModel: item.recommendedModel,
1038
+ }))
1039
+ }
1040
+
1041
+ function aggregateOf(results) {
1042
+ const aggregate = results.map(item => [
1043
+ `## ${item.name} (${item.provider}/${item.model}, ${item.channel === 'official-cli' ? '官方 CLI' : '模型目录 API'})`,
1044
+ item.answer || item.error || '',
1045
+ ].join('\n')).join('\n\n')
1046
+ const status = results.length > 0 && results.every(item => item.ok) ? 'completed'
1047
+ : results.some(item => item.paused) ? 'paused'
1048
+ : results.some(item => item.cancelled) ? 'cancelled'
1049
+ : results.some(item => item.ok) ? 'partial' : 'failed'
1050
+ return { status, packages: results, aggregate }
1051
+ }
1052
+
1053
+ /**
1054
+ * Run `targets` (all packages when null) in plan order. Packages outside the
1055
+ * target set keep their previous results, so a single failed step can be
1056
+ * retried without re-running finished work.
1057
+ */
1058
+ async function runPackages({ packages, task, routingBypassed, routes, previous = [], targets = null, overrides = {}, choices = {}, options }) {
1059
+ const routeList = Array.isArray(routes) ? routes : []
1060
+ const { credentialsFor, onPackage, billing, ...runOptions } = options
1061
+ const results = []
1062
+ const ranIds = []
1063
+ for (const item of packages) {
1064
+ const kept = previous.find(result => result.id === item.id)
1065
+ if (targets && !targets.has(item.id) && kept) {
1066
+ results.push(kept)
1067
+ continue
1068
+ }
1069
+ const override = overrides[item.id]
1070
+ const provider = override?.provider ?? item.recommendedProvider
1071
+ const model = override?.model ?? item.recommendedModel
1072
+ if (runOptions.signal?.aborted) {
1073
+ // Cancelled: later packages are not started (no CLI process, no API spend).
1074
+ const cancelled = { id: item.id, name: item.name, ok: false, provider, model, channel: 'harness-llm', answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
1075
+ results.push(cancelled)
1076
+ ranIds.push(item.id)
1077
+ if (typeof onPackage === 'function') await onPackage(cancelled)
1078
+ continue
1079
+ }
1080
+ const unmet = (item.dependsOn ?? []).filter(id => results.find(result => result.id === id)?.ok !== true)
1081
+ if (unmet.length > 0) {
1082
+ // Waiting behind a paused step (the user decides), or blocked by a failure.
1083
+ const waitingOn = unmet.filter(id => { const dep = results.find(result => result.id === id); return dep?.paused || dep?.waiting })
1084
+ const blocked = {
1085
+ id: item.id, name: item.name, ok: false, provider, model,
1086
+ channel: 'harness-llm', answer: '', fallback: null, blocked: true,
1087
+ ...(waitingOn.length ? { waiting: true } : {}),
1088
+ error: waitingOn.length ? `等待上游步骤确认:${waitingOn.join('、')}` : `依赖未完成:${unmet.join('、')}`,
1089
+ }
1090
+ results.push(blocked)
1091
+ ranIds.push(item.id)
1092
+ if (typeof onPackage === 'function') await onPackage(blocked)
1093
+ continue
1094
+ }
1095
+ const route = routeList.find(candidate => candidate.provider === provider && candidate.model === model)
1096
+ ?? { provider, model }
1097
+ const credentials = typeof credentialsFor === 'function' ? await credentialsFor(route) : runOptions.credentials ?? null
1098
+ const result = await executeRouteWithBilling({
1099
+ ...runOptions,
1100
+ billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
1101
+ credentials,
1102
+ route,
1103
+ task: routingBypassed ? checkedTask(task) : packagePrompt(task, item, results),
1104
+ })
1105
+ const entry = { id: item.id, name: item.name, ...(override ? { reassigned: true } : {}), ...result }
1106
+ results.push(entry)
1107
+ ranIds.push(item.id)
1108
+ if (typeof onPackage === 'function') await onPackage(entry)
1109
+ }
1110
+ return { ...aggregateOf(results), ranIds }
1111
+ }
1112
+
1113
+ /** Run packages in plan order and return one aggregate the router can keep. */
1114
+ export async function executeAssignmentPlan({ plan, task, routes, ...options }) {
1115
+ return runPackages({
1116
+ packages: assignmentPackages(plan, task),
1117
+ task,
1118
+ routingBypassed: plan?.routingBypassed === true,
1119
+ routes,
1120
+ options,
1121
+ })
1122
+ }
1123
+
1124
+ /** Package ids downstream of `packageId`, in plan order. */
1125
+ export function downstreamPackageIds(packages, packageId) {
1126
+ const found = new Set([packageId])
1127
+ for (const item of packages) {
1128
+ if ((item.dependsOn ?? []).some(id => found.has(id))) found.add(item.id)
1129
+ }
1130
+ found.delete(packageId)
1131
+ return packages.map(item => item.id).filter(id => found.has(id))
1132
+ }
1133
+
1134
+ /**
1135
+ * Retry one stored package, optionally on a manually chosen route, and then
1136
+ * any downstream package that had not succeeded. Finished packages keep their
1137
+ * stored answers, which also feed the retried package's dependency context.
1138
+ */
1139
+ export async function rerunAssignmentPackage({
1140
+ task, packages, previous, packageId, routingBypassed = false, routes, override = null, cascade = true, subscriptionChoice = null, ...options
1141
+ }) {
1142
+ const list = Array.isArray(packages) ? packages : []
1143
+ if (!list.some(item => item.id === packageId)) throw new TypeError(`unknown work package ${packageId}`)
1144
+ const prior = Array.isArray(previous) ? previous : []
1145
+ const targets = new Set([packageId])
1146
+ if (cascade) {
1147
+ for (const id of downstreamPackageIds(list, packageId)) {
1148
+ if (prior.find(result => result.id === id)?.ok !== true) targets.add(id)
1149
+ }
1150
+ }
1151
+ const overrides = override?.provider && override?.model ? { [packageId]: { provider: override.provider, model: override.model } } : {}
1152
+ const choices = subscriptionChoice === 'api' || subscriptionChoice === 'subscription' ? { [packageId]: subscriptionChoice } : {}
1153
+ return runPackages({ packages: list, task, routingBypassed, routes, previous: prior, targets, overrides, choices, options })
1154
+ }