dsh-auto-model-routing 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +56 -25
  3. package/README.zh.md +56 -25
  4. package/docs/feedback-evaluation.md +18 -0
  5. package/docs/host-capability-report.md +91 -0
  6. package/docs/jev-policy-v2-validation.md +37 -0
  7. package/docs/magpie-live-acceptance.md +27 -0
  8. package/docs/policy-v2-development.md +68 -0
  9. package/docs/policy-v2-real-evaluation-plan.md +32 -0
  10. package/docs/routing-community-research-2026-10-24.md +98 -0
  11. package/docs/routing-intent-and-gaps.md +56 -0
  12. package/docs/routing-strategy-plan.md +101 -0
  13. package/lib/client/control.d.ts +9 -1
  14. package/lib/client/control.js +16 -1
  15. package/lib/client/index.d.ts +2 -0
  16. package/lib/client/index.js +2 -2
  17. package/lib/client/label.d.ts +4 -0
  18. package/lib/client/label.js +24 -8
  19. package/lib/client/settings-section.js +53 -3
  20. package/lib/client/settings-ui.d.ts +1 -1
  21. package/lib/client/strategy-details.d.ts +4 -0
  22. package/lib/client/strategy-details.js +8 -0
  23. package/lib/client.js +332 -47
  24. package/lib/decision-service.d.ts +39 -0
  25. package/lib/decision-service.js +28 -0
  26. package/lib/endpoints.d.ts +2 -1
  27. package/lib/endpoints.js +22 -1
  28. package/lib/feedback.d.ts +69 -0
  29. package/lib/feedback.js +65 -0
  30. package/lib/hints.js +1 -1
  31. package/lib/index.js +29 -7
  32. package/lib/jev.d.ts +27 -1
  33. package/lib/jev.js +51 -23
  34. package/lib/policy-host.d.ts +68 -0
  35. package/lib/policy-host.js +274 -0
  36. package/lib/policy-store.d.ts +96 -0
  37. package/lib/policy-store.js +60 -0
  38. package/lib/policy-v2-rubric.json +21 -0
  39. package/lib/policy-v2.d.ts +20 -0
  40. package/lib/policy-v2.js +53 -0
  41. package/lib/settings-contract.d.ts +4 -1
  42. package/lib/settings-contract.js +1 -1
  43. package/lib/settings-host.d.ts +2 -2
  44. package/lib/settings-host.js +26 -14
  45. package/lib/settings.js +14 -1
  46. package/lib/subagent-tool.js +32 -5
  47. package/package.json +3 -3
  48. package/skills/subagent-model-routing/SKILL.md +15 -0
  49. package/lib/selection-projection.d.ts +0 -8
  50. package/lib/selection-projection.js +0 -7
@@ -0,0 +1,39 @@
1
+ export declare const DECISION_SERVICES: {
2
+ readonly openrouter: {
3
+ readonly label: "OpenRouter";
4
+ readonly endpoint: "https://openrouter.ai/api/alpha/decisions";
5
+ readonly model: "typesafe/jev-1.13";
6
+ readonly credential: "OPENROUTER_API_KEY";
7
+ };
8
+ readonly typesafe: {
9
+ readonly label: "TypeSafe 官方";
10
+ readonly endpoint: "https://api.typesafe.ai/v1/systemone";
11
+ readonly model: "jev-1.13.0";
12
+ readonly credential: "TYPESAFE_API_KEY";
13
+ };
14
+ readonly concentrate: {
15
+ readonly label: "Concentrate";
16
+ readonly endpoint: "https://api.concentrate.ai/v1/systemone";
17
+ readonly model: "jev-1.13.0";
18
+ readonly credential: "CONCENTRATE_API_KEY";
19
+ };
20
+ readonly custom: {
21
+ readonly label: "自定义 System One";
22
+ readonly endpoint: "";
23
+ readonly model: "jev-1.13.0";
24
+ readonly credential: "AUTO_DECISION_CUSTOM_API_KEY";
25
+ };
26
+ };
27
+ export type DecisionProvider = keyof typeof DECISION_SERVICES;
28
+ export interface DecisionConfig {
29
+ provider: DecisionProvider;
30
+ model?: string | undefined;
31
+ endpoint?: string | undefined;
32
+ }
33
+ export declare function validDecisionEndpoint(value: string): boolean;
34
+ export declare function resolveDecisionService(config?: DecisionConfig): {
35
+ provider: "openrouter" | "typesafe" | "concentrate" | "custom";
36
+ endpoint: string;
37
+ model: string;
38
+ credential: "OPENROUTER_API_KEY" | "TYPESAFE_API_KEY" | "CONCENTRATE_API_KEY" | "AUTO_DECISION_CUSTOM_API_KEY";
39
+ };
@@ -0,0 +1,28 @@
1
+ export const DECISION_SERVICES = {
2
+ openrouter: { label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/alpha/decisions', model: 'typesafe/jev-1.13', credential: 'OPENROUTER_API_KEY' },
3
+ typesafe: { label: 'TypeSafe 官方', endpoint: 'https://api.typesafe.ai/v1/systemone', model: 'jev-1.13.0', credential: 'TYPESAFE_API_KEY' },
4
+ concentrate: { label: 'Concentrate', endpoint: 'https://api.concentrate.ai/v1/systemone', model: 'jev-1.13.0', credential: 'CONCENTRATE_API_KEY' },
5
+ custom: { label: '自定义 System One', endpoint: '', model: 'jev-1.13.0', credential: 'AUTO_DECISION_CUSTOM_API_KEY' },
6
+ };
7
+ export function validDecisionEndpoint(value) {
8
+ try {
9
+ const u = new URL(value);
10
+ return !u.username && !u.password && !u.search && !u.hash && (u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)));
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export function resolveDecisionService(config) {
17
+ const provider = config?.provider ?? 'openrouter';
18
+ const preset = DECISION_SERVICES[provider];
19
+ if (!preset)
20
+ throw new Error('Unknown decision service');
21
+ const endpoint = provider === 'custom' ? config?.endpoint ?? '' : preset.endpoint;
22
+ if (!validDecisionEndpoint(endpoint))
23
+ throw new Error('Invalid decision endpoint');
24
+ const model = config?.model ?? preset.model;
25
+ if (!/^[A-Za-z0-9._:/~-]{1,128}$/.test(model))
26
+ throw new Error('Invalid decision model');
27
+ return { provider, endpoint, model, credential: preset.credential };
28
+ }
@@ -1,3 +1,4 @@
1
+ import { type FeedbackBook } from './feedback.js';
1
2
  import type { AgentRef, Router } from './router.js';
2
3
  import type { RoutingState } from './state.js';
3
4
  import { type Tier } from './effort.js';
@@ -8,4 +9,4 @@ export interface LabelBook {
8
9
  label(sessionId: string, turn: number, label: Tier): void;
9
10
  }
10
11
  /** Query (no `label`) or label the judged turn a reply belongs to (design D13). */
11
- export declare function labelEndpoint(body: unknown, resolveAgent: (id: string) => Promise<AgentRef | undefined>, book: LabelBook): Promise<Response>;
12
+ export declare function labelEndpoint(body: unknown, resolveAgent: (id: string) => Promise<AgentRef | undefined>, book: LabelBook, feedbackBook?: FeedbackBook): Promise<Response>;
package/lib/endpoints.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { allocationFeedback } from './feedback.js';
1
2
  import { tiers } from './effort.js';
2
3
  export async function endpoint(operation, body, resolveAgent, router, state, toastOn = () => false) {
3
4
  if (typeof body !== 'object' || body === null)
@@ -15,7 +16,7 @@ export async function endpoint(operation, body, resolveAgent, router, state, toa
15
16
  return Response.json({ ...state.read(sessionId), ...router.status(agent), ...(decision ? { decision } : {}), ...(toastOn() ? { decisionToast: true } : {}) });
16
17
  }
17
18
  /** Query (no `label`) or label the judged turn a reply belongs to (design D13). */
18
- export async function labelEndpoint(body, resolveAgent, book) {
19
+ export async function labelEndpoint(body, resolveAgent, book, feedbackBook) {
19
20
  if (typeof body !== 'object' || body === null)
20
21
  return Response.json({ error: 'invalid request' }, { status: 400 });
21
22
  const { sessionId, messageId, label } = body;
@@ -24,6 +25,26 @@ export async function labelEndpoint(body, resolveAgent, book) {
24
25
  const agent = await resolveAgent(sessionId);
25
26
  if (!agent || agent.origin === 'subagent')
26
27
  return Response.json({ error: 'unknown ordinary session' }, { status: 404 });
28
+ const hasFeedback = Object.hasOwn(body, 'feedback');
29
+ const feedback = body.feedback;
30
+ if (hasFeedback && (label !== undefined || (feedback !== null && !allocationFeedback.includes(feedback))))
31
+ return Response.json({ error: 'invalid feedback' }, { status: 400 });
32
+ const allocation = feedbackBook?.lookup(sessionId, messageId);
33
+ if (allocation) {
34
+ if (label !== undefined)
35
+ return Response.json({ error: 'use allocation feedback' }, { status: 400 });
36
+ if (hasFeedback) {
37
+ try {
38
+ feedbackBook.set(sessionId, messageId, feedback);
39
+ }
40
+ catch {
41
+ return Response.json({ error: 'could not record feedback' }, { status: 500 });
42
+ }
43
+ }
44
+ return Response.json({ policy: 'policy-v2', ...feedbackBook.lookup(sessionId, messageId) });
45
+ }
46
+ if (hasFeedback)
47
+ return Response.json({ error: 'no applied decision' }, { status: 404 });
27
48
  const record = book.lookup(sessionId, messageId);
28
49
  if (!record)
29
50
  return Response.json({});
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ export declare const allocationFeedback: readonly ["appropriate", "too-high", "too-low", "unknown"];
3
+ declare const schema: z.ZodObject<{
4
+ sessionId: z.ZodString;
5
+ messageId: z.ZodString;
6
+ decisionId: z.ZodNumber;
7
+ depth: z.ZodString;
8
+ model: z.ZodString;
9
+ effort: z.ZodString;
10
+ snapshot: z.ZodOptional<z.ZodObject<{
11
+ decidedAt: z.ZodNumber;
12
+ appliedAt: z.ZodNumber;
13
+ policyVersion: z.ZodString;
14
+ mappingVersion: z.ZodString;
15
+ decisionProvider: z.ZodString;
16
+ decisionModel: z.ZodString;
17
+ ms: z.ZodNumber;
18
+ confidence: z.ZodOptional<z.ZodNumber>;
19
+ probabilities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
20
+ provider: z.ZodString;
21
+ requestSeq: z.ZodNumber;
22
+ requestTurn: z.ZodOptional<z.ZodNumber>;
23
+ }, z.core.$strict>>;
24
+ boundAt: z.ZodOptional<z.ZodNumber>;
25
+ feedback: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
26
+ appropriate: "appropriate";
27
+ "too-high": "too-high";
28
+ "too-low": "too-low";
29
+ unknown: "unknown";
30
+ }>>>;
31
+ feedbackAt: z.ZodOptional<z.ZodNumber>;
32
+ }, z.core.$strict>;
33
+ export type FeedbackRecord = z.infer<typeof schema>;
34
+ /** Immutable decision bindings and separate feedback events; never includes task text or credentials. */
35
+ export declare class FeedbackBook {
36
+ private file;
37
+ private records;
38
+ constructor(file: string);
39
+ private key;
40
+ lookup(sessionId: string, messageId: string): {
41
+ sessionId: string;
42
+ messageId: string;
43
+ decisionId: number;
44
+ depth: string;
45
+ model: string;
46
+ effort: string;
47
+ snapshot?: {
48
+ decidedAt: number;
49
+ appliedAt: number;
50
+ policyVersion: string;
51
+ mappingVersion: string;
52
+ decisionProvider: string;
53
+ decisionModel: string;
54
+ ms: number;
55
+ provider: string;
56
+ requestSeq: number;
57
+ confidence?: number | undefined;
58
+ probabilities?: Record<string, number> | undefined;
59
+ requestTurn?: number | undefined;
60
+ } | undefined;
61
+ boundAt?: number | undefined;
62
+ feedback?: "appropriate" | "too-high" | "too-low" | "unknown" | null | undefined;
63
+ feedbackAt?: number | undefined;
64
+ } | undefined;
65
+ bind(record: FeedbackRecord): void;
66
+ set(sessionId: string, messageId: string, feedback: typeof allocationFeedback[number] | null): void;
67
+ private append;
68
+ }
69
+ export {};
@@ -0,0 +1,65 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+ export const allocationFeedback = ['appropriate', 'too-high', 'too-low', 'unknown'];
5
+ const probability = z.number().min(0).max(1);
6
+ const snapshot = z.object({
7
+ decidedAt: z.number(), appliedAt: z.number(), policyVersion: z.string(), mappingVersion: z.string(),
8
+ decisionProvider: z.string(), decisionModel: z.string(), ms: z.number(),
9
+ confidence: probability.optional(), probabilities: z.record(z.string(), probability).optional(),
10
+ provider: z.string(), requestSeq: z.number(), requestTurn: z.number().optional(),
11
+ }).strict();
12
+ const schema = z.object({ sessionId: z.string(), messageId: z.string(), decisionId: z.number(), depth: z.string(), model: z.string(), effort: z.string(), snapshot: snapshot.optional(), boundAt: z.number().optional(), feedback: z.enum(allocationFeedback).nullable().optional(), feedbackAt: z.number().optional() }).strict();
13
+ const feedbackEvent = z.object({ type: z.literal('feedback'), version: z.literal(2), sessionId: z.string(), messageId: z.string(), decisionId: z.number(), at: z.number(), feedback: z.enum(allocationFeedback).nullable() }).strict();
14
+ /** Immutable decision bindings and separate feedback events; never includes task text or credentials. */
15
+ export class FeedbackBook {
16
+ file;
17
+ records = new Map();
18
+ constructor(file) {
19
+ this.file = file;
20
+ let text;
21
+ try {
22
+ text = readFileSync(file, 'utf8');
23
+ }
24
+ catch (error) {
25
+ if (error.code === 'ENOENT')
26
+ return;
27
+ throw error;
28
+ }
29
+ for (const line of text.split('\n'))
30
+ try {
31
+ const raw = JSON.parse(line);
32
+ const event = feedbackEvent.safeParse(raw);
33
+ if (event.success) {
34
+ const e = event.data;
35
+ const key = this.key(e.sessionId, e.messageId);
36
+ const r = this.records.get(key);
37
+ if (r?.decisionId === e.decisionId)
38
+ this.records.set(key, { ...r, feedback: e.feedback, feedbackAt: e.at });
39
+ continue;
40
+ }
41
+ const r = schema.parse(raw);
42
+ const key = this.key(r.sessionId, r.messageId);
43
+ const existing = this.records.get(key);
44
+ // Old full-record feedback lines remain readable, but cannot replace the original decision.
45
+ if (!existing)
46
+ this.records.set(key, r);
47
+ else if (existing.decisionId === r.decisionId && Object.hasOwn(r, 'feedback'))
48
+ this.records.set(key, { ...existing, feedback: r.feedback });
49
+ }
50
+ catch { /* incomplete/corrupt lines cannot create a binding */ }
51
+ }
52
+ key(sessionId, messageId) { return JSON.stringify([sessionId, messageId]); }
53
+ lookup(sessionId, messageId) { const r = this.records.get(this.key(sessionId, messageId)); return r && structuredClone(r); }
54
+ bind(record) { if (this.lookup(record.sessionId, record.messageId))
55
+ return; const r = schema.parse({ ...record, boundAt: Date.now() }); this.append(r); this.records.set(this.key(r.sessionId, r.messageId), r); }
56
+ set(sessionId, messageId, feedback) {
57
+ const r = this.lookup(sessionId, messageId);
58
+ if (!r)
59
+ throw Error('No applied decision');
60
+ const event = feedbackEvent.parse({ type: 'feedback', version: 2, sessionId, messageId, decisionId: r.decisionId, at: Date.now(), feedback });
61
+ this.append(event);
62
+ this.records.set(this.key(sessionId, messageId), { ...r, feedback, feedbackAt: event.at });
63
+ }
64
+ append(record) { mkdirSync(dirname(this.file), { recursive: true }); appendFileSync(this.file, JSON.stringify(record) + '\n', { mode: 0o600 }); }
65
+ }
package/lib/hints.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Human-readable hint per category, shown on the chip, panel and tool errors. */
2
2
  export const CATEGORY_HINTS = {
3
- 'missing-credential': '未配置 OPENROUTER_API_KEY',
3
+ 'missing-credential': '未配置所选决策服务的 API key',
4
4
  'insufficient-credits': '余额不足,请充值',
5
5
  unauthorized: 'key 无效或无权限',
6
6
  'rate-limited': '请求被限流',
package/lib/index.js CHANGED
@@ -5,11 +5,14 @@ import { homedir } from 'node:os';
5
5
  import { FileStateStore, RoutingState } from './state.js';
6
6
  import { Router } from './router.js';
7
7
  import { endpoint, labelEndpoint } from './endpoints.js';
8
+ import { FeedbackBook } from './feedback.js';
8
9
  import { JudgementBook } from './judgements.js';
9
10
  import { userInput } from './input.js';
10
11
  import { JevClient, rubricVersion } from './jev.js';
11
12
  import { routeSubagentTool } from './subagent-tool.js';
12
13
  import { buildState, previousTurn } from './context.js';
14
+ import { policyHost } from './policy-host.js';
15
+ import { PolicyStore } from './policy-store.js';
13
16
  export const name = 'dsh-auto-model-routing';
14
17
  export const inject = ['llm', 'agents', 'tools'];
15
18
  const knownEfforts = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -17,7 +20,7 @@ export function apply(ctx, config = {}) {
17
20
  const home = config.home ?? process.env.DSH_HOME ?? resolve(homedir(), '.dsh');
18
21
  const settings = new SettingsStore(resolve(home, 'plugins/dsh-auto-model-routing/config.json'));
19
22
  const hostSettings = settingsHost(ctx, settings);
20
- const jev = new JevClient({ get: hostSettings.credential }, config.jevBase, config.jevTimeoutMs ?? (() => settings.read().settings.jevTimeoutMs));
23
+ const jev = new JevClient({ get: hostSettings.credential }, config.jevBase, config.jevTimeoutMs ?? (() => settings.read().settings.jevTimeoutMs), config.jevBase ? undefined : () => settings.read().settings.decisionService);
21
24
  const stateFiles = new FileStateStore(resolve(home, 'state/auto-model-routing/sessions'));
22
25
  const state = new RoutingState(stateFiles);
23
26
  const book = new JudgementBook(resolve(home, 'state/auto-model-routing/judgements.jsonl'));
@@ -32,6 +35,8 @@ export function apply(ctx, config = {}) {
32
35
  ctx.logger.warn(`auto-model-routing: could not record the judgement: ${String(error)}`);
33
36
  }
34
37
  }, () => settings.read().settings);
38
+ const feedback = new FeedbackBook(resolve(home, 'state/auto-model-routing/allocation-feedback.jsonl'));
39
+ const policy = policyHost(ctx, { feedback, jev, state, store: new PolicyStore(resolve(home, 'state/auto-model-routing/policy-v2')), settings: () => settings.read().settings, catalog: () => hostSettings.catalog(true) });
35
40
  const toastOn = () => settings.read().settings.decisionToast;
36
41
  ctx.effect(() => ctx.tools.register(routeSubagentTool(jev, { agents: ctx.agents, state, readSettings: () => settings.read().settings, catalog: hostSettings.catalog, judged: (sessionId, value) => router.judged(sessionId, value) })));
37
42
  ctx.on('agent/created', ({ agent, source }) => {
@@ -55,7 +60,7 @@ export function apply(ctx, config = {}) {
55
60
  ctx.on('agent/pre-step', async (payload, next) => {
56
61
  const decision = await next();
57
62
  // Judge only steps that actually enter, using the messages that enter (a rejected step sends nothing).
58
- if (payload.step !== 1 || decision.kind !== 'enter')
63
+ if (settings.read().settings.policy === 'policy-v2' || payload.step !== 1 || decision.kind !== 'enter')
59
64
  return decision;
60
65
  const agent = ref(payload.agent);
61
66
  if (agent.origin !== 'subagent' && userInput(decision.messages).userTurn)
@@ -88,7 +93,7 @@ export function apply(ctx, config = {}) {
88
93
  const inflight = new Map();
89
94
  ctx.on('agent/request', async (payload, next) => {
90
95
  const config = await next();
91
- if (payload.agent.session.header.origin === 'subagent')
96
+ if (settings.read().settings.policy === 'policy-v2' || payload.agent.session.header.origin === 'subagent')
92
97
  return config;
93
98
  const model = router.needsModelInfo(ref(payload.agent)) ? await ctx.llm.resolveModelInfo(config.provider, config.model, payload.signal) : undefined;
94
99
  const efforts = (model?.reasoning?.efforts ?? []).map(effort => effort.id).filter(id => knownEfforts.has(id));
@@ -114,7 +119,7 @@ export function apply(ctx, config = {}) {
114
119
  child.effect(() => () => { selectModel = undefined; });
115
120
  });
116
121
  ctx.inject(['connection', 'sessionController'], child => {
117
- for (const operation of ['state', 'setMode', 'label', 'settings', 'saveSettings', 'setKey']) {
122
+ for (const operation of ['state', 'setMode', 'label', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
118
123
  child.effect(() => child.connection.fetch.register({
119
124
  path: `/api/auto-model-routing.${operation}`,
120
125
  methods: ['POST'], requestBody: 'buffered',
@@ -135,9 +140,26 @@ export function apply(ctx, config = {}) {
135
140
  return 'agent' in result ? ref(result.agent) : undefined;
136
141
  };
137
142
  const payload = enveloped ? wire.payload : body;
138
- const response = operation === 'settings' || operation === 'saveSettings' || operation === 'setKey'
139
- ? await hostSettings.endpoint(operation, payload)
140
- : operation === 'label' ? await labelEndpoint(payload, resolveAgent, book) : await endpoint(operation, payload, resolveAgent, router, state, toastOn);
143
+ if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
144
+ const id = payload.sessionId;
145
+ const mode = payload.mode;
146
+ if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id))
147
+ policy.invalidate(id);
148
+ }
149
+ const response = operation === 'childRouting'
150
+ ? (typeof payload?.sessionId === 'string'
151
+ ? Response.json(policy.status(payload.sessionId) ?? { status: 'unsupported', reason: '没有已绑定的 policy-v2 决策' })
152
+ : Response.json({ error: 'invalid request' }, { status: 400 }))
153
+ : operation === 'settings' || operation === 'saveSettings' || operation === 'setKey' || operation === 'testDecision'
154
+ ? await hostSettings.endpoint(operation, payload)
155
+ : operation === 'label' ? await labelEndpoint(payload, resolveAgent, book, feedback) : await endpoint(operation, payload, resolveAgent, router, state, toastOn);
156
+ if ((operation === 'state' || operation === 'setMode') && response.ok && settings.read().settings.policy === 'policy-v2') {
157
+ const value = await response.json();
158
+ const main = policy.mainStatus(payload.sessionId);
159
+ const { decision: _legacyDecision, effort: _legacyEffort, reason: _legacyReason, history: _legacyHistory, ...base } = value;
160
+ const result = { ...base, history: [], policy: 'policy-v2', ...main };
161
+ return Response.json(enveloped ? { type: 'server-response', rpcId: wire.rpcId, result: { ok: true, value: result } } : result);
162
+ }
141
163
  if (!enveloped)
142
164
  return response;
143
165
  const value = await response.json();
package/lib/jev.d.ts CHANGED
@@ -1,4 +1,27 @@
1
+ import { type DecisionConfig } from './decision-service.js';
2
+ export interface PolicyMeasure {
3
+ ms: number;
4
+ decisionProvider: string;
5
+ decisionModel: string;
6
+ confidence?: number;
7
+ probabilities?: Record<string, number>;
8
+ capabilityConfidence?: number;
9
+ capabilityProbabilities?: Record<string, number>;
10
+ }
1
11
  import { type Tier } from './effort.js';
12
+ export type Depth = 'lower' | 'baseline' | 'higher';
13
+ export type Capability = 'bounded' | 'analytical' | 'frontier';
14
+ export type PolicyResult = ({
15
+ kind: 'ok';
16
+ depth: Depth;
17
+ capability?: Capability | 'insufficient_context';
18
+ } & PolicyMeasure) | {
19
+ kind: 'failed';
20
+ category: JevCategory;
21
+ } | {
22
+ kind: 'cancelled';
23
+ };
24
+ export declare const policyVersion: string;
2
25
  import type { JevCategory } from './hints.js';
3
26
  export { CATEGORY_HINTS, type JevCategory } from './hints.js';
4
27
  export type Distribution = Partial<Record<Tier, number>>;
@@ -41,9 +64,12 @@ export declare function tierAbove(tier: Tier): Tier;
41
64
  export declare class JevClient {
42
65
  private readonly env;
43
66
  private readonly timeoutMs;
67
+ private readonly service?;
44
68
  readonly url: string;
45
- constructor(env: CredentialSource, base?: string, timeoutMs?: number | (() => number));
69
+ constructor(env: CredentialSource, base?: string, timeoutMs?: number | (() => number), service?: (() => DecisionConfig | undefined) | undefined);
46
70
  /** One request, no retry. Never throws; never returns the body or the key. */
47
71
  /** `state` is assembled and redacted by the caller (`buildState`). */
48
72
  judge(state: string, signal: AbortSignal): Promise<JevResult>;
73
+ judgePolicy(state: string, signal: AbortSignal, scope: 'main' | 'child', dimensions?: 'both' | 'capability' | 'depth'): Promise<PolicyResult>;
74
+ private decide;
49
75
  }
package/lib/jev.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { resolveDecisionService } from './decision-service.js';
3
+ const probability = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
4
+ const distribution = (value) => Object.fromEntries(Object.entries(value ?? {}).filter(([, v]) => probability(v)));
2
5
  import { tiers } from './effort.js';
3
6
  import rubric from './rubric.json' with { type: 'json' };
7
+ import policyQuestions from './policy-v2-rubric.json' with { type: 'json' };
8
+ export const policyVersion = `policy-v2-${createHash('sha256').update(JSON.stringify(policyQuestions)).digest('hex').slice(0, 8)}`;
4
9
  export { CATEGORY_HINTS } from './hints.js';
5
- const MODEL = 'typesafe/jev-1.13';
6
10
  const MAX_BODY = 64 * 1024;
7
11
  /** Shared by the main session and `route_subagent`; only `state` differs (design D4). Single source: rubric.json. */
8
12
  export const TIER_QUESTIONS = rubric;
@@ -26,15 +30,54 @@ function byStatus(status) {
26
30
  export class JevClient {
27
31
  env;
28
32
  timeoutMs;
33
+ service;
29
34
  url;
30
- constructor(env, base = 'https://openrouter.ai', timeoutMs = 3000) {
35
+ constructor(env, base = 'https://openrouter.ai', timeoutMs = 3000, service) {
31
36
  this.env = env;
32
37
  this.timeoutMs = timeoutMs;
38
+ this.service = service;
33
39
  this.url = `${base}/api/alpha/decisions`;
34
40
  }
35
41
  /** One request, no retry. Never throws; never returns the body or the key. */
36
42
  /** `state` is assembled and redacted by the caller (`buildState`). */
37
43
  async judge(state, signal) {
44
+ const result = await this.decide(state, signal, TIER_QUESTIONS);
45
+ if (result.kind !== 'ok')
46
+ return result;
47
+ const answer = result.answers?.tier;
48
+ const choice = answer?.choice;
49
+ if (!tiers.includes(choice))
50
+ return { kind: 'failed', category: 'invalid-response' };
51
+ const unit = (v) => typeof v === 'number' && v >= 0 && v <= 1;
52
+ const probabilities = {};
53
+ for (const t of tiers) {
54
+ const v = answer?.probabilities?.[t];
55
+ if (unit(v))
56
+ probabilities[t] = v;
57
+ }
58
+ return { kind: 'ok', tier: choice, ms: result.ms,
59
+ ...(unit(answer?.confidence) ? { confidence: answer.confidence } : {}),
60
+ ...(Object.keys(probabilities).length ? { probabilities } : {}) };
61
+ }
62
+ async judgePolicy(state, signal, scope, dimensions = 'both') {
63
+ const questions = scope === 'main' || dimensions === 'depth' ? { depth: policyQuestions.depth }
64
+ : dimensions === 'capability' ? { capability: policyQuestions.capability } : policyQuestions;
65
+ const result = await this.decide(state, signal, questions);
66
+ if (result.kind !== 'ok')
67
+ return result;
68
+ const depth = result.answers?.depth?.choice;
69
+ const capability = result.answers?.capability?.choice;
70
+ if (Object.hasOwn(questions, 'depth') && (typeof depth !== 'string' || !Object.hasOwn(policyQuestions.depth.criteria, depth)))
71
+ return { kind: 'failed', category: 'invalid-response' };
72
+ if (Object.hasOwn(questions, 'capability') && (typeof capability !== 'string' || !Object.hasOwn(policyQuestions.capability.criteria, capability)))
73
+ return { kind: 'failed', category: 'invalid-response' };
74
+ return { kind: 'ok', depth: (Object.hasOwn(questions, 'depth') ? depth : 'baseline'), ...(Object.hasOwn(questions, 'capability') ? { capability: capability } : {}), ms: result.ms, decisionProvider: result.decisionProvider, decisionModel: result.decisionModel,
75
+ ...(probability(result.answers.depth?.confidence) ? { confidence: result.answers.depth.confidence } : {}),
76
+ ...(result.answers.depth?.probabilities ? { probabilities: distribution(result.answers.depth.probabilities) } : {}),
77
+ ...(probability(result.answers.capability?.confidence) ? { capabilityConfidence: result.answers.capability.confidence } : {}),
78
+ ...(result.answers.capability?.probabilities ? { capabilityProbabilities: distribution(result.answers.capability.probabilities) } : {}) };
79
+ }
80
+ async decide(state, signal, questions) {
38
81
  if (signal.aborted)
39
82
  return { kind: 'cancelled' };
40
83
  const configuredTimeout = typeof this.timeoutMs === 'function' ? this.timeoutMs() : this.timeoutMs;
@@ -43,6 +86,7 @@ export class JevClient {
43
86
  const deadline = AbortSignal.timeout(timeout);
44
87
  const started = performance.now();
45
88
  try {
89
+ const service = resolveDecisionService(this.service?.());
46
90
  const abort = AbortSignal.any([signal, deadline]);
47
91
  let onAbort = () => { };
48
92
  const cancelled = new Promise((_, reject) => {
@@ -51,7 +95,7 @@ export class JevClient {
51
95
  });
52
96
  let credential;
53
97
  try {
54
- credential = await Promise.race([Promise.resolve(this.env.get('OPENROUTER_API_KEY')), cancelled]);
98
+ credential = await Promise.race([Promise.resolve(this.env.get(service.credential)), cancelled]);
55
99
  }
56
100
  finally {
57
101
  abort.removeEventListener('abort', onAbort);
@@ -63,12 +107,12 @@ export class JevClient {
63
107
  return { kind: 'failed', category: 'timeout' };
64
108
  if (!key)
65
109
  return { kind: 'failed', category: 'missing-credential' };
66
- const response = await fetch(this.url, {
110
+ const response = await fetch(this.service ? service.endpoint : this.url, {
67
111
  method: 'POST',
68
112
  redirect: 'error',
69
113
  signal: AbortSignal.any([signal, deadline]),
70
114
  headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'X-Title': 'ohmydsh auto-model-routing' },
71
- body: JSON.stringify({ model: MODEL, state, questions: TIER_QUESTIONS }),
115
+ body: JSON.stringify({ model: service.model, state, questions }),
72
116
  });
73
117
  if (!response.ok) {
74
118
  await response.body?.cancel().catch(() => { });
@@ -105,25 +149,9 @@ export class JevClient {
105
149
  }
106
150
  if (typeof body?.error?.code === 'number')
107
151
  return { kind: 'failed', category: byStatus(body.error.code) };
108
- const choice = body?.answers?.tier?.choice;
109
- if (!tiers.includes(choice))
152
+ if (!body?.answers || typeof body.answers !== 'object' || Array.isArray(body.answers))
110
153
  return { kind: 'failed', category: 'invalid-response' };
111
- const confidence = body.answers?.tier?.confidence;
112
- const spread = body.answers?.tier?.probabilities;
113
- // Keep only the four tiers' values in [0, 1]; anything else in the reply is ignored.
114
- const unit = (v) => typeof v === 'number' && v >= 0 && v <= 1;
115
- const probabilities = {};
116
- for (const t of tiers) {
117
- const v = spread?.[t];
118
- if (unit(v))
119
- probabilities[t] = v;
120
- }
121
- const ms = Math.round(performance.now() - started);
122
- return {
123
- kind: 'ok', tier: choice, ms,
124
- ...(unit(confidence) ? { confidence } : {}),
125
- ...(Object.keys(probabilities).length ? { probabilities } : {}),
126
- };
154
+ return { kind: 'ok', answers: body.answers, ms: Math.round(performance.now() - started), decisionProvider: service.provider, decisionModel: service.model };
127
155
  }
128
156
  catch (error) {
129
157
  if (signal.aborted)
@@ -0,0 +1,68 @@
1
+ import type { FeedbackBook } from './feedback.js';
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import type { JevClient, Depth } from './jev.js';
4
+ import { PolicyStore, type ChildDecision } from './policy-store.js';
5
+ import type { RoutingState } from './state.js';
6
+ import type { RoutingCatalog, Settings } from './settings-contract.js';
7
+ export declare function policyHost(ctx: Context, deps: {
8
+ feedback: FeedbackBook;
9
+ jev: JevClient;
10
+ state: RoutingState;
11
+ store: PolicyStore;
12
+ settings: () => Settings;
13
+ catalog: () => Promise<RoutingCatalog>;
14
+ }): {
15
+ invalidate: (id: string) => void;
16
+ status: (id: string) => {
17
+ version: 1;
18
+ childId: string;
19
+ parentId: string;
20
+ rootId: string;
21
+ policyVersion: string;
22
+ mappingVersion: string;
23
+ status: "failed" | "recommended" | "unsupported" | "needs-context" | "applied";
24
+ path: "ordinary" | "fork" | "teammate";
25
+ explicit: {
26
+ provider: "claude" | "codex" | "dsh-magpie-connect";
27
+ model?: string | undefined;
28
+ reasoningEffort?: string | undefined;
29
+ };
30
+ family?: "claude" | "codex" | undefined;
31
+ decision?: {
32
+ id: number;
33
+ source: "main" | "subagent";
34
+ tier: string;
35
+ depth: string;
36
+ effort: string;
37
+ status: string;
38
+ ms: number;
39
+ decisionProvider: string;
40
+ decisionModel: string;
41
+ capability?: string | undefined;
42
+ model?: string | undefined;
43
+ provider?: string | undefined;
44
+ confidence?: number | undefined;
45
+ capabilityConfidence?: number | undefined;
46
+ probabilities?: Record<string, number> | undefined;
47
+ capabilityProbabilities?: Record<string, number> | undefined;
48
+ } | undefined;
49
+ route?: {
50
+ provider: "claude" | "codex" | "dsh-magpie-connect";
51
+ model: string;
52
+ reasoningEffort: "low" | "medium" | "high" | "xhigh";
53
+ } | undefined;
54
+ actual?: {
55
+ provider: "claude" | "codex" | "dsh-magpie-connect";
56
+ model: string;
57
+ reasoningEffort: "low" | "medium" | "high" | "xhigh";
58
+ } | undefined;
59
+ reason?: string | undefined;
60
+ } | undefined;
61
+ mainStatus: (id: string) => {
62
+ decision?: NonNullable<ChildDecision["decision"]>;
63
+ policyVersion?: string;
64
+ depth?: Depth;
65
+ effort?: string;
66
+ reason?: string;
67
+ } | undefined;
68
+ };