@apinggithub/mydsh-auto-model-router 0.2.0 → 0.2.2

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/auto-adapter.mjs CHANGED
@@ -1,104 +1,234 @@
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
- import { routesEqual } from './router.mjs'
18
-
19
- export const AUTO_PROVIDER = 'auto'
20
- export const AUTO_MODEL = 'auto'
21
-
22
- /** Provider route metadata shown in the selector. */
23
- export function autoProviderInfo(provider) {
24
- return { id: provider, name: 'Auto Router' }
25
- }
26
-
27
- /** One advertised model: the single "Auto" choice. */
28
- export function autoListModels() {
29
- return [{ provider: AUTO_PROVIDER, id: AUTO_MODEL, name: 'Auto' }]
30
- }
31
-
32
- /** Exact-model metadata for the advertised model. */
33
- export function autoResolveModel(provider, model) {
34
- return { provider, id: model, name: model }
35
- }
36
-
37
- /**
38
- * The virtual adapter object. `stream()` forwards to the router's decision
39
- * when it is reachable, so the auto provider never hard-fails routing.
40
- */
41
- export const AutoAdapter = {
42
- providerInfo: autoProviderInfo,
43
- providerRetryPolicy() {
44
- return undefined
45
- },
46
- listModels: async () => autoListModels(),
47
- resolveModel: async (provider, model) => autoResolveModel(provider, model),
48
- /**
49
- * The newer adapter contract (host 0.2.x): the runtime validates a request
50
- * against the model's capabilities and binds it to one concrete adapter
51
- * generation through `prepareCall(provider, model, signal)`, then dispatches
52
- * through the returned `stream`. The virtual `auto` provider owns no
53
- * generation of its own, so it returns the metadata it already advertises and
54
- * routes the stream through the same forwarding generator below.
55
- *
56
- * Without this, every call the router does not intercept — a title step,
57
- * compaction, a listener ordering gap — died with
58
- * "registration.adapter.prepareCall is not a function" on hosts past 0.1.x.
59
- * (`normalizeModelInfo` requires provider === the registration id, id === the
60
- * requested model, and a non-empty name; everything else is optional.)
61
- */
62
- async prepareCall(provider, model) {
63
- return {
64
- model: await AutoAdapter.resolveModel(provider, model),
65
- stream: (options) => AutoAdapter.stream(options),
66
- }
67
- },
68
- async *stream(options) {
69
- const router = globalThis.__dshAutoModelRouter
70
- if (router !== undefined) {
71
- const sessionId = options?.sessionId ?? ''
72
- const route = router.current(sessionId)
73
- if (route && !routesEqual(route, { provider: AUTO_PROVIDER, model: AUTO_MODEL })) {
74
- // Forward this request to the routed model through the same llm
75
- // runtime; the adapter contract requires we stream chunks back.
76
- const llm = globalThis.__dshLl
77
- if (llm?.stream !== undefined) {
78
- // ctx.llm.stream returns the AsyncIterable directly (not { stream }).
79
- const stream = llm.stream({
80
- ...options,
81
- provider: route.provider,
82
- model: route.model,
83
- ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort },
84
- })
85
- for await (const chunk of stream) yield chunk
86
- return
87
- }
88
- }
89
- }
90
- throw new Error(
91
- 'mydsh-auto-model-router: request reached the virtual "auto" provider with no router '
92
- + 'decision available. Check that the plugin is mounted.',
93
- )
94
- },
95
- }
96
-
97
- /**
98
- * Whether a proposed request config selects the Auto provider.
99
- * @param {object} proposed - { provider, model } from the agent/request waterfall.
100
- * @returns {boolean}
101
- */
102
- export function isAutoSelection(proposed) {
103
- return proposed?.provider === AUTO_PROVIDER
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
+ /**
30
+ * Reasoning efforts advertised when the routed model's own metadata cannot be
31
+ * read: the four values the DeepSeek catalog accepts.
32
+ */
33
+ const FALLBACK_EFFORTS = Object.freeze([
34
+ { id: 'off', name: 'Off' },
35
+ { id: 'low', name: 'Low' },
36
+ { id: 'high', name: 'High' },
37
+ { id: 'max', name: 'Max' },
38
+ ])
39
+
40
+ /**
41
+ * Context window advertised when the routed model does not report one.
42
+ * DeepSeek's 0.2.x catalog tops out at 1M tokens; assuming the largest window
43
+ * merely keeps compaction from firing early — the routed provider still
44
+ * rejects an oversized request and the fallback chain takes over.
45
+ */
46
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000
47
+
48
+ /** Provider route metadata shown in the selector. */
49
+ export function autoProviderInfo(provider) {
50
+ return { id: provider, name: 'Auto Router' }
51
+ }
52
+
53
+ /** One advertised model: the single "Auto" choice. */
54
+ export function autoListModels() {
55
+ return [{ provider: AUTO_PROVIDER, id: AUTO_MODEL, name: 'Auto' }]
56
+ }
57
+
58
+ /** Exact-model metadata for the advertised model. */
59
+ export function autoResolveModel(provider, model) {
60
+ return { provider, id: model, name: model }
61
+ }
62
+
63
+ /**
64
+ * The route the virtual model should borrow its capabilities from.
65
+ *
66
+ * The host asks `resolveModel("auto", "auto")` before it assembles a request,
67
+ * long before any session decision exists, so this picks the configured
68
+ * default level (falling back to the first configured one).
69
+ *
70
+ * @param {object | undefined} router
71
+ * @returns {{ provider: string, model: string, reasoningEffort?: string } | undefined}
72
+ */
73
+ function representativeRoute(router) {
74
+ try {
75
+ const level = router?.config?.costControl?.defaultLevel
76
+ const route = (level === undefined ? undefined : router?.routeForLevel?.(level))
77
+ ?? router?.firstConfiguredRoute?.()
78
+ if (route === undefined || route === null) return undefined
79
+ if (typeof route.provider !== 'string' || route.provider === '') return undefined
80
+ if (typeof route.model !== 'string' || route.model === '') return undefined
81
+ return route
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+
87
+ /** Read the routed model's own metadata, tolerating a host without it. */
88
+ async function underlyingInfo(llm, route) {
89
+ if (route === undefined || typeof llm?.resolveModelInfo !== 'function') return undefined
90
+ try {
91
+ return await llm.resolveModelInfo(route.provider, route.model)
92
+ } catch {
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ /** Copy the routed model's efforts, or the DeepSeek defaults. */
98
+ function reasoningEfforts(info) {
99
+ const efforts = info?.reasoning?.efforts
100
+ if (Array.isArray(efforts) && efforts.length > 0) {
101
+ return efforts.map((effort) => ({
102
+ id: effort.id,
103
+ name: effort.name,
104
+ ...effort.description === undefined ? {} : { description: effort.description },
105
+ }))
106
+ }
107
+ return FALLBACK_EFFORTS.map((effort) => ({ ...effort }))
108
+ }
109
+
110
+ /**
111
+ * Build the virtual adapter for one plugin mount.
112
+ *
113
+ * Both dependencies are read lazily through getters so the adapter always sees
114
+ * the live router / llm service (they are created before the adapter, but the
115
+ * indirection keeps the object serializable and testable).
116
+ *
117
+ * @param {object} [resolve]
118
+ * @param {() => object | undefined} [resolve.router] - returns the ModelRouter.
119
+ * @param {() => object | undefined} [resolve.llm] - returns `ctx.llm`.
120
+ * @returns {object} adapter understood by `ctx.llm.registerAdapter()`
121
+ */
122
+ export function createAutoAdapter(resolve = {}) {
123
+ const readRouter = typeof resolve.router === 'function' ? resolve.router : () => undefined
124
+ const readLlm = typeof resolve.llm === 'function' ? resolve.llm : () => undefined
125
+
126
+ /** @type {object} */
127
+ const adapter = {
128
+ providerInfo: autoProviderInfo,
129
+ providerRetryPolicy() {
130
+ return undefined
131
+ },
132
+ listModels: async () => autoListModels(),
133
+ /**
134
+ * Capability metadata for the virtual model.
135
+ *
136
+ * The host validates a request against the exact model *before* any
137
+ * provider I/O and assembles the conversation with what it learns here:
138
+ * without a context window it cannot budget compaction, and without
139
+ * reasoning efforts it treats the route as non-thinking and stops echoing
140
+ * the `thinking` blocks a thinking-mode provider requires on every turn.
141
+ * Borrowing the routed model's own metadata keeps the virtual provider
142
+ * indistinguishable from the model it forwards to.
143
+ */
144
+ async resolveModel(provider, model) {
145
+ const route = representativeRoute(readRouter())
146
+ const info = await underlyingInfo(readLlm(), route)
147
+ const efforts = reasoningEfforts(info)
148
+ const requested = route?.reasoningEffort
149
+ const wanted = efforts.some((effort) => effort.id === requested)
150
+ ? requested
151
+ : info?.reasoning?.defaultEffort
152
+ const defaultEffort = efforts.some((effort) => effort.id === wanted) ? wanted : efforts[0]?.id
153
+ const contextWindow = Number.isInteger(info?.context?.contextWindow) && info.context.contextWindow > 0
154
+ ? info.context.contextWindow
155
+ : FALLBACK_CONTEXT_WINDOW
156
+ return {
157
+ provider,
158
+ id: model,
159
+ name: 'Auto',
160
+ context: { contextWindow },
161
+ systemPromptUpdate: info?.systemPromptUpdate ?? 'in-history',
162
+ ...info?.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: info.defaultMaxTokens },
163
+ ...info?.inputModalities === undefined ? {} : { inputModalities: info.inputModalities },
164
+ reasoning: {
165
+ efforts,
166
+ ...defaultEffort === undefined ? {} : { defaultEffort },
167
+ },
168
+ }
169
+ },
170
+ /**
171
+ * The 0.2.x adapter contract: the runtime validates a request against the
172
+ * model's capabilities and binds it to one concrete adapter generation
173
+ * through `prepareCall(provider, model, signal)`, then dispatches through
174
+ * the returned `stream`. The virtual `auto` provider owns no generation of
175
+ * its own, so it returns the metadata it already advertises and routes the
176
+ * stream through the forwarding generator below.
177
+ *
178
+ * Hosts before 0.2.x call `stream()` directly; keeping both entry points is
179
+ * what makes one package work on 0.1 and 0.2 alike.
180
+ */
181
+ async prepareCall(provider, model) {
182
+ return {
183
+ model: await adapter.resolveModel(provider, model),
184
+ stream: (options) => adapter.stream(options),
185
+ }
186
+ },
187
+ async *stream(options) {
188
+ const router = readRouter()
189
+ if (router !== undefined && router !== null) {
190
+ const sessionId = options?.sessionId ?? ''
191
+ const route = router.current?.(sessionId)
192
+ if (route && !routesEqual(route, { provider: AUTO_PROVIDER, model: AUTO_MODEL })) {
193
+ // Forward this request to the routed model through the same llm
194
+ // runtime; the adapter contract requires we stream chunks back.
195
+ const llm = readLlm()
196
+ if (typeof llm?.stream === 'function') {
197
+ // ctx.llm.stream returns the AsyncIterable directly (not { stream }).
198
+ const stream = llm.stream({
199
+ ...options,
200
+ provider: route.provider,
201
+ model: route.model,
202
+ ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort },
203
+ })
204
+ for await (const chunk of stream) yield chunk
205
+ return
206
+ }
207
+ }
208
+ }
209
+ throw new Error(
210
+ 'mydsh-auto-model-router: request reached the virtual "auto" provider with no router '
211
+ + 'decision available. Check that the plugin is mounted.',
212
+ )
213
+ },
214
+ }
215
+ return adapter
216
+ }
217
+
218
+ /**
219
+ * Unwired singleton kept for import compatibility with earlier revisions.
220
+ *
221
+ * It advertises the model metadata but cannot forward a stream (it has no
222
+ * router), so `stream()` reports the missing mount instead of silently
223
+ * producing an empty answer.
224
+ */
225
+ export const AutoAdapter = createAutoAdapter()
226
+
227
+ /**
228
+ * Whether a proposed request config selects the Auto provider.
229
+ * @param {object} proposed - { provider, model } from the agent/request waterfall.
230
+ * @returns {boolean}
231
+ */
232
+ export function isAutoSelection(proposed) {
233
+ return proposed?.provider === AUTO_PROVIDER
234
+ }
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.0'
25
+ export const PLUGIN_VERSION = '0.2.2'
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