@apinggithub/mydsh-auto-model-router 0.2.0 → 0.2.1
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 +147 -82
- package/README.zh.md +122 -72
- package/auto-adapter.mjs +132 -104
- package/compat.mjs +199 -0
- package/config.mjs +4 -1
- package/index.mjs +429 -307
- package/package.json +74 -72
package/auto-adapter.mjs
CHANGED
|
@@ -1,104 +1,132 @@
|
|
|
1
|
-
// Virtual "auto" provider adapter. Registers a provider route named `auto`
|
|
2
|
-
// so the model selector shows an "Auto Router" group with one model:
|
|
3
|
-
//
|
|
4
|
-
// Auto Router
|
|
5
|
-
// └─ Auto (auto routing)
|
|
6
|
-
//
|
|
7
|
-
// Selecting it marks the session's provider as `auto`; the router plugin then
|
|
8
|
-
// takes over in `agent/request` and routes through the configured L0–L3
|
|
9
|
-
// levels. Selecting any real provider/model leaves the user's choice
|
|
10
|
-
// untouched (the plugin passes through).
|
|
11
|
-
//
|
|
12
|
-
// If a request still reaches provider `auto` (e.g. the router did not
|
|
13
|
-
// intercept this particular call — a titled step, compaction, or a listener
|
|
14
|
-
// ordering gap), `stream()` forwards to the router's current per-session
|
|
15
|
-
// decision instead of throwing, so routing is never a hard failure.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
1
|
+
// Virtual "auto" provider adapter. Registers a provider route named `auto`
|
|
2
|
+
// so the model selector shows an "Auto Router" group with one model:
|
|
3
|
+
//
|
|
4
|
+
// Auto Router
|
|
5
|
+
// └─ Auto (auto routing)
|
|
6
|
+
//
|
|
7
|
+
// Selecting it marks the session's provider as `auto`; the router plugin then
|
|
8
|
+
// takes over in `agent/request` and routes through the configured L0–L3
|
|
9
|
+
// levels. Selecting any real provider/model leaves the user's choice
|
|
10
|
+
// untouched (the plugin passes through).
|
|
11
|
+
//
|
|
12
|
+
// If a request still reaches provider `auto` (e.g. the router did not
|
|
13
|
+
// intercept this particular call — a titled step, compaction, or a listener
|
|
14
|
+
// ordering gap), `stream()` forwards to the router's current per-session
|
|
15
|
+
// decision instead of throwing, so routing is never a hard failure.
|
|
16
|
+
//
|
|
17
|
+
// The adapter is created per mount through `createAutoAdapter()` and closes
|
|
18
|
+
// over the router and the llm service of *that* context. Earlier revisions
|
|
19
|
+
// published them through `globalThis.__dshAutoModelRouter` / `__dshLl`, which
|
|
20
|
+
// breaks as soon as one process hosts more than one profile (the second mount
|
|
21
|
+
// overwrites the first adapter's dependencies) and leaks a plugin-private API
|
|
22
|
+
// onto the global object.
|
|
23
|
+
|
|
24
|
+
import { routesEqual } from './router.mjs'
|
|
25
|
+
|
|
26
|
+
export const AUTO_PROVIDER = 'auto'
|
|
27
|
+
export const AUTO_MODEL = 'auto'
|
|
28
|
+
|
|
29
|
+
/** Provider route metadata shown in the selector. */
|
|
30
|
+
export function autoProviderInfo(provider) {
|
|
31
|
+
return { id: provider, name: 'Auto Router' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One advertised model: the single "Auto" choice. */
|
|
35
|
+
export function autoListModels() {
|
|
36
|
+
return [{ provider: AUTO_PROVIDER, id: AUTO_MODEL, name: 'Auto' }]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Exact-model metadata for the advertised model. */
|
|
40
|
+
export function autoResolveModel(provider, model) {
|
|
41
|
+
return { provider, id: model, name: model }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the virtual adapter for one plugin mount.
|
|
46
|
+
*
|
|
47
|
+
* Both dependencies are read lazily through getters so the adapter always sees
|
|
48
|
+
* the live router / llm service (they are created before the adapter, but the
|
|
49
|
+
* indirection keeps the object serializable and testable).
|
|
50
|
+
*
|
|
51
|
+
* @param {object} [resolve]
|
|
52
|
+
* @param {() => object | undefined} [resolve.router] - returns the ModelRouter.
|
|
53
|
+
* @param {() => object | undefined} [resolve.llm] - returns `ctx.llm`.
|
|
54
|
+
* @returns {object} adapter understood by `ctx.llm.registerAdapter()`
|
|
55
|
+
*/
|
|
56
|
+
export function createAutoAdapter(resolve = {}) {
|
|
57
|
+
const readRouter = typeof resolve.router === 'function' ? resolve.router : () => undefined
|
|
58
|
+
const readLlm = typeof resolve.llm === 'function' ? resolve.llm : () => undefined
|
|
59
|
+
|
|
60
|
+
/** @type {object} */
|
|
61
|
+
const adapter = {
|
|
62
|
+
providerInfo: autoProviderInfo,
|
|
63
|
+
providerRetryPolicy() {
|
|
64
|
+
return undefined
|
|
65
|
+
},
|
|
66
|
+
listModels: async () => autoListModels(),
|
|
67
|
+
resolveModel: async (provider, model) => autoResolveModel(provider, model),
|
|
68
|
+
/**
|
|
69
|
+
* The 0.2.x adapter contract: the runtime validates a request against the
|
|
70
|
+
* model's capabilities and binds it to one concrete adapter generation
|
|
71
|
+
* through `prepareCall(provider, model, signal)`, then dispatches through
|
|
72
|
+
* the returned `stream`. The virtual `auto` provider owns no generation of
|
|
73
|
+
* its own, so it returns the metadata it already advertises and routes the
|
|
74
|
+
* stream through the forwarding generator below.
|
|
75
|
+
*
|
|
76
|
+
* Hosts before 0.2.x call `stream()` directly; keeping both entry points is
|
|
77
|
+
* what makes one package work on 0.1 and 0.2 alike.
|
|
78
|
+
*/
|
|
79
|
+
async prepareCall(provider, model) {
|
|
80
|
+
return {
|
|
81
|
+
model: await adapter.resolveModel(provider, model),
|
|
82
|
+
stream: (options) => adapter.stream(options),
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async *stream(options) {
|
|
86
|
+
const router = readRouter()
|
|
87
|
+
if (router !== undefined && router !== null) {
|
|
88
|
+
const sessionId = options?.sessionId ?? ''
|
|
89
|
+
const route = router.current?.(sessionId)
|
|
90
|
+
if (route && !routesEqual(route, { provider: AUTO_PROVIDER, model: AUTO_MODEL })) {
|
|
91
|
+
// Forward this request to the routed model through the same llm
|
|
92
|
+
// runtime; the adapter contract requires we stream chunks back.
|
|
93
|
+
const llm = readLlm()
|
|
94
|
+
if (typeof llm?.stream === 'function') {
|
|
95
|
+
// ctx.llm.stream returns the AsyncIterable directly (not { stream }).
|
|
96
|
+
const stream = llm.stream({
|
|
97
|
+
...options,
|
|
98
|
+
provider: route.provider,
|
|
99
|
+
model: route.model,
|
|
100
|
+
...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort },
|
|
101
|
+
})
|
|
102
|
+
for await (const chunk of stream) yield chunk
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw new Error(
|
|
108
|
+
'mydsh-auto-model-router: request reached the virtual "auto" provider with no router '
|
|
109
|
+
+ 'decision available. Check that the plugin is mounted.',
|
|
110
|
+
)
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
return adapter
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Unwired singleton kept for import compatibility with earlier revisions.
|
|
118
|
+
*
|
|
119
|
+
* It advertises the model metadata but cannot forward a stream (it has no
|
|
120
|
+
* router), so `stream()` reports the missing mount instead of silently
|
|
121
|
+
* producing an empty answer.
|
|
122
|
+
*/
|
|
123
|
+
export const AutoAdapter = createAutoAdapter()
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a proposed request config selects the Auto provider.
|
|
127
|
+
* @param {object} proposed - { provider, model } from the agent/request waterfall.
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
export function isAutoSelection(proposed) {
|
|
131
|
+
return proposed?.provider === AUTO_PROVIDER
|
|
132
|
+
}
|
package/compat.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Host-compatibility layer.
|
|
2
|
+
//
|
|
3
|
+
// DSH's boot is all-or-nothing: a bundle whose patch throws takes the whole
|
|
4
|
+
// host down with it. That makes every direct host-API call in `apply()` a
|
|
5
|
+
// compatibility risk — a future DSH that renames or drops one of them would
|
|
6
|
+
// not merely lose the router, it would stop the user's Harness from starting.
|
|
7
|
+
//
|
|
8
|
+
// This module probes the APIs the plugin needs once per mount, classifies the
|
|
9
|
+
// host line against the range this revision was exercised on, and provides
|
|
10
|
+
// small helpers that degrade to a logged no-op instead of throwing. Nothing
|
|
11
|
+
// here imports the host, so it stays unit-testable without cordis.
|
|
12
|
+
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
|
|
15
|
+
/** Host lines this revision has actually been run against. */
|
|
16
|
+
export const TESTED_HOST_RANGE = '>=0.1.0-0 <0.4.0'
|
|
17
|
+
/** Host lines the plugin will still attempt to run on (its peer ceiling). */
|
|
18
|
+
export const SUPPORTED_HOST_RANGE = '>=0.1.0-0 <1.0.0'
|
|
19
|
+
|
|
20
|
+
const NOOP = () => {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read an optional service without tripping cordis's inject fence.
|
|
24
|
+
*
|
|
25
|
+
* cordis throws on `ctx.someService` when that service is not declared in the
|
|
26
|
+
* plugin's `inject` list ("cannot get property ... without inject"), so an
|
|
27
|
+
* optional dependency must always go through `ctx.get()` and still be guarded
|
|
28
|
+
* for hosts that expose no reflection at all.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} ctx
|
|
31
|
+
* @param {string} name
|
|
32
|
+
* @returns {any}
|
|
33
|
+
*/
|
|
34
|
+
export function readService(ctx, name) {
|
|
35
|
+
try {
|
|
36
|
+
return ctx?.get?.(name)
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Property read that tolerates cordis's inject fence and exotic hosts. */
|
|
43
|
+
function readProperty(ctx, name) {
|
|
44
|
+
try {
|
|
45
|
+
return ctx?.[name]
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Snapshot the host surface the plugin depends on.
|
|
53
|
+
*
|
|
54
|
+
* Every entry is a plain boolean so it can be serialized into the status
|
|
55
|
+
* endpoint and used by tests without a live host.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} ctx - the cordis context handed to `apply()`.
|
|
58
|
+
* @returns {{
|
|
59
|
+
* llm: boolean, registerAdapter: boolean, listModels: boolean,
|
|
60
|
+
* stream: boolean, scopedInject: boolean, webServer: boolean,
|
|
61
|
+
* tokenMeter: boolean, provide: boolean,
|
|
62
|
+
* }}
|
|
63
|
+
*/
|
|
64
|
+
export function probeHost(ctx) {
|
|
65
|
+
const llm = readProperty(ctx, 'llm') ?? readService(ctx, 'llm')
|
|
66
|
+
const webServer = readService(ctx, 'webServer')
|
|
67
|
+
const tokenMeter = readService(ctx, 'tokenMeter')
|
|
68
|
+
return {
|
|
69
|
+
llm: Boolean(llm),
|
|
70
|
+
registerAdapter: typeof llm?.registerAdapter === 'function',
|
|
71
|
+
listModels: typeof llm?.listModels === 'function',
|
|
72
|
+
stream: typeof llm?.stream === 'function',
|
|
73
|
+
scopedInject: typeof readProperty(ctx, 'inject') === 'function',
|
|
74
|
+
webServer: typeof webServer?.register === 'function',
|
|
75
|
+
tokenMeter: Boolean(tokenMeter),
|
|
76
|
+
provide: typeof readProperty(ctx, 'provide') === 'function',
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register an HTTP route on a webserver-like service, tolerating a renamed
|
|
82
|
+
* registration method.
|
|
83
|
+
*
|
|
84
|
+
* `register(route)` is the 0.1–0.3 shape; `route(route)` is accepted as a
|
|
85
|
+
* forward-compatible alias. When neither exists the call is a no-op: the
|
|
86
|
+
* settings page will show its "no report yet" state, which is strictly better
|
|
87
|
+
* than refusing to mount.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} scope - a context exposing `webServer` (and usually `effect`).
|
|
90
|
+
* @param {{ kind: string, path: string, handler: Function }} route
|
|
91
|
+
* @returns {() => void} disposer
|
|
92
|
+
*/
|
|
93
|
+
export function registerRoute(scope, route) {
|
|
94
|
+
const server = scope?.webServer
|
|
95
|
+
if (server === undefined || server === null) return NOOP
|
|
96
|
+
for (const method of ['register', 'route', 'addRoute']) {
|
|
97
|
+
if (typeof server[method] !== 'function') continue
|
|
98
|
+
try {
|
|
99
|
+
const dispose = server[method](route)
|
|
100
|
+
return typeof dispose === 'function' ? dispose : NOOP
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// A duplicate path throws in the current host. That is a composition
|
|
103
|
+
// problem worth failing soft on, not a reason to abort the mount.
|
|
104
|
+
scope?.logger?.warn?.(
|
|
105
|
+
`[mydsh-auto-model-router] could not claim ${route.path} via webServer.${method}: ${error?.message ?? error}`,
|
|
106
|
+
)
|
|
107
|
+
return NOOP
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return NOOP
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Per-context dedupe for warnings that would otherwise repeat per session. */
|
|
114
|
+
const warned = new WeakMap()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Log a warning at most once per context and key.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} ctx - cordis context (used for the logger and the dedupe key).
|
|
120
|
+
* @param {string} key - stable identifier for the message.
|
|
121
|
+
* @param {string} message - human-readable text (the plugin prefix is added).
|
|
122
|
+
*/
|
|
123
|
+
export function warnOnce(ctx, key, message) {
|
|
124
|
+
try {
|
|
125
|
+
if (ctx !== null && typeof ctx === 'object') {
|
|
126
|
+
let keys = warned.get(ctx)
|
|
127
|
+
if (keys === undefined) {
|
|
128
|
+
keys = new Set()
|
|
129
|
+
warned.set(ctx, keys)
|
|
130
|
+
}
|
|
131
|
+
if (keys.has(key)) return
|
|
132
|
+
keys.add(key)
|
|
133
|
+
}
|
|
134
|
+
ctx?.logger?.warn?.(`[mydsh-auto-model-router] ${message}`)
|
|
135
|
+
} catch {
|
|
136
|
+
// Logging must never be the thing that breaks a mount.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Version of the running `@deepseek-ai/dsh` package, when it is resolvable.
|
|
142
|
+
*
|
|
143
|
+
* DSH installs the runtime resolution that lets a profile plugin import the
|
|
144
|
+
* packages carried by the installation, so this usually works inside the host
|
|
145
|
+
* and throws (quietly) everywhere else — which is exactly what a best-effort
|
|
146
|
+
* probe wants.
|
|
147
|
+
*
|
|
148
|
+
* @returns {string | undefined}
|
|
149
|
+
*/
|
|
150
|
+
function installVersion() {
|
|
151
|
+
try {
|
|
152
|
+
const require = createRequire(import.meta.url)
|
|
153
|
+
const manifest = require('@deepseek-ai/dsh/package.json')
|
|
154
|
+
if (typeof manifest?.version === 'string' && manifest.version !== '') return manifest.version
|
|
155
|
+
} catch {
|
|
156
|
+
// Not launched by the harness, or the package does not export its manifest.
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Best-effort host version, read from the places a host may expose it.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} ctx - cordis context.
|
|
165
|
+
* @param {Record<string, string | undefined>} [env] - defaults to `process.env`.
|
|
166
|
+
* @returns {string | undefined}
|
|
167
|
+
*/
|
|
168
|
+
export function readHostVersion(ctx, env = globalThis.process?.env) {
|
|
169
|
+
const app = readService(ctx, 'app')
|
|
170
|
+
const candidates = [
|
|
171
|
+
app?.version,
|
|
172
|
+
readProperty(ctx, 'cmdlineArgs')?.version,
|
|
173
|
+
readService(ctx, 'cmdlineArgs')?.version,
|
|
174
|
+
env?.DSH_VERSION,
|
|
175
|
+
env?.npm_package_version,
|
|
176
|
+
installVersion(),
|
|
177
|
+
]
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim()
|
|
180
|
+
}
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Classify a host version against the range this revision was tested on.
|
|
186
|
+
*
|
|
187
|
+
* @param {string | undefined} version
|
|
188
|
+
* @returns {'tested' | 'untested' | 'unknown'}
|
|
189
|
+
*/
|
|
190
|
+
export function classifyHostVersion(version) {
|
|
191
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version ?? '')
|
|
192
|
+
if (match === null) return 'unknown'
|
|
193
|
+
const major = Number(match[1])
|
|
194
|
+
const minor = Number(match[2])
|
|
195
|
+
// DSH is pre-1.0: every 0.x minor may break. 0.1–0.3 are the exercised
|
|
196
|
+
// lines; 0.4+ and 1.x+ are allowed to mount but reported as untested.
|
|
197
|
+
if (major === 0 && minor <= 3) return 'tested'
|
|
198
|
+
return 'untested'
|
|
199
|
+
}
|
package/config.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// fallbackChain: [route, ...] (model-level, appended with the default)
|
|
23
23
|
// maxRetries: per-route retry budget on request-error
|
|
24
24
|
|
|
25
|
-
export const PLUGIN_VERSION = '0.1
|
|
25
|
+
export const PLUGIN_VERSION = '0.2.1'
|
|
26
26
|
|
|
27
27
|
export const LEVEL_IDS = Object.freeze(['L0', 'L1', 'L2', 'L3'])
|
|
28
28
|
export const COST_MODES = Object.freeze(['cost-first', 'quality-first', 'balanced'])
|
|
@@ -150,6 +150,9 @@ export function resolveConfig(input = {}) {
|
|
|
150
150
|
// Nothing declares a window → the guard is inert (no behaviour change).
|
|
151
151
|
cfg.contextGuard.enabled = cfg.contextGuard.enabled
|
|
152
152
|
&& LEVEL_IDS.some((id) => Number.isFinite(cfg.levels[id]?.contextWindow))
|
|
153
|
+
// Exposed to the status endpoint and the browser settings page so a bug
|
|
154
|
+
// report always names the build that produced it.
|
|
155
|
+
cfg.pluginVersion = PLUGIN_VERSION
|
|
153
156
|
return cfg
|
|
154
157
|
}
|
|
155
158
|
|