@ljwei-stak/dsh-model-router 0.13.0
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/.dsh-plugin/client.js +3092 -0
- package/.dsh-plugin/index.mjs +1651 -0
- package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
- package/.dsh-plugin/shared/harness-plan.mjs +179 -0
- package/.dsh-plugin/shared/livebench.mjs +264 -0
- package/.dsh-plugin/shared/model-profiles.mjs +142 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
- package/.dsh-plugin/shared/router-state.mjs +207 -0
- package/.dsh-plugin/shared/router.mjs +1134 -0
- package/.dsh-plugin/shared/routing-presets.mjs +49 -0
- package/.dsh-plugin/shared/run-ledger.mjs +348 -0
- package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
- package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
- package/.dsh-plugin/shared/task-executors.mjs +1154 -0
- package/.dsh-plugin/shared/tool-health.mjs +311 -0
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
- package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
- package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
- package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
- package/CHANGELOG.md +36 -0
- package/INSTALLATION_GUIDE.zh.md +134 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +53 -0
- package/README.i18n.yaml +3 -0
- package/README.md +424 -0
- package/README.zh.md +413 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +96 -0
- package/docs/assets/workbench-usage.svg +119 -0
- package/package.json +161 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small persistent Host state for the router: onboarding flag, last health
|
|
3
|
+
* check, subscription quota exhaustion, and the bounded run ledger (routing decisions, channels, fallback
|
|
4
|
+
* errors, cost, review and user ratings). One JSON file under the DSH home. Never stores keys.
|
|
5
|
+
*
|
|
6
|
+
* Several Host processes may share one DSH home, so every update takes a
|
|
7
|
+
* lock file (`state.json.lock`, exclusive create; stale locks are broken),
|
|
8
|
+
* re-reads the file, applies the change to the fresh copy and writes it back
|
|
9
|
+
* atomically (temp file + rename). Reads re-read the file when its mtime or
|
|
10
|
+
* size changed. A file that cannot be parsed is kept as
|
|
11
|
+
* `state.json.corrupt-<timestamp>` and a notice is recorded instead of
|
|
12
|
+
* silently discarding the history.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const MAX_RUNS = 200
|
|
19
|
+
const STATE_VERSION = 1
|
|
20
|
+
|
|
21
|
+
/** Same DSH home rule as the Host: DSH_HOME (with ~ expansion) or ~/.dsh. */
|
|
22
|
+
export function resolveStateHome(env = process.env, home = homedir(), cwd = process.cwd()) {
|
|
23
|
+
const configured = String(env.DSH_HOME ?? '').trim()
|
|
24
|
+
if (configured === '') return join(home, '.dsh')
|
|
25
|
+
const expanded = configured === '~' ? home
|
|
26
|
+
: configured.startsWith('~/') || configured.startsWith('~\\') ? join(home, configured.slice(2)) : configured
|
|
27
|
+
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defaultStatePath(env = process.env) {
|
|
31
|
+
return join(resolveStateHome(env), 'model-router', 'state.json')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const MAX_NOTICES = 10
|
|
35
|
+
const LOCK_STALE_MS = 15_000
|
|
36
|
+
const LOCK_TIMEOUT_MS = 10_000
|
|
37
|
+
|
|
38
|
+
function emptyState() {
|
|
39
|
+
return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], notices: [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeNotices(value) {
|
|
43
|
+
return Array.isArray(value)
|
|
44
|
+
? value.filter(item => item && typeof item.kind === 'string' && typeof item.message === 'string').slice(-MAX_NOTICES)
|
|
45
|
+
: []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitize(value) {
|
|
49
|
+
if (!value || typeof value !== 'object' || value.version !== STATE_VERSION) return emptyState()
|
|
50
|
+
return {
|
|
51
|
+
version: STATE_VERSION,
|
|
52
|
+
onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
|
|
53
|
+
health: value.health && Array.isArray(value.health.tools) ? value.health : null,
|
|
54
|
+
quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
|
|
55
|
+
authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
|
|
56
|
+
.filter(([, entry]) => Number.isFinite(entry?.at))),
|
|
57
|
+
runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string').slice(-MAX_RUNS) : [],
|
|
58
|
+
notices: sanitizeNotices(value.notices),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
63
|
+
const stamp = at => new Date(at).toISOString().replace(/[:.]/g, '-')
|
|
64
|
+
|
|
65
|
+
/** Exclusive lock file; a lock older than `staleMs` (crashed holder) is broken. */
|
|
66
|
+
async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK_TIMEOUT_MS, now = Date.now } = {}) {
|
|
67
|
+
const started = now()
|
|
68
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
69
|
+
try {
|
|
70
|
+
const handle = await open(lockFile, 'wx')
|
|
71
|
+
await handle.writeFile(`${process.pid} ${now()}\n`, 'utf8').catch(() => {})
|
|
72
|
+
await handle.close()
|
|
73
|
+
return async () => { await unlink(lockFile).catch(() => {}) }
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code !== 'EEXIST') throw error
|
|
76
|
+
const info = await stat(lockFile).catch(() => null)
|
|
77
|
+
if (info && now() - info.mtimeMs > staleMs) {
|
|
78
|
+
await unlink(lockFile).catch(() => {})
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (now() - started > timeoutMs) {
|
|
82
|
+
// A live holder that never releases: break it rather than lose the write.
|
|
83
|
+
await unlink(lockFile).catch(() => {})
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
await sleep(Math.min(5 + attempt * 5, 50) + Math.floor(Math.random() * 10))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now } = {}) {
|
|
92
|
+
let cache = null
|
|
93
|
+
let cacheKey = ''
|
|
94
|
+
let chain = Promise.resolve()
|
|
95
|
+
let pendingNotices = []
|
|
96
|
+
const lockFile = `${file}.lock`
|
|
97
|
+
const fileKey = async () => {
|
|
98
|
+
const info = await stat(file).catch(() => null)
|
|
99
|
+
return info ? `${info.mtimeMs}:${info.size}:${info.ino}` : 'missing'
|
|
100
|
+
}
|
|
101
|
+
/** Parse the file; keep an unparseable file as a backup and record a notice. */
|
|
102
|
+
const readFresh = async () => {
|
|
103
|
+
let raw
|
|
104
|
+
try { raw = await readFile(file, 'utf8') }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error?.code === 'ENOENT') return { state: emptyState(), corrupt: false }
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
try { return { state: sanitize(JSON.parse(raw)), corrupt: false } }
|
|
110
|
+
catch {
|
|
111
|
+
const at = now()
|
|
112
|
+
const backup = `${file}.corrupt-${stamp(at)}`
|
|
113
|
+
let saved = true
|
|
114
|
+
try { await rename(file, backup) } catch { saved = false }
|
|
115
|
+
pendingNotices.push({
|
|
116
|
+
kind: 'state-corrupt', at, backup: saved ? backup : null,
|
|
117
|
+
message: saved
|
|
118
|
+
? `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`
|
|
119
|
+
: '模型路由状态文件已损坏,无法读取,备份失败;已重新开始记录。',
|
|
120
|
+
})
|
|
121
|
+
return { state: emptyState(), corrupt: true }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const load = async () => {
|
|
125
|
+
const key = await fileKey()
|
|
126
|
+
if (cache && key === cacheKey) return cache
|
|
127
|
+
const fresh = await readFresh()
|
|
128
|
+
cache = fresh.state
|
|
129
|
+
cacheKey = fresh.corrupt ? '' : key
|
|
130
|
+
return cache
|
|
131
|
+
}
|
|
132
|
+
const persist = async state => {
|
|
133
|
+
await mkdir(dirname(file), { recursive: true })
|
|
134
|
+
const temp = `${file}.${process.pid}.${now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
135
|
+
await writeFile(temp, `${JSON.stringify(state)}\n`, 'utf8')
|
|
136
|
+
await rename(temp, file)
|
|
137
|
+
cache = state
|
|
138
|
+
cacheKey = await fileKey()
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Serialize read-modify-write updates within this process and, through the
|
|
142
|
+
* lock file, across processes. `change` mutates a freshly read state.
|
|
143
|
+
*/
|
|
144
|
+
const update = change => {
|
|
145
|
+
const next = chain.then(async () => {
|
|
146
|
+
await mkdir(dirname(file), { recursive: true })
|
|
147
|
+
const release = await acquireLock(lockFile, { now })
|
|
148
|
+
try {
|
|
149
|
+
cache = null
|
|
150
|
+
const state = await load()
|
|
151
|
+
if (pendingNotices.length) { state.notices.push(...pendingNotices); pendingNotices = [] }
|
|
152
|
+
const result = await change(state)
|
|
153
|
+
state.runs = state.runs.slice(-maxRuns)
|
|
154
|
+
state.notices = sanitizeNotices(state.notices)
|
|
155
|
+
await persist(state)
|
|
156
|
+
return result
|
|
157
|
+
} finally {
|
|
158
|
+
await release()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
chain = next.catch(() => {})
|
|
162
|
+
return next
|
|
163
|
+
}
|
|
164
|
+
/** A corrupt file found on read is backed up and replaced under the lock. */
|
|
165
|
+
const read = async () => {
|
|
166
|
+
await chain
|
|
167
|
+
const state = await load()
|
|
168
|
+
if (pendingNotices.length) {
|
|
169
|
+
const notices = [...pendingNotices]
|
|
170
|
+
try { await update(() => {}) } catch { return structuredClone({ ...state, notices: [...state.notices, ...notices] }) }
|
|
171
|
+
return structuredClone(cache)
|
|
172
|
+
}
|
|
173
|
+
return structuredClone(state)
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
file,
|
|
177
|
+
read,
|
|
178
|
+
update,
|
|
179
|
+
completeOnboarding: at => update(current => { current.onboarding.completedAt = at; return current.onboarding }),
|
|
180
|
+
saveHealth: report => update(current => { current.health = report; return report }),
|
|
181
|
+
/** Subscription exhaustion snapshot (`createQuotaTracker().snapshot()`). */
|
|
182
|
+
saveQuota: (snapshot, { removed = [] } = {}) => update(current => {
|
|
183
|
+
// Merge: keep other processes' unexpired entries, apply this process's marks and clears.
|
|
184
|
+
const at = now()
|
|
185
|
+
const merged = Object.fromEntries(Object.entries(current.quota ?? {}).filter(([, entry]) => Number.isFinite(entry?.until) && entry.until > at))
|
|
186
|
+
Object.assign(merged, snapshot && typeof snapshot === 'object' ? snapshot : {})
|
|
187
|
+
for (const key of Array.isArray(removed) ? removed : []) delete merged[key]
|
|
188
|
+
current.quota = merged
|
|
189
|
+
return current.quota
|
|
190
|
+
}),
|
|
191
|
+
/** A CLI failed authentication while running on its subscription (null clears it). */
|
|
192
|
+
saveAuthFailure: (toolId, entry) => update(current => {
|
|
193
|
+
if (entry) current.authFailures[toolId] = { at: entry.at, detail: String(entry.detail ?? '').slice(0, 200) }
|
|
194
|
+
else delete current.authFailures[toolId]
|
|
195
|
+
return current.authFailures
|
|
196
|
+
}),
|
|
197
|
+
appendRun: run => update(current => { current.runs.push(run); return run }),
|
|
198
|
+
updateRun: (id, change) => update(current => {
|
|
199
|
+
const run = current.runs.find(item => item.id === id)
|
|
200
|
+
if (!run) throw new Error(`未找到运行记录 ${id}`)
|
|
201
|
+
change(run)
|
|
202
|
+
return structuredClone(run)
|
|
203
|
+
}),
|
|
204
|
+
/** Remove one notice (for example after the user read the corrupt-file warning). */
|
|
205
|
+
dismissNotice: at => update(current => { current.notices = current.notices.filter(item => item.at !== at); return current.notices }),
|
|
206
|
+
}
|
|
207
|
+
}
|