@ljwei-stak/dsh-model-router 0.23.1 → 0.25.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 +2534 -4455
- package/.dsh-plugin/index.mjs +387 -79
- package/.dsh-plugin/shared/budget-reservations.mjs +273 -0
- package/.dsh-plugin/shared/execution-accounting.mjs +61 -0
- package/.dsh-plugin/shared/execution-lifecycle.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +42 -13
- package/.dsh-plugin/shared/official-tools-runtime.mjs +3 -2
- package/.dsh-plugin/shared/router-state.mjs +36 -8
- package/.dsh-plugin/shared/run-ledger.mjs +21 -2
- package/.dsh-plugin/shared/subscription-usage.mjs +210 -0
- package/.dsh-plugin/shared/task-executors.mjs +125 -23
- package/.dsh-plugin/shared/zcode-bundle.mjs +3 -3
- package/CHANGELOG.md +23 -0
- package/README.i18n.yaml +2 -2
- package/README.md +35 -22
- package/README.zh.md +32 -20
- package/docs/WORKBENCH_USER_GUIDE.zh.md +8 -6
- package/docs/assets/workbench-budget-reservations.png +0 -0
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/releases/0.24.0-validation.md +41 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/package.json +11 -3
|
@@ -43,6 +43,24 @@ const BASE_ENV_KEYS = Object.freeze([
|
|
|
43
43
|
])
|
|
44
44
|
|
|
45
45
|
const READ_ONLY_INSTRUCTION = 'Read the task supplied on standard input. Answer in the task language. Do not modify files.'
|
|
46
|
+
const executionBoundaryFailure = error => ['ROUTER_CHECKPOINT_FAILED', 'ROUTER_BUDGET_CHANGED'].includes(error?.code)
|
|
47
|
+
const usageFields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
|
|
48
|
+
|
|
49
|
+
/** Subscription evidence contains reported usage only, never prices or secrets. */
|
|
50
|
+
function subscriptionReceipt(result, { provider, model, toolId = null, channel = 'harness-llm', now = Date.now } = {}) {
|
|
51
|
+
const usage = result?.usage && typeof result.usage === 'object' && !Array.isArray(result.usage)
|
|
52
|
+
? Object.fromEntries(usageFields.filter(key => typeof result.usage[key] === 'number'
|
|
53
|
+
&& Number.isFinite(result.usage[key]) && result.usage[key] >= 0).map(key => [key, result.usage[key]])) : null
|
|
54
|
+
const notStarted = result?.notStarted === true || result?.blocked === true
|
|
55
|
+
const incomplete = result?.usageIncomplete === true || !usage
|
|
56
|
+
|| !['inputTokens', 'outputTokens'].every(key => Object.hasOwn(usage, key))
|
|
57
|
+
|| usageFields.some(key => result?.usage?.[key] !== undefined && !Object.hasOwn(usage ?? {}, key))
|
|
58
|
+
return { provider: String(provider ?? ''), model: String(reportedCliModel(result?.actualModel ?? result?.reportedModel) ?? model ?? ''),
|
|
59
|
+
toolId, channel, usage: usage && Object.keys(usage).length ? usage : null,
|
|
60
|
+
...(incomplete ? { usageIncomplete: true } : {}), ...(notStarted ? { notStarted: true } : {}),
|
|
61
|
+
finishedAt: typeof result?.finishedAt === 'number' && Number.isFinite(result.finishedAt)
|
|
62
|
+
&& result.finishedAt >= 0 ? result.finishedAt : now() }
|
|
63
|
+
}
|
|
46
64
|
|
|
47
65
|
function additionalApiFields(value) {
|
|
48
66
|
if (!value || typeof value !== 'object') return {}
|
|
@@ -409,6 +427,7 @@ export function looksLikeLoginFailure(detail) {
|
|
|
409
427
|
}
|
|
410
428
|
|
|
411
429
|
const count = value => Number.isFinite(value) && value >= 0 ? Math.round(value) : 0
|
|
430
|
+
const knownCount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
412
431
|
|
|
413
432
|
/** Token usage reported by the CLI, in the Harness TokenUsage shape (disjoint cache counts). */
|
|
414
433
|
export function usageFromOutput(format, stdout = '') {
|
|
@@ -422,11 +441,14 @@ export function usageFromOutput(format, stdout = '') {
|
|
|
422
441
|
? Object.keys(result.modelUsage) : []
|
|
423
442
|
return {
|
|
424
443
|
usage: {
|
|
425
|
-
inputTokens: count(usage.input_tokens),
|
|
426
|
-
outputTokens: count(usage.output_tokens),
|
|
444
|
+
...(knownCount(usage.input_tokens) ? { inputTokens: count(usage.input_tokens) } : {}),
|
|
445
|
+
...(knownCount(usage.output_tokens) ? { outputTokens: count(usage.output_tokens) } : {}),
|
|
427
446
|
cacheReadTokens: count(usage.cache_read_input_tokens),
|
|
428
447
|
cacheWriteTokens: count(usage.cache_creation_input_tokens),
|
|
429
448
|
},
|
|
449
|
+
...(!knownCount(usage.input_tokens) || !knownCount(usage.output_tokens)
|
|
450
|
+
|| ['cache_read_input_tokens', 'cache_creation_input_tokens'].some(key => usage[key] !== undefined && !knownCount(usage[key]))
|
|
451
|
+
? { usageIncomplete: true } : {}),
|
|
430
452
|
...(Number.isFinite(reported) && reported >= 0 ? { reportedCostUsd: reported } : {}),
|
|
431
453
|
...(models.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,199}$/.test(models[0]) ? { reportedModel: models[0] } : {}),
|
|
432
454
|
}
|
|
@@ -436,24 +458,33 @@ export function usageFromOutput(format, stdout = '') {
|
|
|
436
458
|
if (!completed) return null
|
|
437
459
|
const cached = count(completed.usage.cached_input_tokens)
|
|
438
460
|
return { usage: {
|
|
439
|
-
inputTokens: Math.max(0, count(completed.usage.input_tokens) - cached),
|
|
440
|
-
outputTokens: count(completed.usage.output_tokens),
|
|
461
|
+
...(knownCount(completed.usage.input_tokens) ? { inputTokens: Math.max(0, count(completed.usage.input_tokens) - cached) } : {}),
|
|
462
|
+
...(knownCount(completed.usage.output_tokens) ? { outputTokens: count(completed.usage.output_tokens) } : {}),
|
|
441
463
|
cacheReadTokens: cached,
|
|
442
464
|
cacheWriteTokens: 0,
|
|
443
|
-
}
|
|
465
|
+
}, ...(!knownCount(completed.usage.input_tokens) || !knownCount(completed.usage.output_tokens)
|
|
466
|
+
|| completed.usage.cached_input_tokens !== undefined && (!knownCount(completed.usage.cached_input_tokens)
|
|
467
|
+
|| completed.usage.cached_input_tokens > completed.usage.input_tokens) ? { usageIncomplete: true } : {}) }
|
|
444
468
|
}
|
|
445
469
|
if (format === 'gemini-json') {
|
|
446
470
|
const models = JSON.parse(stdout)?.stats?.models
|
|
447
|
-
if (!models || typeof models !== 'object') return null
|
|
471
|
+
if (!models || typeof models !== 'object' || Array.isArray(models) || !Object.keys(models).length) return null
|
|
448
472
|
let input = 0
|
|
449
473
|
let output = 0
|
|
450
474
|
let cached = 0
|
|
475
|
+
let inputKnown = true, outputKnown = true, incomplete = false
|
|
451
476
|
for (const entry of Object.values(models)) {
|
|
477
|
+
inputKnown &&= knownCount(entry?.tokens?.prompt)
|
|
478
|
+
outputKnown &&= knownCount(entry?.tokens?.candidates)
|
|
479
|
+
incomplete ||= entry?.tokens?.cached !== undefined && (!knownCount(entry.tokens.cached)
|
|
480
|
+
|| entry.tokens.cached > entry.tokens.prompt)
|
|
452
481
|
input += count(entry?.tokens?.prompt)
|
|
453
482
|
output += count(entry?.tokens?.candidates)
|
|
454
483
|
cached += count(entry?.tokens?.cached)
|
|
455
484
|
}
|
|
456
|
-
return { usage: { inputTokens: Math.max(0, input - cached)
|
|
485
|
+
return { usage: { ...(inputKnown ? { inputTokens: Math.max(0, input - cached) } : {}),
|
|
486
|
+
...(outputKnown ? { outputTokens: output } : {}), cacheReadTokens: cached, cacheWriteTokens: 0 },
|
|
487
|
+
...(!inputKnown || !outputKnown || incomplete ? { usageIncomplete: true } : {}) }
|
|
457
488
|
}
|
|
458
489
|
} catch { /* usage is optional */ }
|
|
459
490
|
return null
|
|
@@ -621,7 +652,7 @@ function captureProcess(spawnImpl, file, args, { cwd, env, stdin, timeoutMs, sig
|
|
|
621
652
|
})
|
|
622
653
|
}
|
|
623
654
|
|
|
624
|
-
async function useApi(apiFallback, { route, task, signal, reason, adapter, preference, exitCode = null, timedOut = false, detail = '', raw = '', skipped = false, attempted = false }) {
|
|
655
|
+
async function useApi(apiFallback, { route, task, signal, reason, adapter, preference, exitCode = null, timedOut = false, detail = '', raw = '', skipped = false, attempted = false, subscriptionResult = null }) {
|
|
625
656
|
const provider = String(route?.provider ?? '')
|
|
626
657
|
const model = String(route?.model ?? '')
|
|
627
658
|
const fallback = {
|
|
@@ -637,7 +668,7 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
|
|
|
637
668
|
}
|
|
638
669
|
}
|
|
639
670
|
try {
|
|
640
|
-
const api = await apiFallback({ route, task, signal, reason, detail, raw: String(raw ?? '').slice(-40_000), toolId: adapter?.id ?? null, attempted, timedOut, exitCode })
|
|
671
|
+
const api = await apiFallback({ route, task, signal, reason, detail, raw: String(raw ?? '').slice(-40_000), toolId: adapter?.id ?? null, attempted, timedOut, exitCode, subscriptionResult })
|
|
641
672
|
// A billing-aware fallback can pause the step for the user's decision.
|
|
642
673
|
if (api?.paused) {
|
|
643
674
|
return {
|
|
@@ -646,6 +677,7 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
|
|
|
646
677
|
pause: { ...api.pause, ...(detail ? { detail } : {}), ...(fallback.loginRequired ? { loginRequired: true } : {}) },
|
|
647
678
|
...reasoningExecutionFields(api),
|
|
648
679
|
...additionalApiFields(api),
|
|
680
|
+
...(api?.billing ? { billing: api.billing } : {}),
|
|
649
681
|
error: api.error,
|
|
650
682
|
}
|
|
651
683
|
}
|
|
@@ -674,6 +706,7 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
|
|
|
674
706
|
error: api?.ok === true && answer.trim() ? undefined : (api?.error || reason),
|
|
675
707
|
}
|
|
676
708
|
} catch (error) {
|
|
709
|
+
if (executionBoundaryFailure(error)) throw error
|
|
677
710
|
if (signal?.aborted) {
|
|
678
711
|
return {
|
|
679
712
|
ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
|
|
@@ -833,6 +866,8 @@ export async function executeAssignedTask({
|
|
|
833
866
|
return useApi(apiFallback, {
|
|
834
867
|
route, task: prompt, signal, adapter, preference, exitCode: verified.exitCode ?? null,
|
|
835
868
|
timedOut: verified.status === 'timed-out', attempted: true,
|
|
869
|
+
subscriptionResult: { usage: verified.usage, usageIncomplete: verified.usageIncomplete,
|
|
870
|
+
notStarted: verified.notStarted === true, actualModel: verified.actualModel, reportedModel: verified.reportedModel },
|
|
836
871
|
reason: verified.error || verified.reason || '官方 CLI 执行失败,已回退模型目录 API。',
|
|
837
872
|
detail: redactDiagnostic(verifiedDiagnostic(verified), childEnvironment(adapter, credentials, credentialMode).secret),
|
|
838
873
|
raw: [verified.error, verified.reason, verified.detail, verified.stderrTail, verified.stdoutTail].filter(item => typeof item === 'string').join('\n'),
|
|
@@ -861,7 +896,7 @@ export async function executeAssignedTask({
|
|
|
861
896
|
: probe.timedOut ? `${adapter.label} 检测超时,已回退模型目录 API。`
|
|
862
897
|
: `${adapter.label} 无法运行,已回退模型目录 API。`
|
|
863
898
|
if (probe.cancelled) {
|
|
864
|
-
return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: null, error: reason, cancelled: true }
|
|
899
|
+
return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: null, error: reason, cancelled: true, notStarted: true }
|
|
865
900
|
}
|
|
866
901
|
return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, reason })
|
|
867
902
|
}
|
|
@@ -871,11 +906,14 @@ export async function executeAssignedTask({
|
|
|
871
906
|
...(Number.isSafeInteger(stopGraceMs) && stopGraceMs >= 10 && stopGraceMs <= STOP_GRACE_MS ? { stopGraceMs } : {}),
|
|
872
907
|
})
|
|
873
908
|
if (run.cancelled) {
|
|
874
|
-
|
|
909
|
+
const usage = usageFromOutput(adapter.format, run.stdout)
|
|
910
|
+
return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: run.exitCode, error: '执行已取消。', cancelled: true,
|
|
911
|
+
credentialSource, ...(usage?.usage ? { usage: usage.usage } : {}), ...(usage?.usageIncomplete ? { usageIncomplete: true } : {}) }
|
|
875
912
|
}
|
|
876
913
|
if (!run.ok) {
|
|
877
914
|
return useApi(apiFallback, {
|
|
878
915
|
route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, timedOut: run.timedOut, attempted: true,
|
|
916
|
+
subscriptionResult: usageFromOutput(adapter.format, run.stdout),
|
|
879
917
|
reason: run.timedOut ? `${adapter.label} 执行超时,已回退模型目录 API。`
|
|
880
918
|
: run.outputLimit ? `${adapter.label} 输出超过上限,已回退模型目录 API。`
|
|
881
919
|
: `${adapter.label} 执行失败,已回退模型目录 API。`,
|
|
@@ -889,6 +927,7 @@ export async function executeAssignedTask({
|
|
|
889
927
|
if (!parsed.ok) {
|
|
890
928
|
return useApi(apiFallback, {
|
|
891
929
|
route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true,
|
|
930
|
+
subscriptionResult: usageFromOutput(adapter.format, run.stdout),
|
|
892
931
|
reason: `${parsed.error} 已回退模型目录 API。`,
|
|
893
932
|
detail: failureDetail(adapter.format, run.stdout, run.stderr, secret),
|
|
894
933
|
raw: `${run.stdout.slice(-20_000)}\n${run.stderr.slice(-20_000)}`,
|
|
@@ -896,7 +935,8 @@ export async function executeAssignedTask({
|
|
|
896
935
|
}
|
|
897
936
|
const answer = redact(parsed.answer, secret)
|
|
898
937
|
if (!answer.trim()) {
|
|
899
|
-
return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true,
|
|
938
|
+
return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true,
|
|
939
|
+
subscriptionResult: usageFromOutput(adapter.format, run.stdout), reason: '官方 CLI 没有返回文本,已回退模型目录 API。' })
|
|
900
940
|
}
|
|
901
941
|
return officialSuccess({
|
|
902
942
|
route, adapter, preference, answer, exitCode: run.exitCode,
|
|
@@ -954,14 +994,26 @@ const failureSummary = text => `${String(text ?? '').replace(/,?已(?:回退
|
|
|
954
994
|
* - subscription-only: never fall back to an API key.
|
|
955
995
|
* - api-only: never use the subscription.
|
|
956
996
|
*/
|
|
957
|
-
export async function executeRouteWithBilling({ route, billing = null, apiFallback, ...options } = {}) {
|
|
997
|
+
export async function executeRouteWithBilling({ route, billing = null, apiFallback, onSubscriptionResult, ...options } = {}) {
|
|
958
998
|
const plan = billingPlan(route, billing?.routes ?? [])
|
|
999
|
+
const subscriptionAttempts = []
|
|
1000
|
+
const recordSubscription = async receipt => {
|
|
1001
|
+
// This executor attempts the subscription once, then may use its API
|
|
1002
|
+
// fallback. Keep a bounded vector so unrelated result fields cannot grow it.
|
|
1003
|
+
if (subscriptionAttempts.length >= 2) throw new Error('订阅尝试证据超过本步上限。')
|
|
1004
|
+
subscriptionAttempts.push(receipt)
|
|
1005
|
+
const event = { ...structuredClone(receipt), billing: 'subscription', subscriptionAttempts: structuredClone(subscriptionAttempts) }
|
|
1006
|
+
await awaitExecutionCheckpoint(onSubscriptionResult, event,
|
|
1007
|
+
() => ({ status: 'failed', packages: [{ ...event, ok: false, ran: !receipt.notStarted }] }), 'subscription-result')
|
|
1008
|
+
}
|
|
959
1009
|
// Every result names the billing mode it ran under, whichever branch produced it.
|
|
960
|
-
const result = await routeWithBilling(plan, { route, billing, apiFallback, ...options })
|
|
961
|
-
return { ...result, billingMode: result?.billingMode ?? plan.mode
|
|
1010
|
+
const result = await routeWithBilling(plan, { route, billing, apiFallback, ...options, onSubscriptionResult: recordSubscription })
|
|
1011
|
+
return { ...result, billingMode: result?.billingMode ?? plan.mode,
|
|
1012
|
+
...(subscriptionAttempts.length ? { subscriptionAttempts,
|
|
1013
|
+
...(result?.billing ? {} : { billing: 'subscription' }) } : {}) }
|
|
962
1014
|
}
|
|
963
1015
|
|
|
964
|
-
async function routeWithBilling(plan, { route, billing = null, apiFallback, ...options } = {}) {
|
|
1016
|
+
async function routeWithBilling(plan, { route, billing = null, apiFallback, onSubscriptionResult, ...options } = {}) {
|
|
965
1017
|
if (!billing) return executeAssignedTask({ ...options, route, apiFallback })
|
|
966
1018
|
const now = typeof billing.now === 'function' ? billing.now : Date.now
|
|
967
1019
|
const sub = plan.subscription
|
|
@@ -972,7 +1024,7 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
|
|
|
972
1024
|
const base = { provider, model, billingMode: plan.mode }
|
|
973
1025
|
const refuse = (error, extra = {}) => ({
|
|
974
1026
|
ok: false, ...base, preference: executionPreference(route), channel: sub?.kind === 'plan-key' ? 'harness-llm' : 'official-cli',
|
|
975
|
-
toolId: sub?.toolId ?? null, answer: '', fallback: null, error, ...extra,
|
|
1027
|
+
toolId: sub?.toolId ?? null, answer: '', fallback: null, notStarted: true, error, ...extra,
|
|
976
1028
|
})
|
|
977
1029
|
const viaApi = async (reason, note, skipped = false) => {
|
|
978
1030
|
if (!apiRoute || (sub?.kind === 'plan-key' && apiRoute.provider === sub.route.provider && apiRoute.model === sub.route.model)) {
|
|
@@ -1016,11 +1068,16 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
|
|
|
1016
1068
|
}
|
|
1017
1069
|
|
|
1018
1070
|
if (sub.kind === 'plan-key') {
|
|
1071
|
+
if (typeof apiFallback !== 'function') return refuse('编程套餐执行器不可用,此步骤未启动。', { notStarted: true, billing: 'subscription' })
|
|
1019
1072
|
const reasoningRoute = routeForReasoningSwitch(route, sub.route)
|
|
1020
1073
|
const originalEffort = typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? route.reasoningEffort : null
|
|
1021
1074
|
let attempt
|
|
1022
1075
|
try { attempt = await apiFallback({ route: reasoningRoute, task: options.task, signal: options.signal, reason: '编程套餐(订阅)', subscription: true }) }
|
|
1023
|
-
catch (error) {
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
if (executionBoundaryFailure(error)) throw error
|
|
1078
|
+
attempt = { ok: false, error: String(error?.message ?? error) }
|
|
1079
|
+
}
|
|
1080
|
+
await onSubscriptionResult(subscriptionReceipt(attempt, { ...sub.route, now }))
|
|
1024
1081
|
const droppedEffort = originalEffort && reasoningRoute.reasoningEffort !== originalEffort ? originalEffort : null
|
|
1025
1082
|
if (attempt?.ok === true && String(attempt.answer ?? '').trim()) {
|
|
1026
1083
|
return {
|
|
@@ -1083,8 +1140,15 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
|
|
|
1083
1140
|
// No subscription login: the existing CLI-with-key / catalog API behaviour.
|
|
1084
1141
|
return executeAssignedTask({ ...options, route, apiFallback })
|
|
1085
1142
|
}
|
|
1143
|
+
const confirmedSubscription = retrySubscription || login === 'subscription' || plan.mode === 'subscription-only'
|
|
1086
1144
|
if (retrySubscription) options = { ...options, skipOfficial: null }
|
|
1087
1145
|
const guarded = async args => {
|
|
1146
|
+
if (args?.attempted === true && !confirmedSubscription) {
|
|
1147
|
+
const pause = pauseFor('CLI 计费方式尚未核对,已暂停,未启动额外 API 调用。', '请先体检确认账号订阅或 API Key,再选择重试方式。')
|
|
1148
|
+
return { ok: false, paused: true, billing: 'unknown', pause, error: pause.reason }
|
|
1149
|
+
}
|
|
1150
|
+
if (args?.attempted === true && confirmedSubscription) await onSubscriptionResult(subscriptionReceipt(args.subscriptionResult,
|
|
1151
|
+
{ provider, model, toolId: args.toolId ?? sub.toolId, channel: 'official-cli', now }))
|
|
1088
1152
|
const vendor = vendorKey({ toolId: args?.toolId ?? sub.toolId })
|
|
1089
1153
|
const info = classify(`${args?.detail ?? ''}
|
|
1090
1154
|
${args?.raw ?? ''}`, vendor)
|
|
@@ -1112,13 +1176,16 @@ ${args?.raw ?? ''}`, vendor)
|
|
|
1112
1176
|
const pause = pauseFor(summary, args?.detail ?? '', { timedOut: args?.timedOut === true, exitCode: args?.exitCode ?? null })
|
|
1113
1177
|
return { ok: false, paused: true, pause, error: pause.reason }
|
|
1114
1178
|
}
|
|
1115
|
-
return apiFallback(args)
|
|
1179
|
+
return { ...await apiFallback(args), billing: 'api' }
|
|
1116
1180
|
}
|
|
1117
1181
|
const result = await executeAssignedTask({ ...options, route, apiFallback: guarded, credentialMode: 'session-only' })
|
|
1182
|
+
if (confirmedSubscription && result.channel === 'official-cli' && (result.ok === true || result.cancelled && !result.notStarted)) {
|
|
1183
|
+
await onSubscriptionResult(subscriptionReceipt(result, { provider, model, toolId: sub.toolId, channel: 'official-cli', now }))
|
|
1184
|
+
}
|
|
1118
1185
|
return {
|
|
1119
1186
|
...result,
|
|
1120
1187
|
billingMode: plan.mode,
|
|
1121
|
-
...(result.
|
|
1188
|
+
...(result.channel === 'official-cli' && !result.billing ? { billing: confirmedSubscription ? 'subscription' : 'unknown' } : {}),
|
|
1122
1189
|
}
|
|
1123
1190
|
}
|
|
1124
1191
|
|
|
@@ -1229,6 +1296,26 @@ export async function observeExecutionProgress(hook, value) {
|
|
|
1229
1296
|
try { await hook(value) } catch { /* Progress reporting does not control execution. */ }
|
|
1230
1297
|
}
|
|
1231
1298
|
|
|
1299
|
+
/** Required Host persistence is a dispatch boundary, unlike progress observers. */
|
|
1300
|
+
export async function awaitExecutionCheckpoint(hook, value, partial, stage) {
|
|
1301
|
+
if (typeof hook !== 'function') return
|
|
1302
|
+
try { await hook(value) } catch (cause) {
|
|
1303
|
+
if (executionBoundaryFailure(cause) && cause.execution) throw cause
|
|
1304
|
+
const budgetChanged = cause?.code === 'ROUTER_BUDGET_CHANGED'
|
|
1305
|
+
const error = new Error(budgetChanged ? String(cause.message) : `执行检查点保存失败:${String(cause?.message ?? cause)}`, { cause })
|
|
1306
|
+
error.code = budgetChanged ? 'ROUTER_BUDGET_CHANGED' : 'ROUTER_CHECKPOINT_FAILED'
|
|
1307
|
+
if (budgetChanged) {
|
|
1308
|
+
error.budget = cause.budget
|
|
1309
|
+
error.runId = cause.runId
|
|
1310
|
+
error.reason = cause.reason
|
|
1311
|
+
}
|
|
1312
|
+
error.checkpointStage = stage
|
|
1313
|
+
// The caller can retain paid receipts even when the persistence sink failed.
|
|
1314
|
+
error.execution = typeof partial === 'function' ? partial() : partial
|
|
1315
|
+
throw error
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1232
1319
|
function checkedPackageSelections(packages, routes, overrides, runVerified, targets) {
|
|
1233
1320
|
const preceding = new Set()
|
|
1234
1321
|
const selected = {}
|
|
@@ -1305,11 +1392,12 @@ async function runManualTool(target, prompt, options) {
|
|
|
1305
1392
|
*/
|
|
1306
1393
|
async function runPackages({ packages, task, routingBypassed, routes, previous = [], targets = null, overrides = {}, choices = {}, options }) {
|
|
1307
1394
|
const routeList = Array.isArray(routes) ? routes : []
|
|
1308
|
-
const { credentialsFor, onPackageStart, onPackage, onStage, billing, ...runOptions } = options
|
|
1395
|
+
const { credentialsFor, onPackageStart, onPackage, onStage, onCheckpointStart, onCheckpointResult, onSubscriptionResult, billing, ...runOptions } = options
|
|
1309
1396
|
await observeExecutionProgress(onStage, 'preparing')
|
|
1310
1397
|
const effectivePackages = checkedPackageSelections(packages, routeList, overrides, runOptions.runVerified, targets)
|
|
1311
1398
|
const results = []
|
|
1312
1399
|
const ranIds = []
|
|
1400
|
+
const partial = () => ({ ...aggregateOf([...results]), status: runOptions.signal?.aborted ? 'cancelled' : 'failed', ranIds: [...ranIds] })
|
|
1313
1401
|
if (!runOptions.signal?.aborted) await observeExecutionProgress(onStage, 'executing')
|
|
1314
1402
|
for (const item of effectivePackages) {
|
|
1315
1403
|
const kept = previous.find(result => result.id === item.id)
|
|
@@ -1330,6 +1418,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1330
1418
|
...identity, answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
|
|
1331
1419
|
results.push(cancelled)
|
|
1332
1420
|
ranIds.push(item.id)
|
|
1421
|
+
await awaitExecutionCheckpoint(onCheckpointResult, cancelled, partial, 'result')
|
|
1333
1422
|
await observeExecutionProgress(onPackage, cancelled)
|
|
1334
1423
|
}
|
|
1335
1424
|
if (runOptions.signal?.aborted) {
|
|
@@ -1349,6 +1438,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1349
1438
|
}
|
|
1350
1439
|
results.push(blocked)
|
|
1351
1440
|
ranIds.push(item.id)
|
|
1441
|
+
await awaitExecutionCheckpoint(onCheckpointResult, blocked, partial, 'result')
|
|
1352
1442
|
await observeExecutionProgress(onPackage, blocked)
|
|
1353
1443
|
continue
|
|
1354
1444
|
}
|
|
@@ -1364,9 +1454,15 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1364
1454
|
continue
|
|
1365
1455
|
}
|
|
1366
1456
|
const startedAt = Date.now()
|
|
1367
|
-
|
|
1457
|
+
const starting = { ...item, ...identity, provider, model, startedAt,
|
|
1368
1458
|
channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm',
|
|
1369
|
-
...(target?.kind === 'tool' ? { toolId: target.toolId } : {}) }
|
|
1459
|
+
...(target?.kind === 'tool' ? { toolId: target.toolId } : {}) }
|
|
1460
|
+
await observeExecutionProgress(onPackageStart, starting)
|
|
1461
|
+
if (runOptions.signal?.aborted) {
|
|
1462
|
+
await cancelledBeforeStart()
|
|
1463
|
+
continue
|
|
1464
|
+
}
|
|
1465
|
+
await awaitExecutionCheckpoint(onCheckpointStart, starting, partial, 'start')
|
|
1370
1466
|
if (runOptions.signal?.aborted) {
|
|
1371
1467
|
await cancelledBeforeStart()
|
|
1372
1468
|
continue
|
|
@@ -1378,6 +1474,11 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1378
1474
|
else {
|
|
1379
1475
|
result = await executeRouteWithBilling({
|
|
1380
1476
|
...runOptions,
|
|
1477
|
+
onSubscriptionResult: async event => {
|
|
1478
|
+
event.id = item.id
|
|
1479
|
+
await awaitExecutionCheckpoint(onSubscriptionResult, event,
|
|
1480
|
+
() => ({ ...partial(), packages: [...results, { ...event, ok: false, ran: !event.notStarted }] }), 'subscription-result')
|
|
1481
|
+
},
|
|
1381
1482
|
billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
|
|
1382
1483
|
credentials, route, task: promptText,
|
|
1383
1484
|
})
|
|
@@ -1404,6 +1505,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1404
1505
|
}
|
|
1405
1506
|
results.push(entry)
|
|
1406
1507
|
ranIds.push(item.id)
|
|
1508
|
+
await awaitExecutionCheckpoint(onCheckpointResult, entry, partial, 'result')
|
|
1407
1509
|
await observeExecutionProgress(onPackage, entry)
|
|
1408
1510
|
}
|
|
1409
1511
|
return { ...aggregateOf(results), ranIds }
|
|
@@ -99,7 +99,7 @@ function rootFromCommand(value) {
|
|
|
99
99
|
const trimmed = value.trim()
|
|
100
100
|
const quoted = /^"([^"]+)"/.exec(trimmed)
|
|
101
101
|
const executable = quoted?.[1] ?? /^(.+?\.(?:exe|ico))(?=,|\s|$)/i.exec(trimmed)?.[1]
|
|
102
|
-
return executable && win32.isAbsolute(executable) ? dirname(executable) : null
|
|
102
|
+
return executable && win32.isAbsolute(executable) ? win32.dirname(executable) : null
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Explicit selection is authoritative, even when the selected bundle is missing. */
|
|
@@ -125,8 +125,8 @@ export function zcodeDiscoveryCandidates({ installDir = '', env = process.env, r
|
|
|
125
125
|
}
|
|
126
126
|
for (const parent of [env.LOCALAPPDATA, env.ProgramFiles, env['ProgramFiles(x86)'], env.ProgramW6432]) {
|
|
127
127
|
if (typeof parent !== 'string' || !win32.isAbsolute(parent)) continue
|
|
128
|
-
add(join(parent, 'Programs', 'ZCode'), 'standard-directory')
|
|
129
|
-
add(join(parent, 'ZCode'), 'standard-directory')
|
|
128
|
+
add(win32.join(parent, 'Programs', 'ZCode'), 'standard-directory')
|
|
129
|
+
add(win32.join(parent, 'ZCode'), 'standard-directory')
|
|
130
130
|
}
|
|
131
131
|
return candidates
|
|
132
132
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
- Separate API financial accounting from subscription usage. API costs and API reservations govern monetary budgets; subscriptions track calls and reported input/output/cache tokens, with incomplete or missing usage retained as unknown.
|
|
6
|
+
- Keep confirmed subscription execution independent of exhausted API budgets or missing API prices. Before an actual API call or subscription-to-API fallback, estimate the actual API route and acquire the applicable confirmation and atomic reservation. A CLI using an API key remains API-billed.
|
|
7
|
+
- Preserve subscription-attempt receipts alongside API fallback results and retain both ledgers across retry replacement and history trimming. Old reference-dollar fields remain readable without converting them into current subscription money or fabricated token counts.
|
|
8
|
+
- Show API money and subscription quantities separately in the workbench, execution preview and history. Subscription tokens do not imply vendor allowance, remaining quota or percentage. Additional API quality/review calls still incur API charges.
|
|
9
|
+
- Upgrade persistent state to schema v3 while migrating readable v1/v2 histories and protected execution state. Fully stop all Hosts before updating and keep state backups before downgrading.
|
|
10
|
+
|
|
11
|
+
Final validation and release evidence are tracked in the [0.25.0 acceptance report](docs/releases/0.25.0-validation.md). No new real vendor inference or installed-DSH end-to-end claim is made by these changes.
|
|
12
|
+
|
|
13
|
+
## 0.24.0 — 2026-10-24
|
|
14
|
+
|
|
15
|
+
- Reserve remaining execution estimates in shared persistent state before admission. Daily/monthly checks account for recorded API spending plus other active or interrupted reservations, preventing concurrent tasks from independently admitting the same estimated capacity. Unknown estimates or unresolved costs under a finite budget require explicit confirmation.
|
|
16
|
+
- Save mandatory package checkpoints before external calls and after receipts, with actual usage replacing the completed estimate before another paid step. Apply the lifecycle to routed runs, retries/reassignment, official teams, single-tool runs and consultations; optional review and quality-upgrade calls reserve extra capacity first. Checkpoint failures stop later calls and retain evidence.
|
|
17
|
+
- Recheck fresh actual spending and remaining reservations before each later paid call. Earlier costs above estimate or unresolved under a finite budget stop downstream calls; after worker cleanup, a budget stop retains completed receipts and releases unused holds. Subscription-to-API fallback re-estimates the actual route and rejects missing-price or over-budget calls. Save the first receipt and pending extra attempt before a quality upgrade starts.
|
|
18
|
+
- Preserve interrupted reservations and unknown usage when an execution owner is confirmed dead. Protect referenced run records from trimming, block rerun/reassignment while a hold exists, and distinguish actual spending from reserved estimates in the UI. No automatic replay, time-based hold expiry or manual reconciliation/release control is added.
|
|
19
|
+
- Upgrade state schema from v1 to v2 while preserving the ledger. Older plugin versions reject v2 state, so fully stop all Host processes before updating and back up state before a downgrade.
|
|
20
|
+
- Add Windows/Linux Node 24 CI for strict frozen dependency installation, the complete test suite, committed and rebuilt client consistency, offline preview compilation, and npm archive verification against raw Git blobs and the runtime import closure.
|
|
21
|
+
|
|
22
|
+
Validation evidence and pending release checks are tracked in the [0.24.0 acceptance report](docs/releases/0.24.0-validation.md). No new real provider or vendor CLI inference is claimed. Budget reservations are local estimates, not a provider billing cap; interrupted fees must be reconciled before their capacity can safely be reused.
|
|
23
|
+
- Preserve observed receipt timestamps across checkpoints; handle Windows lock contention and declared-platform PATH/ZCode paths, with canonical temporary-path and explicit adapter-capability CI fixtures.
|
|
24
|
+
|
|
25
|
+
|
|
3
26
|
## 0.23.1 — 2026-10-23
|
|
4
27
|
|
|
5
28
|
- Check retry budgets against the target and every unfinished downstream package that will execute. Reassignment uses the selected route estimate; successful downstream packages are excluded and unknown prices remain unknown.
|
package/README.i18n.yaml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# README translation pairing for this repository.
|
|
2
2
|
# Reviewed together; Git blob hashes identify the exact synchronized texts.
|
|
3
|
-
README.md:
|
|
4
|
-
README.zh.md:
|
|
3
|
+
README.md: 58ad39f472cdee7cbf9d4a2e07aa5b669e06683c
|
|
4
|
+
README.zh.md: 3737b74fdfb6f9ddf20e575217a5e07a5faa2a91
|