dsh-auto-model-routing 0.5.2 → 0.5.3

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.
@@ -1,7 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { SessionId } from '@deepseek-ai/dsh-session';
3
- import { CATEGORY_HINTS } from './jev.js';
4
- import { choosePolicyRoute, policyEffort, modelFamily, validatePolicyRoute } from './routing.js';
3
+ import { chooseChildRoute, policyEffort, validatePolicyRoute } from './routing.js';
5
4
  import { registry as builtin } from './strategies/index.js';
6
5
  import { userInput } from './input.js';
7
6
  import { buildState, previousTurn } from './context.js';
@@ -38,37 +37,67 @@ export function policyHost(ctx, deps) {
38
37
  latest.delete(agent.id);
39
38
  applied.delete(agent.id);
40
39
  };
40
+ const childEpochs = new Map();
41
+ const notices = new Map();
42
+ let noticeId = 0;
43
+ /** Visible fallback: the child request runs with DSH defaults, the root learns why. */
44
+ const fallback = (record, reason) => {
45
+ const next = { childId: record.childId, parentId: record.parentId, rootId: record.rootId, path: record.path, explicit: record.explicit,
46
+ ...(record.family ? { family: record.family } : {}), ...(record.parentSelection ? { parentSelection: record.parentSelection } : {}), status: 'unsupported', reason };
47
+ deps.store.set(next);
48
+ pending.delete(record.childId);
49
+ childEpochs.delete(record.childId);
50
+ notices.set(record.rootId, { id: ++noticeId, childId: record.childId, reason, at: Date.now() });
51
+ ctx.logger.warn(`auto-model-routing: child ${record.childId} not routed (${reason}); running with DSH defaults`);
52
+ return next;
53
+ };
54
+ const capturedAllowlist = (parentId) => {
55
+ const events = ctx.agents.get(SessionId(parentId))?.session.snapshotEvents() ?? [];
56
+ const captured = events.find(event => event.type === 'subagent/model-selection-policy');
57
+ if (!captured)
58
+ return undefined;
59
+ const allowed = captured.data.allowedModels;
60
+ return Array.isArray(allowed) ? allowed : [];
61
+ };
41
62
  ctx.on('tools/execute', async (exec, next) => {
42
- if (!['subagent', 'subagent_fork', 'spawn_teammate'].includes(exec.name) || !exec.agent)
63
+ if (!['subagent', 'spawn_teammate'].includes(exec.name) || !exec.agent)
64
+ return next();
65
+ const args = isRecord(exec.arguments) ? exec.arguments : {};
66
+ // Fork copies the parent: AUTO does not manage it and opens no binding window.
67
+ if (exec.name === 'spawn_teammate' && args.context === 'fork')
43
68
  return next();
44
69
  const root = rootOf(exec.agent);
45
70
  if (!root || !enabled(root.id))
46
71
  return next();
47
- const args = isRecord(exec.arguments) ? exec.arguments : {};
48
- const path = exec.name === 'subagent_fork' || (exec.name === 'spawn_teammate' && args.context === 'fork') ? 'fork' : exec.name === 'spawn_teammate' ? 'teammate' : 'ordinary';
49
- if (path === 'fork')
50
- throw new Error('unsupported:fork 的真实历史兼容尚未验证;请暂停,改用 fresh 创建,或由用户切到 MANUAL 后继承。');
51
- const provider = args.provider ?? exec.agent.session.requestHeader()?.config.provider ?? exec.agent.options.provider;
52
- if (typeof provider !== 'string' || !registry.hasProvider(provider))
53
- throw new Error('unsupported:无法验证 provider;暂停委派并询问用户');
54
- const parentModel = exec.agent.session.requestHeader()?.config.model ?? exec.agent.options.model;
55
- const family = modelFamily(provider, typeof args.model === 'string' ? args.model : parentModel ?? '', registry);
56
- const explicit = { provider,
72
+ const explicit = { ...(typeof args.provider === 'string' && args.provider ? { provider: args.provider } : {}),
57
73
  ...(typeof args.model === 'string' ? { model: args.model } : {}),
58
74
  ...(typeof args.reasoning_effort === 'string' ? { reasoningEffort: args.reasoning_effort } : {}) };
59
- return delegation.run({ parentId: exec.agent.id, rootId: root.id, path, explicit, ...(family ? { family } : {}) }, next);
75
+ return delegation.run({ parentId: exec.agent.id, rootId: root.id, path: exec.name === 'spawn_teammate' ? 'teammate' : 'ordinary', explicit }, next);
60
76
  });
61
- ctx.on('agent/created', ({ agent }) => {
62
- if (agent.session.header.origin !== 'subagent')
77
+ ctx.on('agent/created', ({ agent, source }) => {
78
+ if (agent.session.header.origin !== 'subagent' || source !== 'startup')
63
79
  return;
64
80
  const binding = delegation.getStore();
65
- if (!binding || binding.parentId !== agent.session.header.parentSession)
81
+ // shortcut: same-window plugin children under this parent are indistinguishable (accepted 2026-10-28), until DSH exposes tool-call→child identity.
82
+ if (!binding || binding.parentId !== agent.session.header.parentSession || !enabled(binding.rootId) || deps.store.wasBound(agent.id))
66
83
  return;
67
- // Creation is precisely inside this official tool's async lifetime, never matched by prompt.
68
- const record = { childId: agent.id, ...binding, status: 'recommended' };
69
- deps.store.set(record);
70
- deps.store.bind(agent.id);
84
+ const parent = ctx.agents.get(SessionId(binding.parentId));
85
+ if (!parent)
86
+ return;
87
+ const config = parent.session.requestHeader()?.config;
88
+ const provider = config?.provider ?? parent.options.provider;
89
+ const model = config?.model ?? parent.options.model;
90
+ const effort = config?.reasoningEffort ?? parent.options.reasoningEffort;
91
+ const routeProvider = binding.explicit.provider ?? provider;
92
+ if (typeof provider !== 'string' || !provider || typeof routeProvider !== 'string' || !routeProvider)
93
+ return;
94
+ const family = registry.familyOfMainModel(binding.explicit.model ?? (typeof model === 'string' ? model : undefined))?.id;
95
+ const record = { childId: agent.id, parentId: binding.parentId, rootId: binding.rootId, path: binding.path, status: 'recommended',
96
+ explicit: { ...binding.explicit, provider: routeProvider }, ...(family ? { family } : {}),
97
+ parentSelection: { provider, ...(typeof model === 'string' ? { model } : {}), ...(typeof effort === 'string' ? { reasoningEffort: effort } : {}) } };
98
+ deps.store.create(record);
71
99
  pending.set(agent.id, record);
100
+ childEpochs.set(agent.id, epoch(binding.rootId));
72
101
  return undefined;
73
102
  });
74
103
  const requireBindingRecord = (agent, record) => {
@@ -127,74 +156,66 @@ export function policyHost(ctx, deps) {
127
156
  }
128
157
  let record = pending.get(agent.id) ?? deps.store.get(agent.id);
129
158
  requireBindingRecord(agent, record);
130
- if (!record) {
131
- const root = rootOf(agent);
132
- if (root && enabled(root.id))
133
- throw new Error('unsupported:创建没有官方工具身份绑定;该子请求未路由。请暂停并改用受支持的创建路径,或由用户切到 MANUAL 后继承。');
159
+ // Unbound children (plugin-created, MANUAL-created, fork) pass through untouched.
160
+ if (!record)
134
161
  return decision;
135
- }
136
162
  if (record.parentId !== agent.session.header.parentSession)
137
163
  throw new Error('子会话身份不匹配');
138
- if (!record.route && !enabled(record.rootId)) {
139
- pending.delete(agent.id);
140
- throw new Error('创建期间模式已改变;请重新创建,不静默继承');
164
+ if (record.route || record.status !== 'recommended')
165
+ return decision;
166
+ if (!pending.has(agent.id)) {
167
+ fallback(record, 'interrupted');
168
+ return decision;
141
169
  }
142
- if (!record.route && enabled(record.rootId)) {
143
- if (!pending.has(agent.id))
144
- throw new Error('初始决策未完成且已恢复;不能用后续消息重新选模,请重新创建子任务');
145
- if (record.status !== 'recommended')
146
- throw new Error(`${record.status}:${record.reason ?? '初始决策未完成'};暂停并询问用户`);
147
- const saveFailure = (status, reason) => { record = { ...record, status, reason }; deps.store.set(record); pending.delete(agent.id); };
148
- const explicit = record.explicit;
149
- let measure;
150
- let capability = 'bounded', depth = 'baseline';
151
- if (!explicit.model || !explicit.reasoningEffort) {
152
- // This is the exact child's entering message, not a parent-history/prompt similarity match.
153
- const text = userInput(decision.messages).text;
154
- const result = await deps.jev.judgePolicy(buildState({ text }), payload.signal, 'child', explicit.model ? 'depth' : explicit.reasoningEffort ? 'capability' : 'both');
155
- if (!enabled(record.rootId)) {
156
- saveFailure('failed', '创建期间模式已变化');
157
- throw new Error('创建期间模式已变化;请重新创建');
158
- }
159
- if (result.kind === 'cancelled') {
160
- saveFailure('failed', '初始判断已取消');
161
- throw new Error('子任务判断已取消');
162
- }
163
- if (result.kind === 'failed') {
164
- saveFailure('failed', result.category);
165
- throw new Error(`Jev 失败:${CATEGORY_HINTS[result.category]};暂停委派并询问用户`);
166
- }
167
- if (result.capability === 'insufficient_context') {
168
- saveFailure('needs-context', '任务目标或范围不足');
169
- throw new Error('needs-context:请补充任务目标与范围');
170
- }
171
- measure = result;
172
- capability = result.capability ?? 'bounded';
173
- depth = result.depth;
170
+ const startedEpoch = childEpochs.get(agent.id) ?? epoch(record.rootId);
171
+ const current = () => enabled(record.rootId) && epoch(record.rootId) === startedEpoch;
172
+ if (!current()) {
173
+ fallback(record, 'mode-changed');
174
+ return decision;
175
+ }
176
+ const explicit = record.explicit;
177
+ let measure;
178
+ let capability = 'bounded', depth = 'baseline';
179
+ if (!explicit.model || !explicit.reasoningEffort) {
180
+ // This is the exact child's entering message, not a parent-history/prompt similarity match.
181
+ const text = userInput(decision.messages).text;
182
+ const result = await deps.jev.judgePolicy(buildState({ text }), payload.signal, 'child', explicit.model ? 'depth' : explicit.reasoningEffort ? 'capability' : 'both');
183
+ if (!current()) {
184
+ fallback(record, 'mode-changed');
185
+ return decision;
174
186
  }
175
- try {
176
- const route = choosePolicyRoute(explicit.provider, capability, depth, await deps.catalog(), deps.settings().effortCap, explicit, record.family, registry);
177
- const parent = ctx.agents.get(SessionId(record.parentId));
178
- const events = parent?.session.snapshotEvents() ?? [];
179
- const captured = events.find(event => event.type === 'subagent/model-selection-policy');
180
- if (captured) {
181
- const allowed = captured.data.allowedModels;
182
- if (!Array.isArray(allowed) || !allowed.some(item => item.provider === route.provider && item.model === route.model))
183
- throw new Error('路由不在父会话创建时的官方允许列表中;请新建会话');
184
- }
185
- if (!enabled(record.rootId) || payload.signal.aborted)
186
- throw new Error('创建已取消或模式已变化');
187
- record = { ...record, route, status: 'recommended', decision: { id: ++decisionId, source: 'subagent', tier: capability, depth, capability, effort: route.reasoningEffort, model: route.model, provider: route.provider, status: 'recommended', ms: measure?.ms ?? 0, decisionProvider: measure?.decisionProvider ?? 'explicit', decisionModel: measure?.decisionModel ?? '未调用', ...(measure?.confidence !== undefined ? { confidence: measure.confidence } : {}), ...(measure?.capabilityConfidence !== undefined ? { capabilityConfidence: measure.capabilityConfidence } : {}), ...(measure?.probabilities ? { probabilities: measure.probabilities } : {}), ...(measure?.capabilityProbabilities ? { capabilityProbabilities: measure.capabilityProbabilities } : {}) } };
188
- deps.store.set(record);
189
- pending.delete(agent.id);
187
+ if (result.kind === 'cancelled') {
188
+ fallback(record, 'jev-cancelled');
189
+ return decision;
190
190
  }
191
- catch (error) {
192
- saveFailure('failed', '配置不可用');
193
- throw error;
191
+ if (result.kind === 'failed') {
192
+ fallback(record, `jev-${result.category}`);
193
+ return decision;
194
+ }
195
+ if (result.capability === 'insufficient_context') {
196
+ fallback(record, 'needs-context');
197
+ return decision;
194
198
  }
199
+ measure = result;
200
+ capability = result.capability ?? 'bounded';
201
+ depth = result.depth;
202
+ }
203
+ const catalog = await deps.catalog(explicit.provider);
204
+ if (!current()) {
205
+ fallback(record, 'mode-changed');
206
+ return decision;
207
+ }
208
+ const parent = record.parentSelection ?? { provider: explicit.provider };
209
+ const chosen = chooseChildRoute({ provider: explicit.provider, parent, explicit, capability, depth, catalog, cap: deps.settings().effortCap, captured: capturedAllowlist(record.parentId) }, registry);
210
+ if ('fallback' in chosen) {
211
+ fallback(record, chosen.fallback);
212
+ return decision;
195
213
  }
196
- if (!record.route && enabled(record.rootId))
197
- throw new Error('决策未完成;暂停子请求');
214
+ const route = chosen.route;
215
+ record = { ...record, route, status: 'recommended', decision: { id: ++decisionId, source: 'subagent', tier: capability, depth, capability, effort: route.reasoningEffort, model: route.model, provider: route.provider, status: 'recommended', ms: measure?.ms ?? 0, decisionProvider: measure?.decisionProvider ?? 'explicit', decisionModel: measure?.decisionModel ?? '未调用', ...(measure?.confidence !== undefined ? { confidence: measure.confidence } : {}), ...(measure?.capabilityConfidence !== undefined ? { capabilityConfidence: measure.capabilityConfidence } : {}), ...(measure?.probabilities ? { probabilities: measure.probabilities } : {}), ...(measure?.capabilityProbabilities ? { capabilityProbabilities: measure.capabilityProbabilities } : {}) } };
216
+ deps.store.set(record);
217
+ pending.delete(agent.id);
218
+ childEpochs.delete(agent.id);
198
219
  return decision;
199
220
  });
200
221
  ctx.on('agent/request', async (payload, next) => {
@@ -245,16 +266,14 @@ export function policyHost(ctx, deps) {
245
266
  }
246
267
  const record = deps.store.get(agent.id);
247
268
  requireBindingRecord(agent, record);
248
- if (record && !record.route)
249
- throw new Error('初始路由未完成;不静默继承');
250
269
  if (!record?.route)
251
270
  return config;
252
271
  if (record.status === 'failed')
253
272
  throw new Error('实际应用失败;暂停子请求并询问用户');
254
273
  if (record.parentId !== agent.session.header.parentSession)
255
274
  throw new Error('决策身份不匹配');
256
- // Existing routed instances keep their initial route even when AUTO stops making new decisions.
257
- validatePolicyRoute(await deps.catalog(), record.route.provider, record.route.model, record.route.reasoningEffort, deps.settings().effortCap, registry);
275
+ // Existing routed instances keep their initial route; live gates are re-checked on every request.
276
+ validatePolicyRoute(await deps.catalog(record.route.provider), record.route.provider, record.route.model, record.route.reasoningEffort, deps.settings().effortCap, capturedAllowlist(record.parentId));
258
277
  return { ...config, ...record.route };
259
278
  }, { prepend: true });
260
279
  const confirmMainRequest = (session, requestSeq, turn) => {
@@ -320,6 +339,7 @@ export function policyHost(ctx, deps) {
320
339
  return {
321
340
  appliedDecision: (id) => enabled(id) ? applied.get(id)?.id : undefined,
322
341
  invalidate: (id) => { epochs.set(id, epoch(id) + 1); mainStatus.delete(id); latest.delete(id); applied.delete(id); },
342
+ childNotice: (rootId) => notices.get(rootId),
323
343
  status: (id) => deps.store.get(id), mainStatus: (id) => enabled(id) ? { ...mainStatus.get(id), ...(latest.get(id) ? { decision: latest.get(id) } : {}) } : undefined,
324
344
  };
325
345
  }
@@ -1,5 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { type Registry } from './strategies/index.js';
3
+ export declare const FALLBACK_REASONS: readonly ["jev-cancelled", "needs-context", "mode-changed", "interrupted", "no-usable-route", "explicit-unavailable"];
4
+ /** New `unsupported` writes use a closed reason set; `jev-<category>` carries the failure category. */
5
+ export declare const closedReason: (reason: string | undefined) => boolean;
3
6
  declare function schemaFor(registry: Registry): z.ZodObject<{
4
7
  childId: z.ZodString;
5
8
  parentId: z.ZodString;
@@ -7,8 +10,8 @@ declare function schemaFor(registry: Registry): z.ZodObject<{
7
10
  status: z.ZodEnum<{
8
11
  failed: "failed";
9
12
  recommended: "recommended";
10
- unsupported: "unsupported";
11
13
  "needs-context": "needs-context";
14
+ unsupported: "unsupported";
12
15
  applied: "applied";
13
16
  }>;
14
17
  path: z.ZodEnum<{
@@ -20,12 +23,15 @@ declare function schemaFor(registry: Registry): z.ZodObject<{
20
23
  [x: string]: string;
21
24
  }>>;
22
25
  explicit: z.ZodObject<{
23
- provider: z.ZodEnum<{
24
- [x: string]: string;
25
- }>;
26
+ provider: z.ZodString;
26
27
  model: z.ZodOptional<z.ZodString>;
27
28
  reasoningEffort: z.ZodOptional<z.ZodString>;
28
29
  }, z.core.$strict>;
30
+ parentSelection: z.ZodOptional<z.ZodObject<{
31
+ provider: z.ZodString;
32
+ model: z.ZodOptional<z.ZodString>;
33
+ reasoningEffort: z.ZodOptional<z.ZodString>;
34
+ }, z.core.$strict>>;
29
35
  decision: z.ZodOptional<z.ZodObject<{
30
36
  id: z.ZodNumber;
31
37
  source: z.ZodEnum<{
@@ -48,9 +54,7 @@ declare function schemaFor(registry: Registry): z.ZodObject<{
48
54
  capabilityProbabilities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
49
55
  }, z.core.$strict>>;
50
56
  route: z.ZodOptional<z.ZodObject<{
51
- provider: z.ZodEnum<{
52
- [x: string]: string;
53
- }>;
57
+ provider: z.ZodString;
54
58
  model: z.ZodString;
55
59
  reasoningEffort: z.ZodEnum<{
56
60
  low: "low";
@@ -60,9 +64,7 @@ declare function schemaFor(registry: Registry): z.ZodObject<{
60
64
  }>;
61
65
  }, z.core.$strict>>;
62
66
  actual: z.ZodOptional<z.ZodObject<{
63
- provider: z.ZodEnum<{
64
- [x: string]: string;
65
- }>;
67
+ provider: z.ZodString;
66
68
  model: z.ZodString;
67
69
  reasoningEffort: z.ZodEnum<{
68
70
  low: "low";
@@ -83,6 +85,8 @@ export declare class PolicyStore {
83
85
  bind(id: string): void;
84
86
  wasBound(id: string): boolean;
85
87
  get(id: string): ChildDecision | undefined;
88
+ /** Initial binding of a new managed child: the parent snapshot is mandatory, then the marker. */
89
+ create(value: ChildDecision): void;
86
90
  set(value: ChildDecision): void;
87
91
  }
88
92
  export {};
@@ -4,18 +4,32 @@ import { join } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { registry as builtin } from './strategies/index.js';
6
6
  const enumOf = (values) => z.enum(values);
7
+ export const FALLBACK_REASONS = ['jev-cancelled', 'needs-context', 'mode-changed', 'interrupted', 'no-usable-route', 'explicit-unavailable'];
8
+ /** New `unsupported` writes use a closed reason set; `jev-<category>` carries the failure category. */
9
+ export const closedReason = (reason) => !!reason && (FALLBACK_REASONS.includes(reason) || /^jev-[a-z-]+$/.test(reason));
7
10
  function schemaFor(registry) {
8
- const providers = enumOf(registry.providers());
9
- const route = z.object({ provider: providers, model: z.string().min(1).max(128), reasoningEffort: z.enum(['low', 'medium', 'high', 'xhigh']) }).strict();
11
+ const provider = z.string().min(1).max(128);
12
+ const route = z.object({ provider, model: z.string().min(1).max(128), reasoningEffort: z.enum(['low', 'medium', 'high', 'xhigh']) }).strict();
10
13
  return z.object({
11
14
  childId: z.string(), parentId: z.string(), rootId: z.string(),
12
15
  status: z.enum(['recommended', 'applied', 'unsupported', 'needs-context', 'failed']),
13
16
  path: z.enum(['ordinary', 'teammate', 'fork']),
14
17
  family: enumOf(registry.ids()).optional(),
15
- explicit: z.object({ provider: providers, model: z.string().optional(), reasoningEffort: z.string().optional() }).strict(),
18
+ explicit: z.object({ provider, model: z.string().optional(), reasoningEffort: z.string().optional() }).strict(),
19
+ parentSelection: z.object({ provider, model: z.string().max(128).optional(), reasoningEffort: z.string().max(32).optional() }).strict().optional(),
16
20
  decision: z.object({ id: z.number(), source: z.enum(['main', 'subagent']), tier: z.string(), depth: z.string(), capability: z.string().optional(), effort: z.string(), model: z.string().optional(), provider: z.string().optional(), status: z.string(), ms: z.number(), decisionProvider: z.string(), decisionModel: z.string(), confidence: z.number().min(0).max(1).optional(), capabilityConfidence: z.number().min(0).max(1).optional(), probabilities: z.record(z.string(), z.number().min(0).max(1)).optional(), capabilityProbabilities: z.record(z.string(), z.number().min(0).max(1)).optional() }).strict().optional(),
17
21
  route: route.optional(), actual: route.optional(), reason: z.string().max(240).optional(),
18
- }).strict();
22
+ }).strict().superRefine((r, ctx) => {
23
+ const bad = (message) => ctx.addIssue({ code: 'custom', message });
24
+ if (r.status === 'recommended' && r.actual)
25
+ bad('recommended 不能含 actual');
26
+ if (r.status === 'applied' && (!r.route || !r.actual))
27
+ bad('applied 需要 route 与 actual');
28
+ if (r.status === 'unsupported' && (r.route || r.actual))
29
+ bad('unsupported 不能含 route');
30
+ if (r.status === 'needs-context' && r.route)
31
+ bad('needs-context 不能含 route');
32
+ });
19
33
  }
20
34
  /** Records written before 0.5.0 carried these keys; reads accept exactly these values, then drop them. */
21
35
  const historical = z.object({ version: z.literal(1).optional(), policyVersion: z.string().optional(), mappingVersion: z.string().optional() });
@@ -62,8 +76,17 @@ export class PolicyStore {
62
76
  throw new Error('子任务决策无法恢复;暂停该子会话');
63
77
  }
64
78
  }
79
+ /** Initial binding of a new managed child: the parent snapshot is mandatory, then the marker. */
80
+ create(value) {
81
+ if (!value.parentSelection)
82
+ throw new Error('新建初始记录缺少 parentSelection');
83
+ this.set(value);
84
+ this.bind(value.childId);
85
+ }
65
86
  set(value) {
66
87
  const checked = this.schema.parse(value);
88
+ if (checked.status === 'unsupported' && !closedReason(checked.reason))
89
+ throw new Error(`未知的未路由原因:${checked.reason ?? ''}`);
67
90
  mkdirSync(this.directory, { recursive: true, mode: 0o700 });
68
91
  chmodSync(this.directory, 0o700);
69
92
  const target = this.path(value.childId), temp = `${target}.${randomUUID()}.tmp`;
package/lib/routing.d.ts CHANGED
@@ -1,20 +1,44 @@
1
1
  import type { Capability, Depth } from './jev.js';
2
2
  import { type AutomaticEffort, type RoutingCatalog } from './settings-contract.js';
3
3
  import { type Registry } from './strategies/index.js';
4
- /** Family of a child model on an exact transport; undefined when no registered strategy owns it. */
5
- export declare function modelFamily(provider: string, model: string, registry?: Registry): string | undefined;
6
- export declare function validatePolicyRoute(catalog: RoutingCatalog, provider: string, model: string, effort: string, cap: AutomaticEffort, registry?: Registry): void;
4
+ type Allowed = readonly {
5
+ provider: string;
6
+ model: string;
7
+ }[];
8
+ /** Whether a route is usable now: live catalog, enabled official allowlist, captured allowlist (if any) and the effort cap. */
9
+ export declare function routeUsable(catalog: RoutingCatalog, provider: string, model: string, effort: string, cap: AutomaticEffort, captured?: Allowed): boolean;
10
+ /** Bound routes are re-checked on every request; any gate failure rejects that request. */
11
+ export declare function validatePolicyRoute(catalog: RoutingCatalog, provider: string, model: string, effort: string, cap: AutomaticEffort, captured?: Allowed): void;
7
12
  export declare function policyEffort(model: string, depth: Depth, supported: readonly string[], cap: AutomaticEffort, defaultEffort?: string, registry?: Registry): AutomaticEffort;
8
13
  export interface PolicyRoute {
9
14
  provider: string;
10
15
  model: string;
11
16
  reasoningEffort: AutomaticEffort;
12
17
  }
18
+ export type RouteFallback = 'no-usable-route' | 'explicit-unavailable';
19
+ export interface ChildRouteInput {
20
+ provider: string;
21
+ parent: {
22
+ model?: string | undefined;
23
+ reasoningEffort?: string | undefined;
24
+ };
25
+ explicit: {
26
+ model?: string | undefined;
27
+ reasoningEffort?: string | undefined;
28
+ };
29
+ capability: Capability;
30
+ depth: Depth;
31
+ catalog: RoutingCatalog;
32
+ cap: AutomaticEffort;
33
+ captured?: Allowed | undefined;
34
+ }
13
35
  /**
14
- * Child route: explicit dimensions are kept; otherwise the family's capability slot on this transport.
15
- * A transport owned by a single family (empty prefix) implies that family; others need `family`.
36
+ * Fallback chain: capability slot → keep model with depth effort → parent effort (automatic effort only) → no rewrite.
37
+ * Provider never changes; explicit dimensions are never replaced.
16
38
  */
17
- export declare function choosePolicyRoute(provider: string, capability: Capability, depth: Depth, catalog: RoutingCatalog, cap: AutomaticEffort, explicit?: {
18
- model?: string | undefined;
19
- reasoningEffort?: string | undefined;
20
- }, family?: string, registry?: Registry): PolicyRoute;
39
+ export declare function chooseChildRoute(input: ChildRouteInput, registry?: Registry): {
40
+ route: PolicyRoute;
41
+ } | {
42
+ fallback: RouteFallback;
43
+ };
44
+ export {};
package/lib/routing.js CHANGED
@@ -1,19 +1,17 @@
1
1
  import { AUTO_EFFORTS } from './settings-contract.js';
2
- import { MODEL_ID_PATTERN } from './settings-validation.js';
3
2
  import { registry as builtin } from './strategies/index.js';
4
- /** Family of a child model on an exact transport; undefined when no registered strategy owns it. */
5
- export function modelFamily(provider, model, registry = builtin) {
6
- return registry.familyOfChild(provider, model)?.id;
7
- }
8
- /** A transport whose model ids carry a family prefix (`<family>/<model>`), e.g. a gateway. */
9
- function prefixedTransport(provider, registry) {
10
- return registry.strategies.some(s => (s.transports[provider] ?? '') !== '');
11
- }
12
- export function validatePolicyRoute(catalog, provider, model, effort, cap, registry = builtin) {
3
+ const listed = (list, provider, model) => list.some(m => m.provider === provider && m.model === model);
4
+ /** Whether a route is usable now: live catalog, enabled official allowlist, captured allowlist (if any) and the effort cap. */
5
+ export function routeUsable(catalog, provider, model, effort, cap, captured) {
13
6
  const entry = catalog.models.find(m => m.provider === provider && m.model === model);
14
- const validId = prefixedTransport(provider, registry) ? /^[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)+$/.test(model) && model.length <= 128 : MODEL_ID_PATTERN.test(model);
15
- if (!registry.hasProvider(provider) || !validId || !entry || !AUTO_EFFORTS.includes(effort) || AUTO_EFFORTS.indexOf(effort) > AUTO_EFFORTS.indexOf(cap) || !entry.reasoningEfforts.includes(effort) || catalog.childAllowlist.status !== 'enabled' || !catalog.childAllowlist.allowedModels.some(m => m.provider === provider && m.model === model))
16
- throw new Error('子任务路由不在当前目录/允许列表或 effort 上限内;暂停委派并询问用户');
7
+ return !!entry && model.length <= 128 && AUTO_EFFORTS.includes(effort) && AUTO_EFFORTS.indexOf(effort) <= AUTO_EFFORTS.indexOf(cap)
8
+ && entry.reasoningEfforts.includes(effort) && catalog.childAllowlist.status === 'enabled' && listed(catalog.childAllowlist.allowedModels, provider, model)
9
+ && (!captured || listed(captured, provider, model));
10
+ }
11
+ /** Bound routes are re-checked on every request; any gate failure rejects that request. */
12
+ export function validatePolicyRoute(catalog, provider, model, effort, cap, captured) {
13
+ if (!routeUsable(catalog, provider, model, effort, cap, captured))
14
+ throw new Error('子任务路由不在当前目录/允许列表或 effort 上限内;拒绝本次请求');
17
15
  }
18
16
  export function policyEffort(model, depth, supported, cap, defaultEffort, registry = builtin) {
19
17
  const start = registry.effortStart(model) ?? defaultEffort;
@@ -32,20 +30,33 @@ export function policyEffort(model, depth, supported, cap, defaultEffort, regist
32
30
  return AUTO_EFFORTS.indexOf(chosen) > AUTO_EFFORTS.indexOf(cap) ? allowed.at(-1) : chosen;
33
31
  }
34
32
  /**
35
- * Child route: explicit dimensions are kept; otherwise the family's capability slot on this transport.
36
- * A transport owned by a single family (empty prefix) implies that family; others need `family`.
33
+ * Fallback chain: capability slot → keep model with depth effort → parent effort (automatic effort only) → no rewrite.
34
+ * Provider never changes; explicit dimensions are never replaced.
37
35
  */
38
- export function choosePolicyRoute(provider, capability, depth, catalog, cap, explicit = {}, family, registry = builtin) {
39
- if (!registry.hasProvider(provider))
40
- throw new Error('unsupported:provider 不在已注册家族中;暂停委派并询问用户');
41
- const implied = registry.familyOfChild(provider, '')?.id;
42
- const selectedFamily = implied ?? family;
43
- const slot = selectedFamily ? registry.childModel(selectedFamily, provider, capability) : undefined;
44
- if (!explicit.model && !slot)
45
- throw new Error('unsupported:没有已定义的模型能力家族;请显式指定模型');
46
- const model = explicit.model ?? slot;
47
- const entry = catalog.models.find(m => m.provider === provider && m.model === model);
48
- const reasoningEffort = explicit.reasoningEffort ?? policyEffort(model, depth, entry?.reasoningEfforts ?? [], cap, entry?.defaultEffort, registry);
49
- validatePolicyRoute(catalog, provider, model, reasoningEffort, cap, registry);
50
- return { provider, model, reasoningEffort: reasoningEffort };
36
+ export function chooseChildRoute(input, registry = builtin) {
37
+ const { provider, parent, explicit, depth, catalog, cap, captured } = input;
38
+ const entry = (model) => catalog.models.find(m => m.provider === provider && m.model === model);
39
+ const depthEffort = (model) => { try {
40
+ const e = entry(model);
41
+ return e ? policyEffort(model, depth, e.reasoningEfforts, cap, e.defaultEffort, registry) : undefined;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ } };
46
+ const take = (model, effort) => model && effort && routeUsable(catalog, provider, model, effort, cap, captured) ? { route: { provider, model, reasoningEffort: effort } } : undefined;
47
+ const family = parent.model ? registry.familyOfMainModel(parent.model)?.id : undefined;
48
+ if (!explicit.model && family && parent.model) {
49
+ const slot = registry.resolveSlot(family, input.capability, parent.model, catalog.models.filter(m => m.provider === provider).map(m => m.model));
50
+ const hit = slot && take(slot, explicit.reasoningEffort ?? depthEffort(slot));
51
+ if (hit)
52
+ return hit;
53
+ }
54
+ const model = explicit.model ?? parent.model;
55
+ const kept = model && take(model, explicit.reasoningEffort ?? depthEffort(model));
56
+ if (kept)
57
+ return kept;
58
+ const inherited = !explicit.reasoningEffort && take(model, parent.reasoningEffort);
59
+ if (inherited)
60
+ return inherited;
61
+ return { fallback: explicit.model || explicit.reasoningEffort ? 'explicit-unavailable' : 'no-usable-route' };
51
62
  }
@@ -1,10 +1,9 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import { SettingsStore } from './settings.js';
3
3
  import type { RoutingCatalog } from './settings-contract.js';
4
- import { type Registry } from './strategies/index.js';
5
4
  /** Only the fixed credential reference and public catalog metadata cross this adapter. */
6
- export declare function settingsHost(ctx: Context, store: SettingsStore, registry?: Registry): {
7
- catalog: () => Promise<RoutingCatalog>;
5
+ export declare function settingsHost(ctx: Context, store: SettingsStore): {
6
+ catalog: (only?: string) => Promise<RoutingCatalog>;
8
7
  read: () => Promise<{
9
8
  models: readonly import("./settings-contract.js").RoutingModel[];
10
9
  childAllowlist: {
@@ -1,16 +1,30 @@
1
1
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
2
2
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
3
3
  import { DEFAULT_SETTINGS, SettingsValidationError } from './settings.js';
4
- import { registry as builtin } from './strategies/index.js';
5
4
  import { JevClient } from './jev.js';
6
5
  import { DECISION_SERVICES } from './decision-service.js';
7
6
  const keyRef = (provider = 'openrouter') => credentialRef(DECISION_SERVICES[provider].credential);
8
7
  /** Only the fixed credential reference and public catalog metadata cross this adapter. */
9
- export function settingsHost(ctx, store, registry = builtin) {
8
+ export function settingsHost(ctx, store) {
10
9
  const environment = launchEnvironmentOf(ctx);
11
10
  const credentials = () => ctx.get('credentials');
12
- const catalog = async () => {
13
- const models = (await Promise.all(ctx.llm.listProviders().filter(p => registry.hasProvider(p.id)).map(async (provider) => {
11
+ const allowlist = () => {
12
+ // The optional provider is owned by dsh-tool-subagent, not this package.
13
+ const service = ctx.get('subagentModelSelection');
14
+ try {
15
+ if (service) {
16
+ const current = service.current();
17
+ return { status: current.enabled ? 'enabled' : 'disabled', allowedModels: current.allowedModels };
18
+ }
19
+ }
20
+ catch { /* An unavailable policy must never broaden the allowed routes. */ }
21
+ return { status: 'unavailable', allowedModels: [] };
22
+ };
23
+ /** One provider when routing; otherwise only the providers the allowlist names (never every host provider). */
24
+ const catalog = async (only) => {
25
+ const childAllowlist = allowlist();
26
+ const wanted = new Set(only ? [only] : childAllowlist.allowedModels.map(r => r.provider));
27
+ const models = (await Promise.all(ctx.llm.listProviders().filter(p => wanted.has(p.id)).map(async (provider) => {
14
28
  try {
15
29
  return await Promise.all((await ctx.llm.listModels(provider.id)).map(async (model) => {
16
30
  try {
@@ -26,16 +40,7 @@ export function settingsHost(ctx, store, registry = builtin) {
26
40
  return [];
27
41
  }
28
42
  }))).flat();
29
- // The optional provider is owned by dsh-tool-subagent, not this package.
30
- const service = ctx.get('subagentModelSelection');
31
- try {
32
- if (service) {
33
- const current = service.current();
34
- return { models, childAllowlist: { status: current.enabled ? 'enabled' : 'disabled', allowedModels: current.allowedModels.filter(r => registry.hasProvider(r.provider)) } };
35
- }
36
- }
37
- catch { /* An unavailable policy must never broaden the allowed routes. */ }
38
- return { models, childAllowlist: { status: 'unavailable', allowedModels: [] } };
43
+ return { models, childAllowlist };
39
44
  };
40
45
  const keyStatus = async (provider = store.read().settings.decisionService?.provider ?? 'openrouter') => {
41
46
  const ref = keyRef(provider);
@@ -2,11 +2,6 @@ export declare const claude: {
2
2
  readonly id: "claude";
3
3
  readonly label: "Claude";
4
4
  readonly modelPrefixes: readonly ["claude-"];
5
- readonly transports: {
6
- readonly claude: "";
7
- readonly 'dsh-magpie-connect': "claude/";
8
- readonly 'dsh-magpie-connector': "claude/";
9
- };
10
5
  readonly effortStarts: {
11
6
  readonly 'claude-opus-5-5': "medium";
12
7
  readonly 'claude-sonnet-5-5': "medium";
@@ -2,7 +2,6 @@ export const claude = {
2
2
  id: 'claude',
3
3
  label: 'Claude',
4
4
  modelPrefixes: ['claude-'],
5
- transports: { claude: '', 'dsh-magpie-connect': 'claude/', 'dsh-magpie-connector': 'claude/' },
6
5
  effortStarts: { 'claude-opus-5-5': 'medium', 'claude-sonnet-5-5': 'medium', 'claude-haiku-5-5': 'medium', 'claude-fable-5-1': 'high' },
7
6
  childModels: { bounded: 'claude-haiku-5-5', analytical: 'claude-sonnet-5-5', frontier: 'claude-opus-5-5' },
8
7
  };
@@ -3,11 +3,6 @@ export declare const codex: {
3
3
  readonly id: "codex";
4
4
  readonly label: "GPT";
5
5
  readonly modelPrefixes: readonly ["gpt-"];
6
- readonly transports: {
7
- readonly codex: "";
8
- readonly 'dsh-magpie-connect': "codex/";
9
- readonly 'dsh-magpie-connector': "codex/";
10
- };
11
6
  readonly effortStarts: {
12
7
  readonly 'gpt-6.1-sol': "medium";
13
8
  readonly 'gpt-6-luna': "high";
@@ -3,7 +3,6 @@ export const codex = {
3
3
  id: 'codex',
4
4
  label: 'GPT',
5
5
  modelPrefixes: ['gpt-'],
6
- transports: { codex: '', 'dsh-magpie-connect': 'codex/', 'dsh-magpie-connector': 'codex/' },
7
6
  effortStarts: { 'gpt-6.1-sol': 'medium', 'gpt-6-luna': 'high', 'gpt-6-astra': 'low' },
8
7
  childModels: { bounded: 'gpt-6-luna', analytical: 'gpt-6.1-sol', frontier: 'gpt-6.1-sol' },
9
8
  };