@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/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
+ }