@apinggithub/mydsh-auto-model-router 0.0.0-stage → 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/client.js ADDED
@@ -0,0 +1,123 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@apinggithub/mydsh-auto-model-router",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ var _react = require("react");
8
+ //#region client/gate.mjs
9
+ const PLUGIN_ID = '@apinggithub/mydsh-auto-model-router'
10
+ const NS = 'mydsh-auto-model-router'
11
+
12
+ const EN_COPY = {
13
+ nav: 'Auto Router',
14
+ title: 'Auto Model Router',
15
+ description: 'Routes each user input through the configured L0–L3 levels.',
16
+ hintTitle: 'How to change this configuration',
17
+ hintBody: 'Tell the AI in conversation, e.g. "change L2 to deepseek-v4-pro with '
18
+ + 'max reasoning" or "switch cost mode to cost-first". The AI edits '
19
+ + 'cordis.patch.yml; restart DSH to apply.',
20
+ noReport: 'No router status report found yet. Send a message in a session '
21
+ + 'first — the host injects the current policy on session start.',
22
+ reportLabel: 'Current policy',
23
+ }
24
+
25
+ const ZH_COPY = {
26
+ nav: 'Auto Router',
27
+ title: 'Auto 模型路由',
28
+ description: '按配置的 L0–L3 层级,为每次用户输入自动选择模型。',
29
+ hintTitle: '如何修改配置',
30
+ hintBody: '直接在对话中告诉 AI,例如:"把 L2 改成 deepseek-v4-pro 且 max 思考"'
31
+ + '或"成本模式切换为 cost-first"。AI 会帮你编辑 cordis.patch.yml,'
32
+ + '重启 DSH 后生效。',
33
+ noReport: '尚未找到路由状态报告。先在会话中发一条消息——host 会在'
34
+ + '会话开始时注入当前策略。',
35
+ reportLabel: '当前策略',
36
+ }
37
+
38
+ const copy = () => {
39
+ const lang = document.documentElement.lang || navigator.language || ''
40
+ return /^zh(?:-|$)/i.test(lang) ? ZH_COPY : EN_COPY
41
+ }
42
+
43
+ const STATUS_ENDPOINT = '/api/mydsh-auto-model-router/status'
44
+
45
+ /**
46
+ * The settings section component. Rendered with _react.createElement from the
47
+ * module-table react entry; no JSX build step. Fetches the current policy
48
+ * from the host HTTP endpoint on mount.
49
+ */
50
+ function RouterSection() {
51
+ const t = copy()
52
+ const styles = {
53
+ section: {
54
+ display: 'flex', flexDirection: 'column', gap: '16px',
55
+ fontSize: '14px', lineHeight: '22px',
56
+ },
57
+ title: { margin: 0, fontSize: '16px', fontWeight: 600 },
58
+ description: { margin: 0, color: 'var(--dsw-alias-label-secondary, #666)' },
59
+ block: {
60
+ margin: 0, padding: '12px 14px',
61
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
62
+ borderRadius: '12px',
63
+ background: 'var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.03))',
64
+ font: '12px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
65
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
66
+ },
67
+ hint: {
68
+ margin: 0, padding: '10px 12px',
69
+ borderLeft: '3px solid var(--dsw-alias-state-info-primary, #2b7fff)',
70
+ background: 'var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.03))',
71
+ },
72
+ hintTitle: { margin: '0 0 4px', fontWeight: 600 },
73
+ hintBody: { margin: 0 },
74
+ }
75
+ const h = (tag, props, ...children) => _react.createElement(tag, props ?? {}, ...children)
76
+
77
+ const [report, setReport] = _react.useState(null)
78
+ _react.useEffect(() => {
79
+ let cancelled = false
80
+ fetch(STATUS_ENDPOINT, { cache: 'no-store' })
81
+ .then((response) => (response.ok ? response.json() : null))
82
+ .then((data) => {
83
+ if (cancelled || data?.report === undefined) return
84
+ setReport(data.report)
85
+ })
86
+ .catch(() => { /* offline / no host route: keep "no report" */ })
87
+ return () => { cancelled = true }
88
+ }, [])
89
+
90
+ return h('div', { style: styles.section },
91
+ h('h3', { style: styles.title }, t.title),
92
+ h('p', { style: styles.description }, t.description),
93
+ h('p', { style: styles.block },
94
+ report === null ? t.noReport : `【${t.reportLabel}】\n${report}`),
95
+ h('div', { style: styles.hint },
96
+ h('p', { style: styles.hintTitle }, t.hintTitle),
97
+ h('p', { style: styles.hintBody }, t.hintBody)),
98
+ )
99
+ }
100
+
101
+ const name = PLUGIN_ID
102
+ const inject = ['slots', 'locale']
103
+
104
+ function apply(ctx) {
105
+ const t = () => copy()
106
+ ctx.effect(() => {
107
+ return ctx.slots.inject('settings.section', () => ctx.slots.register({
108
+ name: 'settings.section',
109
+ id: 'auto-router',
110
+ order: 60,
111
+ label: () => t().nav,
112
+ locale: NS,
113
+ }, RouterSection))
114
+ }, 'mydsh-auto-model-router: settings section')
115
+ }
116
+ //#endregion
117
+ //#region client/index.mjs
118
+
119
+ //#endregion
120
+ module.exports = { name, inject, apply };
121
+ return module.exports;
122
+ }
123
+ });
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
+ }