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.
- package/CHANGELOG.md +11 -0
- package/README.md +56 -25
- package/README.zh.md +56 -25
- package/docs/feedback-evaluation.md +18 -0
- package/docs/host-capability-report.md +91 -0
- package/docs/jev-policy-v2-validation.md +37 -0
- package/docs/magpie-live-acceptance.md +27 -0
- package/docs/policy-v2-development.md +68 -0
- package/docs/policy-v2-real-evaluation-plan.md +32 -0
- package/docs/routing-community-research-2026-10-24.md +98 -0
- package/docs/routing-intent-and-gaps.md +56 -0
- package/docs/routing-strategy-plan.md +101 -0
- package/lib/client/control.d.ts +9 -1
- package/lib/client/control.js +16 -1
- package/lib/client/index.d.ts +2 -0
- package/lib/client/index.js +2 -2
- package/lib/client/label.d.ts +4 -0
- package/lib/client/label.js +24 -8
- package/lib/client/settings-section.js +53 -3
- package/lib/client/settings-ui.d.ts +1 -1
- package/lib/client/strategy-details.d.ts +4 -0
- package/lib/client/strategy-details.js +8 -0
- package/lib/client.js +332 -47
- package/lib/decision-service.d.ts +39 -0
- package/lib/decision-service.js +28 -0
- package/lib/endpoints.d.ts +2 -1
- package/lib/endpoints.js +22 -1
- package/lib/feedback.d.ts +69 -0
- package/lib/feedback.js +65 -0
- package/lib/hints.js +1 -1
- package/lib/index.js +29 -7
- package/lib/jev.d.ts +27 -1
- package/lib/jev.js +51 -23
- package/lib/policy-host.d.ts +68 -0
- package/lib/policy-host.js +274 -0
- package/lib/policy-store.d.ts +96 -0
- package/lib/policy-store.js +60 -0
- package/lib/policy-v2-rubric.json +21 -0
- package/lib/policy-v2.d.ts +20 -0
- package/lib/policy-v2.js +53 -0
- package/lib/settings-contract.d.ts +4 -1
- package/lib/settings-contract.js +1 -1
- package/lib/settings-host.d.ts +2 -2
- package/lib/settings-host.js +26 -14
- package/lib/settings.js +14 -1
- package/lib/subagent-tool.js +32 -5
- package/package.json +3 -3
- package/skills/subagent-model-routing/SKILL.md +15 -0
- package/lib/selection-projection.d.ts +0 -8
- 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
|
+
}
|
package/lib/endpoints.d.ts
CHANGED
|
@@ -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 {};
|
package/lib/feedback.js
ADDED
|
@@ -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': '
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|