@ljwei-stak/dsh-model-router 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.dsh-plugin/client.js +3092 -0
  2. package/.dsh-plugin/index.mjs +1651 -0
  3. package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
  4. package/.dsh-plugin/shared/harness-plan.mjs +179 -0
  5. package/.dsh-plugin/shared/livebench.mjs +264 -0
  6. package/.dsh-plugin/shared/model-profiles.mjs +142 -0
  7. package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
  8. package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
  9. package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
  10. package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
  11. package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
  12. package/.dsh-plugin/shared/router-state.mjs +207 -0
  13. package/.dsh-plugin/shared/router.mjs +1134 -0
  14. package/.dsh-plugin/shared/routing-presets.mjs +49 -0
  15. package/.dsh-plugin/shared/run-ledger.mjs +348 -0
  16. package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
  17. package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
  18. package/.dsh-plugin/shared/task-executors.mjs +1154 -0
  19. package/.dsh-plugin/shared/tool-health.mjs +311 -0
  20. package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
  21. package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
  22. package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
  23. package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
  24. package/CHANGELOG.md +36 -0
  25. package/INSTALLATION_GUIDE.zh.md +134 -0
  26. package/LICENSE +21 -0
  27. package/MIGRATION.md +53 -0
  28. package/README.i18n.yaml +3 -0
  29. package/README.md +424 -0
  30. package/README.zh.md +413 -0
  31. package/cordis.patch.yml +12 -0
  32. package/docs/assets/candidate-pruning.svg +80 -0
  33. package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
  34. package/docs/assets/router-only-0.12.0.png +0 -0
  35. package/docs/assets/routing-workflow.svg +96 -0
  36. package/docs/assets/workbench-usage.svg +119 -0
  37. package/package.json +161 -0
@@ -0,0 +1,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
+ }