@dsh-jev/router 0.1.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/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @dsh-jev/router
2
+
3
+ dsh (DeepSeek Harness) cordis plugin: per-turn model routing decided by
4
+ [jev System One](https://typesafe.ai) via `@dsh-jev/core`.
5
+
6
+ Before each turn's first model request, the plugin asks jev to classify the
7
+ turn's complexity:
8
+
9
+ - **heavy** (multi-step coding, debugging, architecture) → `deepseek-official`
10
+ - **light** (simple Q&A, formatting) → `zai-coding-cn` / `glm-5.3-flash`
11
+
12
+ ## Safety design
13
+
14
+ - **Never registers or modifies LLM routes.** Route registration belongs to
15
+ `dsh-llm-deepseek` / `dsh-llm-pi-ai` / `dsh-auth`; cross-plugin registration
16
+ conflicts are a known dsh footgun. This plugin only *selects* between routes
17
+ already present in `ctx.llm`, via the `agent/request` waterfall — and only
18
+ after double-checking the target provider is actually registered
19
+ (`ctx.llm.listProviders()`). An unregistered target keeps the default route.
20
+ - **Shadow-run by default.** Decisions are logged to stdout
21
+ (`[dsh-jev-router] turn=… picked=… target=… shadow`) and nothing changes.
22
+ Set `mode: enforce` in the plugin config to actually switch.
23
+ - **Degrades, never breaks.** jev unreachable / timeout / non-2xx → the
24
+ resolved config is returned untouched (default route is kept) and the
25
+ degrade reason is logged. The listener itself never throws.
26
+
27
+ ## Install into a dsh profile
28
+
29
+ ```yaml
30
+ # <profile>/cordis.patch.yml
31
+ - insert:
32
+ - id: dsh-jev-router
33
+ # local files go under `name:` (dsh converts fs paths to file:// URLs);
34
+ # a `path:` field is silently ignored and the import crashes
35
+ name: /Users/you/workspace/opensource/dsh-jev/packages/router/dist/index.js
36
+ config:
37
+ mode: shadow # shadow | enforce
38
+ heavy: { provider: deepseek-official, model: deepseek-flash }
39
+ light: { provider: zai-coding-cn, model: glm-5.3-flash }
40
+ ```
41
+
42
+ All config keys are optional; `JEV_API_KEY` / `JEV_ENDPOINT` env vars are
43
+ honored via `@dsh-jev/core`.
44
+
45
+ ## Development
46
+
47
+ ```sh
48
+ pnpm --filter @dsh-jev/router build
49
+ pnpm --filter @dsh-jev/router test # 13 tests: routing, fallback, shadow vs enforce
50
+ ```
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @dsh-jev/router — per-turn jev routing for DeepSeek Harness (dsh).
3
+ *
4
+ * RED LINE (do not cross): this plugin NEVER registers, patches, or removes
5
+ * LLM routes. Route registration is owned by dsh-llm-deepseek / dsh-llm-pi-ai
6
+ * / dsh-auth, and cross-plugin registration conflicts are a known footgun
7
+ * (see ~/.config/dsh/AGENTS.md). We only *choose* between routes that are
8
+ * already registered in `ctx.llm` — via the `agent/request` waterfall — and
9
+ * only when `mode: enforce` is explicitly configured. The default is
10
+ * `shadow`: decisions are logged to stdout and the request is untouched.
11
+ *
12
+ * Failure semantics: any jev failure (network, timeout, non-2xx, malformed)
13
+ * degrades to "no opinion" — the resolved config is returned unchanged, so
14
+ * the agent keeps whatever default route it would have used.
15
+ */
16
+ import { JevOutcome, ChoiceAnswer } from '@dsh-jev/core';
17
+ export interface RouteSelection {
18
+ provider: string;
19
+ model: string;
20
+ reasoningEffort?: string;
21
+ }
22
+ export interface RouterConfig {
23
+ /** `shadow` (default) logs decisions without switching; `enforce` actually switches. */
24
+ mode?: 'shadow' | 'enforce';
25
+ /** Route for complex turns. */
26
+ heavy?: RouteSelection;
27
+ /** Route for simple turns. */
28
+ light?: RouteSelection;
29
+ endpoint?: string;
30
+ apiKey?: string;
31
+ timeoutMs?: number;
32
+ /** Injectable fetch for tests. */
33
+ fetchImpl?: typeof fetch;
34
+ /** Injectable logger for tests. */
35
+ log?: (line: string) => void;
36
+ }
37
+ export declare const DEFAULT_HEAVY: RouteSelection;
38
+ export declare const DEFAULT_LIGHT: RouteSelection;
39
+ interface TextBlock {
40
+ type: 'text';
41
+ text: string;
42
+ }
43
+ interface UserMessage {
44
+ content?: Array<TextBlock | {
45
+ type: string;
46
+ [k: string]: unknown;
47
+ }>;
48
+ }
49
+ /** The `agent/pre-step` payload slice we consume. */
50
+ interface PreStepPayload {
51
+ agent: unknown;
52
+ messages: UserMessage[];
53
+ turn: number;
54
+ step: number;
55
+ signal: AbortSignal;
56
+ }
57
+ /** The `agent/request` payload slice we consume. */
58
+ interface RequestPayload {
59
+ agent: unknown;
60
+ turn: number;
61
+ step: number;
62
+ signal: AbortSignal;
63
+ }
64
+ /** Frozen LlmCallConfig slice returned by the `agent/request` waterfall. */
65
+ interface CallConfig {
66
+ provider: string;
67
+ model: string;
68
+ reasoningEffort?: string;
69
+ maxTokens?: number;
70
+ [k: string]: unknown;
71
+ }
72
+ /** Minimal `ctx.llm` surface (read-only; we never mutate registrations). */
73
+ interface LlmRegistry {
74
+ listProviders(): Array<{
75
+ id: string;
76
+ name?: string;
77
+ }>;
78
+ }
79
+ interface PluginContext {
80
+ on(event: 'agent/pre-step', listener: (payload: PreStepPayload, next: () => Promise<unknown>) => Promise<unknown>): unknown;
81
+ on(event: 'agent/request', listener: (payload: RequestPayload, next: () => Promise<CallConfig>) => Promise<CallConfig>): unknown;
82
+ llm?: LlmRegistry;
83
+ }
84
+ export declare const name = "dsh-jev-router";
85
+ export declare const inject: string[];
86
+ /** Concatenate the text blocks of the newest user message for jev context. */
87
+ export declare function extractUserText(messages: UserMessage[]): string;
88
+ export declare function sameRoute(a: {
89
+ provider: string;
90
+ model: string;
91
+ }, b: {
92
+ provider: string;
93
+ model: string;
94
+ }): boolean;
95
+ export declare function isProviderRegistered(llm: LlmRegistry | undefined, provider: string): boolean;
96
+ /** Map a jev choice outcome to the target route, or undefined to keep default. */
97
+ export declare function selectRoute(outcome: JevOutcome<ChoiceAnswer>, resolved: CallConfig, config: RouterConfig, llm: LlmRegistry | undefined): {
98
+ route: RouteSelection;
99
+ keepDefault: boolean;
100
+ reason: string;
101
+ };
102
+ export declare function apply(ctx: PluginContext, config?: RouterConfig): void;
103
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @dsh-jev/router — per-turn jev routing for DeepSeek Harness (dsh).
3
+ *
4
+ * RED LINE (do not cross): this plugin NEVER registers, patches, or removes
5
+ * LLM routes. Route registration is owned by dsh-llm-deepseek / dsh-llm-pi-ai
6
+ * / dsh-auth, and cross-plugin registration conflicts are a known footgun
7
+ * (see ~/.config/dsh/AGENTS.md). We only *choose* between routes that are
8
+ * already registered in `ctx.llm` — via the `agent/request` waterfall — and
9
+ * only when `mode: enforce` is explicitly configured. The default is
10
+ * `shadow`: decisions are logged to stdout and the request is untouched.
11
+ *
12
+ * Failure semantics: any jev failure (network, timeout, non-2xx, malformed)
13
+ * degrades to "no opinion" — the resolved config is returned unchanged, so
14
+ * the agent keeps whatever default route it would have used.
15
+ */
16
+ import { createJevClient } from '@dsh-jev/core';
17
+ export const DEFAULT_HEAVY = {
18
+ provider: 'deepseek-official',
19
+ model: 'deepseek-flash',
20
+ };
21
+ export const DEFAULT_LIGHT = {
22
+ provider: 'zai-coding-cn',
23
+ model: 'glm-5.3-flash',
24
+ };
25
+ export const name = 'dsh-jev-router';
26
+ export const inject = ['llm'];
27
+ // ---------------------------------------------------------------------------
28
+ // Pure helpers (unit-tested directly).
29
+ // ---------------------------------------------------------------------------
30
+ /** Concatenate the text blocks of the newest user message for jev context. */
31
+ export function extractUserText(messages) {
32
+ for (let i = messages.length - 1; i >= 0; i--) {
33
+ const blocks = messages[i]?.content;
34
+ if (!Array.isArray(blocks) || blocks.length === 0)
35
+ continue;
36
+ const text = blocks
37
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
38
+ .map((b) => b.text)
39
+ .join('\n')
40
+ .trim();
41
+ if (text)
42
+ return text.slice(0, 4000);
43
+ }
44
+ return '';
45
+ }
46
+ export function sameRoute(a, b) {
47
+ return a.provider === b.provider && a.model === b.model;
48
+ }
49
+ export function isProviderRegistered(llm, provider) {
50
+ try {
51
+ const providers = llm?.listProviders();
52
+ if (!Array.isArray(providers))
53
+ return false;
54
+ return providers.some((p) => p?.id === provider);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ /** Map a jev choice outcome to the target route, or undefined to keep default. */
61
+ export function selectRoute(outcome, resolved, config, llm) {
62
+ const heavy = { ...DEFAULT_HEAVY, ...config.heavy };
63
+ const light = { ...DEFAULT_LIGHT, ...config.light };
64
+ if (!outcome.ok) {
65
+ return { route: heavy, keepDefault: true, reason: `jev degraded (${outcome.error ?? 'unknown'}) — keeping default route` };
66
+ }
67
+ const picked = outcome.value.picked;
68
+ const route = picked === 'light' ? light : picked === 'heavy' ? heavy : heavy;
69
+ if (sameRoute(route, resolved)) {
70
+ return { route, keepDefault: true, reason: `already on ${route.provider}/${route.model}` };
71
+ }
72
+ if (!isProviderRegistered(llm, route.provider)) {
73
+ return { route, keepDefault: true, reason: `target route ${route.provider} not registered — keeping default (registration is not this plugin's job)` };
74
+ }
75
+ return { route, keepDefault: false, reason: 'ok' };
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Plugin entry.
79
+ // ---------------------------------------------------------------------------
80
+ export function apply(ctx, config = {}) {
81
+ const mode = config.mode ?? 'shadow';
82
+ const log = config.log ?? ((line) => process.stdout.write(line + '\n'));
83
+ const jev = createJevClient({
84
+ ...(config.endpoint !== undefined ? { endpoint: config.endpoint } : {}),
85
+ ...(config.apiKey !== undefined ? { apiKey: config.apiKey } : {}),
86
+ ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
87
+ ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
88
+ });
89
+ /** Latest user text per agent per turn, captured at pre-step. */
90
+ const turnText = new WeakMap();
91
+ const evaluate = async (agent, turn) => {
92
+ const text = turnText.get(agent)?.get(turn) ?? '';
93
+ const question = 'Classify the complexity of this assistant turn for model routing. ' +
94
+ 'Choose "heavy" when it needs strong reasoning (multi-step coding, debugging, architecture, long-context analysis). ' +
95
+ 'Choose "light" when a fast, cheap model suffices (simple Q&A, formatting, small lookups, chit-chat).';
96
+ return await jev.choice({ question, options: ['heavy', 'light'], context: text ? { userTurn: text } : {} }, { pickedIndex: 0, picked: 'heavy' } // structural fallback; degraded outcomes keep the default route anyway
97
+ );
98
+ };
99
+ ctx.on('agent/pre-step', async (payload, next) => {
100
+ const decision = await next();
101
+ const perTurn = turnText.get(payload.agent) ?? new Map();
102
+ perTurn.set(payload.turn, extractUserText(payload.messages));
103
+ turnText.set(payload.agent, perTurn);
104
+ return decision;
105
+ });
106
+ ctx.on('agent/request', async (payload, next) => {
107
+ const resolved = await next();
108
+ let outcome;
109
+ try {
110
+ outcome = await evaluate(payload.agent, payload.turn);
111
+ }
112
+ catch {
113
+ // JevClient contractually never throws, but stay defensive: keep default.
114
+ return resolved;
115
+ }
116
+ const { route, keepDefault, reason } = selectRoute(outcome, resolved, config, ctx.llm);
117
+ const label = `turn=${payload.turn} step=${payload.step} picked=${outcome.value.picked}` +
118
+ (outcome.ok ? '' : ` (degraded)`) +
119
+ ` target=${route.provider}/${route.model}` +
120
+ ` from=${resolved.provider}/${resolved.model}`;
121
+ if (keepDefault) {
122
+ log(`[dsh-jev-router] ${label} keep-default — ${reason}`);
123
+ return resolved;
124
+ }
125
+ if (mode !== 'enforce') {
126
+ log(`[dsh-jev-router] ${label} shadow (set mode: enforce to switch)`);
127
+ return resolved;
128
+ }
129
+ log(`[dsh-jev-router] ${label} enforcing switch`);
130
+ return {
131
+ ...resolved,
132
+ provider: route.provider,
133
+ model: route.model,
134
+ ...(route.reasoningEffort !== undefined ? { reasoningEffort: route.reasoningEffort } : {}),
135
+ };
136
+ });
137
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { apply, extractUserText, selectRoute } from './index.js';
3
+ function mockJevFetch(picked) {
4
+ const calls = [];
5
+ const fetchImpl = (async (url, init) => {
6
+ calls.push({ url: String(url), body: JSON.parse(init.body) });
7
+ if (picked === 'DEGRADE') {
8
+ return new Response('nope', { status: 403 });
9
+ }
10
+ return Response.json({ answer: { pickedIndex: picked === 'light' ? 1 : 0, picked, rationale: 'test' } });
11
+ });
12
+ return { fetchImpl, calls };
13
+ }
14
+ function makeHarness(llm = { listProviders: () => [{ id: 'deepseek-official' }, { id: 'zai-coding-cn' }] }) {
15
+ const listeners = new Map();
16
+ const ctx = {
17
+ llm,
18
+ on: (event, listener) => listeners.set(event, listener),
19
+ };
20
+ return { listeners, ctx };
21
+ }
22
+ const resolvedConfig = { provider: 'deepseek-official', model: 'deepseek-flash' };
23
+ async function runRequest(h, turn = 1, next = async () => ({ ...resolvedConfig })) {
24
+ const listener = h.listeners.get('agent/request');
25
+ const agent = {};
26
+ await h.listeners.get('agent/pre-step')({ agent, turn, step: 1, messages: [], signal: new AbortController().signal }, async () => ({ kind: 'enter', messages: [] }));
27
+ return listener({ agent, turn, step: 1, signal: new AbortController().signal }, next);
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // extractUserText
31
+ // ---------------------------------------------------------------------------
32
+ describe('extractUserText', () => {
33
+ it('returns the newest user message text', () => {
34
+ const messages = [
35
+ { content: [{ type: 'text', text: 'old question' }] },
36
+ { content: [{ type: 'text', text: 'new question' }] },
37
+ ];
38
+ expect(extractUserText(messages)).toBe('new question');
39
+ });
40
+ it('skips messages without text blocks and truncates long input', () => {
41
+ expect(extractUserText([{ content: [{ type: 'image', url: 'x' }] }, { content: [] }])).toBe('');
42
+ const long = { content: [{ type: 'text', text: 'x'.repeat(9000) }] };
43
+ expect(extractUserText([long]).length).toBe(4000);
44
+ });
45
+ });
46
+ // ---------------------------------------------------------------------------
47
+ // selectRoute
48
+ // ---------------------------------------------------------------------------
49
+ const okOutcome = (picked) => ({
50
+ ok: true,
51
+ value: { pickedIndex: picked === 'light' ? 1 : 0, picked },
52
+ durationMs: 5,
53
+ });
54
+ const degraded = {
55
+ ok: false,
56
+ value: { pickedIndex: 0, picked: 'heavy' },
57
+ error: 'HTTP 403',
58
+ durationMs: 3,
59
+ };
60
+ describe('selectRoute', () => {
61
+ const llm = { listProviders: () => [{ id: 'deepseek-official' }, { id: 'zai-coding-cn' }] };
62
+ it('light pick maps to the light route', () => {
63
+ const r = selectRoute(okOutcome('light'), resolvedConfig, {}, llm);
64
+ expect(r.keepDefault).toBe(false);
65
+ expect(r.route).toEqual({ provider: 'zai-coding-cn', model: 'glm-5.3-flash' });
66
+ });
67
+ it('heavy pick keeps the heavy route when already there', () => {
68
+ const r = selectRoute(okOutcome('heavy'), resolvedConfig, {}, llm);
69
+ expect(r.keepDefault).toBe(true); // already on heavy
70
+ });
71
+ it('degraded outcome keeps the default route', () => {
72
+ const r = selectRoute(degraded, resolvedConfig, {}, llm);
73
+ expect(r.keepDefault).toBe(true);
74
+ expect(r.reason).toContain('degraded');
75
+ });
76
+ it('unregistered target provider keeps the default route', () => {
77
+ const emptyLlm = { listProviders: () => [{ id: 'deepseek-official' }] };
78
+ const r = selectRoute(okOutcome('light'), resolvedConfig, {}, emptyLlm);
79
+ expect(r.keepDefault).toBe(true);
80
+ expect(r.reason).toContain('not registered');
81
+ });
82
+ it('throwing listProviders is treated as unregistered', () => {
83
+ const badLlm = { listProviders: () => { throw new Error('boom'); } };
84
+ expect(selectRoute(okOutcome('light'), resolvedConfig, {}, badLlm).keepDefault).toBe(true);
85
+ });
86
+ });
87
+ // ---------------------------------------------------------------------------
88
+ // plugin wiring
89
+ // ---------------------------------------------------------------------------
90
+ describe('apply', () => {
91
+ it('shadow mode (default): logs but never changes config', async () => {
92
+ const { fetchImpl } = mockJevFetch('light');
93
+ const lines = [];
94
+ const h = makeHarness();
95
+ apply(h.ctx, { fetchImpl, log: (l) => lines.push(l) });
96
+ const out = await runRequest(h);
97
+ expect(out).toEqual(resolvedConfig);
98
+ expect(lines).toHaveLength(1);
99
+ expect(lines[0]).toContain('shadow');
100
+ expect(lines[0]).toContain('zai-coding-cn/glm-5.3-flash');
101
+ });
102
+ it('enforce mode: switches to the light route on a light pick', async () => {
103
+ const { fetchImpl, calls } = mockJevFetch('light');
104
+ const lines = [];
105
+ const h = makeHarness();
106
+ apply(h.ctx, { fetchImpl, log: (l) => lines.push(l), mode: 'enforce' });
107
+ const out = await runRequest(h);
108
+ expect(out).toEqual({ provider: 'zai-coding-cn', model: 'glm-5.3-flash' });
109
+ expect(lines[0]).toContain('enforcing switch');
110
+ // the captured user turn reached jev as context
111
+ expect(calls[0].body.context.userTurn ?? '').toBe('');
112
+ });
113
+ it('enforce mode: heavy pick from a light default switches up', async () => {
114
+ const { fetchImpl } = mockJevFetch('heavy');
115
+ const h = makeHarness();
116
+ apply(h.ctx, { fetchImpl, log: () => { }, mode: 'enforce' });
117
+ const out = await runRequest(h, 1, async () => ({ provider: 'zai-coding-cn', model: 'glm-5.3-flash' }));
118
+ expect(out).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' });
119
+ });
120
+ it('jev unreachable: keeps default and logs the degrade', async () => {
121
+ const { fetchImpl } = mockJevFetch('DEGRADE');
122
+ const lines = [];
123
+ const h = makeHarness();
124
+ apply(h.ctx, { fetchImpl, log: (l) => lines.push(l), mode: 'enforce' });
125
+ const out = await runRequest(h);
126
+ expect(out).toEqual(resolvedConfig);
127
+ expect(lines[0]).toContain('degraded');
128
+ });
129
+ it('pre-step captured user text is sent to jev', async () => {
130
+ const { fetchImpl, calls } = mockJevFetch('heavy');
131
+ const h = makeHarness();
132
+ apply(h.ctx, { fetchImpl, log: () => { } });
133
+ const agent = {};
134
+ await h.listeners.get('agent/pre-step')({ agent, turn: 3, step: 1, messages: [{ content: [{ type: 'text', text: 'refactor the parser' }] }], signal: new AbortController().signal }, async () => ({ kind: 'enter', messages: [] }));
135
+ await h.listeners.get('agent/request')({ agent, turn: 3, step: 1, signal: new AbortController().signal }, async () => ({ ...resolvedConfig }));
136
+ expect(calls[0].body.context.userTurn).toBe('refactor the parser');
137
+ expect(calls[0].body.options).toEqual(['heavy', 'light']);
138
+ });
139
+ it('custom routes are honored', async () => {
140
+ const { fetchImpl } = mockJevFetch('light');
141
+ const h = makeHarness();
142
+ const config = {
143
+ fetchImpl,
144
+ log: () => { },
145
+ mode: 'enforce',
146
+ light: { provider: 'zai-coding-cn', model: 'glm-5.3' },
147
+ };
148
+ apply(h.ctx, config);
149
+ const out = await runRequest(h);
150
+ expect(out).toEqual({ provider: 'zai-coding-cn', model: 'glm-5.3' });
151
+ });
152
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@dsh-jev/router",
3
+ "version": "0.1.0",
4
+ "description": "dsh cordis plugin: per-turn jev System One complexity routing between already-registered LLM routes (shadow-run by default)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist"],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "test": "vitest run",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@dsh-jev/core": "workspace:*"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.0.0",
25
+ "typescript": "^5.6.0",
26
+ "vitest": "^2.1.0"
27
+ },
28
+ "license": "MIT"
29
+ }