@aiwayds/dsh-llm-proxy 0.2.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/README.md +156 -0
- package/cordis.patch.yml +9 -0
- package/lib/index.d.ts +102 -0
- package/lib/index.js +335 -0
- package/lib/index.js.map +1 -0
- package/lib/match.d.ts +25 -0
- package/lib/match.js +103 -0
- package/lib/match.js.map +1 -0
- package/lib/router.d.ts +44 -0
- package/lib/router.js +88 -0
- package/lib/router.js.map +1 -0
- package/package.json +76 -0
- package/skills/dsh-llm-proxy/SKILL.md +71 -0
- package/src/index.ts +429 -0
- package/src/match.ts +107 -0
- package/src/router.ts +127 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm-proxy — SYSTEM proxy + LLM traffic-splitting proxy for dsh.
|
|
3
|
+
*
|
|
4
|
+
* Installs a single process-global undici routing dispatcher:
|
|
5
|
+
* - requests whose origin hits an `llmProxy` list entry go through that
|
|
6
|
+
* entry's proxy (undici ProxyAgent);
|
|
7
|
+
* - everything else goes through a shared EnvHttpProxyAgent, which applies
|
|
8
|
+
* the startup environment's HTTPS_PROXY / HTTP_PROXY / ALL_PROXY / NO_PROXY
|
|
9
|
+
* semantics (upper- and lowercase);
|
|
10
|
+
* - with no env proxy configured the fallback is a direct connection.
|
|
11
|
+
*
|
|
12
|
+
* Priority: llmProxy hit > SYSTEM (env) > direct.
|
|
13
|
+
*
|
|
14
|
+
* apply() also registers a bundled skill (skills/dsh-llm-proxy/SKILL.md) that
|
|
15
|
+
* carries the plugin's configuration and troubleshooting guide.
|
|
16
|
+
*
|
|
17
|
+
* User configuration arrives exclusively through the `dsh-llm-proxy`
|
|
18
|
+
* settings.yaml namespace (registered via installSettingsSection, the same
|
|
19
|
+
* seam harness core plugins use); the bundle entry config is the base layer.
|
|
20
|
+
* Edits to the settings section hot-publish: the router is rebuilt in place,
|
|
21
|
+
* unless the resolved values are unchanged, in which case the rebuild is
|
|
22
|
+
* skipped (the settings attach itself fires one redundant onChange).
|
|
23
|
+
*
|
|
24
|
+
* Environment variables are only read, never written: mutating
|
|
25
|
+
* process.env.HTTP_PROXY in an already-started process has no effect on
|
|
26
|
+
* undici/Node internals and is deliberately avoided.
|
|
27
|
+
*
|
|
28
|
+
* @module @aiwayds/dsh-llm-proxy
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFile } from 'node:fs/promises'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
34
|
+
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
35
|
+
import {
|
|
36
|
+
BUNDLED_SKILL_RANK,
|
|
37
|
+
type SkillCandidate,
|
|
38
|
+
type SkillDefinition,
|
|
39
|
+
type SkillProvider,
|
|
40
|
+
} from '@deepseek-ai/dsh-skill'
|
|
41
|
+
import z from '@deepseek-ai/schemastery'
|
|
42
|
+
import * as undici from 'undici'
|
|
43
|
+
import { createProxyRouterDispatcher, withUndiciErrorListener, type LlmProxyEntry } from './router.ts'
|
|
44
|
+
|
|
45
|
+
export { matchOrigin } from './match.ts'
|
|
46
|
+
export { createProxyRouterDispatcher, resolveRoute, type LlmProxyEntry, type RouteDecision } from './router.ts'
|
|
47
|
+
|
|
48
|
+
export const name = 'dsh-llm-proxy'
|
|
49
|
+
|
|
50
|
+
/** Service required by the bundled skill provider. */
|
|
51
|
+
export const inject = ['skills']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The settings.yaml namespace this plugin owns. dsh feeds user configuration
|
|
55
|
+
* to plugins only through registered settings namespaces; the document
|
|
56
|
+
* section key that reaches this plugin is exactly this string.
|
|
57
|
+
*/
|
|
58
|
+
const SETTINGS_NAMESPACE = settingsNamespace('dsh-llm-proxy')
|
|
59
|
+
|
|
60
|
+
/** Plugin configuration. */
|
|
61
|
+
export interface Config {
|
|
62
|
+
/** Master switch; `false` leaves globals untouched (default `true`). */
|
|
63
|
+
enabled?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Fallback mode for requests no llmProxy entry matches (default `env`):
|
|
66
|
+
* `env` applies HTTPS_PROXY / HTTP_PROXY / ALL_PROXY / NO_PROXY from the
|
|
67
|
+
* startup environment; `off` connects directly.
|
|
68
|
+
*/
|
|
69
|
+
systemMode?: 'env' | 'off'
|
|
70
|
+
/** Ordered LLM proxy list; the first entry whose match hits wins. */
|
|
71
|
+
llmProxy?: Array<{ match: string; proxy: string }>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Runtime schema for {@link Config}. */
|
|
75
|
+
export const Config = z.object({
|
|
76
|
+
enabled: z.boolean().default(true),
|
|
77
|
+
systemMode: z.union([z.const('env'), z.const('off')]).default('env'),
|
|
78
|
+
llmProxy: z.array(z.object({
|
|
79
|
+
match: z.string(),
|
|
80
|
+
proxy: z.string(),
|
|
81
|
+
})),
|
|
82
|
+
}) as unknown as z<Config>
|
|
83
|
+
|
|
84
|
+
const CONFIG_KEYS: ReadonlySet<string> = new Set(['enabled', 'systemMode', 'llmProxy'])
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mask the userinfo part of a proxy URL ("user:pass@host") so log lines and
|
|
88
|
+
* error messages never carry credentials.
|
|
89
|
+
*/
|
|
90
|
+
export function redactProxyUrl(proxyUrl: string): string {
|
|
91
|
+
let parsed: URL
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(proxyUrl)
|
|
94
|
+
} catch {
|
|
95
|
+
// Unparsable input: best-effort masking of a credential-shaped token.
|
|
96
|
+
return proxyUrl.replace(/(^|\/\/|\s)[^\s@/]+@/, '$1***@')
|
|
97
|
+
}
|
|
98
|
+
if (!parsed.username && !parsed.password) return proxyUrl
|
|
99
|
+
const auth = parsed.password ? '***:***@' : '***@'
|
|
100
|
+
const path = parsed.pathname === '/' ? '' : parsed.pathname
|
|
101
|
+
return `${parsed.protocol}//${auth}${parsed.host}${path}${parsed.search}${parsed.hash}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateProxyUrl(proxy: string, index: number): void {
|
|
105
|
+
let parsed: URL
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(proxy)
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(`dsh-llm-proxy: config.llmProxy[${index}].proxy is not a valid URL: "${redactProxyUrl(proxy)}"`)
|
|
110
|
+
}
|
|
111
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`dsh-llm-proxy: config.llmProxy[${index}].proxy must use http:// or https:// `
|
|
114
|
+
+ `(undici ProxyAgent does not support ${parsed.protocol}//; SOCKS is not supported)`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Fully resolved plugin policy captured at apply time. */
|
|
120
|
+
export interface ResolvedConfig {
|
|
121
|
+
readonly enabled: boolean
|
|
122
|
+
readonly systemMode: 'env' | 'off'
|
|
123
|
+
readonly llmProxy: readonly LlmProxyEntry[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate, default, and freeze the plugin configuration.
|
|
128
|
+
* @param config - optional plugin configuration; omission selects defaults.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveConfig(config: Config | undefined): ResolvedConfig {
|
|
131
|
+
if (config !== undefined) {
|
|
132
|
+
for (const key of Object.keys(config)) {
|
|
133
|
+
if (!CONFIG_KEYS.has(key)) throw new Error(`dsh-llm-proxy: config: unknown key "${key}"`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const enabled = config?.enabled ?? true
|
|
137
|
+
const systemMode = config?.systemMode ?? 'env'
|
|
138
|
+
if (systemMode !== 'env' && systemMode !== 'off') {
|
|
139
|
+
throw new Error('dsh-llm-proxy: config.systemMode must be "env" or "off"')
|
|
140
|
+
}
|
|
141
|
+
const entries = (config?.llmProxy ?? []).map((entry, index): LlmProxyEntry => {
|
|
142
|
+
if (typeof entry?.match !== 'string' || !entry.match.trim()) {
|
|
143
|
+
throw new Error(`dsh-llm-proxy: config.llmProxy[${index}].match must be a non-empty string`)
|
|
144
|
+
}
|
|
145
|
+
if (typeof entry?.proxy !== 'string' || !entry.proxy.trim()) {
|
|
146
|
+
throw new Error(`dsh-llm-proxy: config.llmProxy[${index}].proxy must be a non-empty string`)
|
|
147
|
+
}
|
|
148
|
+
validateProxyUrl(entry.proxy, index)
|
|
149
|
+
return { match: entry.match.trim(), proxy: entry.proxy.trim() }
|
|
150
|
+
})
|
|
151
|
+
return Object.freeze({ enabled, systemMode, llmProxy: Object.freeze(entries) })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Node 26's bundled fetch can consume compressed responses through npm
|
|
155
|
+
// undici's dispatcher without decompressing them when the two implementations
|
|
156
|
+
// skew. Takeover keeps fetch on the same undici copy as the dispatcher; a
|
|
157
|
+
// deliberate override by someone else must survive.
|
|
158
|
+
//
|
|
159
|
+
// Reload windows are the hazard. Cordis Group.update creates the new plugin
|
|
160
|
+
// instance BEFORE removing the old one, and vendor HMR re-imports the module
|
|
161
|
+
// before disposing it — so "new apply" can interleave with "old dispose", and
|
|
162
|
+
// fresh module copies lose module-level bookkeeping. All ownership state
|
|
163
|
+
// therefore lives in a Symbol.for-keyed slot shared across every module copy:
|
|
164
|
+
// each live install records one layer; dispose pops its own layer and hands
|
|
165
|
+
// the globals back to the nearest remaining layer (or the pristine values).
|
|
166
|
+
interface GlobalsState {
|
|
167
|
+
/** Pristine dispatcher/fetch, captured once before the first-ever install. */
|
|
168
|
+
originalDispatcher: undici.Dispatcher | undefined
|
|
169
|
+
originalFetch: (typeof globalThis.fetch) | undefined
|
|
170
|
+
/** Live layers, bottom-first; each records what it put on the globals. */
|
|
171
|
+
layers: Array<{ router: undici.Dispatcher; fetch?: typeof globalThis.fetch }>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const GLOBALS_KEY = Symbol.for('dsh-llm-proxy.global-state')
|
|
175
|
+
|
|
176
|
+
function globalsState(): GlobalsState {
|
|
177
|
+
const host = globalThis as Record<symbol, unknown>
|
|
178
|
+
return (host[GLOBALS_KEY] ??= {
|
|
179
|
+
originalDispatcher: undefined,
|
|
180
|
+
originalFetch: undefined,
|
|
181
|
+
layers: [],
|
|
182
|
+
}) as GlobalsState
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Undo handle for one {@link takeoverGlobals} call. */
|
|
186
|
+
interface GlobalLayer {
|
|
187
|
+
restore(): void
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Put `router` — and, when fetch is still pristine or still ours, npm
|
|
192
|
+
* undici's fetch — on top of the process globals, recording the layer so
|
|
193
|
+
* dispose can unwind exactly one level even under interleaved reloads.
|
|
194
|
+
*/
|
|
195
|
+
function takeoverGlobals(router: undici.Dispatcher): GlobalLayer {
|
|
196
|
+
const state = globalsState()
|
|
197
|
+
state.originalDispatcher ??= undici.getGlobalDispatcher()
|
|
198
|
+
state.originalFetch ??= globalThis.fetch
|
|
199
|
+
|
|
200
|
+
// Take over fetch only when it is still pristine or still owned by the top
|
|
201
|
+
// layer; a deliberate override by someone else must survive untouched.
|
|
202
|
+
const under = state.layers[state.layers.length - 1]
|
|
203
|
+
let fetch: (typeof globalThis.fetch) | undefined
|
|
204
|
+
if (globalThis.fetch === (under?.fetch ?? state.originalFetch)) {
|
|
205
|
+
undici.install?.()
|
|
206
|
+
fetch = globalThis.fetch
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const layer = { router, fetch }
|
|
210
|
+
state.layers.push(layer)
|
|
211
|
+
undici.setGlobalDispatcher(router)
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
restore(): void {
|
|
215
|
+
const index = state.layers.lastIndexOf(layer)
|
|
216
|
+
if (index !== -1) state.layers.splice(index, 1)
|
|
217
|
+
// Restore only what this instance still owns: after an interleaved
|
|
218
|
+
// reload a newer router may sit on top, and clobbering it would break
|
|
219
|
+
// that one. Unwind hands control to the nearest live layer below, or
|
|
220
|
+
// back to the pristine values once no layer remains.
|
|
221
|
+
if (undici.getGlobalDispatcher() === router) {
|
|
222
|
+
const above = state.layers[state.layers.length - 1]
|
|
223
|
+
undici.setGlobalDispatcher(above?.router ?? state.originalDispatcher!)
|
|
224
|
+
}
|
|
225
|
+
if (fetch !== undefined && globalThis.fetch === fetch) {
|
|
226
|
+
const above = [...state.layers].reverse().find((candidate) => candidate.fetch !== undefined)
|
|
227
|
+
globalThis.fetch = above?.fetch ?? state.originalFetch!
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Injectable seams for tests; production defaults build real undici agents. */
|
|
234
|
+
export interface ApplyInternals {
|
|
235
|
+
/**
|
|
236
|
+
* Creates the shared system fallback; the default builds an
|
|
237
|
+
* EnvHttpProxyAgent for `env`, a direct Agent for `off`.
|
|
238
|
+
*/
|
|
239
|
+
createSystemDispatcher?: (systemMode: 'env' | 'off') => undici.Dispatcher
|
|
240
|
+
/** Creates the per-proxy dispatcher; defaults to ProxyAgent. Overridable in tests. */
|
|
241
|
+
createProxyDispatcher?: (proxyUrl: string) => undici.Dispatcher
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One live router installation owned by the plugin context. */
|
|
245
|
+
interface ActiveLayer {
|
|
246
|
+
readonly router: undici.Dispatcher
|
|
247
|
+
readonly globals: GlobalLayer
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Bundled skill -----------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/** Provider name under `ctx.skills`; doubles as the skill name. */
|
|
253
|
+
const SKILL_PROVIDER_NAME = 'dsh-llm-proxy'
|
|
254
|
+
|
|
255
|
+
/** Packaged skill body; `../skills/` resolves to the package root from both lib/ and src/. */
|
|
256
|
+
const SKILL_BODY_URL = new URL('../skills/dsh-llm-proxy/SKILL.md', import.meta.url)
|
|
257
|
+
|
|
258
|
+
/** Resource base served with the skill so its relative links resolve. */
|
|
259
|
+
const SKILL_RESOURCE_BASE = {
|
|
260
|
+
kind: 'directory',
|
|
261
|
+
path: fileURLToPath(new URL('../skills/dsh-llm-proxy/', import.meta.url)),
|
|
262
|
+
} as const
|
|
263
|
+
|
|
264
|
+
const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
|
|
265
|
+
|
|
266
|
+
/** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
|
|
267
|
+
const SKILL_DESCRIPTION = 'dsh 出站代理 / LLM 分流插件(@aiwayds/dsh-llm-proxy)使用指南。凡给 dsh 插件配置 HTTP 代理、LLM 出站分流,或排查代理网络问题时先读本指南:settings.yaml 顶层 `dsh-llm-proxy:` 段(enabled/systemMode/llmProxy)、llmProxy match 规则、代理 407、CONNECT 挂起、socks5 报错(SOCKS 不支持)、NODE_USE_ENV_PROXY 等价替代。触发词:dsh 代理、HTTP 代理、LLM 分流、llmProxy、llm-proxy、407、CONNECT 挂起、SOCKS、HTTPS_PROXY、NO_PROXY、出站代理、NODE_USE_ENV_PROXY。'
|
|
268
|
+
|
|
269
|
+
const SKILL_CANDIDATE: SkillCandidate = {
|
|
270
|
+
name: SKILL_PROVIDER_NAME,
|
|
271
|
+
description: SKILL_DESCRIPTION,
|
|
272
|
+
invocation: SKILL_INVOCATION,
|
|
273
|
+
provider: SKILL_PROVIDER_NAME,
|
|
274
|
+
source: 'bundled',
|
|
275
|
+
resourceBase: SKILL_RESOURCE_BASE,
|
|
276
|
+
rank: BUNDLED_SKILL_RANK,
|
|
277
|
+
locator: SKILL_BODY_URL,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const skillProvider: SkillProvider = {
|
|
281
|
+
name: SKILL_PROVIDER_NAME,
|
|
282
|
+
list: () => Promise.resolve([SKILL_CANDIDATE]),
|
|
283
|
+
async get(_candidate): Promise<SkillDefinition> {
|
|
284
|
+
return {
|
|
285
|
+
name: SKILL_CANDIDATE.name,
|
|
286
|
+
description: SKILL_CANDIDATE.description,
|
|
287
|
+
invocation: SKILL_CANDIDATE.invocation,
|
|
288
|
+
provider: SKILL_CANDIDATE.provider,
|
|
289
|
+
source: SKILL_CANDIDATE.source,
|
|
290
|
+
resourceBase: SKILL_RESOURCE_BASE,
|
|
291
|
+
content: stripFrontmatter(await readFile(SKILL_BODY_URL, 'utf8')),
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Strip a leading YAML frontmatter block (`---` / body / `---`) from a skill
|
|
298
|
+
* markdown file. `SkillDefinition.content` must be the instruction body after
|
|
299
|
+
* metadata removal — the same shape the filesystem provider serves — so the
|
|
300
|
+
* bundled SKILL.md, which keeps its frontmatter for the GitHub/manual install
|
|
301
|
+
* paths, has the block removed when served through {@link skillProvider.get}.
|
|
302
|
+
* Tolerant by design: input that does not open with a `---` line, or whose
|
|
303
|
+
* frontmatter block is never closed, is returned unchanged. Mirrors the
|
|
304
|
+
* delimiter semantics of the upstream skill-filesystem provider.
|
|
305
|
+
*/
|
|
306
|
+
export function stripFrontmatter(raw: string): string {
|
|
307
|
+
const firstLineEnd = raw.indexOf('\n')
|
|
308
|
+
if (firstLineEnd < 0 || raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return raw
|
|
309
|
+
let lineStart = firstLineEnd + 1
|
|
310
|
+
while (lineStart <= raw.length) {
|
|
311
|
+
const nextNewline = raw.indexOf('\n', lineStart)
|
|
312
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
|
|
313
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
314
|
+
return raw.slice(nextNewline < 0 ? raw.length : nextNewline + 1).trim()
|
|
315
|
+
}
|
|
316
|
+
if (nextNewline < 0) return raw
|
|
317
|
+
lineStart = nextNewline + 1
|
|
318
|
+
}
|
|
319
|
+
return raw
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Install the global routing dispatcher, wire the `dsh-llm-proxy` settings
|
|
324
|
+
* namespace, register the bundled skill provider, and register teardown.
|
|
325
|
+
* @param ctx - plugin context owning the dispose effect.
|
|
326
|
+
* @param config - composition entry config; the base layer under the
|
|
327
|
+
* settings.yaml `dsh-llm-proxy` section.
|
|
328
|
+
* @param internals - injectable dispatcher factories for tests.
|
|
329
|
+
*/
|
|
330
|
+
export function apply(ctx: Context, config: Config = {}, internals: ApplyInternals = {}): void {
|
|
331
|
+
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
332
|
+
// register unconditionally so a missing service fails loud instead of
|
|
333
|
+
// silently dropping the bundled skill.
|
|
334
|
+
ctx.skills.registerProvider(() => skillProvider)
|
|
335
|
+
// Current authoritative config source: the entry until a settings scope
|
|
336
|
+
// layers the user's settings.yaml section on top (and back to the entry if
|
|
337
|
+
// the settings provider detaches). The seam hands over a thunk so every
|
|
338
|
+
// rejudge reads the live layered value instead of a stale snapshot.
|
|
339
|
+
let getSource: () => Config = () => config
|
|
340
|
+
let active: ActiveLayer | undefined
|
|
341
|
+
let activePolicy: ResolvedConfig | undefined
|
|
342
|
+
let effectRegistered = false
|
|
343
|
+
|
|
344
|
+
function installLayer(policy: ResolvedConfig): ActiveLayer {
|
|
345
|
+
const systemDispatcher =
|
|
346
|
+
internals.createSystemDispatcher?.(policy.systemMode)
|
|
347
|
+
?? withUndiciErrorListener(
|
|
348
|
+
policy.systemMode === 'env' ? new undici.EnvHttpProxyAgent() : new undici.Agent(),
|
|
349
|
+
)
|
|
350
|
+
const router = createProxyRouterDispatcher(policy.llmProxy, {
|
|
351
|
+
systemDispatcher,
|
|
352
|
+
proxyFactory: internals.createProxyDispatcher
|
|
353
|
+
?? ((proxyUrl) => withUndiciErrorListener(new undici.ProxyAgent(proxyUrl))),
|
|
354
|
+
})
|
|
355
|
+
return { router, globals: takeoverGlobals(router) }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function teardownLayer(layer: ActiveLayer): void {
|
|
359
|
+
layer.globals.restore()
|
|
360
|
+
// Non-blocking teardown: close() waits for in-flight requests to drain,
|
|
361
|
+
// which would stall hot reloads behind long LLM streams. The globals are
|
|
362
|
+
// already handed back above, so no new request reaches this router; let
|
|
363
|
+
// it finish draining and closing in the background instead of awaiting.
|
|
364
|
+
void layer.router.close().catch(() => {})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const ensureDisposeEffect = (): void => {
|
|
368
|
+
if (effectRegistered) return
|
|
369
|
+
effectRegistered = true
|
|
370
|
+
ctx.effect(() => async () => {
|
|
371
|
+
const current = active
|
|
372
|
+
active = undefined
|
|
373
|
+
if (current) teardownLayer(current)
|
|
374
|
+
}, 'dsh-llm-proxy: restore global dispatcher/fetch and close proxies')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const rejudge = (failFast: boolean): void => {
|
|
378
|
+
let policy: ResolvedConfig
|
|
379
|
+
try {
|
|
380
|
+
policy = resolveConfig(getSource())
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (failFast) throw error
|
|
383
|
+
ctx.logger?.error(
|
|
384
|
+
'dsh-llm-proxy: ignoring invalid settings update, keeping current routing: %s',
|
|
385
|
+
error instanceof Error ? error.message : String(error),
|
|
386
|
+
)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
// Attaching the settings scope fires onChange unconditionally, even when
|
|
390
|
+
// the layered section resolves to the same values as the entry base
|
|
391
|
+
// layer; rebuilding then would tear down and reinstall an identical
|
|
392
|
+
// router for nothing. Skip when no resolved value changed.
|
|
393
|
+
if (activePolicy !== undefined && deepEqualJson(policy, activePolicy)) return
|
|
394
|
+
if (!policy.enabled) {
|
|
395
|
+
const current = active
|
|
396
|
+
active = undefined
|
|
397
|
+
activePolicy = policy
|
|
398
|
+
if (current) teardownLayer(current)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
let next: ActiveLayer
|
|
402
|
+
try {
|
|
403
|
+
next = installLayer(policy)
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (failFast) throw error
|
|
406
|
+
ctx.logger?.error(
|
|
407
|
+
'dsh-llm-proxy: failed to rebuild routing, keeping current routing: %o',
|
|
408
|
+
error,
|
|
409
|
+
)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
const previous = active
|
|
413
|
+
active = next
|
|
414
|
+
activePolicy = policy
|
|
415
|
+
ensureDisposeEffect()
|
|
416
|
+
if (previous) teardownLayer(previous)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Start immediately with the entry config so the plugin works even on hosts
|
|
420
|
+
// that never mount a settings service; when the service appears, the
|
|
421
|
+
// layered section takes over and subsequent edits hot-publish here.
|
|
422
|
+
rejudge(true)
|
|
423
|
+
installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
424
|
+
setSource: (current) => {
|
|
425
|
+
getSource = current
|
|
426
|
+
},
|
|
427
|
+
onChange: () => rejudge(false),
|
|
428
|
+
})
|
|
429
|
+
}
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin matcher for the LLM proxy routing list.
|
|
3
|
+
*
|
|
4
|
+
* A match pattern is one of:
|
|
5
|
+
* - a full origin ("https://api.deepseek.org") — case-insensitive exact match
|
|
6
|
+
* against the request origin;
|
|
7
|
+
* - a host authority ("api.deepseek.org", "api.deepseek.org:8443") — matches
|
|
8
|
+
* the host exactly, or with a leading `*.` wildcard also any subdomain and
|
|
9
|
+
* the bare domain itself ("*.volces.com" matches "volces.com" and
|
|
10
|
+
* "a.volces.com"). An explicit port in the pattern must equal the request
|
|
11
|
+
* origin's port (scheme defaults count: https=443, http=80).
|
|
12
|
+
*
|
|
13
|
+
* All comparisons are lowercase; matching is purely lexical — no DNS, no I/O.
|
|
14
|
+
* Hostnames are normalized the way WHATWG URL parsing normalizes request
|
|
15
|
+
* origins: lowercase ASCII with IDN labels converted to punycode, so a
|
|
16
|
+
* pattern and an origin written in either the unicode or the xn-- form of
|
|
17
|
+
* the same domain compare equal.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PORTS: ReadonlyMap<string, string> = new Map([
|
|
21
|
+
['https:', '443'],
|
|
22
|
+
['http:', '80'],
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Canonical origin of a full-origin URL: lowercase scheme/host with IDN
|
|
27
|
+
* labels punycoded; `undefined` when the value does not parse.
|
|
28
|
+
*/
|
|
29
|
+
function canonicalOrigin(value: string): string | undefined {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(value).origin
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a bare hostname (no port, no scheme) the way WHATWG URL parsing
|
|
39
|
+
* would for a request origin: lowercase ASCII, IDN labels to punycode.
|
|
40
|
+
* Falls back to plain lowercasing when the value cannot be parsed.
|
|
41
|
+
*/
|
|
42
|
+
function normalizeHost(host: string): string {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(`http://${host}`).hostname
|
|
45
|
+
} catch {
|
|
46
|
+
return host.toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AuthorityParts {
|
|
51
|
+
readonly host: string
|
|
52
|
+
readonly port: string | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Split "host[:port]" into host and an optional numeric port. */
|
|
56
|
+
function splitAuthority(authority: string): AuthorityParts {
|
|
57
|
+
const colon = authority.lastIndexOf(':')
|
|
58
|
+
if (colon === -1) return { host: authority, port: undefined }
|
|
59
|
+
const port = authority.slice(colon + 1)
|
|
60
|
+
if (/^\d+$/.test(port)) return { host: authority.slice(0, colon), port }
|
|
61
|
+
// Not a numeric suffix (e.g. IPv6 literals); treat the whole token as host.
|
|
62
|
+
return { host: authority, port: undefined }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Wildcard-aware host comparison: "*.base" matches base and subdomains. */
|
|
66
|
+
function hostMatches(pattern: string, host: string): boolean {
|
|
67
|
+
if (pattern.startsWith('*.')) {
|
|
68
|
+
const base = pattern.slice(2)
|
|
69
|
+
return base.length > 0 && (host === base || host.endsWith(`.${base}`))
|
|
70
|
+
}
|
|
71
|
+
return host === pattern
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a request origin hits a routing-list match pattern.
|
|
76
|
+
* @param match - pattern from the llmProxy list entry.
|
|
77
|
+
* @param origin - request origin, e.g. "https://api.example.com" or
|
|
78
|
+
* "https://api.example.com:8443"; a bare host[:port] is tolerated.
|
|
79
|
+
*/
|
|
80
|
+
export function matchOrigin(match: string, origin: string): boolean {
|
|
81
|
+
const normalizedMatch = match.trim().toLowerCase()
|
|
82
|
+
if (!normalizedMatch) return false
|
|
83
|
+
const trimmedOrigin = origin.trim()
|
|
84
|
+
|
|
85
|
+
if (normalizedMatch.includes('://')) {
|
|
86
|
+
// Full-origin form: compare canonical origins, so scheme/host casing and
|
|
87
|
+
// punycode vs unicode IDN spellings do not affect the outcome.
|
|
88
|
+
const patternOrigin = canonicalOrigin(normalizedMatch)
|
|
89
|
+
return patternOrigin !== undefined && patternOrigin === canonicalOrigin(trimmedOrigin)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let parsed: URL
|
|
93
|
+
try {
|
|
94
|
+
parsed = new URL(trimmedOrigin.includes('://') ? trimmedOrigin : `http://${trimmedOrigin}`)
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
const { host: patternHost, port: patternPort } = splitAuthority(normalizedMatch)
|
|
99
|
+
if (!patternHost) return false
|
|
100
|
+
if (patternPort !== undefined) {
|
|
101
|
+
const actualPort = parsed.port || DEFAULT_PORTS.get(parsed.protocol) || ''
|
|
102
|
+
if (patternPort !== actualPort) return false
|
|
103
|
+
}
|
|
104
|
+
// parsed.hostname is already punycode-normalized by URL parsing; normalize
|
|
105
|
+
// the pattern host the same way so both sides compare symmetrically.
|
|
106
|
+
return hostMatches(normalizeHost(patternHost), parsed.hostname)
|
|
107
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routing dispatcher: forwards every request to the first llmProxy entry
|
|
3
|
+
* whose match pattern hits the request origin, or to a shared fallback
|
|
4
|
+
* (system) dispatcher otherwise. Priority is decided here; the fallback
|
|
5
|
+
* itself — an EnvHttpProxyAgent for env semantics, or a direct Agent — is
|
|
6
|
+
* supplied by the caller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EventEmitter } from 'node:events'
|
|
10
|
+
import * as undici from 'undici'
|
|
11
|
+
import { matchOrigin } from './match.ts'
|
|
12
|
+
|
|
13
|
+
/** One configured routing entry: origin pattern plus its proxy URL. */
|
|
14
|
+
export interface LlmProxyEntry {
|
|
15
|
+
readonly match: string
|
|
16
|
+
readonly proxy: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Route decision for an origin: either the first matching llmProxy entry, or
|
|
21
|
+
* the shared system fallback.
|
|
22
|
+
*/
|
|
23
|
+
export type RouteDecision =
|
|
24
|
+
| { readonly kind: 'llm'; readonly index: number; readonly proxyUrl: string }
|
|
25
|
+
| { readonly kind: 'system' }
|
|
26
|
+
|
|
27
|
+
const ignoreUndiciDispatcherError = (_error: unknown): void => {}
|
|
28
|
+
|
|
29
|
+
// Undici can emit an internal Client "error" while terminating a mid-stream
|
|
30
|
+
// fetch body. The body stream still rejects through reader.read(); this
|
|
31
|
+
// listener only prevents EventEmitter's unhandled "error" special case from
|
|
32
|
+
// crashing the host process. Applied to every dispatcher this plugin creates.
|
|
33
|
+
export function withUndiciErrorListener<T extends undici.Dispatcher>(dispatcher: T): T {
|
|
34
|
+
if (dispatcher instanceof EventEmitter) {
|
|
35
|
+
EventEmitter.prototype.on.call(dispatcher, 'error', ignoreUndiciDispatcherError)
|
|
36
|
+
}
|
|
37
|
+
return dispatcher
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which route an origin takes under the given entry list.
|
|
42
|
+
* @param entries - ordered llmProxy list; first hit wins.
|
|
43
|
+
* @param origin - request origin string.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRoute(
|
|
46
|
+
entries: readonly LlmProxyEntry[],
|
|
47
|
+
origin: string,
|
|
48
|
+
): RouteDecision {
|
|
49
|
+
const index = entries.findIndex((entry) => matchOrigin(entry.match, origin))
|
|
50
|
+
if (index === -1) return { kind: 'system' }
|
|
51
|
+
return { kind: 'llm', index, proxyUrl: entries[index].proxy }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function originToString(origin: unknown): string {
|
|
55
|
+
if (origin instanceof URL) return origin.origin
|
|
56
|
+
if (typeof origin === 'string') return origin
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Injectable seams for tests and custom construction. */
|
|
61
|
+
export interface RouterDeps {
|
|
62
|
+
/** Fallback for requests no llmProxy entry matches. Defaults to a direct Agent. */
|
|
63
|
+
systemDispatcher?: undici.Dispatcher
|
|
64
|
+
/** Creates the per-proxy dispatcher; defaults to ProxyAgent. Overridable in tests. */
|
|
65
|
+
proxyFactory?: (proxyUrl: string) => undici.Dispatcher
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class ProxyRouterDispatcher extends undici.Dispatcher {
|
|
69
|
+
readonly #entries: readonly LlmProxyEntry[]
|
|
70
|
+
readonly #systemDispatcher: undici.Dispatcher
|
|
71
|
+
readonly #proxyFactory: (proxyUrl: string) => undici.Dispatcher
|
|
72
|
+
readonly #proxies = new Map<string, undici.Dispatcher>()
|
|
73
|
+
|
|
74
|
+
constructor(entries: readonly LlmProxyEntry[], deps: RouterDeps) {
|
|
75
|
+
super()
|
|
76
|
+
this.#entries = entries
|
|
77
|
+
this.#systemDispatcher = deps.systemDispatcher ?? withUndiciErrorListener(new undici.Agent())
|
|
78
|
+
this.#proxyFactory = deps.proxyFactory ?? ((proxyUrl) =>
|
|
79
|
+
withUndiciErrorListener(new undici.ProxyAgent(proxyUrl)))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
dispatch(options: undici.Dispatcher.DispatchOptions, handler: undici.Dispatcher.DispatchHandler): boolean {
|
|
83
|
+
const decision = resolveRoute(this.#entries, originToString(options.origin))
|
|
84
|
+
const target =
|
|
85
|
+
decision.kind === 'llm'
|
|
86
|
+
? this.#proxyFor(decision.proxyUrl)
|
|
87
|
+
: this.#systemDispatcher
|
|
88
|
+
return target.dispatch(options, handler)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#proxyFor(proxyUrl: string): undici.Dispatcher {
|
|
92
|
+
let proxy = this.#proxies.get(proxyUrl)
|
|
93
|
+
if (!proxy) {
|
|
94
|
+
proxy = this.#proxyFactory(proxyUrl)
|
|
95
|
+
this.#proxies.set(proxyUrl, proxy)
|
|
96
|
+
}
|
|
97
|
+
return proxy
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** All dispatchers owned by this router, including the system fallback. */
|
|
101
|
+
#owned(): undici.Dispatcher[] {
|
|
102
|
+
return [this.#systemDispatcher, ...this.#proxies.values()]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async close(): Promise<void> {
|
|
106
|
+
await Promise.all(this.#owned().map((dispatcher) => dispatcher.close()))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async destroy(): Promise<void> {
|
|
110
|
+
await Promise.all(this.#owned().map((dispatcher) => dispatcher.destroy()))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create the process-global routing dispatcher.
|
|
116
|
+
* @param entries - ordered llmProxy list; first hit wins.
|
|
117
|
+
* @param deps - optional injectable system dispatcher / proxy factory.
|
|
118
|
+
*/
|
|
119
|
+
export function createProxyRouterDispatcher(
|
|
120
|
+
entries: readonly LlmProxyEntry[],
|
|
121
|
+
deps: RouterDeps = {},
|
|
122
|
+
): undici.Dispatcher {
|
|
123
|
+
// The router itself performs no I/O and currently emits no 'error', but it
|
|
124
|
+
// is an EventEmitter; attach the guard here so the factory exit always
|
|
125
|
+
// hands out a protected dispatcher even if that ever changes.
|
|
126
|
+
return withUndiciErrorListener(new ProxyRouterDispatcher(entries, deps))
|
|
127
|
+
}
|