@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/README.md +147 -82
- package/README.zh.md +122 -72
- package/auto-adapter.mjs +234 -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,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
|
-
|
|
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
|
+
/**
|
|
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.
|
|
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
|
|