@aiwayds/dsh-llm-proxy 0.6.0 → 0.7.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/README.md +1 -1
- package/package.json +7 -6
- package/scripts/link-dsh-closure.mjs +102 -0
- package/scripts/smoke-boot.mjs +154 -0
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ LLM 列表命中 > SYSTEM proxy(env 语义)> 直连
|
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
# 方式一:作为 npm 插件加入 profile
|
|
24
|
-
dsh plugin add @aiwayds/dsh-llm-proxy
|
|
24
|
+
dsh plugin add @aiwayds/dsh-llm-proxy
|
|
25
25
|
|
|
26
26
|
# 方式二:手动 patch 挂载(本包自带 cordis.patch.yml)
|
|
27
27
|
# 在 profile 的 bundles 中插入:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-llm-proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "dsh plugin: SYSTEM proxy (env) + LLM traffic-splitting proxy via a process-global undici routing dispatcher",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"lib",
|
|
29
|
+
"scripts",
|
|
29
30
|
"cordis.patch.yml",
|
|
30
31
|
"src",
|
|
31
32
|
"skills",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
38
39
|
"pretest": "npm run build",
|
|
39
40
|
"prepare": "npm run build",
|
|
40
|
-
"test": "node --test test/match.test.mjs test/router.test.mjs test/config.test.mjs test/skill.test.mjs test/dispose.test.mjs test/e2e.test.mjs",
|
|
41
|
+
"test": "node --test test/match.test.mjs test/router.test.mjs test/config.test.mjs test/skill.test.mjs test/dispose.test.mjs test/e2e.test.mjs && node test/packaged-install.test.mjs",
|
|
41
42
|
"smoke": "node scripts/smoke-boot.mjs",
|
|
42
43
|
"postinstall": "node scripts/link-dsh-closure.mjs"
|
|
43
44
|
},
|
|
@@ -47,8 +48,8 @@
|
|
|
47
48
|
"peerDependencies": {
|
|
48
49
|
"@deepseek-ai/cordis": "4.0.4",
|
|
49
50
|
"@deepseek-ai/schemastery": "3.18.4",
|
|
50
|
-
"@deepseek-ai/dsh-settings": ">=0.
|
|
51
|
-
"@deepseek-ai/dsh-skill": ">=0.
|
|
51
|
+
"@deepseek-ai/dsh-settings": ">=0.2.0-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-skill": ">=0.2.0-rc.2"
|
|
52
53
|
},
|
|
53
54
|
"peerDependenciesMeta": {
|
|
54
55
|
"@deepseek-ai/cordis": {
|
|
@@ -64,8 +65,8 @@
|
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@deepseek-ai/cordis": "4.0.4",
|
|
66
67
|
"@deepseek-ai/schemastery": "3.18.4",
|
|
67
|
-
"@deepseek-ai/dsh-settings": "0.
|
|
68
|
-
"@deepseek-ai/dsh-skill": "0.
|
|
68
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-skill": "0.2.0-rc.2",
|
|
69
70
|
"@types/node": "^24.0.0",
|
|
70
71
|
"typescript": "^5.9.0"
|
|
71
72
|
},
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postinstall linker: point every `node_modules/@deepseek-ai/*` entry at the
|
|
3
|
+
* global dsh closure.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: dsh-tui-pi is a plugin that runs *inside* the installed
|
|
6
|
+
* dsh CLI, and its source imports the `@deepseek-ai/*` packages (cordis,
|
|
7
|
+
* dsh-session, dsh-settings, schemastery, …). Those packages are **not**
|
|
8
|
+
* resolvable from the public npm registry in a usable way (their rc.6
|
|
9
|
+
* versions live only in the dsh install's own node_modules), so the repo
|
|
10
|
+
* resolves them from the *global dsh closure* —
|
|
11
|
+
* `$(realpath $(which dsh))/node_modules/@deepseek-ai` — via plain symlinks.
|
|
12
|
+
*
|
|
13
|
+
* The contract: ALL `@deepseek-ai/*` resolve to that single closure instance,
|
|
14
|
+
* so there is exactly one `@deepseek-ai/cordis` in the type graph. Declaring
|
|
15
|
+
* any of them in package.json made `pnpm install` create a second local copy
|
|
16
|
+
* in `.pnpm`, which broke the cordis `declare module` augmentation
|
|
17
|
+
* (`Property 'settings' does not exist on type 'Context'`), so they must stay
|
|
18
|
+
* *undeclared*. pnpm then treats the closure links as extraneous — and, as
|
|
19
|
+
* it prunes entries it once managed, the links can still disappear after an
|
|
20
|
+
* install. This script re-creates them on every `pnpm install` (postinstall).
|
|
21
|
+
*
|
|
22
|
+
* It is a no-op (exit 0) when no global dsh install is found — a dev machine
|
|
23
|
+
* without dsh simply cannot typecheck against dsh types.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process'
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
|
|
31
|
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
32
|
+
// Guard: this postinstall is a dev-machine convenience for THIS repo. A
|
|
33
|
+
// published tarball ships scripts/ (the hook must resolve), but the linker
|
|
34
|
+
// must never touch a consumer's node_modules there — it would delete their
|
|
35
|
+
// @deepseek-ai packages and replace them with symlinks into their global dsh
|
|
36
|
+
// closure. pnpm ≥10 blocks dependency lifecycle scripts by default, but npm
|
|
37
|
+
// would run this — exit silently outside the repo. The repo markers must be
|
|
38
|
+
// things the files allow-list never ships: tsconfig.json (dev typecheck
|
|
39
|
+
// input) and .git.
|
|
40
|
+
if (!existsSync(join(repoRoot, 'tsconfig.json')) || !existsSync(join(repoRoot, '.git'))) {
|
|
41
|
+
process.exit(0)
|
|
42
|
+
}
|
|
43
|
+
const scopeDir = join(repoRoot, 'node_modules', '@deepseek-ai')
|
|
44
|
+
|
|
45
|
+
/** The global dsh package's own `node_modules/@deepseek-ai` closure dir. */
|
|
46
|
+
function findDshClosure() {
|
|
47
|
+
// 0) Explicit override for dev/typecheck against an unreleased dsh line,
|
|
48
|
+
// e.g. a scratch closure from: npm i --prefix ~/tmp/dsh-alpha-closure @deepseek-ai/dsh@alpha
|
|
49
|
+
// DSH_CLOSURE_DIR=~/tmp/dsh-alpha-closure/node_modules/@deepseek-ai node scripts/link-dsh-closure.mjs
|
|
50
|
+
const override = process.env.DSH_CLOSURE_DIR
|
|
51
|
+
if (override !== undefined && override !== "") {
|
|
52
|
+
const dir = realpathSync(override)
|
|
53
|
+
if (existsSync(join(dir, "cordis"))) return dir
|
|
54
|
+
console.warn(`[link-dsh-closure] DSH_CLOSURE_DIR=${override} lacks @deepseek-ai/cordis — ignoring override`)
|
|
55
|
+
}
|
|
56
|
+
// 1) Follow the `dsh` bin — the most faithful pointer to the installed CLI
|
|
57
|
+
// (`/opt/homebrew/bin/dsh` → …/lib/bin.js → pkg dir → its node_modules).
|
|
58
|
+
try {
|
|
59
|
+
const bin = execFileSync('which', ['dsh'], { encoding: 'utf8' }).trim()
|
|
60
|
+
if (bin !== '') {
|
|
61
|
+
const real = realpathSync(bin)
|
|
62
|
+
const closure = join(dirname(dirname(real)), 'node_modules', '@deepseek-ai')
|
|
63
|
+
if (existsSync(join(closure, 'cordis'))) return closure
|
|
64
|
+
}
|
|
65
|
+
} catch { /* dsh not on PATH */ }
|
|
66
|
+
// 2) Fall back to the global node_modules root — the dsh package's own
|
|
67
|
+
// nested closure (what current npm produces for a global install).
|
|
68
|
+
try {
|
|
69
|
+
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim()
|
|
70
|
+
const nested = join(root, '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai')
|
|
71
|
+
if (existsSync(join(nested, 'cordis'))) return nested
|
|
72
|
+
// 3) Last resort: npm's flat global layout, where dsh's @deepseek-ai/*
|
|
73
|
+
// deps are hoisted straight into <npm root -g>/@deepseek-ai next to
|
|
74
|
+
// the dsh package itself.
|
|
75
|
+
const flat = join(root, '@deepseek-ai')
|
|
76
|
+
if (existsSync(join(flat, 'cordis'))) return flat
|
|
77
|
+
} catch { /* npm unavailable */ }
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const closure = findDshClosure()
|
|
82
|
+
if (closure === undefined) {
|
|
83
|
+
console.warn('[link-dsh-closure] global dsh not found — skipping @deepseek-ai links (dev without dsh)')
|
|
84
|
+
process.exit(0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
mkdirSync(scopeDir, { recursive: true })
|
|
88
|
+
let linked = 0
|
|
89
|
+
for (const name of readdirSync(closure)) {
|
|
90
|
+
const target = join(scopeDir, name)
|
|
91
|
+
const source = join(closure, name)
|
|
92
|
+
try {
|
|
93
|
+
// Replace any existing entry (stale symlink, or a local .pnpm copy a
|
|
94
|
+
// previous install created) with the closure link.
|
|
95
|
+
rmSync(target, { recursive: true, force: true })
|
|
96
|
+
symlinkSync(source, target, 'junction')
|
|
97
|
+
linked++
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.warn(`[link-dsh-closure] failed to link ${name}: ${(error instanceof Error ? error.message : String(error))}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
console.log(`[link-dsh-closure] linked ${linked} @deepseek-ai/* packages from ${closure}`)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Boot-smoke: mount the freshly built plugin into a scratch dsh profile
|
|
3
|
+
// (bundle patch + packed tarball) and boot it with the real dsh CLI.
|
|
4
|
+
//
|
|
5
|
+
// 1. npm pack the repo → tarball
|
|
6
|
+
// 2. scratch $DSH_HOME/profiles/smoke with the plugin as a file: dep and a
|
|
7
|
+
// dsh.profile.bundles entry (same shape as the user's real profiles)
|
|
8
|
+
// 3. npm install — lifecycle scripts ON, i.e. the consumer path. pnpm ≥10
|
|
9
|
+
// blocks dependency lifecycle scripts by default, which is exactly how
|
|
10
|
+
// the #2-class tarball breakage (postinstall → unshipped file) stayed
|
|
11
|
+
// invisible to every pnpm-based install; npm-class clients (the desktop
|
|
12
|
+
// plugin manager) are the ones that run it.
|
|
13
|
+
// 4. `dsh --profile smoke --dump-config` must compose the plugin into the
|
|
14
|
+
// tree (mount/patch-layer proof)
|
|
15
|
+
// 5. a real boot under a timeout must load the plugin tree without a
|
|
16
|
+
// loader error (a healthy boot is silent and survives to the kill
|
|
17
|
+
// signal; a broken plugin dies within ~1s with the loader error)
|
|
18
|
+
// 6. `dsh plugin --profile smoke remove <pkg>` must reconcile the profile
|
|
19
|
+
// tree back to stock: the bundles entry spliced, the patch layer
|
|
20
|
+
// dropped, and the plugin id gone from a fresh --dump-config
|
|
21
|
+
//
|
|
22
|
+
// Coverage notes (empirical, dsh 0.1.1-rc.2 and 0.1.2-alpha.3 hosts):
|
|
23
|
+
// - Everything apply() does synchronously — ctx.skills.registerProvider(),
|
|
24
|
+
// the entry-config router install, the global dispatcher takeover — is
|
|
25
|
+
// gated hard: a throw there dies with "plugin tree failed to load".
|
|
26
|
+
// - The ctx.inject(['settings']) listener wiring is NOT judgeable from the
|
|
27
|
+
// boot: a cordis inject sub-fiber that throws (missing method, rejected
|
|
28
|
+
// event registration) is contained silently. That seam is gated instead by
|
|
29
|
+
// `npm run check` against the linked dsh closure (signature-level) plus
|
|
30
|
+
// the unit suite (semantics-level). The valid settings.yaml section below
|
|
31
|
+
// exercises the host's one-shot legacy import path as far as the boot can
|
|
32
|
+
// show (on dsh >= 0.1.7-rc.1 hosts it feeds the entry's volatile config).
|
|
33
|
+
// - The host must match the plugin's target line (CI installs dsh@alpha):
|
|
34
|
+
// a mismatched older host can boot "cleanly" while the contained inject
|
|
35
|
+
// error silently drops the settings integration.
|
|
36
|
+
//
|
|
37
|
+
// Exit 0 = mounted and boots clean. Temp dir is kept and printed on failure,
|
|
38
|
+
// removed on success.
|
|
39
|
+
|
|
40
|
+
import { spawnSync } from 'node:child_process'
|
|
41
|
+
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
42
|
+
import { readFile } from 'node:fs/promises'
|
|
43
|
+
import { tmpdir } from 'node:os'
|
|
44
|
+
import path from 'node:path'
|
|
45
|
+
import { fileURLToPath } from 'node:url'
|
|
46
|
+
|
|
47
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
48
|
+
const pkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8'))
|
|
49
|
+
const ownName = pkg.name // @aiwayds/dsh-llm-proxy
|
|
50
|
+
|
|
51
|
+
const work = mkdtempSync(path.join(tmpdir(), 'dsh-llm-proxy-smoke-'))
|
|
52
|
+
const home = path.join(work, 'dsh-home')
|
|
53
|
+
const profile = path.join(home, 'profiles', 'smoke')
|
|
54
|
+
mkdirSync(profile, { recursive: true })
|
|
55
|
+
|
|
56
|
+
function fail(message, output = '') {
|
|
57
|
+
console.error(`smoke-boot: FAIL — ${message}`)
|
|
58
|
+
if (output) console.error(output.split('\n').slice(0, 30).join('\n'))
|
|
59
|
+
console.error(`smoke-boot: scratch kept at ${work}`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pack = spawnSync('npm', ['pack', '--pack-destination', work], { cwd: repoRoot, encoding: 'utf8' })
|
|
64
|
+
if (pack.status !== 0 || pack.error) fail('npm pack failed', `${pack.stdout}\n${pack.stderr}`)
|
|
65
|
+
const tarball = path.join(work, pack.stdout.trim().split('\n').at(-1))
|
|
66
|
+
|
|
67
|
+
writeFileSync(path.join(profile, 'cordis.yml'), '# dsh profile root — empty; the tree is composed from the bundle patches\n[]\n')
|
|
68
|
+
writeFileSync(path.join(profile, 'cordis.patch.yml'), '# scratch smoke profile: no extra patch layer\n[]\n')
|
|
69
|
+
writeFileSync(path.join(profile, 'package.json'), JSON.stringify({
|
|
70
|
+
name: 'dsh-profile-smoke',
|
|
71
|
+
private: true,
|
|
72
|
+
dependencies: {
|
|
73
|
+
[ownName]: `file:${tarball}`,
|
|
74
|
+
},
|
|
75
|
+
dsh: {
|
|
76
|
+
profile: {
|
|
77
|
+
bundles: [
|
|
78
|
+
'@deepseek-ai/dsh-base',
|
|
79
|
+
ownName,
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}, null, 2) + '\n')
|
|
84
|
+
|
|
85
|
+
// A user settings.yaml section matching the plugin's entry id: on dsh
|
|
86
|
+
// >= 0.1.7-rc.1 hosts the boot's one-shot legacy import consumes it (all
|
|
87
|
+
// three keys are volatile config fields, so the section imports cleanly and
|
|
88
|
+
// the values land in the profile patch for this entry).
|
|
89
|
+
writeFileSync(path.join(home, 'settings.yaml'), `\
|
|
90
|
+
# smoke: user section consumed by the mounted dsh-llm-proxy plugin
|
|
91
|
+
dsh-llm-proxy:
|
|
92
|
+
enabled: true
|
|
93
|
+
systemMode: env
|
|
94
|
+
llmProxy:
|
|
95
|
+
- match: "smoke.example.test"
|
|
96
|
+
proxy: "http://127.0.0.1:1"
|
|
97
|
+
`)
|
|
98
|
+
|
|
99
|
+
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: profile, encoding: 'utf8' })
|
|
100
|
+
if (install.status !== 0 || install.error) fail('npm install in the scratch profile failed (lifecycle scripts on — the consumer path)', `${install.stdout}\n${install.stderr}`)
|
|
101
|
+
|
|
102
|
+
// Consumer stand-down: the linker must not materialize a @deepseek-ai scope
|
|
103
|
+
// dir inside the installed package's own tree (that would shadow the host's
|
|
104
|
+
// bundled closure — the failure mode the optional-peer contract exists for).
|
|
105
|
+
const installedOwn = path.join(profile, 'node_modules', ownName)
|
|
106
|
+
if (existsSync(path.join(installedOwn, 'node_modules', '@deepseek-ai'))) {
|
|
107
|
+
fail('the closure linker ran inside the consumer profile — it must stand down outside the repo')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dshEnv = { ...process.env, DSH_HOME: home }
|
|
111
|
+
|
|
112
|
+
// Phase 1 — mount proof: the composed tree must include the plugin.
|
|
113
|
+
const dump = spawnSync('dsh', ['--profile', 'smoke', '--dump-config'], { cwd: profile, encoding: 'utf8', env: dshEnv })
|
|
114
|
+
if (dump.status !== 0 || dump.error) fail('dsh --dump-config failed on the scratch profile', `${dump.stdout}\n${dump.stderr}`)
|
|
115
|
+
if (!dump.stdout.includes(ownName)) {
|
|
116
|
+
fail(`the composed profile tree does not contain ${ownName} — the bundle patch insert is broken`, dump.stdout)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Phase 2 — boot proof: the plugin tree must LOAD without a loader error.
|
|
120
|
+
const bootSeconds = 25
|
|
121
|
+
const boot = spawnSync('dsh', ['--profile', 'smoke'], {
|
|
122
|
+
cwd: profile,
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
timeout: bootSeconds * 1000,
|
|
125
|
+
killSignal: 'SIGKILL',
|
|
126
|
+
env: dshEnv,
|
|
127
|
+
})
|
|
128
|
+
const output = `${boot.stdout ?? ''}\n${boot.stderr ?? ''}`
|
|
129
|
+
const loaderErrors = [
|
|
130
|
+
/plugin tree failed to load/,
|
|
131
|
+
/failed to apply loader entry/,
|
|
132
|
+
/cannot get property ".*" without inject/,
|
|
133
|
+
/cannot get required service/,
|
|
134
|
+
/Cannot find (package|module)/,
|
|
135
|
+
]
|
|
136
|
+
const hit = loaderErrors.filter((re) => re.test(output))
|
|
137
|
+
if (hit.length > 0) {
|
|
138
|
+
fail('the real host failed to load the plugin tree:', output.split('\n').filter((line) => hit.some((re) => re.test(line)) || /Error/.test(line)).slice(0, 15).join('\n'))
|
|
139
|
+
}
|
|
140
|
+
if (boot.signal !== 'SIGKILL' && boot.status !== 0) {
|
|
141
|
+
fail(`dsh exited early with code ${boot.status} and no loader error — unexpected`, output)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Phase 3 — uninstall leg: removal must reconcile the profile tree back to stock.
|
|
145
|
+
const remove = spawnSync('dsh', ['plugin', '--profile', 'smoke', 'remove', ownName], { cwd: profile, encoding: 'utf8', env: dshEnv })
|
|
146
|
+
if (remove.status !== 0 || remove.error) fail('dsh plugin remove failed', `${remove.stdout}\n${remove.stderr}`)
|
|
147
|
+
const dumpAfter = spawnSync('dsh', ['--profile', 'smoke', '--dump-config'], { cwd: profile, encoding: 'utf8', env: dshEnv })
|
|
148
|
+
if (dumpAfter.status !== 0 || dumpAfter.error) fail('dsh --dump-config failed after removal', `${dumpAfter.stdout}\n${dumpAfter.stderr}`)
|
|
149
|
+
if (dumpAfter.stdout.includes('dsh-llm-proxy')) {
|
|
150
|
+
fail('the composed tree still contains the plugin entry after removal', dumpAfter.stdout)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`smoke-boot: PASS — ${ownName} composed into the scratch profile tree and booted clean in real dsh (${boot.signal === 'SIGKILL' ? `survived ${bootSeconds}s boot window` : `exited ${boot.status}`}); removal restored the stock tree`)
|
|
154
|
+
rmSync(work, { recursive: true, force: true })
|