@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 +50 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +137 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +152 -0
- package/package.json +29 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|