@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/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
- 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
+ /** 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.0'
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