@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.
- package/.dsh-plugin/client.js +3092 -0
- package/.dsh-plugin/index.mjs +1651 -0
- package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
- package/.dsh-plugin/shared/harness-plan.mjs +179 -0
- package/.dsh-plugin/shared/livebench.mjs +264 -0
- package/.dsh-plugin/shared/model-profiles.mjs +142 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
- package/.dsh-plugin/shared/router-state.mjs +207 -0
- package/.dsh-plugin/shared/router.mjs +1134 -0
- package/.dsh-plugin/shared/routing-presets.mjs +49 -0
- package/.dsh-plugin/shared/run-ledger.mjs +348 -0
- package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
- package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
- package/.dsh-plugin/shared/task-executors.mjs +1154 -0
- package/.dsh-plugin/shared/tool-health.mjs +311 -0
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
- package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
- package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
- package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
- package/CHANGELOG.md +36 -0
- package/INSTALLATION_GUIDE.zh.md +134 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +53 -0
- package/README.i18n.yaml +3 -0
- package/README.md +424 -0
- package/README.zh.md +413 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +96 -0
- package/docs/assets/workbench-usage.svg +119 -0
- 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
|
+
}
|