@ljwei-stak/dsh-model-router 0.21.0 → 0.22.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 +889 -91
- package/.dsh-plugin/index.mjs +225 -25
- package/.dsh-plugin/shared/assignment-solver.mjs +235 -35
- package/.dsh-plugin/shared/harness-plan.mjs +16 -4
- package/.dsh-plugin/shared/manual-plan.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +8 -1
- package/.dsh-plugin/shared/official-tool-registry.mjs +5 -3
- package/.dsh-plugin/shared/official-tools-remote.mjs +10 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +1 -0
- package/.dsh-plugin/shared/package-assignments.mjs +71 -0
- package/.dsh-plugin/shared/quality-gates.mjs +169 -0
- package/.dsh-plugin/shared/reasoning-execution.mjs +92 -0
- package/.dsh-plugin/shared/router-state.mjs +2 -1
- package/.dsh-plugin/shared/router.mjs +124 -12
- package/.dsh-plugin/shared/routing-evidence.mjs +720 -0
- package/.dsh-plugin/shared/run-ledger.mjs +85 -3
- package/.dsh-plugin/shared/task-executors.mjs +182 -27
- package/.dsh-plugin/shared/tool-install-preferences.mjs +11 -3
- package/CHANGELOG.md +21 -0
- package/README.i18n.yaml +2 -2
- package/README.md +13 -9
- package/README.zh.md +13 -9
- package/docs/INSTALLATION_GUIDE.zh.md +12 -12
- package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +46 -0
- package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +91 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +5 -3
- package/docs/releases/0.22.0-validation.md +28 -0
- package/package.json +15 -4
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -9,18 +9,23 @@ import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, routingPreset } from '.
|
|
|
9
9
|
import { HEALTH_CACHE_MS, apiKeyEnvPresent, healthCache, runHealthCheck, subscriptionLoginOf } from './shared/tool-health.mjs'
|
|
10
10
|
import { latestVersions } from './shared/latest-versions.mjs'
|
|
11
11
|
import {
|
|
12
|
-
DEFAULT_COOLDOWN_MINUTES, billingOverview, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
|
|
12
|
+
DEFAULT_COOLDOWN_MINUTES, billingOverview, billingPlan, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
|
|
13
13
|
} from './shared/subscription-billing.mjs'
|
|
14
14
|
import { createRouterState } from './shared/router-state.mjs'
|
|
15
15
|
import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
|
|
16
|
+
import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
|
|
17
|
+
import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
|
|
18
|
+
import { parseQualityGateJson, runQualityCascade } from './shared/quality-gates.mjs'
|
|
16
19
|
import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, routingData } from './shared/dynamic-data.mjs'
|
|
17
20
|
import { projectDynamicData } from './shared/dynamic-data-view.mjs'
|
|
18
21
|
import {
|
|
19
22
|
budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
|
|
20
|
-
spending, storedResults, actualCost, teamExecutionResults,
|
|
23
|
+
spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults,
|
|
21
24
|
} from './shared/run-ledger.mjs'
|
|
22
25
|
import { routeBoundaries } from './shared/security-boundaries.mjs'
|
|
23
26
|
import { createPlanFromRoutes } from './shared/harness-plan.mjs'
|
|
27
|
+
import { applyManualPackagePlan } from './shared/manual-plan.mjs'
|
|
28
|
+
import { parsePackageAssignments, packagePlanKey } from './shared/package-assignments.mjs'
|
|
24
29
|
import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
|
|
25
30
|
import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
|
|
26
31
|
import { parseRunRequest } from './shared/official-tools-remote.mjs'
|
|
@@ -70,6 +75,10 @@ export const Config = z.object({
|
|
|
70
75
|
budgetUsd: z.number().min(0).max(1_000_000).default(DEFAULT_ROUTER_SETTINGS.budgetUsd).volatile(),
|
|
71
76
|
maxConsultOutputChars: z.number().step(1).min(500).max(50_000).default(12_000).volatile(),
|
|
72
77
|
modelProfilesJson: z.string().max(32_000).default('[]').volatile(),
|
|
78
|
+
routingEvidenceJson: z.string().max(200_000).default('').volatile(),
|
|
79
|
+
resourceLearningEnabled: z.boolean().default(true).volatile(),
|
|
80
|
+
qualityEscalationEnabled: z.boolean().default(false).volatile(),
|
|
81
|
+
qualityGateJson: z.string().max(8_000).default('').volatile(),
|
|
73
82
|
routingPreset: z.union(['economy', 'balanced', 'quality']).default(DEFAULT_ROUTING_PRESET).volatile(),
|
|
74
83
|
dailyBudgetUsd: z.number().min(0).max(1_000_000).default(0).volatile(),
|
|
75
84
|
monthlyBudgetUsd: z.number().min(0).max(1_000_000).default(0).volatile(),
|
|
@@ -440,6 +449,11 @@ async function routingSavedState(config) {
|
|
|
440
449
|
// Cached source A must not masquerade as a newly configured source B.
|
|
441
450
|
saved.dynamicData = projectDynamicData(refreshedData ?? saved.dynamicData, dynamicOptions(config))
|
|
442
451
|
saved.routingLearning = buildFeedbackProfile(saved.runs, feedbackOptions(config))
|
|
452
|
+
const imported = text(valueOf(config, 'routingEvidenceJson', ''))
|
|
453
|
+
saved.routingEvidence = buildRoutingEvidence({
|
|
454
|
+
runs: valueOf(config, 'resourceLearningEnabled', true) !== false ? saved.runs : [],
|
|
455
|
+
qualityEvidence: imported ? parseRoutingEvidenceJson(imported) : null,
|
|
456
|
+
})
|
|
443
457
|
return saved
|
|
444
458
|
}
|
|
445
459
|
|
|
@@ -447,6 +461,7 @@ function planDataOptions(saved, config) {
|
|
|
447
461
|
return {
|
|
448
462
|
...routingData(saved.dynamicData, dynamicOptions(config).enabled),
|
|
449
463
|
learning: saved.routingLearning ?? buildFeedbackProfile(saved.runs, feedbackOptions(config)),
|
|
464
|
+
routingEvidence: saved.routingEvidence ?? buildRoutingEvidence(),
|
|
450
465
|
}
|
|
451
466
|
}
|
|
452
467
|
|
|
@@ -473,7 +488,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
|
|
|
473
488
|
? Promise.resolve(Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
|
|
474
489
|
: installedToolIds(),
|
|
475
490
|
])
|
|
476
|
-
const readiness = options.skipToolProbe === true ? []
|
|
491
|
+
const readiness = Array.isArray(options.readiness) ? options.readiness : options.skipToolProbe === true ? []
|
|
477
492
|
: await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
|
|
478
493
|
const executable = new Set(Array.isArray(options.runnableToolIds)
|
|
479
494
|
? options.runnableToolIds : readiness.filter(item => item.ready && (item.readyModes ?? item.modes ?? []).includes('read-only')).map(item => item.id))
|
|
@@ -487,7 +502,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
|
|
|
487
502
|
preset: routingPresetOf(config, options.preset),
|
|
488
503
|
loggedOutToolIds: loggedOut,
|
|
489
504
|
})
|
|
490
|
-
return { ...plan, budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
|
|
505
|
+
return { ...plan, packagePlanKey: packagePlanKey(plan, taskText), budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
|
|
491
506
|
}
|
|
492
507
|
|
|
493
508
|
/** One bounded, independent call through the same official LLM service. */
|
|
@@ -499,6 +514,15 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
|
|
|
499
514
|
if (!taskText) throw new Error('任务内容为空,请先描述任务。')
|
|
500
515
|
throwIfAborted(signal)
|
|
501
516
|
const limit = boundedInteger(outputLimit, 12_000, 1, 50_000)
|
|
517
|
+
const reasoning = apiReasoningRequest(route)
|
|
518
|
+
if (reasoning.error) {
|
|
519
|
+
return {
|
|
520
|
+
ok: false, provider, model, answer: '', truncated: false, finish: { kind: 'unknown' },
|
|
521
|
+
error: reasoning.error, ...reasoning.fields, notStarted: true,
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const messageText = `You are an independent specialist consulted by another AI agent. Give a concise, evidence-oriented answer in the task's language.\n\nTask:\n${taskText}`
|
|
525
|
+
const startedAt = Date.now()
|
|
502
526
|
const controller = new AbortController()
|
|
503
527
|
const onAbort = () => controller.abort(signal.reason)
|
|
504
528
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
@@ -506,6 +530,7 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
|
|
|
506
530
|
let characterLimitReached = false
|
|
507
531
|
let finish = { kind: 'unknown' }
|
|
508
532
|
let usage = null
|
|
533
|
+
let usageIncomplete = false
|
|
509
534
|
try {
|
|
510
535
|
const stream = ctx.llm.stream({
|
|
511
536
|
provider,
|
|
@@ -514,11 +539,12 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
|
|
|
514
539
|
role: 'user',
|
|
515
540
|
content: [{
|
|
516
541
|
type: 'text',
|
|
517
|
-
text:
|
|
542
|
+
text: messageText,
|
|
518
543
|
}],
|
|
519
544
|
}],
|
|
520
545
|
maxTokens: Math.min(4_096, Math.max(256, Math.ceil(limit / 2))),
|
|
521
546
|
signal: controller.signal,
|
|
547
|
+
...reasoning.options,
|
|
522
548
|
})
|
|
523
549
|
for await (const chunk of stream) {
|
|
524
550
|
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
@@ -530,11 +556,11 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
|
|
|
530
556
|
break
|
|
531
557
|
}
|
|
532
558
|
} else if (chunk?.type === 'usage' && chunk.usage && typeof chunk.usage === 'object') {
|
|
533
|
-
usage = {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
559
|
+
usage = {}
|
|
560
|
+
usageIncomplete = false
|
|
561
|
+
for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
|
|
562
|
+
if (typeof chunk.usage[key] === 'number' && Number.isFinite(chunk.usage[key]) && chunk.usage[key] >= 0) usage[key] = chunk.usage[key]
|
|
563
|
+
else if (Object.hasOwn(chunk.usage, key) || ['inputTokens', 'outputTokens'].includes(key)) usageIncomplete = true
|
|
538
564
|
}
|
|
539
565
|
} else if (chunk?.type === 'finish') {
|
|
540
566
|
const reason = chunk.reason
|
|
@@ -544,20 +570,44 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
|
|
|
544
570
|
}
|
|
545
571
|
}
|
|
546
572
|
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
finish = { kind: signal?.aborted ? 'aborted' : 'error', error: String(error?.message ?? error).slice(0, 2_000) }
|
|
547
575
|
} finally {
|
|
548
576
|
signal?.removeEventListener('abort', onAbort)
|
|
549
577
|
}
|
|
550
|
-
|
|
578
|
+
const durationMs = Date.now() - startedAt
|
|
579
|
+
const inputChars = messageText.length
|
|
551
580
|
const tokenLimitReached = finish.kind === 'max-tokens'
|
|
552
581
|
const truncated = characterLimitReached || tokenLimitReached
|
|
553
|
-
if (!characterLimitReached && (finish.kind === 'error' || finish.kind === 'aborted')) {
|
|
554
|
-
return {
|
|
582
|
+
if (signal?.aborted || !characterLimitReached && (finish.kind === 'error' || finish.kind === 'aborted')) {
|
|
583
|
+
return {
|
|
584
|
+
ok: false, provider, model, answer, truncated, finish,
|
|
585
|
+
...reasoning.fields,
|
|
586
|
+
...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
|
|
587
|
+
...(Number.isFinite(inputChars) && inputChars >= 0 ? { inputChars } : {}),
|
|
588
|
+
error: finish.error,
|
|
589
|
+
...(signal?.aborted ? { cancelled: true } : {}),
|
|
590
|
+
...(usageIncomplete ? { usageIncomplete: true } : {}),
|
|
591
|
+
...(usage ? { usage } : {}),
|
|
592
|
+
}
|
|
555
593
|
}
|
|
556
594
|
if (!answer.trim()) {
|
|
557
|
-
return {
|
|
595
|
+
return {
|
|
596
|
+
ok: false, provider, model, answer, truncated, finish,
|
|
597
|
+
...reasoning.fields,
|
|
598
|
+
...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
|
|
599
|
+
...(Number.isFinite(inputChars) && inputChars >= 0 ? { inputChars } : {}),
|
|
600
|
+
error: 'model returned no text',
|
|
601
|
+
...(usageIncomplete ? { usageIncomplete: true } : {}),
|
|
602
|
+
...(usage ? { usage } : {}),
|
|
603
|
+
}
|
|
558
604
|
}
|
|
559
605
|
return {
|
|
560
606
|
ok: true, provider, model, answer, truncated, finish,
|
|
607
|
+
...(usageIncomplete ? { usageIncomplete: true } : {}),
|
|
608
|
+
...reasoning.fields,
|
|
609
|
+
...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
|
|
610
|
+
...(Number.isFinite(inputChars) && inputChars >= 0 ? { inputChars } : {}),
|
|
561
611
|
...(usage ? { usage } : {}),
|
|
562
612
|
...(tokenLimitReached
|
|
563
613
|
? { truncationReason: 'model-token-limit', notice: '模型达到本次调用的输出 token 上限,回答可能不完整。' }
|
|
@@ -699,6 +749,11 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
699
749
|
const problem = taskTextProblem(taskText)
|
|
700
750
|
if (problem) throw new Error(problem)
|
|
701
751
|
const direct = text(options.provider) || text(options.model)
|
|
752
|
+
const manual = options.assignmentMode === 'manual'
|
|
753
|
+
if (options.assignmentMode !== undefined && !['algorithm', 'manual'].includes(options.assignmentMode)) throw new Error('不支持的分配方式。')
|
|
754
|
+
if (manual && direct) throw new Error('手动分配工作包不能同时指定全局单一模型。')
|
|
755
|
+
if (!manual && (options.packageAssignments !== undefined || options.packagePlanKey !== undefined)) throw new Error('工作包绑定需要选择手动分配。')
|
|
756
|
+
if (manual && valueOf(config, 'allowManualReassign', true) === false) throw new Error('设置中已关闭手动改派。')
|
|
702
757
|
if (direct && (!text(options.provider) || !text(options.model))) throw new Error('指定模型需要同时提供 provider 和 model。')
|
|
703
758
|
const saved = await routingSavedState(config)
|
|
704
759
|
const routes = await routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning)
|
|
@@ -708,7 +763,7 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
708
763
|
const installed = options.skipToolProbe === true
|
|
709
764
|
? (Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
|
|
710
765
|
: await installedToolIds()
|
|
711
|
-
const readiness = options.skipToolProbe === true ? []
|
|
766
|
+
const readiness = Array.isArray(options.readiness) ? options.readiness : options.skipToolProbe === true ? []
|
|
712
767
|
: await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
|
|
713
768
|
const runnableToolIds = Array.isArray(options.runnableToolIds)
|
|
714
769
|
? options.runnableToolIds
|
|
@@ -729,8 +784,18 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
729
784
|
budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
730
785
|
})
|
|
731
786
|
let plan = planWith(preset, options.budgetUsd)
|
|
787
|
+
if (manual) {
|
|
788
|
+
plan = applyManualPackagePlan(plan, { task: taskText, packageAssignments: options.packageAssignments,
|
|
789
|
+
packagePlanKey: options.packagePlanKey }, routes, planDataOptions(saved, config))
|
|
790
|
+
await validateManualTools(ctx, plan.manualAssignments, { ...options, installedToolIds: installed, readiness })
|
|
791
|
+
}
|
|
732
792
|
let budget = budgetFor(config, saved, plan.estimatedCost)
|
|
733
|
-
|
|
793
|
+
const perRunLimit = Number(options.budgetUsd) || 0
|
|
794
|
+
if (manual && perRunLimit > 0 && (plan.estimatedCost === null || plan.estimatedCost > perRunLimit)) {
|
|
795
|
+
budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.estimatedCost,
|
|
796
|
+
message: plan.estimatedCost === null ? '手动分配包含费用未知的目标,无法确认满足本次预算。' : '手动分配的预估费用超出本次预算。' }
|
|
797
|
+
}
|
|
798
|
+
if (budget.exceeded && budget.action === 'downgrade' && !direct && !manual) {
|
|
734
799
|
const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
|
|
735
800
|
const recheck = budgetFor(config, saved, cheaper.estimatedCost)
|
|
736
801
|
if (!recheck.exceeded) {
|
|
@@ -742,9 +807,48 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
742
807
|
return { taskText, direct: Boolean(direct), plan, budget, routes, preset }
|
|
743
808
|
}
|
|
744
809
|
|
|
810
|
+
/** Check every explicit tool before any package can incur cost. */
|
|
811
|
+
async function validateManualTools(ctx, assignments, options = {}) {
|
|
812
|
+
const targets = parsePackageAssignments(assignments)
|
|
813
|
+
const ids = [...new Set(Object.values(targets ?? {}).filter(item => item.kind === 'tool').map(item => item.toolId))]
|
|
814
|
+
if (!ids.length) return
|
|
815
|
+
if (typeof ctx.sandbox?.confine !== 'function') throw new Error('手动分配的官方工具需要 Harness 进程沙箱;请检查执行环境后重新预览。')
|
|
816
|
+
const installed = options.installedToolIds ?? await installedToolIds()
|
|
817
|
+
const capabilities = options.executionCapabilities ?? officialToolExecutionCapabilities()
|
|
818
|
+
const knownReadiness = options.manualReadiness ?? options.readiness ?? []
|
|
819
|
+
for (const id of ids) {
|
|
820
|
+
if (!installed.includes(id)) throw new Error(`手动分配的官方工具 ${id} 尚未安装。`)
|
|
821
|
+
if (!capabilities.some(item => item.id === id && item.modes?.includes('read-only'))) throw new Error(`官方工具 ${id} 不支持当前平台的只读执行。`)
|
|
822
|
+
const ready = knownReadiness.find(item => item.id === id)
|
|
823
|
+
?? await (options.readinessFor ?? officialToolReadiness)(id, options.workspace ?? process.cwd())
|
|
824
|
+
if (ready?.ready !== true || !(ready.readyModes ?? ready.modes ?? []).includes('read-only')) {
|
|
825
|
+
throw new Error(`官方工具 ${id} 尚未就绪:${ready?.reason ?? '请检查工具安装和只读执行支持。'}`)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Structural validation is opt-in; each API attempt retains its own price and usage. */
|
|
831
|
+
async function consultWithQualityGate(ctx, route, task, limit, signal, { gate = null, alternatives = [], approveUpgrade } = {}) {
|
|
832
|
+
if (!gate) return consultConfiguredModel(ctx, route, task, limit, signal)
|
|
833
|
+
const checked = await runQualityCascade({
|
|
834
|
+
route, task, gate, alternatives, approveUpgrade, signal,
|
|
835
|
+
call: request => consultConfiguredModel(ctx, request.route, request.task, limit, request.signal),
|
|
836
|
+
})
|
|
837
|
+
return { ...checked.result,
|
|
838
|
+
provider: checked.result.provider ?? route.provider, model: checked.result.model ?? route.model,
|
|
839
|
+
...(checked.attempts.length ? { qualityAttempts: checked.attempts } : {}),
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function apiUpgradeEligible(route, routes) {
|
|
844
|
+
return (route?.billing === undefined || route.billing === 'api-only') && route?.execution !== 'official'
|
|
845
|
+
&& billingPlan(route, routes).subscription === null
|
|
846
|
+
&& (route?.execution === 'api' || !toolForProvider(route?.provider))
|
|
847
|
+
}
|
|
848
|
+
|
|
745
849
|
/** Route, or honor one explicit model, then run each package through its adapter. */
|
|
746
850
|
export async function executeConfiguredAssignment(ctx, task, config = {}, options = {}) {
|
|
747
|
-
const { taskText, direct, plan, budget, routes, preset } = await planAssignment(ctx, task, config, options)
|
|
851
|
+
const { taskText, direct, plan, budget, routes, preset } = options.preparedAssignment ?? await planAssignment(ctx, task, config, options)
|
|
748
852
|
if (budget.exceeded && options.confirmOverBudget !== true) {
|
|
749
853
|
return {
|
|
750
854
|
plan, execution: null, paused: true,
|
|
@@ -757,6 +861,42 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
|
|
|
757
861
|
const billing = await billingContext(config, options)
|
|
758
862
|
const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
|
|
759
863
|
const startedAt = Date.now()
|
|
864
|
+
const gate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
|
|
865
|
+
const gatedApi = async ({ route, task: packageTask, signal, subscription }) => {
|
|
866
|
+
if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, signal)
|
|
867
|
+
const quality = routeQuality(route)
|
|
868
|
+
const alternatives = gate && !direct && route.manualPinned !== true && apiUpgradeEligible(route, routes)
|
|
869
|
+
&& valueOf(config, 'qualityEscalationEnabled', false) === true && quality !== null
|
|
870
|
+
? routes.filter(candidate => apiUpgradeEligible(candidate, routes) && routeQuality(candidate) !== null
|
|
871
|
+
&& routeQuality(candidate) > quality && (candidate.provider !== route.provider || candidate.model !== route.model))
|
|
872
|
+
.slice().sort((left, right) => routeQuality(right) - routeQuality(left)
|
|
873
|
+
|| `${left.provider}/${left.model}`.localeCompare(`${right.provider}/${right.model}`))
|
|
874
|
+
: []
|
|
875
|
+
return consultWithQualityGate(ctx, route, packageTask, limit, signal, { gate, alternatives,
|
|
876
|
+
approveUpgrade: async ({ to, attempts }) => {
|
|
877
|
+
if (valueOf(config, 'qualityEscalationEnabled', false) !== true || direct || route.manualPinned === true
|
|
878
|
+
|| !apiUpgradeEligible(route, routes) || !apiUpgradeEligible(to, routes)) return false
|
|
879
|
+
const saved = await routingSavedState(config)
|
|
880
|
+
const limits = budgetSettings(config)
|
|
881
|
+
const perRunLimit = Number(plan.optimization?.budgetUsd) || 0
|
|
882
|
+
const constrained = limits.dailyLimitUsd > 0 || limits.monthlyLimitUsd > 0 || perRunLimit > 0
|
|
883
|
+
const spent = spending(saved.runs, undefined, saved.archivedSpending)
|
|
884
|
+
if ((limits.dailyLimitUsd > 0 && spent.unknownToday > 0) || (limits.monthlyLimitUsd > 0 && spent.unknownMonth > 0)) return false
|
|
885
|
+
const estimate = createPlanFromRoutes(packageTask, [to], {
|
|
886
|
+
...planDataOptions(saved, config), mode: 'direct', directProvider: to.provider, directModel: to.model,
|
|
887
|
+
}).estimatedCost
|
|
888
|
+
if (!Number.isFinite(estimate) || estimate < 0) return false
|
|
889
|
+
const costs = [...finished.map(result => !hasStartedQualityAttempts(result)
|
|
890
|
+
&& (result.blocked || result.notStarted || billingForResult(result) === 'subscription') ? { costUsd: 0 }
|
|
891
|
+
: actualCost(result, routePricing(routes, result))), ...attempts.map(result => actualCost(result, result.pricingSnapshot))]
|
|
892
|
+
const pending = plan.costBreakdown.slice(finished.length + 1).map(item => item.estimatedCost)
|
|
893
|
+
if (costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))) return !constrained
|
|
894
|
+
const projected = costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
|
|
895
|
+
if (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit) return false
|
|
896
|
+
return !budgetFor(config, saved, projected).exceeded
|
|
897
|
+
},
|
|
898
|
+
})
|
|
899
|
+
}
|
|
760
900
|
let execution
|
|
761
901
|
try {
|
|
762
902
|
execution = await executeAssignmentPlan({
|
|
@@ -767,7 +907,7 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
|
|
|
767
907
|
signal: options.signal,
|
|
768
908
|
...hooks,
|
|
769
909
|
billing,
|
|
770
|
-
apiFallback:
|
|
910
|
+
apiFallback: gatedApi,
|
|
771
911
|
})
|
|
772
912
|
} catch (error) {
|
|
773
913
|
// Steps that already ran (and may have been paid for) are always recorded with their spend.
|
|
@@ -882,12 +1022,37 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
|
|
|
882
1022
|
const cwd = text(workspace) || run.workspace
|
|
883
1023
|
if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
|
|
884
1024
|
if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root: text(root) || cwd, signal, options, config })
|
|
1025
|
+
const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
|
|
1026
|
+
.filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
|
|
1027
|
+
const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
|
|
1028
|
+
.map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
|
|
1029
|
+
if (Object.keys(manualRetries).length) {
|
|
1030
|
+
for (const [id, assignment] of Object.entries(parsePackageAssignments(manualRetries))) {
|
|
1031
|
+
if (assignment.kind !== 'api') continue
|
|
1032
|
+
const route = routes.find(item => item.provider === assignment.provider && item.model === assignment.model)
|
|
1033
|
+
if (!route) throw new Error('重跑所选的模型已不在官方目录中,请重新分配。')
|
|
1034
|
+
const policy = billingPlan(route, routes)
|
|
1035
|
+
if (policy.mode === 'subscription-only' || policy.subscription?.kind === 'plan-key') throw new Error('该模型使用订阅策略,不能作为手动 API 目标重跑。')
|
|
1036
|
+
const item = run.packages.find(entry => entry.id === id)
|
|
1037
|
+
if (item?.type === 'vision' && Array.isArray(route.inputModalities) && route.inputModalities.length
|
|
1038
|
+
&& !route.inputModalities.some(value => String(value).toLowerCase() === 'image')) {
|
|
1039
|
+
throw new Error(`工作包 ${id} 需要图像模型,所选重跑目标仅声明其他输入能力。`)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
await validateManualTools(ctx, manualRetries, { ...options, workspace: cwd })
|
|
1043
|
+
}
|
|
885
1044
|
const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
|
|
1045
|
+
const retryGate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
|
|
886
1046
|
const execution = await rerunAssignmentPackage({
|
|
887
1047
|
task: run.task,
|
|
888
1048
|
packages: run.packages.map(item => ({
|
|
889
1049
|
id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
|
|
1050
|
+
type: item.type, purpose: item.purpose,
|
|
1051
|
+
...(item.manualAssignment ? { manualAssignment: item.manualAssignment, selectionSource: 'manual',
|
|
1052
|
+
algorithmRecommendation: item.algorithmRecommendation } : {}),
|
|
890
1053
|
recommendedProvider: item.provider ?? item.recommendedProvider, recommendedModel: item.model ?? item.recommendedModel,
|
|
1054
|
+
...(item.plannedReasoningEffort && item.provider === item.recommendedProvider && item.model === item.recommendedModel
|
|
1055
|
+
? { recommendedReasoningEffort: item.plannedReasoningEffort } : {}),
|
|
891
1056
|
})),
|
|
892
1057
|
previous: storedResults(run),
|
|
893
1058
|
packageId: target.id,
|
|
@@ -899,7 +1064,9 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
|
|
|
899
1064
|
...(choice ? { subscriptionChoice: choice } : {}),
|
|
900
1065
|
...executionHooks(ctx, options),
|
|
901
1066
|
billing: await billingContext(config, options),
|
|
902
|
-
apiFallback: async ({ route, task: packageTask, signal: callSignal }) =>
|
|
1067
|
+
apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => subscription
|
|
1068
|
+
? consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
|
|
1069
|
+
: consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate }),
|
|
903
1070
|
})
|
|
904
1071
|
const updated = await routerStateStore().updateRun(run.id, current => {
|
|
905
1072
|
mergeRerun(current, execution, { rerunIds: execution.ranIds, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
|
|
@@ -935,6 +1102,9 @@ function teamPlanFromRun(run, ids, override, targetId) {
|
|
|
935
1102
|
verificationChecklist: item.verificationChecklist ?? [],
|
|
936
1103
|
recommendedProvider: item.id === targetId && override ? override.provider : item.recommendedProvider,
|
|
937
1104
|
recommendedModel: item.id === targetId && override ? override.model : item.recommendedModel,
|
|
1105
|
+
...(item.plannedReasoningEffort && !(item.id === targetId && override
|
|
1106
|
+
&& (override.provider !== item.recommendedProvider || override.model !== item.recommendedModel))
|
|
1107
|
+
? { recommendedReasoningEffort: item.plannedReasoningEffort } : {}),
|
|
938
1108
|
estimatedCost: item.estimatedCost ?? null,
|
|
939
1109
|
}))
|
|
940
1110
|
return { mode: 'team', team: { workPackages } }
|
|
@@ -1156,6 +1326,7 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
|
|
|
1156
1326
|
// Legacy scalar quality biases are intentionally no longer learned.
|
|
1157
1327
|
biases: {},
|
|
1158
1328
|
learning: saved.routingLearning,
|
|
1329
|
+
routingEvidence: saved.routingEvidence,
|
|
1159
1330
|
dynamicData: dynamicDataSummary(saved.dynamicData, { ...dynamicOptions(config), now: Date.now() }),
|
|
1160
1331
|
routingData: {
|
|
1161
1332
|
...routingData(saved.dynamicData, dynamicOptions(config).enabled),
|
|
@@ -1276,7 +1447,7 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
|
|
|
1276
1447
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1277
1448
|
const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
|
|
1278
1449
|
const plan = createPlanFromRoutes(taskText, executableRoutes, {
|
|
1279
|
-
...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed,
|
|
1450
|
+
...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
|
|
1280
1451
|
runnableToolIds: installed.filter(id => supported.has(id)), preset: routingPresetOf(config, args.preset),
|
|
1281
1452
|
})
|
|
1282
1453
|
const bindingsText = text(args.cliModelsJson)
|
|
@@ -1338,6 +1509,10 @@ export function decisionSummary(plan) {
|
|
|
1338
1509
|
difficulty: item.difficulty ?? null, estimatedCost: item.estimatedCost ?? null,
|
|
1339
1510
|
channel: item.executionChannel ?? null, channelDetail: item.channelDetail ?? '',
|
|
1340
1511
|
dependsOn: item.dependsOn ?? [],
|
|
1512
|
+
...(item.manualAssignment ? { manualAssignment: item.manualAssignment, selectionSource: 'manual',
|
|
1513
|
+
algorithmRecommendation: item.algorithmRecommendation ?? null,
|
|
1514
|
+
...(item.manualAssignment.kind === 'tool' ? { toolId: item.manualAssignment.toolId,
|
|
1515
|
+
toolLabel: getOfficialTool(item.manualAssignment.toolId)?.label ?? item.manualAssignment.toolId } : {}) } : {}),
|
|
1341
1516
|
})),
|
|
1342
1517
|
}
|
|
1343
1518
|
}
|
|
@@ -1413,7 +1588,8 @@ export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = n
|
|
|
1413
1588
|
if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
|
|
1414
1589
|
if (name === 'model_router_rerun_step' && args.subscriptionChoice === 'api') codes.push('subscription-api')
|
|
1415
1590
|
if (args.confirmOverBudget === true) codes.push('over-budget')
|
|
1416
|
-
const directRun = name === 'model_router_execute'
|
|
1591
|
+
const directRun = name === 'model_router_execute' && args.assignmentMode !== 'manual'
|
|
1592
|
+
|| (name === 'model_router_rerun_step' && (!rerunRun || rerunSupport(rerunRun).kind === 'assign') && rerunRun?.assignmentMode !== 'manual')
|
|
1417
1593
|
if ((name === 'model_router_team_execute' && args.executionEnvironment === 'cli-native')
|
|
1418
1594
|
|| (name === 'model_router_rerun_step' && rerunRun?.executionEnvironment === 'cli-native')) codes.push('unsandboxed')
|
|
1419
1595
|
if (directRun && valueOf(config, 'confirmUnsandboxedCli', true) !== false && mayLaunchDirectCli(args)) codes.push('unsandboxed')
|
|
@@ -1475,7 +1651,12 @@ export async function workbenchWorkspace(requested, runs = []) {
|
|
|
1475
1651
|
return chosen
|
|
1476
1652
|
}
|
|
1477
1653
|
|
|
1478
|
-
|
|
1654
|
+
function manualRequestOptions(request) {
|
|
1655
|
+
return request.assignmentMode === 'manual' ? { assignmentMode: 'manual',
|
|
1656
|
+
packageAssignments: request.packageAssignments, packagePlanKey: request.packagePlanKey } : {}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/** Resolve one fresh preview and retain its exact plan for the confirmed run. */
|
|
1479
1660
|
async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {}) {
|
|
1480
1661
|
const request = parseRunRequest(supplied)
|
|
1481
1662
|
const saved = await savedState()
|
|
@@ -1511,12 +1692,16 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
|
|
|
1511
1692
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1512
1693
|
const planned = await planAssignment(ctx, request.task, config, {
|
|
1513
1694
|
provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
|
|
1695
|
+
...manualRequestOptions(request),
|
|
1514
1696
|
budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)), workspace, signal: options.signal, ...options.planOptions,
|
|
1515
1697
|
})
|
|
1516
1698
|
await hydrateHealth()
|
|
1517
|
-
const codes =
|
|
1518
|
-
|
|
1699
|
+
const codes = request.assignmentMode === 'manual'
|
|
1700
|
+
? planned.budget.exceeded ? ['over-budget'] : []
|
|
1701
|
+
: approvalReasonCodes('model_router_execute', { provider: request.provider, confirmOverBudget: Boolean(planned.budget.exceeded) }, config)
|
|
1702
|
+
return { request, planned, preview: {
|
|
1519
1703
|
workspace,
|
|
1704
|
+
assignmentMode: request.assignmentMode ?? 'algorithm', packagePlanKey: packagePlanKey(planned.plan, request.task),
|
|
1520
1705
|
decision: decisionSummary(planned.plan),
|
|
1521
1706
|
selected: planned.plan.selected ?? null,
|
|
1522
1707
|
routingBypassed: planned.plan.routingBypassed === true,
|
|
@@ -1553,8 +1738,10 @@ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}
|
|
|
1553
1738
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1554
1739
|
const result = await (options.execute ?? executeConfiguredAssignment)(ctx, request.task, config, {
|
|
1555
1740
|
provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
|
|
1741
|
+
...manualRequestOptions(request),
|
|
1556
1742
|
budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)),
|
|
1557
1743
|
confirmOverBudget: confirmed.has('over-budget'), workspace: preview.workspace, signal: options.signal, ...options.planOptions,
|
|
1744
|
+
preparedAssignment: prepared.planned,
|
|
1558
1745
|
})
|
|
1559
1746
|
const awaiting = result.awaitingConfirmation ?? []
|
|
1560
1747
|
return {
|
|
@@ -1980,12 +2167,15 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
|
|
|
1980
2167
|
}))
|
|
1981
2168
|
ctx.tools.register(defineTool({
|
|
1982
2169
|
name: 'model_router_execute',
|
|
1983
|
-
description: 'Run a task on configured models and aggregate the results. Omit provider and model to route first. Supply both to use that one model and skip routing.
|
|
2170
|
+
description: 'Run a task on configured models and aggregate the results. Omit provider and model to route first. Supply both to use that one model and skip routing. With assignmentMode manual, use the packagePlanKey from model_router_plan and packageAssignmentsJson to pin each package to an exact API route or registered official tool. Manual tool failures do not fall back to API. Automatic profiles retain CLI/API billing policy. This call is read-only and does not apply file edits.',
|
|
1984
2171
|
parameters: {
|
|
1985
2172
|
task: { type: 'string', required: true, description: 'Task to execute.' },
|
|
1986
2173
|
provider: { type: 'string', description: 'Configured provider. Pair with model to bypass routing.' },
|
|
1987
2174
|
model: { type: 'string', description: 'Configured model. Pair with provider to bypass routing.' },
|
|
1988
2175
|
planMode: { type: 'string', enum: ['single', 'team'], description: 'Used only when provider and model are omitted.' },
|
|
2176
|
+
assignmentMode: { type: 'string', enum: ['algorithm', 'manual'], description: 'Use algorithm recommendations, or pin every decomposed package to the user-selected API model or official tool.' },
|
|
2177
|
+
packagePlanKey: { type: 'string', description: 'Required for manual mode: the current decomposition key returned by model_router_plan for the same task and mode.' },
|
|
2178
|
+
packageAssignmentsJson: { type: 'string', description: 'Required for manual mode: JSON object keyed by every package id, values {kind:"api",provider,model} or {kind:"tool",toolId}. Tools use their configured default model. No commands or arguments.' },
|
|
1989
2179
|
budgetUsd: { type: 'number', description: 'Optional local estimate ceiling when routing. Not a vendor billing cap.' },
|
|
1990
2180
|
preset: { type: 'string', enum: ['economy', 'balanced', 'quality'], description: 'Optional routing preset for this call; defaults to the saved setting.' },
|
|
1991
2181
|
confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the daily/monthly budget. Triggers an approval prompt.' },
|
|
@@ -1994,10 +2184,19 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
|
|
|
1994
2184
|
async execute(args, exec) {
|
|
1995
2185
|
const { cwd } = await sessionWorkspace(ctx, exec)
|
|
1996
2186
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
2187
|
+
let assignments
|
|
2188
|
+
if (args.packageAssignmentsJson !== undefined) {
|
|
2189
|
+
if (typeof args.packageAssignmentsJson !== 'string' || args.packageAssignmentsJson.length > 32_000) throw new Error('工作包分配 JSON 超出限制。')
|
|
2190
|
+
try { assignments = parsePackageAssignments(JSON.parse(args.packageAssignmentsJson)) }
|
|
2191
|
+
catch { throw new Error('工作包分配 JSON 无效;请按包 ID 提供模型 API 或官方工具目标。') }
|
|
2192
|
+
}
|
|
1997
2193
|
const result = await executeConfiguredAssignment(ctx, args.task, config, {
|
|
1998
2194
|
provider: args.provider,
|
|
1999
2195
|
model: args.model,
|
|
2000
2196
|
planMode: args.planMode,
|
|
2197
|
+
assignmentMode: args.assignmentMode,
|
|
2198
|
+
...(assignments === undefined ? {} : { packageAssignments: assignments }),
|
|
2199
|
+
...(args.packagePlanKey === undefined ? {} : { packagePlanKey: args.packagePlanKey }),
|
|
2001
2200
|
preset: args.preset,
|
|
2002
2201
|
budgetUsd: finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)),
|
|
2003
2202
|
confirmOverBudget: args.confirmOverBudget === true,
|
|
@@ -2016,6 +2215,7 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
|
|
|
2016
2215
|
notice: result.paused
|
|
2017
2216
|
? '已因预算暂停,未启动任何模型。请向用户说明预估费用,用户同意后再以 confirmOverBudget: true 调用。'
|
|
2018
2217
|
: result.awaitingConfirmation ? SUBSCRIPTION_PAUSE_NOTICE
|
|
2218
|
+
: result.plan.assignmentMode === 'manual' ? '已按手动分配执行只读工作包;API 模型和官方工具目标固定,工具使用厂商默认模型,失败不会自动改派。可用 model_router_rerun_step 按保存的目标重跑。'
|
|
2019
2219
|
: '官方 CLI 以只读无界面方式运行。可编辑改动仍使用 model_router_tool_run 或 model_router_team_execute。未安装、未登录、失败或配置为 api 的模型走模型目录 API;回退原因与 CLI 原始错误见 fallback。可用 model_router_rerun_step 重跑失败步骤,model_router_rate 记录评价。',
|
|
2020
2220
|
})
|
|
2021
2221
|
},
|