@ljwei-stak/model-router-galgame 0.4.10 → 0.4.12

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.
@@ -59760,7 +59760,7 @@ var OBJECTIVE_WEIGHTS = Object.freeze({
59760
59760
  balanced: Object.freeze({ quality: 0.45, cost: 0.3, latency: 0.1, specialty: 0.1, risk: 0.05 }),
59761
59761
  complex: Object.freeze({ quality: 0.55, cost: 0.16, latency: 0.06, specialty: 0.16, risk: 0.07 })
59762
59762
  });
59763
- var QUALITY_FLOORS = Object.freeze({ simple: 0.64, balanced: 0.72, complex: 0.78 });
59763
+ var QUALITY_FLOORS = Object.freeze({ simple: 0.75, balanced: 0.78, complex: 0.82 });
59764
59764
  var MODEL_ROUTER_SETTINGS_NAMESPACE = "model-router";
59765
59765
  var DEFAULT_ROUTER_SETTINGS = Object.freeze({
59766
59766
  pricing: Object.freeze({}),
@@ -59958,48 +59958,176 @@ function taskQualityFloor(band, task) {
59958
59958
  }
59959
59959
  function taskPackages(taskType, text6, band) {
59960
59960
  if (band !== "complex") {
59961
- const task = { name: "\u76F4\u63A5\u56DE\u7B54\u4E0E\u5FC5\u8981\u6821\u9A8C", type: taskType, purpose: "execution", criticality: 0.65 };
59961
+ const task = { id: "execution", name: "\u76F4\u63A5\u56DE\u7B54\u4E0E\u5FC5\u8981\u6821\u9A8C", type: taskType, purpose: "execution", criticality: 0.65, dependsOn: [] };
59962
59962
  return [{ ...task, qualityFloor: taskQualityFloor(band, task) }];
59963
59963
  }
59964
59964
  const value = String(text6 ?? "");
59965
59965
  const packages = [
59966
- { name: "\u95EE\u9898\u5EFA\u6A21\u4E0E\u7EA6\u675F\u63D0\u53D6", type: "reasoning", purpose: "analysis", criticality: 0.92 }
59966
+ { id: "analysis", name: "\u95EE\u9898\u5EFA\u6A21\u4E0E\u7EA6\u675F\u63D0\u53D6", type: "reasoning", purpose: "analysis", criticality: 0.92, dependsOn: [] }
59967
59967
  ];
59968
59968
  const domains = [...new Set([...detectTaskTypes(text6), taskType].filter((type) => type !== "general"))];
59969
59969
  for (const type of domains.length > 0 ? domains : [taskType]) {
59970
59970
  packages.push({
59971
+ id: `execution-${type}`,
59971
59972
  name: `${TASK_TYPE_LABELS[type] ?? type}\u65B9\u5411\u5904\u7406`,
59972
59973
  type,
59973
59974
  purpose: "execution",
59974
- criticality: domains.length > 1 ? 0.8 : 0.78
59975
+ criticality: domains.length > 1 ? 0.8 : 0.78,
59976
+ dependsOn: ["analysis"]
59975
59977
  });
59976
59978
  }
59977
59979
  if (/(测试|验证|评估|对比|benchmark|test|verify|audit)/i.test(value)) {
59978
- packages.push({ name: "\u9A8C\u8BC1\u3001\u53CD\u4F8B\u4E0E\u98CE\u9669\u5BA1\u67E5", type: "reasoning", purpose: "verification", criticality: 0.88 });
59980
+ packages.push({
59981
+ id: "verification",
59982
+ name: "\u9A8C\u8BC1\u3001\u53CD\u4F8B\u4E0E\u98CE\u9669\u5BA1\u67E5",
59983
+ type: "reasoning",
59984
+ purpose: "verification",
59985
+ criticality: 0.88,
59986
+ dependsOn: packages.filter((task) => task.purpose === "execution").map((task) => task.id)
59987
+ });
59979
59988
  }
59980
- packages.push({ name: "\u7ED3\u679C\u6821\u9A8C\u4E0E\u6574\u5408", type: "reasoning", purpose: "synthesis", criticality: 1 });
59989
+ packages.push({
59990
+ id: "synthesis",
59991
+ name: "\u7ED3\u679C\u6821\u9A8C\u4E0E\u6574\u5408",
59992
+ type: "reasoning",
59993
+ purpose: "synthesis",
59994
+ criticality: 1,
59995
+ dependsOn: packages.filter((task) => task.purpose !== "analysis").map((task) => task.id)
59996
+ });
59981
59997
  return packages.map((task) => ({ ...task, qualityFloor: taskQualityFloor(band, task) }));
59982
59998
  }
59999
+ var SYNTHESIS_WEIGHTS = Object.freeze({ quality: 0.7, cost: 0.1, latency: 0.04, specialty: 0.1, risk: 0.06 });
60000
+ var ROUTING_BEAM_WIDTH = 256;
60001
+ var ROUTING_CANDIDATE_LIMIT = 12;
60002
+ function weightsForTask(weights, task) {
60003
+ return task.purpose === "synthesis" ? SYNTHESIS_WEIGHTS : weights;
60004
+ }
60005
+ function compareText(left, right) {
60006
+ const a2 = String(left);
60007
+ const b3 = String(right);
60008
+ return a2 < b3 ? -1 : a2 > b3 ? 1 : 0;
60009
+ }
60010
+ function compareRowsStable(left, right) {
60011
+ return compareText(routeKey(left.provider, left.model), routeKey(right.provider, right.model));
60012
+ }
59983
60013
  function candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio = 0, cacheWriteRatio = 0) {
59984
60014
  const quality = qualityForTask(row, task.type);
59985
60015
  const floor = Number(task.qualityFloor ?? taskQualityFloor("complex", task));
59986
60016
  const qualityGap = Math.max(0, floor - quality);
59987
60017
  const duplicatePenalty = usedRoutes.has(routeKey(row.provider, row.model)) ? 0.08 : 0;
60018
+ const synthesisPreference = task.purpose === "synthesis" && /deepseek[- ]?v4[- ]?pro/i.test(row.model) ? 0.025 : 0;
59988
60019
  const cost = costScore(row.pricing, maxCost, cacheReadRatio, cacheWriteRatio);
59989
- const score = weights.quality * quality + weights.cost * cost + weights.latency * (1 - clamp(row.latency)) + weights.specialty * specialtyForTask(row, task.type) - weights.risk * row.risk - duplicatePenalty - qualityGap * (task.criticality ?? 0.75);
60020
+ const score = weights.quality * quality + weights.cost * cost + weights.latency * (1 - clamp(row.latency)) + weights.specialty * specialtyForTask(row, task.type) - weights.risk * row.risk - duplicatePenalty - qualityGap * (task.criticality ?? 0.75) + synthesisPreference;
59990
60021
  return { score, floor, qualityGap };
59991
60022
  }
59992
- function chooseAssignment(rows, task, weights, maxCost, usedRoutes, preferred, cacheReadRatio = 0, cacheWriteRatio = 0) {
59993
- const ordered = rows.map((row) => ({ row, decision: candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio, cacheWriteRatio) })).sort((left, right) => right.decision.score - left.decision.score);
59994
- const feasible = ordered.filter((item) => qualityForTask(item.row, task.type) >= item.decision.floor);
59995
- const chosen = (preferred === true ? feasible : feasible.filter((item) => !usedRoutes.has(routeKey(item.row.provider, item.row.model))))[0] ?? feasible[0] ?? ordered[0];
59996
- if (chosen === void 0) return { row: null, relaxed: true, floor: 0, qualityGap: 1 };
59997
- return { row: chosen.row, relaxed: qualityForTask(chosen.row, task.type) < chosen.decision.floor, floor: chosen.decision.floor, qualityGap: chosen.decision.qualityGap };
59998
- }
59999
60023
  function taskCost(row, task, text6, complexity, cacheReadRatio = 0, cacheWriteRatio = 0) {
60000
60024
  const tokens = taskTokenBudget(text6, task, complexity, cacheReadRatio, cacheWriteRatio);
60001
60025
  return row === null ? 0 : ((tokens.inputTokens - tokens.cacheReadTokens - tokens.cacheWriteTokens) * row.pricing.input + tokens.cacheReadTokens * row.pricing.cacheRead + tokens.cacheWriteTokens * row.pricing.cacheWrite + tokens.outputTokens * row.pricing.output) / 1e6;
60002
60026
  }
60027
+ function dominates(left, right, task, text6, complexity, cacheReadRatio, cacheWriteRatio) {
60028
+ const leftValues = {
60029
+ quality: qualityForTask(left, task.type),
60030
+ cost: taskCost(left, task, text6, complexity, cacheReadRatio, cacheWriteRatio),
60031
+ latency: clamp(left.latency),
60032
+ specialty: specialtyForTask(left, task.type),
60033
+ risk: clamp(left.risk)
60034
+ };
60035
+ const rightValues = {
60036
+ quality: qualityForTask(right, task.type),
60037
+ cost: taskCost(right, task, text6, complexity, cacheReadRatio, cacheWriteRatio),
60038
+ latency: clamp(right.latency),
60039
+ specialty: specialtyForTask(right, task.type),
60040
+ risk: clamp(right.risk)
60041
+ };
60042
+ const noWorse = leftValues.quality >= rightValues.quality && leftValues.cost <= rightValues.cost && leftValues.latency <= rightValues.latency && leftValues.specialty >= rightValues.specialty && leftValues.risk <= rightValues.risk;
60043
+ const strictlyBetter = leftValues.quality > rightValues.quality || leftValues.cost < rightValues.cost || leftValues.latency < rightValues.latency || leftValues.specialty > rightValues.specialty || leftValues.risk < rightValues.risk;
60044
+ return noWorse && strictlyBetter;
60045
+ }
60046
+ function candidatePool(rows, task, weights, maxCost, text6, complexity, cacheReadRatio, cacheWriteRatio) {
60047
+ const floor = Number(task.qualityFloor ?? 0);
60048
+ const feasible = rows.filter((row) => qualityForTask(row, task.type) >= floor);
60049
+ const source = feasible.length > 0 ? feasible : rows.slice().sort((left, right) => qualityForTask(right, task.type) - qualityForTask(left, task.type) || compareRowsStable(left, right)).slice(0, 3);
60050
+ const taskWeights = weightsForTask(weights, task);
60051
+ const scored = source.map((row) => ({
60052
+ row,
60053
+ decision: candidateUtility(row, task, taskWeights, maxCost, /* @__PURE__ */ new Set(), cacheReadRatio, cacheWriteRatio),
60054
+ cost: taskCost(row, task, text6, complexity, cacheReadRatio, cacheWriteRatio)
60055
+ }));
60056
+ const frontier = scored.filter((item) => !source.some((other) => other !== item.row && dominates(other, item.row, task, text6, complexity, cacheReadRatio, cacheWriteRatio)));
60057
+ const essential = [
60058
+ scored.slice().sort((left, right) => left.cost - right.cost || compareRowsStable(left.row, right.row))[0],
60059
+ scored.slice().sort((left, right) => right.decision.score - left.decision.score || compareRowsStable(left.row, right.row))[0],
60060
+ scored.slice().sort((left, right) => qualityForTask(right.row, task.type) - qualityForTask(left.row, task.type) || compareRowsStable(left.row, right.row))[0]
60061
+ ].filter(Boolean);
60062
+ const ordered = [...frontier, ...essential].filter((item, index2, all3) => all3.findIndex((candidate) => candidate.row === item.row) === index2).sort((left, right) => right.decision.score - left.decision.score || left.cost - right.cost || compareRowsStable(left.row, right.row)).slice(0, ROUTING_CANDIDATE_LIMIT);
60063
+ return {
60064
+ options: ordered,
60065
+ relaxed: feasible.length === 0,
60066
+ pruned: Math.max(0, rows.length - ordered.length)
60067
+ };
60068
+ }
60069
+ function stateSignature(state) {
60070
+ return state.assignments.map((assignment) => routeKey(assignment.row?.provider, assignment.row?.model)).join("|");
60071
+ }
60072
+ function compareUtilityStates(left, right) {
60073
+ return left.relaxedCount - right.relaxedCount || left.qualityShortfall - right.qualityShortfall || right.score - left.score || left.cost - right.cost || left.switches - right.switches || compareText(stateSignature(left), stateSignature(right));
60074
+ }
60075
+ function compareCostStates(left, right) {
60076
+ return left.relaxedCount - right.relaxedCount || left.qualityShortfall - right.qualityShortfall || left.cost - right.cost || right.score - left.score || left.switches - right.switches || compareText(stateSignature(left), stateSignature(right));
60077
+ }
60078
+ function solveAssignments({ rows, tasks, weights, maxCost, text: text6, complexity, budget, cacheReadRatio, cacheWriteRatio, minimizeCost = false }) {
60079
+ const pools = tasks.map((task) => candidatePool(rows, task, weights, maxCost, text6, complexity, cacheReadRatio, cacheWriteRatio));
60080
+ if (pools.some((pool) => pool.options.length === 0)) return null;
60081
+ const suffixMinimum = Array(tasks.length + 1).fill(0);
60082
+ for (let index2 = tasks.length - 1; index2 >= 0; index2 -= 1) {
60083
+ suffixMinimum[index2] = suffixMinimum[index2 + 1] + Math.min(...pools[index2].options.map((option2) => option2.cost));
60084
+ }
60085
+ if (Number.isFinite(budget) && suffixMinimum[0] > budget + 1e-12) return null;
60086
+ let states = [{ assignments: [], routesByTask: /* @__PURE__ */ new Map(), usedRoutes: /* @__PURE__ */ new Set(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }];
60087
+ for (let index2 = 0; index2 < tasks.length; index2 += 1) {
60088
+ const task = tasks[index2];
60089
+ const pool = pools[index2];
60090
+ const expanded = [];
60091
+ for (const state of states) {
60092
+ for (const option2 of pool.options) {
60093
+ const nextCost = state.cost + option2.cost;
60094
+ if (Number.isFinite(budget) && nextCost + suffixMinimum[index2 + 1] > budget + 1e-12) continue;
60095
+ const taskWeights = weightsForTask(weights, task);
60096
+ const decision = candidateUtility(option2.row, task, taskWeights, maxCost, state.usedRoutes, cacheReadRatio, cacheWriteRatio);
60097
+ const route = routeKey(option2.row.provider, option2.row.model);
60098
+ const dependencySwitches = (task.dependsOn ?? []).reduce((count, dependency) => {
60099
+ const dependencyRoute = state.routesByTask.get(dependency);
60100
+ return count + (dependencyRoute !== void 0 && dependencyRoute !== route ? 1 : 0);
60101
+ }, 0);
60102
+ const handoffPenalty = dependencySwitches * 0.015;
60103
+ const qualityShortfall = Math.max(0, decision.floor - qualityForTask(option2.row, task.type));
60104
+ const usedRoutes = new Set(state.usedRoutes);
60105
+ usedRoutes.add(route);
60106
+ const routesByTask = new Map(state.routesByTask);
60107
+ routesByTask.set(task.id, route);
60108
+ expanded.push({
60109
+ assignments: [...state.assignments, { task, row: option2.row, decision: { ...decision, relaxed: qualityShortfall > 0 }, estimatedCost: option2.cost, handoffPenalty }],
60110
+ routesByTask,
60111
+ usedRoutes,
60112
+ score: state.score + decision.score - handoffPenalty,
60113
+ cost: nextCost,
60114
+ switches: state.switches + dependencySwitches,
60115
+ relaxedCount: state.relaxedCount + (qualityShortfall > 0 ? 1 : 0),
60116
+ qualityShortfall: state.qualityShortfall + qualityShortfall
60117
+ });
60118
+ }
60119
+ }
60120
+ if (expanded.length === 0) return null;
60121
+ expanded.sort(minimizeCost ? compareCostStates : compareUtilityStates);
60122
+ states = expanded.slice(0, ROUTING_BEAM_WIDTH);
60123
+ }
60124
+ states.sort(minimizeCost ? compareCostStates : compareUtilityStates);
60125
+ return {
60126
+ ...states[0],
60127
+ candidatePools: pools,
60128
+ minimumFeasibleCost: suffixMinimum[0]
60129
+ };
60130
+ }
60003
60131
  function buildPlan({ text: text6 = "", available = [], mode = "collective", pricing = {}, liveBench = null, liveBenchError = "", budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0 } = {}) {
60004
60132
  const complexity = assessComplexity(text6);
60005
60133
  const taskType = classifyTask(text6);
@@ -60048,48 +60176,65 @@ function buildPlan({ text: text6 = "", available = [], mode = "collective", pric
60048
60176
  });
60049
60177
  }
60050
60178
  const taskNodes = taskPackages(taskType, text6, complexity.band);
60051
- const usedRoutes = /* @__PURE__ */ new Set();
60052
- let constraintRelaxed = false;
60053
- const assignments = [];
60054
- for (const task of taskNodes) {
60055
- const preferred = task.purpose === "synthesis" ? rows.find((row2) => /deepseek[- ]?v4[- ]?pro/i.test(row2.model)) ?? rows.find((row2) => /deepseek/i.test(row2.model)) : null;
60056
- const selectedAssignment = preferred === null ? chooseAssignment(rows, task, weights, maxCost, usedRoutes, false, cacheReadRatio, cacheWriteRatio) : { row: preferred, relaxed: qualityForTask(preferred, task.type) < task.qualityFloor, floor: task.qualityFloor, qualityGap: Math.max(0, task.qualityFloor - qualityForTask(preferred, task.type)) };
60057
- const row = selectedAssignment.row;
60058
- if (row !== null) {
60059
- row.score = candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio, cacheWriteRatio).score;
60060
- usedRoutes.add(routeKey(row.provider, row.model));
60061
- }
60062
- constraintRelaxed || (constraintRelaxed = selectedAssignment.relaxed);
60063
- assignments.push({ task, row, decision: selectedAssignment });
60064
- }
60065
60179
  const budget = Number(budgetUsd);
60066
- const totalFor = () => assignments.reduce((sum, assignment) => sum + taskCost(assignment.row, assignment.task, text6, complexity.band, cacheReadRatio, cacheWriteRatio), 0);
60067
- if (budget > 0 && totalFor() > budget) {
60068
- const movable = assignments.filter((assignment) => assignment.task.purpose !== "synthesis").sort((left, right) => (left.task.criticality ?? 0) - (right.task.criticality ?? 0));
60069
- for (const assignment of movable) {
60070
- if (totalFor() <= budget) break;
60071
- const alternatives = rows.filter((row) => row !== assignment.row && qualityForTask(row, assignment.task.type) >= assignment.task.qualityFloor).sort((left, right) => taskCost(left, assignment.task, text6, complexity.band, cacheReadRatio, cacheWriteRatio) - taskCost(right, assignment.task, text6, complexity.band, cacheReadRatio, cacheWriteRatio));
60072
- const replacement = alternatives.find((row) => taskCost(row, assignment.task, text6, complexity.band, cacheReadRatio, cacheWriteRatio) < taskCost(assignment.row, assignment.task, text6, complexity.band, cacheReadRatio, cacheWriteRatio));
60073
- if (replacement !== void 0) assignment.row = replacement;
60074
- }
60075
- }
60076
- usedRoutes.clear();
60077
- for (const assignment of assignments) if (assignment.row !== null) usedRoutes.add(routeKey(assignment.row.provider, assignment.row.model));
60078
- rows.sort((a2, b3) => b3.score - a2.score);
60180
+ const utilityPlan = solveAssignments({
60181
+ rows,
60182
+ tasks: taskNodes,
60183
+ weights,
60184
+ maxCost,
60185
+ text: text6,
60186
+ complexity: complexity.band,
60187
+ budget: Number.POSITIVE_INFINITY,
60188
+ cacheReadRatio,
60189
+ cacheWriteRatio
60190
+ });
60191
+ const budgetPlan = budget > 0 ? solveAssignments({
60192
+ rows,
60193
+ tasks: taskNodes,
60194
+ weights,
60195
+ maxCost,
60196
+ text: text6,
60197
+ complexity: complexity.band,
60198
+ budget,
60199
+ cacheReadRatio,
60200
+ cacheWriteRatio
60201
+ }) : null;
60202
+ const minimumCostPlan = budget > 0 && budgetPlan === null ? solveAssignments({
60203
+ rows,
60204
+ tasks: taskNodes,
60205
+ weights,
60206
+ maxCost,
60207
+ text: text6,
60208
+ complexity: complexity.band,
60209
+ budget: Number.POSITIVE_INFINITY,
60210
+ cacheReadRatio,
60211
+ cacheWriteRatio,
60212
+ minimizeCost: true
60213
+ }) : null;
60214
+ const optimized = budget > 0 ? budgetPlan ?? minimumCostPlan ?? utilityPlan : utilityPlan;
60215
+ const assignments = optimized?.assignments ?? [];
60216
+ const usedRoutes = optimized?.usedRoutes ?? /* @__PURE__ */ new Set();
60217
+ const constraintRelaxed = (optimized?.relaxedCount ?? 0) > 0;
60218
+ for (const row of rows) {
60219
+ row.score = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band] }, weights, maxCost, /* @__PURE__ */ new Set(), cacheReadRatio, cacheWriteRatio).score;
60220
+ }
60221
+ rows.sort((left, right) => right.score - left.score || compareRowsStable(left, right));
60079
60222
  const selected = assignments[0]?.row ?? rows[0] ?? null;
60080
60223
  const synthesizer = assignments.at(-1)?.row ?? rows.find((row) => /deepseek/i.test(row.model)) ?? rows[0];
60081
60224
  const subtasks = assignments.map(({ task, row }) => ({
60225
+ id: task.id,
60082
60226
  name: task.name,
60083
60227
  type: task.type,
60084
60228
  recommended: row?.model ?? "\u5F85\u53D1\u73B0\u6A21\u578B",
60085
60229
  recommendedProvider: row?.provider ?? "",
60086
60230
  purpose: task.purpose,
60087
60231
  criticality: task.criticality,
60088
- qualityFloor: Number(task.qualityFloor.toFixed(3))
60232
+ qualityFloor: Number(task.qualityFloor.toFixed(3)),
60233
+ dependsOn: [...task.dependsOn ?? []]
60089
60234
  }));
60090
- const costBreakdown = assignments.map(({ task, row }, index2) => {
60235
+ const costBreakdown = assignments.map(({ task, row, estimatedCost, handoffPenalty }, index2) => {
60091
60236
  const tokens = taskTokenBudget(text6, task, complexity.band, cacheReadRatio, cacheWriteRatio);
60092
- const estimatedCost = taskCost(row, task, text6, complexity.band, cacheReadRatio, cacheWriteRatio);
60237
+ const taskEstimate = estimatedCost ?? taskCost(row, task, text6, complexity.band, cacheReadRatio, cacheWriteRatio);
60093
60238
  return {
60094
60239
  stage: index2 + 1,
60095
60240
  purpose: task.purpose,
@@ -60099,8 +60244,9 @@ function buildPlan({ text: text6 = "", available = [], mode = "collective", pric
60099
60244
  cacheReadTokens: tokens.cacheReadTokens,
60100
60245
  cacheWriteTokens: tokens.cacheWriteTokens,
60101
60246
  outputTokens: tokens.outputTokens,
60102
- estimatedCost: Number(estimatedCost.toFixed(6)),
60103
- quality: Number((row === null ? 0 : qualityForTask(row, task.type)).toFixed(3))
60247
+ estimatedCost: Number(taskEstimate.toFixed(6)),
60248
+ quality: Number((row === null ? 0 : qualityForTask(row, task.type)).toFixed(3)),
60249
+ handoffPenalty: Number(Number(handoffPenalty ?? 0).toFixed(3))
60104
60250
  };
60105
60251
  });
60106
60252
  const totalEstimate = costBreakdown.reduce((sum, row) => sum + row.estimatedCost, 0);
@@ -60111,7 +60257,9 @@ function buildPlan({ text: text6 = "", available = [], mode = "collective", pric
60111
60257
  }, 0);
60112
60258
  const budgetExceeded = Number(budgetUsd) > 0 && totalEstimate > Number(budgetUsd);
60113
60259
  const savings = baselineCost <= 0 ? 0 : clamp((baselineCost - totalEstimate) / baselineCost);
60114
- const reason = selected === null ? "\u5C1A\u672A\u53D1\u73B0\u53EF\u7528\u6A21\u578B\uFF0C\u4FDD\u7559 Harness \u539F\u59CB\u6A21\u578B\u9009\u62E9\u3002" : `${complexity.band === "simple" ? "\u4F4E\u590D\u6742\u5EA6\u4F18\u5148\u6210\u672C\u4E0E\u54CD\u5E94\u901F\u5EA6" : complexity.band === "balanced" ? "\u5728\u8D28\u91CF\u3001\u6210\u672C\u3001\u5EF6\u8FDF\u4E0E\u98CE\u9669\u4E4B\u95F4\u5E73\u8861" : "\u9AD8\u590D\u6742\u5EA6\u6309\u5173\u952E\u5EA6\u8BBE\u7F6E\u8D28\u91CF\u4E0B\u9650\uFF0C\u518D\u5728\u53EF\u884C\u5019\u9009\u4E2D\u6700\u5C0F\u5316\u8D39\u7528"}\uFF1B\u4EFB\u52A1\u7C7B\u578B\u4E3A ${taskType}\uFF0C\u5DF2\u5BF9 ${String(subtasks.length)} \u4E2A\u5DE5\u4F5C\u5305\u8FDB\u884C\u7EA6\u675F\u6307\u6D3E\u3002`;
60260
+ const paretoPruned = (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.pruned, 0);
60261
+ const minimumFeasibleCost = optimized?.minimumFeasibleCost ?? minimumCostPlan?.cost ?? 0;
60262
+ const reason = selected === null ? "\u5C1A\u672A\u53D1\u73B0\u53EF\u7528\u6A21\u578B\uFF0C\u4FDD\u7559 Harness \u539F\u59CB\u6A21\u578B\u9009\u62E9\u3002" : `${complexity.band === "simple" ? "\u4F4E\u590D\u6742\u5EA6\u4F18\u5148\u6210\u672C\u4E0E\u54CD\u5E94\u901F\u5EA6" : complexity.band === "balanced" ? "\u5728\u8D28\u91CF\u3001\u6210\u672C\u3001\u5EF6\u8FDF\u4E0E\u98CE\u9669\u4E4B\u95F4\u5E73\u8861" : "\u9AD8\u590D\u6742\u5EA6\u6267\u884C\u4F9D\u8D56\u611F\u77E5\u7684\u5168\u5C40\u7EA6\u675F\u5206\u914D"}\uFF1B\u4EFB\u52A1\u7C7B\u578B\u4E3A ${taskType}\uFF0C\u5DF2\u5BF9 ${String(subtasks.length)} \u4E2A\u5DE5\u4F5C\u5305\u8FDB\u884C Pareto \u526A\u679D\u548C\u6709\u754C\u7EC4\u5408\u641C\u7D22\u3002`;
60115
60263
  return {
60116
60264
  mode,
60117
60265
  complexity: { value: Number(complexity.value.toFixed(3)), band: complexity.band },
@@ -60125,7 +60273,7 @@ function buildPlan({ text: text6 = "", available = [], mode = "collective", pric
60125
60273
  estimatedCost: Number(totalEstimate.toFixed(6)),
60126
60274
  costBreakdown,
60127
60275
  optimization: {
60128
- solver: "quality-constrained greedy assignment with diversity penalty",
60276
+ solver: "pareto-pruned quality-constrained beam assignment",
60129
60277
  qualityFloor: QUALITY_FLOORS[complexity.band],
60130
60278
  budgetUsd: Number(Number(budgetUsd) > 0 ? Number(budgetUsd) : 0),
60131
60279
  cacheReadRatio: normalizedCacheRatios(cacheReadRatio, cacheWriteRatio).read,
@@ -60135,6 +60283,11 @@ function buildPlan({ text: text6 = "", available = [], mode = "collective", pric
60135
60283
  baselineAllStrongCost: Number(baselineCost.toFixed(6)),
60136
60284
  estimatedSavings: Number(savings.toFixed(4)),
60137
60285
  distinctRoutes: usedRoutes.size,
60286
+ handoffCount: optimized?.switches ?? 0,
60287
+ paretoPruned,
60288
+ beamWidth: ROUTING_BEAM_WIDTH,
60289
+ budgetFeasible: budget <= 0 || budgetPlan !== null,
60290
+ minimumFeasibleCost: Number(Number(minimumFeasibleCost).toFixed(6)),
60138
60291
  liveBench: liveBench?.fetchedAt ? { source: liveBench.source ?? "livebench", fetchedAt: liveBench.fetchedAt, models: Object.keys(liveBench.models ?? {}).length, stale: String(liveBenchError).length > 0, error: String(liveBenchError || "") } : { source: "experimental-baseline", fetchedAt: null, models: 0, stale: false, error: String(liveBenchError || "") }
60139
60292
  },
60140
60293
  reason,
@@ -61587,7 +61740,7 @@ var gal_scene_default = { version: 1, settings: { stageW: 1920, stageH: 1080, sh
61587
61740
  var name = "gal-view";
61588
61741
  var PROJECT_URL = "https://github.com/ljwei-stak/deepseek-harness";
61589
61742
  var RELEASES_URL = `${PROJECT_URL}/releases`;
61590
- var PLUGIN_VERSION = "0.4.10";
61743
+ var PLUGIN_VERSION = "0.4.12";
61591
61744
  function createUpdateApi() {
61592
61745
  const bridge = globalThis.deepSeekHarnessDesktop;
61593
61746
  const openExternal = (url) => {
@@ -15,7 +15,7 @@ export const OBJECTIVE_WEIGHTS = Object.freeze({
15
15
  })
16
16
 
17
17
  /** Quality floor for a task node before a cost-saving substitution is allowed. */
18
- export const QUALITY_FLOORS = Object.freeze({ simple: 0.64, balanced: 0.72, complex: 0.78 })
18
+ export const QUALITY_FLOORS = Object.freeze({ simple: 0.75, balanced: 0.78, complex: 0.82 })
19
19
 
20
20
  /** Host settings namespace used by the manual pricing editor. */
21
21
  export const MODEL_ROUTER_SETTINGS_NAMESPACE = 'model-router'
@@ -349,34 +349,69 @@ function taskQualityFloor(band, task) {
349
349
 
350
350
  function taskPackages(taskType, text, band) {
351
351
  if (band !== 'complex') {
352
- const task = { name: '直接回答与必要校验', type: taskType, purpose: 'execution', criticality: 0.65 }
352
+ const task = { id: 'execution', name: '直接回答与必要校验', type: taskType, purpose: 'execution', criticality: 0.65, dependsOn: [] }
353
353
  return [{ ...task, qualityFloor: taskQualityFloor(band, task) }]
354
354
  }
355
355
  const value = String(text ?? '')
356
356
  const packages = [
357
- { name: '问题建模与约束提取', type: 'reasoning', purpose: 'analysis', criticality: 0.92 },
357
+ { id: 'analysis', name: '问题建模与约束提取', type: 'reasoning', purpose: 'analysis', criticality: 0.92, dependsOn: [] },
358
358
  ]
359
359
  const domains = [...new Set([...(detectTaskTypes(text)), taskType].filter(type => type !== 'general'))]
360
360
  for (const type of domains.length > 0 ? domains : [taskType]) {
361
361
  packages.push({
362
+ id: `execution-${type}`,
362
363
  name: `${TASK_TYPE_LABELS[type] ?? type}方向处理`,
363
364
  type,
364
365
  purpose: 'execution',
365
366
  criticality: domains.length > 1 ? 0.80 : 0.78,
367
+ dependsOn: ['analysis'],
366
368
  })
367
369
  }
368
370
  if (/(测试|验证|评估|对比|benchmark|test|verify|audit)/i.test(value)) {
369
- packages.push({ name: '验证、反例与风险审查', type: 'reasoning', purpose: 'verification', criticality: 0.88 })
371
+ packages.push({
372
+ id: 'verification',
373
+ name: '验证、反例与风险审查',
374
+ type: 'reasoning',
375
+ purpose: 'verification',
376
+ criticality: 0.88,
377
+ dependsOn: packages.filter(task => task.purpose === 'execution').map(task => task.id),
378
+ })
370
379
  }
371
- packages.push({ name: '结果校验与整合', type: 'reasoning', purpose: 'synthesis', criticality: 1 })
380
+ packages.push({
381
+ id: 'synthesis',
382
+ name: '结果校验与整合',
383
+ type: 'reasoning',
384
+ purpose: 'synthesis',
385
+ criticality: 1,
386
+ dependsOn: packages.filter(task => task.purpose !== 'analysis').map(task => task.id),
387
+ })
372
388
  return packages.map(task => ({ ...task, qualityFloor: taskQualityFloor(band, task) }))
373
389
  }
374
390
 
391
+ const SYNTHESIS_WEIGHTS = Object.freeze({ quality: 0.70, cost: 0.10, latency: 0.04, specialty: 0.10, risk: 0.06 })
392
+ const ROUTING_BEAM_WIDTH = 256
393
+ const ROUTING_CANDIDATE_LIMIT = 12
394
+
395
+ function weightsForTask(weights, task) {
396
+ return task.purpose === 'synthesis' ? SYNTHESIS_WEIGHTS : weights
397
+ }
398
+
399
+ function compareText(left, right) {
400
+ const a = String(left)
401
+ const b = String(right)
402
+ return a < b ? -1 : a > b ? 1 : 0
403
+ }
404
+
405
+ function compareRowsStable(left, right) {
406
+ return compareText(routeKey(left.provider, left.model), routeKey(right.provider, right.model))
407
+ }
408
+
375
409
  function candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio = 0, cacheWriteRatio = 0) {
376
410
  const quality = qualityForTask(row, task.type)
377
411
  const floor = Number(task.qualityFloor ?? taskQualityFloor('complex', task))
378
412
  const qualityGap = Math.max(0, floor - quality)
379
413
  const duplicatePenalty = usedRoutes.has(routeKey(row.provider, row.model)) ? 0.08 : 0
414
+ const synthesisPreference = task.purpose === 'synthesis' && /deepseek[- ]?v4[- ]?pro/i.test(row.model) ? 0.025 : 0
380
415
  const cost = costScore(row.pricing, maxCost, cacheReadRatio, cacheWriteRatio)
381
416
  const score = weights.quality * quality
382
417
  + weights.cost * cost
@@ -385,6 +420,7 @@ function candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRati
385
420
  - weights.risk * row.risk
386
421
  - duplicatePenalty
387
422
  - qualityGap * (task.criticality ?? 0.75)
423
+ + synthesisPreference
388
424
  return { score, floor, qualityGap }
389
425
  }
390
426
 
@@ -410,6 +446,140 @@ function taskCost(row, task, text, complexity, cacheReadRatio = 0, cacheWriteRat
410
446
  + (tokens.outputTokens * row.pricing.output)) / 1_000_000
411
447
  }
412
448
 
449
+ function dominates(left, right, task, text, complexity, cacheReadRatio, cacheWriteRatio) {
450
+ const leftValues = {
451
+ quality: qualityForTask(left, task.type),
452
+ cost: taskCost(left, task, text, complexity, cacheReadRatio, cacheWriteRatio),
453
+ latency: clamp(left.latency),
454
+ specialty: specialtyForTask(left, task.type),
455
+ risk: clamp(left.risk),
456
+ }
457
+ const rightValues = {
458
+ quality: qualityForTask(right, task.type),
459
+ cost: taskCost(right, task, text, complexity, cacheReadRatio, cacheWriteRatio),
460
+ latency: clamp(right.latency),
461
+ specialty: specialtyForTask(right, task.type),
462
+ risk: clamp(right.risk),
463
+ }
464
+ const noWorse = leftValues.quality >= rightValues.quality
465
+ && leftValues.cost <= rightValues.cost
466
+ && leftValues.latency <= rightValues.latency
467
+ && leftValues.specialty >= rightValues.specialty
468
+ && leftValues.risk <= rightValues.risk
469
+ const strictlyBetter = leftValues.quality > rightValues.quality
470
+ || leftValues.cost < rightValues.cost
471
+ || leftValues.latency < rightValues.latency
472
+ || leftValues.specialty > rightValues.specialty
473
+ || leftValues.risk < rightValues.risk
474
+ return noWorse && strictlyBetter
475
+ }
476
+
477
+ function candidatePool(rows, task, weights, maxCost, text, complexity, cacheReadRatio, cacheWriteRatio) {
478
+ const floor = Number(task.qualityFloor ?? 0)
479
+ const feasible = rows.filter(row => qualityForTask(row, task.type) >= floor)
480
+ const source = feasible.length > 0
481
+ ? feasible
482
+ : rows.slice().sort((left, right) => qualityForTask(right, task.type) - qualityForTask(left, task.type) || compareRowsStable(left, right)).slice(0, 3)
483
+ const taskWeights = weightsForTask(weights, task)
484
+ const scored = source.map(row => ({
485
+ row,
486
+ decision: candidateUtility(row, task, taskWeights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio),
487
+ cost: taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio),
488
+ }))
489
+ const frontier = scored.filter(item => !source.some(other => other !== item.row && dominates(other, item.row, task, text, complexity, cacheReadRatio, cacheWriteRatio)))
490
+ const essential = [
491
+ scored.slice().sort((left, right) => left.cost - right.cost || compareRowsStable(left.row, right.row))[0],
492
+ scored.slice().sort((left, right) => right.decision.score - left.decision.score || compareRowsStable(left.row, right.row))[0],
493
+ scored.slice().sort((left, right) => qualityForTask(right.row, task.type) - qualityForTask(left.row, task.type) || compareRowsStable(left.row, right.row))[0],
494
+ ].filter(Boolean)
495
+ const ordered = [...frontier, ...essential]
496
+ .filter((item, index, all) => all.findIndex(candidate => candidate.row === item.row) === index)
497
+ .sort((left, right) => right.decision.score - left.decision.score || left.cost - right.cost || compareRowsStable(left.row, right.row))
498
+ .slice(0, ROUTING_CANDIDATE_LIMIT)
499
+ return {
500
+ options: ordered,
501
+ relaxed: feasible.length === 0,
502
+ pruned: Math.max(0, rows.length - ordered.length),
503
+ }
504
+ }
505
+
506
+ function stateSignature(state) {
507
+ return state.assignments.map(assignment => routeKey(assignment.row?.provider, assignment.row?.model)).join('|')
508
+ }
509
+
510
+ function compareUtilityStates(left, right) {
511
+ return left.relaxedCount - right.relaxedCount
512
+ || left.qualityShortfall - right.qualityShortfall
513
+ || right.score - left.score
514
+ || left.cost - right.cost
515
+ || left.switches - right.switches
516
+ || compareText(stateSignature(left), stateSignature(right))
517
+ }
518
+
519
+ function compareCostStates(left, right) {
520
+ return left.relaxedCount - right.relaxedCount
521
+ || left.qualityShortfall - right.qualityShortfall
522
+ || left.cost - right.cost
523
+ || right.score - left.score
524
+ || left.switches - right.switches
525
+ || compareText(stateSignature(left), stateSignature(right))
526
+ }
527
+
528
+ function solveAssignments({ rows, tasks, weights, maxCost, text, complexity, budget, cacheReadRatio, cacheWriteRatio, minimizeCost = false }) {
529
+ const pools = tasks.map(task => candidatePool(rows, task, weights, maxCost, text, complexity, cacheReadRatio, cacheWriteRatio))
530
+ if (pools.some(pool => pool.options.length === 0)) return null
531
+ const suffixMinimum = Array(tasks.length + 1).fill(0)
532
+ for (let index = tasks.length - 1; index >= 0; index -= 1) {
533
+ suffixMinimum[index] = suffixMinimum[index + 1] + Math.min(...pools[index].options.map(option => option.cost))
534
+ }
535
+ if (Number.isFinite(budget) && suffixMinimum[0] > budget + 1e-12) return null
536
+
537
+ let states = [{ assignments: [], routesByTask: new Map(), usedRoutes: new Set(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
538
+ for (let index = 0; index < tasks.length; index += 1) {
539
+ const task = tasks[index]
540
+ const pool = pools[index]
541
+ const expanded = []
542
+ for (const state of states) {
543
+ for (const option of pool.options) {
544
+ const nextCost = state.cost + option.cost
545
+ if (Number.isFinite(budget) && nextCost + suffixMinimum[index + 1] > budget + 1e-12) continue
546
+ const taskWeights = weightsForTask(weights, task)
547
+ const decision = candidateUtility(option.row, task, taskWeights, maxCost, state.usedRoutes, cacheReadRatio, cacheWriteRatio)
548
+ const route = routeKey(option.row.provider, option.row.model)
549
+ const dependencySwitches = (task.dependsOn ?? []).reduce((count, dependency) => {
550
+ const dependencyRoute = state.routesByTask.get(dependency)
551
+ return count + (dependencyRoute !== undefined && dependencyRoute !== route ? 1 : 0)
552
+ }, 0)
553
+ const handoffPenalty = dependencySwitches * 0.015
554
+ const qualityShortfall = Math.max(0, decision.floor - qualityForTask(option.row, task.type))
555
+ const usedRoutes = new Set(state.usedRoutes)
556
+ usedRoutes.add(route)
557
+ const routesByTask = new Map(state.routesByTask)
558
+ routesByTask.set(task.id, route)
559
+ expanded.push({
560
+ assignments: [...state.assignments, { task, row: option.row, decision: { ...decision, relaxed: qualityShortfall > 0 }, estimatedCost: option.cost, handoffPenalty }],
561
+ routesByTask,
562
+ usedRoutes,
563
+ score: state.score + decision.score - handoffPenalty,
564
+ cost: nextCost,
565
+ switches: state.switches + dependencySwitches,
566
+ relaxedCount: state.relaxedCount + (qualityShortfall > 0 ? 1 : 0),
567
+ qualityShortfall: state.qualityShortfall + qualityShortfall,
568
+ })
569
+ }
570
+ }
571
+ if (expanded.length === 0) return null
572
+ expanded.sort(minimizeCost ? compareCostStates : compareUtilityStates)
573
+ states = expanded.slice(0, ROUTING_BEAM_WIDTH)
574
+ }
575
+ states.sort(minimizeCost ? compareCostStates : compareUtilityStates)
576
+ return {
577
+ ...states[0],
578
+ candidatePools: pools,
579
+ minimumFeasibleCost: suffixMinimum[0],
580
+ }
581
+ }
582
+
413
583
  export function buildPlan({ text = '', available = [], mode = 'collective', pricing = {}, liveBench = null, liveBenchError = '', budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0 } = {}) {
414
584
  const complexity = assessComplexity(text)
415
585
  const taskType = classifyTask(text)
@@ -454,50 +624,57 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
454
624
  })
455
625
  }
456
626
  const taskNodes = taskPackages(taskType, text, complexity.band)
457
- const usedRoutes = new Set()
458
- let constraintRelaxed = false
459
- const assignments = []
460
- for (const task of taskNodes) {
461
- const preferred = task.purpose === 'synthesis'
462
- ? rows.find(row => /deepseek[- ]?v4[- ]?pro/i.test(row.model))
463
- ?? rows.find(row => /deepseek/i.test(row.model))
464
- : null
465
- const selectedAssignment = preferred === null
466
- ? chooseAssignment(rows, task, weights, maxCost, usedRoutes, false, cacheReadRatio, cacheWriteRatio)
467
- : { row: preferred, relaxed: qualityForTask(preferred, task.type) < task.qualityFloor, floor: task.qualityFloor, qualityGap: Math.max(0, task.qualityFloor - qualityForTask(preferred, task.type)) }
468
- const row = selectedAssignment.row
469
- if (row !== null) {
470
- row.score = candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio, cacheWriteRatio).score
471
- usedRoutes.add(routeKey(row.provider, row.model))
472
- }
473
- constraintRelaxed ||= selectedAssignment.relaxed
474
- assignments.push({ task, row, decision: selectedAssignment })
475
- }
476
-
477
- // A user budget is a hard secondary constraint. If the first utility pass
478
- // exceeds it, progressively replace the least-critical non-synthesis stage
479
- // with the cheapest candidate that still satisfies its quality floor.
480
627
  const budget = Number(budgetUsd)
481
- const totalFor = () => assignments.reduce((sum, assignment) => sum + taskCost(assignment.row, assignment.task, text, complexity.band, cacheReadRatio, cacheWriteRatio), 0)
482
- if (budget > 0 && totalFor() > budget) {
483
- const movable = assignments
484
- .filter(assignment => assignment.task.purpose !== 'synthesis')
485
- .sort((left, right) => (left.task.criticality ?? 0) - (right.task.criticality ?? 0))
486
- for (const assignment of movable) {
487
- if (totalFor() <= budget) break
488
- const alternatives = rows
489
- .filter(row => row !== assignment.row && qualityForTask(row, assignment.task.type) >= assignment.task.qualityFloor)
490
- .sort((left, right) => taskCost(left, assignment.task, text, complexity.band, cacheReadRatio, cacheWriteRatio) - taskCost(right, assignment.task, text, complexity.band, cacheReadRatio, cacheWriteRatio))
491
- const replacement = alternatives.find(row => taskCost(row, assignment.task, text, complexity.band, cacheReadRatio, cacheWriteRatio) < taskCost(assignment.row, assignment.task, text, complexity.band, cacheReadRatio, cacheWriteRatio))
492
- if (replacement !== undefined) assignment.row = replacement
493
- }
628
+ const utilityPlan = solveAssignments({
629
+ rows,
630
+ tasks: taskNodes,
631
+ weights,
632
+ maxCost,
633
+ text,
634
+ complexity: complexity.band,
635
+ budget: Number.POSITIVE_INFINITY,
636
+ cacheReadRatio,
637
+ cacheWriteRatio,
638
+ })
639
+ const budgetPlan = budget > 0
640
+ ? solveAssignments({
641
+ rows,
642
+ tasks: taskNodes,
643
+ weights,
644
+ maxCost,
645
+ text,
646
+ complexity: complexity.band,
647
+ budget,
648
+ cacheReadRatio,
649
+ cacheWriteRatio,
650
+ })
651
+ : null
652
+ const minimumCostPlan = budget > 0 && budgetPlan === null
653
+ ? solveAssignments({
654
+ rows,
655
+ tasks: taskNodes,
656
+ weights,
657
+ maxCost,
658
+ text,
659
+ complexity: complexity.band,
660
+ budget: Number.POSITIVE_INFINITY,
661
+ cacheReadRatio,
662
+ cacheWriteRatio,
663
+ minimizeCost: true,
664
+ })
665
+ : null
666
+ const optimized = budget > 0 ? (budgetPlan ?? minimumCostPlan ?? utilityPlan) : utilityPlan
667
+ const assignments = optimized?.assignments ?? []
668
+ const usedRoutes = optimized?.usedRoutes ?? new Set()
669
+ const constraintRelaxed = (optimized?.relaxedCount ?? 0) > 0
670
+ for (const row of rows) {
671
+ row.score = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band] }, weights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio).score
494
672
  }
495
- usedRoutes.clear()
496
- for (const assignment of assignments) if (assignment.row !== null) usedRoutes.add(routeKey(assignment.row.provider, assignment.row.model))
497
- rows.sort((a, b) => b.score - a.score)
673
+ rows.sort((left, right) => right.score - left.score || compareRowsStable(left, right))
498
674
  const selected = assignments[0]?.row ?? rows[0] ?? null
499
675
  const synthesizer = assignments.at(-1)?.row ?? rows.find(row => /deepseek/i.test(row.model)) ?? rows[0]
500
676
  const subtasks = assignments.map(({ task, row }) => ({
677
+ id: task.id,
501
678
  name: task.name,
502
679
  type: task.type,
503
680
  recommended: row?.model ?? '待发现模型',
@@ -505,10 +682,11 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
505
682
  purpose: task.purpose,
506
683
  criticality: task.criticality,
507
684
  qualityFloor: Number(task.qualityFloor.toFixed(3)),
685
+ dependsOn: [...(task.dependsOn ?? [])],
508
686
  }))
509
- const costBreakdown = assignments.map(({ task, row }, index) => {
687
+ const costBreakdown = assignments.map(({ task, row, estimatedCost, handoffPenalty }, index) => {
510
688
  const tokens = taskTokenBudget(text, task, complexity.band, cacheReadRatio, cacheWriteRatio)
511
- const estimatedCost = taskCost(row, task, text, complexity.band, cacheReadRatio, cacheWriteRatio)
689
+ const taskEstimate = estimatedCost ?? taskCost(row, task, text, complexity.band, cacheReadRatio, cacheWriteRatio)
512
690
  return {
513
691
  stage: index + 1,
514
692
  purpose: task.purpose,
@@ -518,8 +696,9 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
518
696
  cacheReadTokens: tokens.cacheReadTokens,
519
697
  cacheWriteTokens: tokens.cacheWriteTokens,
520
698
  outputTokens: tokens.outputTokens,
521
- estimatedCost: Number(estimatedCost.toFixed(6)),
699
+ estimatedCost: Number(taskEstimate.toFixed(6)),
522
700
  quality: Number((row === null ? 0 : qualityForTask(row, task.type)).toFixed(3)),
701
+ handoffPenalty: Number(Number(handoffPenalty ?? 0).toFixed(3)),
523
702
  }
524
703
  })
525
704
  const totalEstimate = costBreakdown.reduce((sum, row) => sum + row.estimatedCost, 0)
@@ -533,9 +712,11 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
533
712
  }, 0)
534
713
  const budgetExceeded = Number(budgetUsd) > 0 && totalEstimate > Number(budgetUsd)
535
714
  const savings = baselineCost <= 0 ? 0 : clamp((baselineCost - totalEstimate) / baselineCost)
715
+ const paretoPruned = (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.pruned, 0)
716
+ const minimumFeasibleCost = optimized?.minimumFeasibleCost ?? minimumCostPlan?.cost ?? 0
536
717
  const reason = selected === null
537
718
  ? '尚未发现可用模型,保留 Harness 原始模型选择。'
538
- : `${complexity.band === 'simple' ? '低复杂度优先成本与响应速度' : complexity.band === 'balanced' ? '在质量、成本、延迟与风险之间平衡' : '高复杂度按关键度设置质量下限,再在可行候选中最小化费用'};任务类型为 ${taskType},已对 ${String(subtasks.length)} 个工作包进行约束指派。`
719
+ : `${complexity.band === 'simple' ? '低复杂度优先成本与响应速度' : complexity.band === 'balanced' ? '在质量、成本、延迟与风险之间平衡' : '高复杂度执行依赖感知的全局约束分配'};任务类型为 ${taskType},已对 ${String(subtasks.length)} 个工作包进行 Pareto 剪枝和有界组合搜索。`
539
720
  return {
540
721
  mode,
541
722
  complexity: { value: Number(complexity.value.toFixed(3)), band: complexity.band },
@@ -549,7 +730,7 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
549
730
  estimatedCost: Number(totalEstimate.toFixed(6)),
550
731
  costBreakdown,
551
732
  optimization: {
552
- solver: 'quality-constrained greedy assignment with diversity penalty',
733
+ solver: 'pareto-pruned quality-constrained beam assignment',
553
734
  qualityFloor: QUALITY_FLOORS[complexity.band],
554
735
  budgetUsd: Number(Number(budgetUsd) > 0 ? Number(budgetUsd) : 0),
555
736
  cacheReadRatio: normalizedCacheRatios(cacheReadRatio, cacheWriteRatio).read,
@@ -559,6 +740,11 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
559
740
  baselineAllStrongCost: Number(baselineCost.toFixed(6)),
560
741
  estimatedSavings: Number(savings.toFixed(4)),
561
742
  distinctRoutes: usedRoutes.size,
743
+ handoffCount: optimized?.switches ?? 0,
744
+ paretoPruned,
745
+ beamWidth: ROUTING_BEAM_WIDTH,
746
+ budgetFeasible: budget <= 0 || budgetPlan !== null,
747
+ minimumFeasibleCost: Number(Number(minimumFeasibleCost).toFixed(6)),
562
748
  liveBench: liveBench?.fetchedAt
563
749
  ? { source: liveBench.source ?? 'livebench', fetchedAt: liveBench.fetchedAt, models: Object.keys(liveBench.models ?? {}).length, stale: String(liveBenchError).length > 0, error: String(liveBenchError || '') }
564
750
  : { source: 'experimental-baseline', fetchedAt: null, models: 0, stale: false, error: String(liveBenchError || '') },
package/README.md CHANGED
@@ -20,24 +20,179 @@ This plugin installs on an original DeepSeek Harness checkout. It adds a cost-aw
20
20
 
21
21
  ## Installation
22
22
 
23
- After publishing to npm, install the package into an original Harness Web or Desktop profile:
23
+ ### Requirements
24
24
 
25
- ```text
26
- pnpm dsh plugin --profile web add @ljwei-stak/model-router-galgame@0.4.10
27
- pnpm dsh plugin --profile desktop add @ljwei-stak/model-router-galgame@0.4.10
25
+ - DeepSeek Harness / DSH Desktop 0.4.8 or newer.
26
+ - Node.js 22.19 or newer (the current DSH Desktop release uses Node 24).
27
+ - At least one LLM provider configured in Harness.
28
+ - A network connection to the npm registry for the first installation.
29
+
30
+ ### Recommended: install the published npm package
31
+
32
+ The package is public and already includes the official `@liustack/modlens@3.25.4` bundle. You do not need to install Docker, Python, a ModLens server, or a second ModLens package.
33
+
34
+ 1. Open PowerShell in the DSH Desktop checkout:
35
+
36
+ ```powershell
37
+ cd F:\DeepSeek_harness\DSH-Desktop
38
+ ```
39
+
40
+ 2. Use the official npm registry. This avoids a temporary 404 when a mirror has not synchronized a newly published package:
41
+
42
+ ```powershell
43
+ pnpm config set registry https://registry.npmjs.org/
44
+ pnpm config get registry
45
+ ```
46
+
47
+ The second command should print `https://registry.npmjs.org/`.
48
+
49
+ 3. Install into the profile you use:
50
+
51
+ ```powershell
52
+ # Query the newest version that is actually visible on npm.
53
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
54
+ $routerVersion
55
+
56
+ # Web profile
57
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
58
+
59
+ # Desktop profile (use this when the desktop application runs the desktop profile)
60
+ pnpm dsh plugin --profile desktop add "@ljwei-stak/model-router-galgame@$routerVersion"
61
+ ```
62
+
63
+ The public registry may lag behind a just-created release. After publication,
64
+ the same commands automatically use the newest visible version. If npm reports
65
+ `No matching version found`, do not guess a version: rerun the query and use the
66
+ version it prints.
67
+
68
+ If you do not want to change the global pnpm registry, add this option to the `add` command instead:
69
+
70
+ ```powershell
71
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
72
+ pnpm dsh plugin --profile web add --registry=https://registry.npmjs.org "@ljwei-stak/model-router-galgame@$routerVersion"
28
73
  ```
29
74
 
30
- The official `@liustack/modlens@3.25.4` bundle is installed automatically as a dependency. Restart the selected profile after installation.
75
+ 4. Verify that the router and its ModLens dependency are present:
76
+
77
+ ```powershell
78
+ pnpm dsh --profile web --dump-config | Select-String "model-router-galgame|modlens"
79
+ ```
31
80
 
32
- For local development, install the checkout instead:
81
+ The output should contain:
33
82
 
34
83
  ```text
35
- dsh plugin --profile web add <plugin-directory>
84
+ @ljwei-stak/model-router-galgame
85
+ @liustack/modlens
86
+ ```
87
+
88
+ Do not add `@liustack/modlens` separately after installing this package. The
89
+ router already includes the official ModLens bundle. If you previously added
90
+ ModLens separately to the same profile, remove that standalone dependency first,
91
+ then reinstall the router:
92
+
93
+ ```powershell
94
+ pnpm dsh plugin --profile web remove @liustack/modlens
95
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
96
+ ```
97
+
98
+ 5. Stop any already-running DSH process, then start the selected profile:
99
+
100
+ ```powershell
101
+ pnpm dsh web
102
+ # or, for the desktop profile:
103
+ pnpm dsh --profile desktop
104
+ ```
105
+
106
+ Do not start a second process on the same profile. If port 3080 is already in
107
+ use, or startup says `task-board ledger is already owned by process ...`, close
108
+ the existing DSH process before retrying. You can use another port only after
109
+ the old process has released its profile lock:
110
+
111
+ ```powershell
112
+ pnpm dsh web --no-open --port 3081
113
+ ```
114
+
115
+ ### Verify ModLens
116
+
117
+ Run the diagnostic from the installed Web profile (the command is forwarded to
118
+ the profile's installed binary):
119
+
120
+ ```powershell
121
+ pnpm dsh plugin --profile web exec modlens doctor
122
+ ```
123
+
124
+ Configure the vision provider in the DSH settings page or in `C:\Users\<your-user>\.modlens\config.json`. Then create a conversation, upload an image, and ask the model to transcribe or explain it. Text-only models appear with a `(modlens vision)` entry when a compatible upstream route is available.
125
+
126
+ ### Update
127
+
128
+ To install the newest version visible on npm:
129
+
130
+ ```powershell
131
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
132
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
133
+ ```
134
+
135
+ For a reproducible deployment, replace `$routerVersion` with a concrete version
136
+ that you have verified with `npm view` (for example `0.4.12`):
137
+
138
+ ```powershell
139
+ pnpm dsh plugin --profile web add @ljwei-stak/model-router-galgame@0.4.12
140
+ ```
141
+
142
+ You can also ask pnpm to update an already-installed package within its declared
143
+ version range:
144
+
145
+ ```powershell
146
+ pnpm dsh plugin --profile web update @ljwei-stak/model-router-galgame
147
+ ```
148
+
149
+ Restart DSH after updating. Repeat the same command with `--profile desktop` for the desktop profile.
150
+
151
+ ### Clean reinstall after a loader or profile error
152
+
153
+ If startup reports `duplicate loader entry id: modlens`, it means the same
154
+ ModLens bundle was installed separately and is also included by the router.
155
+ Stop DSH, remove the standalone ModLens dependency, and add the router again:
156
+
157
+ ```powershell
158
+ cd F:\DeepSeek_harness\DSH-Desktop
159
+ pnpm dsh plugin --profile web remove @liustack/modlens
160
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
161
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
162
+ pnpm dsh --profile web --dump-config | Select-String "model-router-galgame|modlens"
163
+ ```
164
+
165
+ The dump should show one `modlens` row and one `model-router-galgame` row. If the
166
+ error is `EADDRINUSE` on port 3080, or `task-board ledger is already owned by
167
+ process ...`, an old DSH process is still running; close that process before
168
+ starting another instance, or choose another port:
169
+
170
+ ```powershell
171
+ pnpm dsh web --no-open --port 3081
172
+ ```
173
+
174
+ ### Local development installation
175
+
176
+ To test the checkout in `F:\DeepSeek_harness` without downloading from npm:
177
+
178
+ ```powershell
179
+ cd F:\DeepSeek_harness\DSH-Desktop
180
+ pnpm dsh plugin --profile web add F:\DeepSeek_harness\model-router-galgame
181
+ ```
182
+
183
+ The local path form is only for development. The installed package is still named `@ljwei-stak/model-router-galgame` after the package is published.
184
+
185
+ ### Remove the plugin
186
+
187
+ ```powershell
188
+ cd F:\DeepSeek_harness\DSH-Desktop
189
+ pnpm dsh plugin --profile web remove @ljwei-stak/model-router-galgame
190
+ pnpm dsh plugin --profile desktop remove @ljwei-stak/model-router-galgame
36
191
  ```
37
192
 
38
- Restart Harness after installation. If no model is available, native model selection remains intact and the conversation is not blocked.
193
+ Removing the router also removes its profile layer. It does not delete your provider credentials or `C:\Users\<your-user>\.modlens\config.json`.
39
194
 
40
- See the [native installation tutorial](../../docs/cookbook/model-router-galgame-installation.md) for the complete setup, provider configuration, desktop, update, and troubleshooting procedure.
195
+ If no model is available, native model selection remains intact and the conversation is not blocked. For the full provider setup and troubleshooting guide, see [INSTALLATION_GUIDE.zh.md](INSTALLATION_GUIDE.zh.md) and [MODLENS_DEPLOYMENT.md](MODLENS_DEPLOYMENT.md).
41
196
 
42
197
  ## Commands
43
198
 
package/README.zh.md CHANGED
@@ -20,24 +20,173 @@
20
20
 
21
21
  ## 安装
22
22
 
23
- 发布到 npm 后,可直接安装到原版 Harness 的 Web 或 Desktop profile:
23
+ ### 前置条件
24
24
 
25
- ```text
26
- pnpm dsh plugin --profile web add @ljwei-stak/model-router-galgame@0.4.10
27
- pnpm dsh plugin --profile desktop add @ljwei-stak/model-router-galgame@0.4.10
25
+ - DeepSeek Harness / DSH Desktop 0.4.8 或更高版本。
26
+ - Node.js 22.19 或更高版本(当前 DSH Desktop 使用 Node 24)。
27
+ - Harness 中至少配置一个 LLM provider。
28
+ - 首次安装需要能够访问 npm registry。
29
+
30
+ ### 推荐方式:安装已发布的 npm 包
31
+
32
+ 这个公开包已经把官方 `@liustack/modlens@3.25.4` 作为依赖和 DSH bundle 一起打包。无需安装 Docker、Python、8000 端口服务,也无需再单独安装 ModLens。
33
+
34
+ 1. 在 PowerShell 中进入 DSH Desktop 目录:
35
+
36
+ ```powershell
37
+ cd F:\DeepSeek_harness\DSH-Desktop
38
+ ```
39
+
40
+ 2. 使用官方 npm 源。如果使用国内镜像,刚发布的新包可能暂时返回 404:
41
+
42
+ ```powershell
43
+ pnpm config set registry https://registry.npmjs.org/
44
+ pnpm config get registry
45
+ ```
46
+
47
+ 第二条命令应输出 `https://registry.npmjs.org/`。
48
+
49
+ 3. 安装到你实际使用的 profile:
50
+
51
+ ```powershell
52
+ # 查询 npm registry 当前实际可见的最新版本
53
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
54
+ $routerVersion
55
+
56
+ # Web profile
57
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
58
+
59
+ # Desktop profile(桌面程序使用 desktop profile 时执行)
60
+ pnpm dsh plugin --profile desktop add "@ljwei-stak/model-router-galgame@$routerVersion"
61
+ ```
62
+
63
+ 公开 registry 可能会在新版本发布后短暂延迟同步;同步完成后,同样的命令会自动使用
64
+ 最新可见版本。如果 npm 提示 `No matching version found`,不要猜版本号;重新执行查询,
65
+ 并使用它实际输出的版本。
66
+
67
+ 如果不想修改全局 pnpm 源,可以只在安装命令中指定:
68
+
69
+ ```powershell
70
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
71
+ pnpm dsh plugin --profile web add --registry=https://registry.npmjs.org "@ljwei-stak/model-router-galgame@$routerVersion"
28
72
  ```
29
73
 
30
- 官方 `@liustack/modlens@3.25.4` 会作为依赖自动安装。安装或更新后请重启对应 profile
74
+ 4. 检查路由器和它的 ModLens 依赖是否已加入 profile
75
+
76
+ ```powershell
77
+ pnpm dsh --profile web --dump-config | Select-String "model-router-galgame|modlens"
78
+ ```
31
79
 
32
- 本地开发时仍可直接安装源码目录:
80
+ 输出中应包含:
33
81
 
34
82
  ```text
35
- dsh plugin --profile web add <plugin-directory>
83
+ @ljwei-stak/model-router-galgame
84
+ @liustack/modlens
85
+ ```
86
+
87
+ 安装本插件后不要再单独添加 `@liustack/modlens`。本插件已经包含官方 ModLens
88
+ bundle。如果以前在同一个 profile 中单独安装过 ModLens,先删除那条独立依赖,
89
+ 再重新安装路由器:
90
+
91
+ ```powershell
92
+ pnpm dsh plugin --profile web remove @liustack/modlens
93
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
94
+ ```
95
+
96
+ 5. 关闭已经运行的 DSH,再启动对应 profile:
97
+
98
+ ```powershell
99
+ pnpm dsh web
100
+ # 或 desktop profile:
101
+ pnpm dsh --profile desktop
102
+ ```
103
+
104
+ 不要在同一个 profile 上重复启动两个 DSH 进程。如果出现 `EADDRINUSE`,或启动时
105
+ 提示 `task-board ledger is already owned by process ...`,先关闭旧 DSH 进程再重试。
106
+ 只有旧进程释放 profile 锁后,才适合换端口启动:
107
+
108
+ ```powershell
109
+ pnpm dsh web --no-open --port 3081
110
+ ```
111
+
112
+ ### 检查 ModLens
113
+
114
+ 在已安装的 Web profile 中运行:
115
+
116
+ ```powershell
117
+ pnpm dsh plugin --profile web exec modlens doctor
118
+ ```
119
+
120
+ 然后在 DSH 设置页或 `C:\Users\<你的用户名>\.modlens\config.json` 中配置视觉引擎。新建对话、上传图片并要求模型转录或解释图片即可。存在兼容的上游路由时,纯文本模型会显示对应的 `(modlens vision)` 条目。
121
+
122
+ ### 更新插件
123
+
124
+ 安装 npm 当前可见的最新版本:
125
+
126
+ ```powershell
127
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
128
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
129
+ ```
130
+
131
+ 如果需要可复现部署,请把 `$routerVersion` 替换为通过 `npm view` 确认过的具体版本号
132
+ (例如 `0.4.12`):
133
+
134
+ ```powershell
135
+ pnpm dsh plugin --profile web add @ljwei-stak/model-router-galgame@0.4.12
136
+ ```
137
+
138
+ 已经安装过插件时,也可以让 pnpm 在当前版本范围内更新:
139
+
140
+ ```powershell
141
+ pnpm dsh plugin --profile web update @ljwei-stak/model-router-galgame
142
+ ```
143
+
144
+ 更新后请重启 DSH;Desktop profile 使用同样的命令并把 profile 改为 `desktop`。
145
+
146
+ ### 出现加载器或 profile 错误时重新安装
147
+
148
+ 如果启动时报 `duplicate loader entry id: modlens`,表示 ModLens 被单独安装过,
149
+ 又被本插件内置 bundle 再加载了一次。先关闭 DSH,再删除单独安装的 ModLens,
150
+ 然后重新安装本插件:
151
+
152
+ ```powershell
153
+ cd F:\DeepSeek_harness\DSH-Desktop
154
+ pnpm dsh plugin --profile web remove @liustack/modlens
155
+ $routerVersion = npm view @ljwei-stak/model-router-galgame version --registry=https://registry.npmjs.org/
156
+ pnpm dsh plugin --profile web add "@ljwei-stak/model-router-galgame@$routerVersion"
157
+ pnpm dsh --profile web --dump-config | Select-String "model-router-galgame|modlens"
158
+ ```
159
+
160
+ 输出应只显示一条 `modlens` 行和一条 `model-router-galgame` 行。如果报
161
+ `EADDRINUSE` 且端口为 3080,或报 `task-board ledger is already owned by process ...`,
162
+ 说明旧的 DSH 进程仍在运行;先关闭旧进程,或换一个端口启动:
163
+
164
+ ```powershell
165
+ pnpm dsh web --no-open --port 3081
166
+ ```
167
+
168
+ ### 本地源码安装
169
+
170
+ 如果要测试 `F:\DeepSeek_harness` 中的源码,不从 npm 下载:
171
+
172
+ ```powershell
173
+ cd F:\DeepSeek_harness\DSH-Desktop
174
+ pnpm dsh plugin --profile web add F:\DeepSeek_harness\model-router-galgame
175
+ ```
176
+
177
+ 本地路径方式只用于开发测试;正式发布后推荐使用上面的 scoped npm 包名。
178
+
179
+ ### 卸载插件
180
+
181
+ ```powershell
182
+ cd F:\DeepSeek_harness\DSH-Desktop
183
+ pnpm dsh plugin --profile web remove @ljwei-stak/model-router-galgame
184
+ pnpm dsh plugin --profile desktop remove @ljwei-stak/model-router-galgame
36
185
  ```
37
186
 
38
- 重启 Harness 后即可使用。没有可用模型时插件保留原生选择,不阻塞对话。
187
+ 卸载只会移除插件的 profile 层,不会删除 provider 凭据,也不会删除 `C:\Users\<你的用户名>\.modlens\config.json`。
39
188
 
40
- 完整的原生安装、配置、桌面端、更新和故障排查步骤见[安装操作说明](../../docs/cookbook/model-router-galgame-installation.zh.md)。
189
+ 没有可用模型时,插件仍保留 Harness 原生模型选择,不会阻塞对话。完整的 provider 配置和故障排查见 [INSTALLATION_GUIDE.zh.md](INSTALLATION_GUIDE.zh.md) 与 [MODLENS_DEPLOYMENT.md](MODLENS_DEPLOYMENT.md)
41
190
 
42
191
  ## 命令
43
192
 
package/cordis.patch.yml CHANGED
@@ -3,6 +3,8 @@
3
3
  - insert:
4
4
  - id: modlens
5
5
  name: '@liustack/modlens'
6
+ # The package dependency is transitive from the profile's point of view,
7
+ # so this bundle entry is needed when the router is installed by itself.
6
8
  - id: model-router-galgame
7
9
  name: '@ljwei-stak/model-router-galgame'
8
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ljwei-stak/model-router-galgame",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness plugin: adaptive model routing, cost analysis and GAL view",
6
6
  "repository": {