@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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing presets. Each preset tilts the planner's objective weights between
|
|
3
|
+
* quality and cost and shifts the per-task quality floor that a cheaper
|
|
4
|
+
* substitute must clear. 均衡 (balanced) reproduces the original planner.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_ROUTING_PRESET = 'balanced'
|
|
8
|
+
|
|
9
|
+
export const ROUTING_PRESETS = Object.freeze({
|
|
10
|
+
economy: Object.freeze({
|
|
11
|
+
id: 'economy', label: '省钱优先',
|
|
12
|
+
description: '更看重单价,允许质量略低的模型承担简单和中等任务。',
|
|
13
|
+
floorDelta: -0.04, tilt: Object.freeze({ quality: 0.75, cost: 1.6, latency: 1.1 }),
|
|
14
|
+
}),
|
|
15
|
+
balanced: Object.freeze({
|
|
16
|
+
id: 'balanced', label: '均衡',
|
|
17
|
+
description: '默认:按任务难度平衡质量、成本和速度。',
|
|
18
|
+
floorDelta: 0, tilt: Object.freeze({}),
|
|
19
|
+
}),
|
|
20
|
+
quality: Object.freeze({
|
|
21
|
+
id: 'quality', label: '效果优先',
|
|
22
|
+
description: '更看重质量,提高替代模型必须达到的质量门槛。',
|
|
23
|
+
floorDelta: 0.04, tilt: Object.freeze({ quality: 1.35, cost: 0.5, specialty: 1.2, reasoning: 1.2 }),
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export function normalizeRoutingPreset(value) {
|
|
28
|
+
return Object.hasOwn(ROUTING_PRESETS, value) ? value : DEFAULT_ROUTING_PRESET
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function routingPreset(value) {
|
|
32
|
+
return ROUTING_PRESETS[normalizeRoutingPreset(value)]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Tilt objective weights and renormalize to the original total. */
|
|
36
|
+
export function presetWeights(weights, preset) {
|
|
37
|
+
const { tilt } = routingPreset(preset)
|
|
38
|
+
if (!weights || Object.keys(tilt).length === 0) return weights
|
|
39
|
+
const total = Object.values(weights).reduce((sum, value) => sum + value, 0)
|
|
40
|
+
const tilted = Object.fromEntries(Object.entries(weights).map(([key, value]) => [key, value * (tilt[key] ?? 1)]))
|
|
41
|
+
const tiltedTotal = Object.values(tilted).reduce((sum, value) => sum + value, 0)
|
|
42
|
+
return Object.freeze(Object.fromEntries(Object.entries(tilted).map(([key, value]) => [key, value * total / tiltedTotal])))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Quality floor after the preset shift, kept within [0.5, 0.97]. */
|
|
46
|
+
export function presetFloor(floor, preset) {
|
|
47
|
+
const value = Number(floor) + routingPreset(preset).floorDelta
|
|
48
|
+
return Math.max(0.5, Math.min(0.97, value))
|
|
49
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure run-ledger logic shared by the Host and the Desktop panel: actual cost
|
|
3
|
+
* from reported token usage, daily/monthly spending, budget decisions, and
|
|
4
|
+
* gentle routing adjustments from user ratings and reviews.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const MAX_STORED_ANSWER = 4_000
|
|
8
|
+
export const MAX_STORED_TASK = 20_000
|
|
9
|
+
/** Ratings move a route's quality by at most this much (quality is 0–1). */
|
|
10
|
+
export const MAX_QUALITY_BIAS = 0.04
|
|
11
|
+
|
|
12
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value)
|
|
13
|
+
const routeKey = (provider, model) => `${String(provider ?? '')}\u0000${String(model ?? '')}`
|
|
14
|
+
|
|
15
|
+
/** USD cost of one package. Prefers a CLI-reported cost, else usage × configured price. */
|
|
16
|
+
export function actualCost(result, pricing) {
|
|
17
|
+
if (finite(result?.reportedCostUsd) && result.reportedCostUsd >= 0) {
|
|
18
|
+
return { costUsd: result.reportedCostUsd, costSource: 'cli-reported' }
|
|
19
|
+
}
|
|
20
|
+
const usage = result?.usage
|
|
21
|
+
if (!usage || !pricing || !finite(pricing.input) || !finite(pricing.output)) {
|
|
22
|
+
return { costUsd: null, costSource: usage ? 'price-missing' : 'usage-missing' }
|
|
23
|
+
}
|
|
24
|
+
const input = finite(usage.inputTokens) ? usage.inputTokens : 0
|
|
25
|
+
const output = finite(usage.outputTokens) ? usage.outputTokens : 0
|
|
26
|
+
const cacheRead = finite(usage.cacheReadTokens) ? usage.cacheReadTokens : 0
|
|
27
|
+
const cacheWrite = finite(usage.cacheWriteTokens) ? usage.cacheWriteTokens : 0
|
|
28
|
+
const costUsd = (input * pricing.input
|
|
29
|
+
+ cacheRead * (finite(pricing.cacheRead) ? pricing.cacheRead : pricing.input)
|
|
30
|
+
+ cacheWrite * (finite(pricing.cacheWrite) ? pricing.cacheWrite : pricing.input)
|
|
31
|
+
+ output * pricing.output) / 1_000_000
|
|
32
|
+
return { costUsd, costSource: 'usage' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Who pays for a package. `api`: an API key or the model-catalog API, counted
|
|
37
|
+
* against budgets. `subscription`: an official CLI on its own account login with
|
|
38
|
+
* no API key injected; its CLI-reported or usage × price figure is only an
|
|
39
|
+
* API-equivalent reference and is not counted against budgets.
|
|
40
|
+
* `loginBilling` comes from the health check ('api-key' when the CLI's own
|
|
41
|
+
* login is an API key). `apiKeyPresent` is for runners that do not report
|
|
42
|
+
* `credentialSource` (the signed team runner inherits vendor key variables).
|
|
43
|
+
*/
|
|
44
|
+
export function billingOf(result, { loginBilling = null, apiKeyPresent = false } = {}) {
|
|
45
|
+
if (!result || result.blocked) return null
|
|
46
|
+
// The subscription-first executor states the channel it billed explicitly.
|
|
47
|
+
if (result.billing === 'api' || result.billing === 'subscription') return result.billing
|
|
48
|
+
if (result.channel !== 'official-cli') return 'api'
|
|
49
|
+
if (result.credentialSource === 'configured-api-key' || result.credentialSource === 'process-environment') return 'api'
|
|
50
|
+
if (!result.credentialSource && apiKeyPresent) return 'api'
|
|
51
|
+
return loginBilling === 'api-key' ? 'api' : 'subscription'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Cost fields for storage: subscription runs keep the figure as `referenceCostUsd` only. */
|
|
55
|
+
export function billedCost(result, pricing, billing) {
|
|
56
|
+
const cost = actualCost(result, pricing)
|
|
57
|
+
if (billing !== 'subscription') return { ...cost, billing: billing ?? 'api', referenceCostUsd: null }
|
|
58
|
+
return { costUsd: null, costSource: cost.costSource, billing, referenceCostUsd: cost.costUsd }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pad = value => String(value).padStart(2, '0')
|
|
62
|
+
/** Local calendar keys (the Host's time zone, which is the user's machine). */
|
|
63
|
+
export const dayKey = at => { const date = new Date(at); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` }
|
|
64
|
+
export const monthKey = at => { const date = new Date(at); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}` }
|
|
65
|
+
|
|
66
|
+
/** Recorded spending for today and this month, plus packages whose cost is unknown. */
|
|
67
|
+
export function spending(runs, at = Date.now()) {
|
|
68
|
+
const today = dayKey(at)
|
|
69
|
+
const month = monthKey(at)
|
|
70
|
+
const total = { today: 0, month: 0, unknownToday: 0, unknownMonth: 0, subscriptionToday: 0, subscriptionMonth: 0, subscriptionRunsToday: 0, subscriptionRunsMonth: 0 }
|
|
71
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
72
|
+
for (const item of [...(run.packages ?? []), ...(run.reviews ?? [])]) {
|
|
73
|
+
const when = item.finishedAt ?? run.createdAt
|
|
74
|
+
if (!finite(when)) continue
|
|
75
|
+
const inMonth = monthKey(when) === month
|
|
76
|
+
if (!inMonth) continue
|
|
77
|
+
const inDay = dayKey(when) === today
|
|
78
|
+
if (item.billing === 'subscription') {
|
|
79
|
+
// API-equivalent reference only; a subscription login is not budget spend.
|
|
80
|
+
if (item.ran !== true) continue
|
|
81
|
+
total.subscriptionRunsMonth += 1
|
|
82
|
+
if (inDay) total.subscriptionRunsToday += 1
|
|
83
|
+
if (finite(item.referenceCostUsd)) {
|
|
84
|
+
total.subscriptionMonth += item.referenceCostUsd
|
|
85
|
+
if (inDay) total.subscriptionToday += item.referenceCostUsd
|
|
86
|
+
}
|
|
87
|
+
} else if (finite(item.costUsd)) {
|
|
88
|
+
total.month += item.costUsd
|
|
89
|
+
if (inDay) total.today += item.costUsd
|
|
90
|
+
} else if (item.ran === true) {
|
|
91
|
+
total.unknownMonth += 1
|
|
92
|
+
if (inDay) total.unknownToday += 1
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return total
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide whether a run fits the configured limits. A zero limit is off.
|
|
101
|
+
* `estimateUsd` null means prices are missing; only already-exceeded limits block then.
|
|
102
|
+
*/
|
|
103
|
+
/** USD amounts in budget messages and the workbench: always four decimals, so limits and spend line up. */
|
|
104
|
+
export function formatUsd(value) {
|
|
105
|
+
return typeof value === 'number' && Number.isFinite(value) ? `$${value.toFixed(4)}` : '—'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function budgetCheck({ estimateUsd = null, spent = { today: 0, month: 0 }, dailyLimitUsd = 0, monthlyLimitUsd = 0 } = {}) {
|
|
109
|
+
const limits = []
|
|
110
|
+
if (finite(dailyLimitUsd) && dailyLimitUsd > 0) limits.push({ period: 'daily', label: '今日', limit: dailyLimitUsd, spent: spent.today ?? 0 })
|
|
111
|
+
if (finite(monthlyLimitUsd) && monthlyLimitUsd > 0) limits.push({ period: 'monthly', label: '本月', limit: monthlyLimitUsd, spent: spent.month ?? 0 })
|
|
112
|
+
const estimateKnown = finite(estimateUsd)
|
|
113
|
+
const remaining = limits.length ? Math.max(0, Math.min(...limits.map(item => item.limit - item.spent))) : null
|
|
114
|
+
const exceeded = limits.find(item => item.spent >= item.limit || (estimateKnown && item.spent + estimateUsd > item.limit)) ?? null
|
|
115
|
+
return {
|
|
116
|
+
limited: limits.length > 0,
|
|
117
|
+
estimateKnown,
|
|
118
|
+
estimateUsd: estimateKnown ? estimateUsd : null,
|
|
119
|
+
remainingUsd: remaining,
|
|
120
|
+
exceeded: exceeded ? exceeded.period : null,
|
|
121
|
+
message: exceeded
|
|
122
|
+
? `${exceeded.label}预算 ${formatUsd(exceeded.limit)},已用 ${formatUsd(exceeded.spent)}${estimateKnown ? `,本次预估 ${formatUsd(estimateUsd)}` : ''},将超出上限。`
|
|
123
|
+
: limits.length && !estimateKnown ? '部分路线缺少单价,无法预估本次费用;仅在已用金额达到上限时阻止。' : '',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Per-route quality bias from ratings (+1/-1) and reviews (score 1–5).
|
|
129
|
+
* Bayesian-shrunk toward 0 so a single rating barely moves routing.
|
|
130
|
+
*/
|
|
131
|
+
export function routeQualityBiases(runs) {
|
|
132
|
+
const tally = new Map()
|
|
133
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
134
|
+
for (const item of run.packages ?? []) {
|
|
135
|
+
const key = routeKey(item.provider, item.model)
|
|
136
|
+
const entry = tally.get(key) ?? { sum: 0, weight: 0, ratings: 0 }
|
|
137
|
+
if (item.rating === 1 || item.rating === -1) { entry.sum += item.rating; entry.weight += 1; entry.ratings += 1 }
|
|
138
|
+
if (finite(item.review?.score)) { entry.sum += 0.5 * ((item.review.score - 3) / 2); entry.weight += 0.5 }
|
|
139
|
+
tally.set(key, entry)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const biases = {}
|
|
143
|
+
for (const [key, entry] of tally) {
|
|
144
|
+
if (entry.weight === 0) continue
|
|
145
|
+
const bias = MAX_QUALITY_BIAS * entry.sum / (entry.weight + 3)
|
|
146
|
+
biases[key] = Math.max(-MAX_QUALITY_BIAS, Math.min(MAX_QUALITY_BIAS, Number(bias.toFixed(4))))
|
|
147
|
+
}
|
|
148
|
+
return biases
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Attach biases to exact routes; the planner adds them to estimated quality. */
|
|
152
|
+
export function applyQualityBiases(routes, biases) {
|
|
153
|
+
if (!biases || typeof biases !== 'object') return routes
|
|
154
|
+
return (Array.isArray(routes) ? routes : []).map(route => {
|
|
155
|
+
const bias = biases[routeKey(route.provider, route.model)]
|
|
156
|
+
return finite(bias) && bias !== 0 ? { ...route, qualityBias: bias } : route
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function storedPackage(planned, result, pricing, finishedAt, billingFor = billingOf) {
|
|
161
|
+
const cost = !result || result.blocked || result.notStarted
|
|
162
|
+
? { costUsd: null, costSource: 'not-run', billing: null, referenceCostUsd: null }
|
|
163
|
+
: billedCost(result, pricing, billingFor(result))
|
|
164
|
+
return {
|
|
165
|
+
id: planned.id,
|
|
166
|
+
name: planned.name,
|
|
167
|
+
objective: String(planned.objective ?? '').slice(0, 2_000),
|
|
168
|
+
...(planned.type ? { type: String(planned.type) } : {}),
|
|
169
|
+
...(planned.purpose ? { purpose: String(planned.purpose) } : {}),
|
|
170
|
+
...(Array.isArray(planned.verificationChecklist) ? { verificationChecklist: planned.verificationChecklist.slice(0, 12).map(point => String(point).slice(0, 300)) } : {}),
|
|
171
|
+
dependsOn: [...(planned.dependsOn ?? [])],
|
|
172
|
+
recommendedProvider: planned.recommendedProvider,
|
|
173
|
+
recommendedModel: planned.recommendedModel,
|
|
174
|
+
difficulty: planned.difficulty ?? null,
|
|
175
|
+
estimatedCost: finite(planned.estimatedCost) ? planned.estimatedCost : null,
|
|
176
|
+
plannedChannel: planned.executionChannel ?? null,
|
|
177
|
+
provider: result?.provider ?? planned.recommendedProvider,
|
|
178
|
+
model: result?.model ?? planned.recommendedModel,
|
|
179
|
+
status: !result ? 'pending' : result.ok ? (result.fallback ? 'fallback' : 'succeeded') : result.paused ? 'paused'
|
|
180
|
+
: result.waiting ? 'waiting' : result.blocked ? 'blocked' : result.cancelled ? 'cancelled' : 'failed',
|
|
181
|
+
ok: result?.ok === true,
|
|
182
|
+
ran: Boolean(result) && !result.blocked && !result.notStarted,
|
|
183
|
+
blocked: result?.blocked === true,
|
|
184
|
+
...(result?.paused ? { paused: true, pause: { ...result.pause } } : {}),
|
|
185
|
+
...(result?.waiting ? { waiting: true } : {}),
|
|
186
|
+
reassigned: result?.reassigned === true,
|
|
187
|
+
channel: result?.channel ?? null,
|
|
188
|
+
toolId: result?.toolId ?? null,
|
|
189
|
+
credentialSource: result?.credentialSource ?? null,
|
|
190
|
+
...(result?.actualModel ? { actualModel: String(result.actualModel) } : {}),
|
|
191
|
+
fallback: result?.fallback ?? null,
|
|
192
|
+
...(result?.billingMode ? { billingMode: String(result.billingMode) } : {}),
|
|
193
|
+
...(result?.billingSwitch ? { billingSwitch: { ...result.billingSwitch } } : {}),
|
|
194
|
+
...(result?.subscriptionRoute ? { subscriptionRoute: { provider: String(result.subscriptionRoute.provider ?? ''), model: String(result.subscriptionRoute.model ?? '') } } : {}),
|
|
195
|
+
error: result?.ok ? null : (result?.error ?? null),
|
|
196
|
+
answer: String(result?.answer ?? '').slice(0, MAX_STORED_ANSWER),
|
|
197
|
+
answerTruncated: String(result?.answer ?? '').length > MAX_STORED_ANSWER,
|
|
198
|
+
usage: result?.usage ?? null,
|
|
199
|
+
...cost,
|
|
200
|
+
finishedAt,
|
|
201
|
+
review: planned.review ?? null,
|
|
202
|
+
rating: planned.rating ?? null,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Planned packages with their routing reason, for ledger and UI. */
|
|
207
|
+
export function plannedPackages(plan, task) {
|
|
208
|
+
if (plan?.routingBypassed) {
|
|
209
|
+
const route = plan.directRoute ?? plan.selected
|
|
210
|
+
return [{ id: 'direct', name: '指定模型', objective: String(task ?? ''), dependsOn: [],
|
|
211
|
+
recommendedProvider: route?.provider, recommendedModel: route?.model,
|
|
212
|
+
difficulty: plan.complexity?.band ?? null, estimatedCost: plan.estimatedCost ?? null,
|
|
213
|
+
executionChannel: plan.executionChannel ?? null }]
|
|
214
|
+
}
|
|
215
|
+
return (plan?.team?.workPackages ?? []).map(item => ({ ...item }))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One ledger record for a plan and its execution. */
|
|
219
|
+
export function buildRunRecord({ id, createdAt, task, plan, execution, preset = 'balanced', workspace = '', pricingFor = () => null, billingFor = billingOf, budget = null, finishedAt = createdAt, kind = 'assign', executionMode = 'read-only' }) {
|
|
220
|
+
const planned = plannedPackages(plan, task)
|
|
221
|
+
const results = Array.isArray(execution?.packages) ? execution.packages : []
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
createdAt,
|
|
225
|
+
finishedAt,
|
|
226
|
+
task: String(task ?? '').slice(0, MAX_STORED_TASK),
|
|
227
|
+
workspace,
|
|
228
|
+
kind,
|
|
229
|
+
executionMode,
|
|
230
|
+
routingBypassed: plan?.routingBypassed === true,
|
|
231
|
+
mode: plan?.mode ?? 'single',
|
|
232
|
+
preset,
|
|
233
|
+
decision: {
|
|
234
|
+
selected: plan?.selected ? { provider: plan.selected.provider, model: plan.selected.model } : null,
|
|
235
|
+
reason: String(plan?.reason ?? ''),
|
|
236
|
+
complexity: plan?.complexity ? { band: plan.complexity.band, value: finite(plan.complexity.value) ? Number(plan.complexity.value.toFixed(3)) : null } : null,
|
|
237
|
+
estimatedCost: finite(plan?.estimatedCost) ? plan.estimatedCost : null,
|
|
238
|
+
executionChannel: plan?.executionChannel ?? null,
|
|
239
|
+
},
|
|
240
|
+
budget,
|
|
241
|
+
status: execution?.status ?? 'pending',
|
|
242
|
+
packages: planned.map(item => storedPackage(item, results.find(result => result.id === item.id), pricingFor(results.find(result => result.id === item.id) ?? item), finishedAt, billingFor)),
|
|
243
|
+
reviews: [],
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Replace the retried packages (`rerunIds`) with new results; untouched ones keep ratings and reviews. */
|
|
248
|
+
export function mergeRerun(run, execution, { rerunIds = [], pricingFor = () => null, billingFor = billingOf, finishedAt = Date.now() } = {}) {
|
|
249
|
+
const results = Array.isArray(execution?.packages) ? execution.packages : []
|
|
250
|
+
const retried = new Set(rerunIds)
|
|
251
|
+
run.packages = run.packages.map(stored => {
|
|
252
|
+
const result = results.find(item => item.id === stored.id)
|
|
253
|
+
if (!result || !retried.has(stored.id)) return stored
|
|
254
|
+
return storedPackage({ ...stored, review: null, rating: null }, result, pricingFor(result), finishedAt, billingFor)
|
|
255
|
+
})
|
|
256
|
+
run.status = execution?.status ?? run.status
|
|
257
|
+
run.finishedAt = finishedAt
|
|
258
|
+
return run
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Results in executor shape, from stored packages, for a retry. */
|
|
262
|
+
export function storedResults(run) {
|
|
263
|
+
return (run?.packages ?? []).map(item => ({
|
|
264
|
+
id: item.id, name: item.name, ok: item.ok, provider: item.provider, model: item.model,
|
|
265
|
+
channel: item.channel, answer: item.answer, error: item.error, fallback: item.fallback,
|
|
266
|
+
blocked: item.blocked, finishedAt: item.finishedAt,
|
|
267
|
+
...(item.paused ? { paused: true } : {}), ...(item.waiting ? { waiting: true } : {}),
|
|
268
|
+
}))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const TEAM_FAILURE = new Set(['failed', 'timed-out', 'output-limit', 'model-mismatch', 'unsupported', 'integration-pending'])
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Convert the signed team runner's per-package results into the executor
|
|
275
|
+
* result shape used by the ledger. Packages the runner never reached are
|
|
276
|
+
* blocked behind the first failure (or by the preflight `blocking` list).
|
|
277
|
+
*/
|
|
278
|
+
export function teamExecutionResults(plan, execution) {
|
|
279
|
+
const planned = plan?.team?.workPackages ?? []
|
|
280
|
+
const results = Array.isArray(execution?.results) ? execution.results : []
|
|
281
|
+
const blockingReason = (execution?.blocking ?? []).map(item => item.reason).filter(Boolean).join(';')
|
|
282
|
+
const converted = results.map(result => ({
|
|
283
|
+
id: result.id,
|
|
284
|
+
ok: result.status === 'succeeded',
|
|
285
|
+
cancelled: result.status === 'cancelled',
|
|
286
|
+
provider: result.provider,
|
|
287
|
+
model: result.recommendedModel,
|
|
288
|
+
actualModel: result.actualModel ?? null,
|
|
289
|
+
channel: 'official-cli',
|
|
290
|
+
toolId: result.toolId ?? null,
|
|
291
|
+
answer: String(result.finalText ?? ''),
|
|
292
|
+
error: result.status === 'succeeded' ? null : String(result.error ?? (TEAM_FAILURE.has(result.status) ? result.status : '官方 CLI 未成功完成。')),
|
|
293
|
+
stderrTail: result.outputTail ?? null,
|
|
294
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
295
|
+
...(finite(result.reportedCostUsd) ? { reportedCostUsd: result.reportedCostUsd } : {}),
|
|
296
|
+
}))
|
|
297
|
+
const reached = new Set(converted.map(item => item.id))
|
|
298
|
+
for (const item of planned) {
|
|
299
|
+
if (reached.has(item.id)) continue
|
|
300
|
+
converted.push({ id: item.id, ok: false, blocked: true, provider: item.recommendedProvider, model: item.recommendedModel,
|
|
301
|
+
error: execution?.status === 'blocked' ? (blockingReason || '团队执行预检未通过。')
|
|
302
|
+
: execution?.status === 'cancelled' ? '团队执行已取消。' : '前置工作包未成功,团队执行已停止。' })
|
|
303
|
+
}
|
|
304
|
+
return { status: execution?.status ?? 'pending', packages: converted }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A ledger record for model_router_team_execute. */
|
|
308
|
+
export function buildTeamRunRecord({ plan, execution, mode = 'read-only', ...rest }) {
|
|
309
|
+
const record = buildRunRecord({ ...rest, plan, execution: teamExecutionResults(plan, execution), kind: 'team', executionMode: mode })
|
|
310
|
+
record.mode = 'team'
|
|
311
|
+
if (execution?.workspace && execution.workspace !== rest.workspace) record.isolatedWorkspace = String(execution.workspace)
|
|
312
|
+
if (execution?.integration) record.integration = { ignoredArtifacts: execution.integration.ignoredArtifacts ?? 0 }
|
|
313
|
+
if (typeof execution?.baseCommit === 'string') record.baseCommit = execution.baseCommit
|
|
314
|
+
return record
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A ledger record for model_router_tool_run (one package, one CLI). */
|
|
318
|
+
export function buildToolRunRecord({ toolId, toolLabel, provider, model, cliModel = null, mode = 'read-only', result, estimatedCost = null, rerunOf = null, ...rest }) {
|
|
319
|
+
const packageId = 'direct'
|
|
320
|
+
const plan = {
|
|
321
|
+
mode: 'single', routingBypassed: true,
|
|
322
|
+
directRoute: { provider: provider || toolId, model: model || result?.requestedModel || '默认模型' },
|
|
323
|
+
selected: provider && model ? { provider, model } : null,
|
|
324
|
+
reason: `直接调用 ${toolLabel ?? toolId},未经过路由。`,
|
|
325
|
+
complexity: null, estimatedCost: finite(estimatedCost) ? estimatedCost : null, executionChannel: 'official-cli',
|
|
326
|
+
}
|
|
327
|
+
const succeeded = result?.status === 'succeeded'
|
|
328
|
+
const execution = {
|
|
329
|
+
status: result?.status ?? 'pending',
|
|
330
|
+
packages: [{
|
|
331
|
+
id: packageId, ok: succeeded, cancelled: result?.status === 'cancelled',
|
|
332
|
+
provider: plan.directRoute.provider, model: plan.directRoute.model,
|
|
333
|
+
channel: 'official-cli', toolId,
|
|
334
|
+
actualModel: result?.reportedModel && typeof result.reportedModel === 'string' ? result.reportedModel : null,
|
|
335
|
+
answer: String(result?.finalText ?? ''),
|
|
336
|
+
error: succeeded ? null : String(result?.error ?? result?.reason ?? result?.status ?? '官方 CLI 未成功完成。'),
|
|
337
|
+
...(result?.usage ? { usage: result.usage } : {}),
|
|
338
|
+
...(finite(result?.reportedCostUsd) ? { reportedCostUsd: result.reportedCostUsd } : {}),
|
|
339
|
+
}],
|
|
340
|
+
}
|
|
341
|
+
const record = buildRunRecord({ ...rest, plan, execution, kind: 'tool', executionMode: mode })
|
|
342
|
+
record.packages[0].name = `${toolLabel ?? toolId} 单次调用`
|
|
343
|
+
// What a rerun needs to repeat this call exactly (no credentials).
|
|
344
|
+
record.toolRun = { toolId, provider: provider || null, model: model || null, cliModel: cliModel || null }
|
|
345
|
+
if (rerunOf) record.rerunOf = String(rerunOf)
|
|
346
|
+
if (typeof result?.isolatedWorkspace === 'string') record.isolatedWorkspace = result.isolatedWorkspace
|
|
347
|
+
return record
|
|
348
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each execution channel can read and write. Shown in settings and the
|
|
3
|
+
* workbench so users can see the boundary before allowing a run. Pure data.
|
|
4
|
+
*/
|
|
5
|
+
import { toolForProvider } from './official-tool-registry.mjs'
|
|
6
|
+
|
|
7
|
+
const READ_ONLY_SCOPE = Object.freeze({
|
|
8
|
+
'claude-code': '当前会话工作区(仅 Read/Glob/Grep 工具,dontAsk 模式拒绝未授权的工作区外读取)',
|
|
9
|
+
codex: '当前用户可读的全部文件(Codex read-only 沙箱只禁止写入和联网命令)',
|
|
10
|
+
gemini: '当前会话工作区(Gemini CLI 默认工作区限制)',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
/** `sandboxed` is true when the Harness process sandbox wraps the launch (verified runner). */
|
|
14
|
+
export function toolBoundary(toolId, { sandboxed = false, platform = 'unknown' } = {}) {
|
|
15
|
+
const directOnly = toolId === 'gemini'
|
|
16
|
+
const harnessSandbox = sandboxed && !directOnly
|
|
17
|
+
return {
|
|
18
|
+
toolId,
|
|
19
|
+
readOnly: {
|
|
20
|
+
readable: READ_ONLY_SCOPE[toolId] ?? '当前会话工作区',
|
|
21
|
+
writable: '无(只读运行,不应用任何改动)',
|
|
22
|
+
sandbox: harnessSandbox
|
|
23
|
+
? platform === 'win32' ? 'Harness 进程沙箱(Windows ACL 后端为部分强制)' : 'Harness 进程沙箱'
|
|
24
|
+
: '无 Harness 沙箱:直接启动 CLI,只读仅由 CLI 自身参数保证',
|
|
25
|
+
direct: !harnessSandbox,
|
|
26
|
+
},
|
|
27
|
+
write: toolId === 'gemini'
|
|
28
|
+
? null
|
|
29
|
+
: {
|
|
30
|
+
readable: '独立 Git 工作树(当前仓库的干净副本)',
|
|
31
|
+
writable: '独立 Git 工作树;CLI 成功且原工作区未变动时,才把源代码补丁应用回当前工作区',
|
|
32
|
+
sandbox: 'Harness 进程沙箱,缺少沙箱时拒绝启动',
|
|
33
|
+
requiresApproval: true,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Boundary rows for configured routes: CLI routes show their tool, others are API-only. */
|
|
39
|
+
export function routeBoundaries(routes, { sandboxedToolIds = [], platform = 'unknown' } = {}) {
|
|
40
|
+
return (Array.isArray(routes) ? routes : []).map(route => {
|
|
41
|
+
const tool = toolForProvider(route.provider)
|
|
42
|
+
if (!tool || route.execution === 'api') {
|
|
43
|
+
return {
|
|
44
|
+
provider: route.provider, model: route.model, toolId: null, toolLabel: null,
|
|
45
|
+
readOnly: { readable: '无本机文件访问(只把任务文本发给模型目录 API)', writable: '无', sandbox: '不启动本地进程', direct: false },
|
|
46
|
+
write: null,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
provider: route.provider, model: route.model, toolLabel: tool.label,
|
|
51
|
+
...toolBoundary(tool.id, { sandboxed: sandboxedToolIds.includes(tool.id), platform }),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|