dsh-cline-pass 0.1.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/lib/panel.js ADDED
@@ -0,0 +1,464 @@
1
+ /**
2
+ * The host half of the Cline Pass setup panel.
3
+ *
4
+ * The `cline_pass_*` tools let an agent configure the provider; this publishes
5
+ * the same control surface to the package's browser half (`lib/client.js`) as an
6
+ * exact route inside Connection's authenticated `/api` prefix, so a user can
7
+ * paste a key and click once instead of composing a tool call.
8
+ *
9
+ * The panel is a thin projection, never a second source of truth: reads go
10
+ * through the live config and the observation store, and writes go through the
11
+ * same `control` surface the tools use. Only lossless JSON crosses the wire —
12
+ * credentials are reported as a masked hint, never as a value.
13
+ *
14
+ * @module dsh-cline-pass/panel
15
+ */
16
+ /**
17
+ * The exact `/api` Fetch route this package owns.
18
+ *
19
+ * It lives under `/api` deliberately: that prefix belongs to Connection, whose
20
+ * handler is the only thing applying the Host/Origin fence and browser-cookie
21
+ * authentication. A route registered on the bare webserver would be reachable
22
+ * by anything that can reach the port.
23
+ */
24
+ export const PANEL_PATH = '/api/cline-pass'
25
+
26
+ /** RPC failure code shared by every endpoint of this channel. */
27
+ export const PANEL_ERROR_CODE = 'cline-pass/panel'
28
+
29
+ /** How many models one `setup.auto` run may consider. */
30
+ const AUTO_MAX_CHANNELS = 12
31
+
32
+ /** Collapse a value into one display line. */
33
+ function firstLine(value, max = 200) {
34
+ return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
35
+ }
36
+
37
+ /** Mask a secret, keeping just enough to recognize it. */
38
+ export function maskKey(value) {
39
+ const key = String(value ?? '')
40
+ if (key.length === 0) return ''
41
+ if (key.length <= 10) return `${key.slice(0, 2)}…${key.slice(-2)}`
42
+ return `${key.slice(0, 6)}…${key.slice(-4)}`
43
+ }
44
+
45
+ /** Order availability verdicts from most to least usable. */
46
+ const VERDICT_RANK = { ok: 0, limited: 1, unknown: 2, bad: 3, auth: 4 }
47
+
48
+ /** Rank one channel verdict for auto-selection. */
49
+ function rankVerdict(status) {
50
+ return VERDICT_RANK[status] ?? VERDICT_RANK.unknown
51
+ }
52
+
53
+ /** Project one model from the live configuration plus what the store observed. */
54
+ export function projectModel(model, pin, meta) {
55
+ const upstreamStatus = Object.entries(meta?.upstreamStatus ?? {}).map(([upstream, verdict]) => ({
56
+ upstream: String(upstream),
57
+ status: String(verdict?.status ?? 'unknown'),
58
+ ms: Number(verdict?.ms ?? 0),
59
+ note: firstLine(verdict?.note, 160),
60
+ checkedAt: Number(verdict?.checkedAt ?? 0),
61
+ }))
62
+ return {
63
+ id: String(model),
64
+ displayName: String(model).replace(/^cline-pass\//, ''),
65
+ pipeline: String(meta?.pipeline ?? ''),
66
+ pinnable: meta?.pinnable === true,
67
+ pinMode: pin?.pinMode === 'preferred' ? 'preferred' : 'strict',
68
+ sort: String(pin?.sort ?? ''),
69
+ pinned: Array.isArray(pin?.upstreams) ? pin.upstreams.map(String) : [],
70
+ excluded: Array.isArray(pin?.exclude) ? pin.exclude.map(String) : [],
71
+ upstreams: Array.isArray(meta?.upstreams) ? meta.upstreams.map(String) : [],
72
+ upstreamStatus,
73
+ lastProvider: String(meta?.lastProvider ?? ''),
74
+ lastMs: Number(meta?.lastMs ?? 0),
75
+ probedAt: Number(meta?.probedAt ?? 0),
76
+ validatedAt: Number(meta?.validatedAt ?? 0),
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Build the panel's JSON API over the plugin's control surface.
82
+ *
83
+ * @param options - `{ control, engine, store }`, exactly what the tools receive.
84
+ * @returns an endpoint table: `name -> (payload) => Promise<json>`.
85
+ */
86
+ export function createPanel({ control, engine, store }) {
87
+ const readConfig = () => control.readConfig()
88
+
89
+ /** The model id a payload names, or a helpful refusal. */
90
+ const modelOf = (payload) => {
91
+ const model = String(payload?.model ?? '').trim()
92
+ if (model.length === 0) throw new Error('this action needs a `model` id')
93
+ return model
94
+ }
95
+
96
+ /** One account as the panel shows it: never the key itself. */
97
+ const accountView = (account) => ({
98
+ key: String(account.key),
99
+ displayName: String(account.displayName ?? account.key),
100
+ apiKeyEnv: String(account.apiKeyEnv ?? ''),
101
+ enabled: account.enabled !== false,
102
+ keyConfigured: account.keyConfigured === true,
103
+ keyHint: String(account.keyHint ?? ''),
104
+ })
105
+
106
+ /** The whole panel snapshot in one round trip. */
107
+ async function state() {
108
+ const config = readConfig()
109
+ const accounts = (await control.accountsWithKeys()).map(accountView)
110
+ const models = (config.knownModels ?? []).map((id) => projectModel(id, config.perModel?.[id] ?? {}, store.metaOf(id)))
111
+ const usable = accounts.some((account) => account.keyConfigured && account.enabled)
112
+ const pinned = models.filter((model) => model.pinned.length > 0).length
113
+ return {
114
+ provider: control.providerName,
115
+ displayName: control.displayName,
116
+ baseURL: String(config.baseURL ?? ''),
117
+ settingsAvailable: control.settingsAvailable(),
118
+ accountMode: config.accountMode === 'roundrobin' ? 'roundrobin' : 'single',
119
+ activeAccount: String(config.activeAccount ?? ''),
120
+ accounts,
121
+ ready: usable,
122
+ models,
123
+ pinnedModels: pinned,
124
+ catalogCount: Number(store.catalog()?.ids?.length ?? 0),
125
+ historySize: store.historySize(),
126
+ }
127
+ }
128
+
129
+ /** Store one credential literal under its reference. */
130
+ async function keySet(payload) {
131
+ const ref = String(payload?.ref ?? '').trim()
132
+ const value = String(payload?.value ?? '').trim()
133
+ if (ref.length === 0) throw new Error('storing a key needs the credential reference it belongs to')
134
+ if (value.length === 0) throw new Error('the key is empty')
135
+ await control.setCredential(ref, value)
136
+ return { ref, stored: true, ...(await state()) }
137
+ }
138
+
139
+ /**
140
+ * Verify one account by making a single tiny real request.
141
+ *
142
+ * A value supplied here is tested WITHOUT being stored, so a user can check a
143
+ * key before committing it.
144
+ */
145
+ async function keyTest(payload) {
146
+ const typed = String(payload?.value ?? '').trim()
147
+ const ref = String(payload?.ref ?? '').trim()
148
+ const config = readConfig()
149
+ let key = typed
150
+ let baseURL = String(config.baseURL ?? '')
151
+ let label = typed.length > 0 ? '(typed key)' : ref
152
+ if (key.length === 0) {
153
+ if (ref.length === 0) throw new Error('testing a key needs a `value` or the `ref` of a stored account')
154
+ const account = control.accounts().find((entry) => String(entry.apiKeyEnv) === ref)
155
+ key = await control.readCredential(ref)
156
+ if (key === '') throw new Error(`no key is stored at ${ref}`)
157
+ baseURL = String(account?.baseURL ?? '') || baseURL
158
+ label = String(account?.key ?? ref)
159
+ }
160
+ const model = (config.knownModels ?? [])[0] ?? 'cline-pass/glm-5.3-flash'
161
+ const result = await engine.testAccount({ key, baseURL, model })
162
+ return {
163
+ ok: result.authorized === true,
164
+ label,
165
+ ms: Number(result.ms ?? 0),
166
+ error: firstLine(result.error, 240),
167
+ }
168
+ }
169
+
170
+ /** Add or update one account, optionally storing its key in the same step. */
171
+ async function accountAdd(payload) {
172
+ const name = String(payload?.name ?? '').trim()
173
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('the account name may use letters, digits, dot, dash and underscore only')
174
+ const config = readConfig()
175
+ const accounts = { ...(config.accounts ?? {}) }
176
+ // Materialize the implicit default account before the first explicit one
177
+ // replaces it, so a top-level key is never silently orphaned.
178
+ if (Object.keys(accounts).length === 0) {
179
+ accounts.default = {
180
+ displayName: String(config.displayName ?? 'default'),
181
+ apiKeyEnv: String(config.apiKeyEnv ?? ''),
182
+ enabled: true,
183
+ baseURL: '',
184
+ }
185
+ }
186
+ const apiKeyEnv = String(payload?.apiKeyEnv ?? '').trim()
187
+ || `CLINE_PASS_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_KEY`
188
+ accounts[name] = {
189
+ displayName: String(payload?.displayName ?? '').trim() || name,
190
+ apiKeyEnv,
191
+ enabled: payload?.enabled !== false,
192
+ baseURL: '',
193
+ }
194
+ await control.updateConfig({ accounts })
195
+ const value = String(payload?.key ?? '').trim()
196
+ if (value.length > 0) await control.setCredential(apiKeyEnv, value)
197
+ return { name, apiKeyEnv, keyStored: value.length > 0, ...(await state()) }
198
+ }
199
+
200
+ /** Remove one account; its stored credential is left in place. */
201
+ async function accountRemove(payload) {
202
+ const name = String(payload?.name ?? '').trim()
203
+ const config = readConfig()
204
+ const accounts = { ...(config.accounts ?? {}) }
205
+ if (accounts[name] === undefined) throw new Error(`no account named ${JSON.stringify(name)}`)
206
+ delete accounts[name]
207
+ const activeAccount = String(config.activeAccount ?? '') === name ? '' : config.activeAccount
208
+ await control.updateConfig({ accounts, activeAccount })
209
+ return { removed: name, ...(await state()) }
210
+ }
211
+
212
+ /** Switch the pool between a manual account and round-robin. */
213
+ async function accountMode(payload) {
214
+ const patch = {}
215
+ if (payload?.mode !== undefined) patch.accountMode = payload.mode === 'roundrobin' ? 'roundrobin' : 'single'
216
+ if (payload?.active !== undefined) patch.activeAccount = String(payload.active ?? '').trim()
217
+ if (Object.keys(patch).length === 0) throw new Error('switching the account mode needs `mode` or `active`')
218
+ await control.updateConfig(patch)
219
+ return await state()
220
+ }
221
+
222
+ /**
223
+ * Persist one model's pin.
224
+ *
225
+ * Omitted fields keep their stored value, so a single toggle in the panel
226
+ * never rewrites the rest of the pin.
227
+ */
228
+ async function modelPin(payload) {
229
+ const model = modelOf(payload)
230
+ const config = readConfig()
231
+ const current = config.perModel?.[model] ?? {}
232
+ const next = {
233
+ upstreams: Array.isArray(payload?.upstreams) ? payload.upstreams.map(String) : (current.upstreams ?? []).map(String),
234
+ exclude: Array.isArray(payload?.exclude) ? payload.exclude.map(String) : (current.exclude ?? []).map(String),
235
+ pinMode: payload?.pinMode === 'preferred' || payload?.pinMode === 'strict'
236
+ ? payload.pinMode
237
+ : (current.pinMode === 'preferred' ? 'preferred' : 'strict'),
238
+ sort: payload?.sort === undefined
239
+ ? (current.sort ?? '')
240
+ : (payload.sort === 'none' || payload.sort === '' ? '' : String(payload.sort)),
241
+ }
242
+ await control.updateConfig({ perModel: { ...(config.perModel ?? {}), [model]: next } })
243
+ return { model, pin: next, ...(await state()) }
244
+ }
245
+
246
+ /** Discover one model's pipeline and channel list (one tiny real request). */
247
+ async function modelProbe(payload) {
248
+ const model = modelOf(payload)
249
+ const result = await engine.probe(model)
250
+ return { model, result: { ...result, error: firstLine(result.error, 300) } }
251
+ }
252
+
253
+ /** Test every known channel of one model and record the verdicts. */
254
+ async function modelValidate(payload) {
255
+ const model = modelOf(payload)
256
+ const meta = store.metaOf(model)
257
+ if (!Array.isArray(meta.upstreams) || meta.upstreams.length === 0) {
258
+ throw new Error('no known channels yet: probe this model first')
259
+ }
260
+ const outcome = await engine.validate(model)
261
+ return {
262
+ model,
263
+ summary: outcome.summary,
264
+ results: outcome.results.map((result) => ({
265
+ upstream: String(result.upstream),
266
+ status: String(result.status),
267
+ ms: Number(result.ms ?? 0),
268
+ note: firstLine(result.note, 160),
269
+ })),
270
+ ...(await state()),
271
+ }
272
+ }
273
+
274
+ /** Try one pin without persisting it, and report what actually served it. */
275
+ async function modelTest(payload) {
276
+ const model = modelOf(payload)
277
+ const result = await engine.test(model, {
278
+ upstreams: Array.isArray(payload?.upstreams) ? payload.upstreams.map(String) : undefined,
279
+ exclude: Array.isArray(payload?.exclude) ? payload.exclude.map(String) : undefined,
280
+ })
281
+ return {
282
+ model,
283
+ ok: result.ok === true,
284
+ error: firstLine(result.error, 300),
285
+ actual: String(result.actual ?? ''),
286
+ pipeline: String(result.pipeline ?? ''),
287
+ ms: Number(result.ms ?? 0),
288
+ content: String(result.content ?? '').slice(0, 120),
289
+ trace: (result.trace ?? []).map((entry) => ({
290
+ upstream: String(entry.upstream ?? '(auto)'),
291
+ status: Number(entry.status ?? 0),
292
+ ms: Number(entry.ms ?? 0),
293
+ note: firstLine(entry.note, 160),
294
+ })),
295
+ }
296
+ }
297
+
298
+ /** Rescan the official subscription list and adopt newly published models. */
299
+ async function modelsRefresh() {
300
+ const scan = await control.refreshCatalog()
301
+ return { added: scan.added.map(String), sources: (scan.sources ?? []).map(String), ...(await state()) }
302
+ }
303
+
304
+ /**
305
+ * The one-click path: probe, measure every channel, pin the ones that
306
+ * answered in preference order, then verify with a real call. Nothing usable
307
+ * leaves the model on automatic routing rather than pinned to a dead channel.
308
+ */
309
+ async function setupAuto(payload) {
310
+ const model = modelOf(payload)
311
+ // Every return path carries the same keys, so a caller can render one shape
312
+ // without guarding each field.
313
+ const shape = {
314
+ model, ok: false, stage: 'probe', error: '', pipeline: '', channels: [],
315
+ pinned: [], excluded: [], available: [], rateLimited: [], unusable: [],
316
+ verified: false, actual: '', summary: { ok: 0, limited: 0, bad: 0, auth: 0, unknown: 0 },
317
+ }
318
+ const probe = await engine.probe(model)
319
+ if (probe.ok !== true) {
320
+ return { ...shape, error: firstLine(probe.error, 300) }
321
+ }
322
+ const channels = (store.metaOf(model).upstreams ?? []).map(String).slice(0, AUTO_MAX_CHANNELS)
323
+ if (channels.length === 0) {
324
+ return {
325
+ ...shape,
326
+ stage: 'discover',
327
+ pipeline: String(probe.pipeline ?? ''),
328
+ error: 'the gateway disclosed no channels for this model; it stays on automatic routing',
329
+ }
330
+ }
331
+ const validated = await engine.validate(model)
332
+ const statusOf = new Map(validated.results.map((result) => [String(result.upstream), String(result.status)]))
333
+ const usable = channels.filter((channel) => rankVerdict(statusOf.get(channel)) === VERDICT_RANK.ok)
334
+ const limited = channels.filter((channel) => rankVerdict(statusOf.get(channel)) === VERDICT_RANK.limited)
335
+ const broken = channels.filter((channel) => rankVerdict(statusOf.get(channel)) >= VERDICT_RANK.bad)
336
+ // A measured-fast channel leads; the rest keep the measured availability
337
+ // order so failover walks from healthy to merely busy.
338
+ const bySpeed = (list) => [...list].sort((left, right) => {
339
+ const timeOf = (name) => Number(validated.results.find((result) => String(result.upstream) === name)?.ms ?? Number.MAX_SAFE_INTEGER)
340
+ return timeOf(left) - timeOf(right)
341
+ })
342
+ const preferred = [...bySpeed(usable), ...limited]
343
+ const pin = {
344
+ upstreams: preferred.length > 0 ? preferred : [],
345
+ exclude: broken,
346
+ pinMode: 'preferred',
347
+ sort: preferred.length > 1 ? 'ttft' : '',
348
+ }
349
+ await modelPin({ model, ...pin })
350
+ let verified = { ok: false, error: 'no candidate was tried' }
351
+ if (preferred.length > 0) verified = await engine.test(model)
352
+ const stored = readConfig().perModel?.[model] ?? {}
353
+ return {
354
+ ...shape,
355
+ stage: 'done',
356
+ ok: preferred.length > 0 && verified.ok === true,
357
+ pipeline: String(probe.pipeline ?? ''),
358
+ channels,
359
+ pinned: (stored.upstreams ?? []).map(String),
360
+ excluded: (stored.exclude ?? []).map(String),
361
+ available: usable,
362
+ rateLimited: limited,
363
+ unusable: broken,
364
+ verified: verified.ok === true,
365
+ actual: String(verified.actual ?? ''),
366
+ error: preferred.length > 0 && verified.ok === true ? '' : firstLine(verified.error, 300),
367
+ summary: validated.summary,
368
+ }
369
+ }
370
+
371
+ /** Drop one model's pin and return it to automatic routing. */
372
+ async function modelReset(payload) {
373
+ return await modelPin({ model: modelOf(payload), upstreams: [], exclude: [], sort: 'none', pinMode: 'preferred' })
374
+ }
375
+
376
+ /** The live request history, newest first. */
377
+ async function history(payload) {
378
+ const limit = Number.isSafeInteger(payload?.limit) && payload.limit > 0 ? Math.min(payload.limit, 100) : 25
379
+ return {
380
+ total: store.historySize(),
381
+ entries: store.readHistory(limit, String(payload?.model ?? '')).map((entry) => ({
382
+ ts: Number(entry.ts ?? 0),
383
+ model: String(entry.model ?? ''),
384
+ provider: String(entry.provider ?? ''),
385
+ account: String(entry.account ?? ''),
386
+ ms: Number(entry.ms ?? 0),
387
+ stream: entry.stream === true,
388
+ error: firstLine(entry.error, 200),
389
+ })),
390
+ }
391
+ }
392
+
393
+ return {
394
+ state,
395
+ 'key.set': keySet,
396
+ 'key.test': keyTest,
397
+ 'account.add': accountAdd,
398
+ 'account.remove': accountRemove,
399
+ 'account.mode': accountMode,
400
+ 'model.pin': modelPin,
401
+ 'model.probe': modelProbe,
402
+ 'model.validate': modelValidate,
403
+ 'model.test': modelTest,
404
+ 'model.reset': modelReset,
405
+ 'models.refresh': modelsRefresh,
406
+ 'setup.auto': setupAuto,
407
+ history,
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Publish the panel API inside Connection's authentication fence.
413
+ *
414
+ * An exact Fetch route on the shared `/api` channel is the only way a plugin
415
+ * gets that prefix: Connection owns it, so registering our own would collide.
416
+ * Joining it inherits the same Host/Origin fence and browser-cookie
417
+ * authentication, with no extra port and no unauthenticated surface.
418
+ *
419
+ * @param options - `{ control, engine, store, logger }`.
420
+ * @returns the disposer, or undefined when no route was registered.
421
+ */
422
+ export function registerPanel(ctx, { control, engine, store, logger }) {
423
+ const endpoints = createPanel({ control, engine, store })
424
+ const handle = async (endpoint, payload) => {
425
+ const route = endpoints[endpoint]
426
+ if (route === undefined) {
427
+ return { ok: false, error: { code: PANEL_ERROR_CODE, message: `unknown panel action ${JSON.stringify(endpoint)}`, details: {} } }
428
+ }
429
+ try {
430
+ return { ok: true, value: await route(payload ?? {}) }
431
+ } catch (error) {
432
+ return {
433
+ ok: false,
434
+ error: { code: PANEL_ERROR_CODE, message: firstLine(error?.message ?? error, 400), details: {} },
435
+ }
436
+ }
437
+ }
438
+
439
+ const dispose = ctx.inject(['connection'], (connectionCtx) => {
440
+ const registered = connectionCtx.connection.fetch.register({
441
+ path: PANEL_PATH,
442
+ methods: ['POST'],
443
+ requestBody: 'buffered',
444
+ fetch: async (request) => {
445
+ let body
446
+ try {
447
+ body = await request.json()
448
+ } catch {
449
+ return Response.json(
450
+ { ok: false, error: { code: PANEL_ERROR_CODE, message: 'the request body is not JSON', details: {} } },
451
+ { status: 400, headers: { 'cache-control': 'no-store' } },
452
+ )
453
+ }
454
+ // Every action is one dispatch entry, so one route carries the whole
455
+ // panel and the browser names the action it wants.
456
+ const result = await handle(String(body?.endpoint ?? ''), body?.payload)
457
+ return Response.json(result, { headers: { 'cache-control': 'no-store' } })
458
+ },
459
+ })
460
+ logger?.info('[cline-pass] setup panel route %s published', PANEL_PATH)
461
+ return registered
462
+ })
463
+ return dispose
464
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Cline Pass wire protocol: pipeline detection, pin injection, failover
3
+ * expansion, and error classification. Every function is pure and synchronous.
4
+ *
5
+ * The two pipelines spell a pin differently:
6
+ * - **direct** (OpenRouter): top-level `provider.only/order/sort`.
7
+ * - **planner** (Vercel AI Gateway): only `providerOptions.gateway.*` survives.
8
+ *
9
+ * Routing behavior reimplements the MIT-licensed cline-pass-switcher findings.
10
+ *
11
+ * @module dsh-cline-pass/protocol
12
+ */
13
+ /**
14
+ * The gateway wraps some answers in a `{ data: … }` envelope; unwrap it.
15
+ * @param json - raw decoded response body.
16
+ * @returns the OpenAI-shaped payload.
17
+ */
18
+ export function unwrapEnvelope(json) {
19
+ if (json !== null && typeof json === 'object' && json.data !== null && typeof json.data === 'object' && json.data.choices !== undefined) {
20
+ return json.data
21
+ }
22
+ return json
23
+ }
24
+
25
+ /** Turn any error-shaped value into display text. */
26
+ export function errorText(value) {
27
+ if (value === undefined || value === null) return ''
28
+ if (typeof value === 'string') return value
29
+ if (typeof value === 'object' && typeof value.message === 'string') return value.message
30
+ try {
31
+ return JSON.stringify(value)
32
+ } catch {
33
+ return String(value)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Read the routing facts out of one completed gateway response.
39
+ * @param json - raw decoded response body (enveloped or not).
40
+ * @returns pipeline, canonical slug, chosen upstream and fallback list.
41
+ */
42
+ export function parseRouting(json) {
43
+ const payload = unwrapEnvelope(json)
44
+ const message = payload?.choices?.[0]?.message
45
+ const routing = message?.provider_metadata?.gateway?.routing ?? payload?.provider_metadata?.gateway?.routing ?? {}
46
+ const direct = typeof payload?.provider === 'string' ? payload.provider : null
47
+ return {
48
+ content: typeof message?.content === 'string' ? message.content : null,
49
+ toolCalls: Array.isArray(message?.tool_calls) ? message.tool_calls : [],
50
+ usage: payload?.usage ?? null,
51
+ pipeline: routing.finalProvider ? 'planner' : (direct === null ? null : 'direct'),
52
+ canonicalSlug: routing.canonicalSlug ?? (typeof payload?.model === 'string' && payload.model.includes('/') ? payload.model : null),
53
+ finalProvider: routing.finalProvider ?? (direct === null ? null : slugify(direct)),
54
+ finalProviderName: routing.finalProvider ?? direct,
55
+ fallbacks: Array.isArray(routing.fallbacksAvailable) ? routing.fallbacksAvailable.map(String) : [],
56
+ plan: typeof routing.planningReasoning === 'string' ? routing.planningReasoning : '',
57
+ }
58
+ }
59
+
60
+ /** Lowercase a display name into an upstream slug. */
61
+ export function slugify(value) {
62
+ return String(value).toLowerCase().replace(/\s+/g, '-')
63
+ }
64
+
65
+ /** OpenRouter's names for the three sort metrics. */
66
+ export const OPENROUTER_SORT = { cost: 'price', ttft: 'latency', tps: 'throughput' }
67
+
68
+ /**
69
+ * Write one attempt's upstream preference into a request body.
70
+ *
71
+ * Excludes are compiled into an `only` allow-list because the gateway ignores
72
+ * the exclude/ignore fields. An unknown pipeline gets both spellings; each
73
+ * pipeline ignores the other.
74
+ *
75
+ * @returns a new body with the pin applied.
76
+ */
77
+ export function injectPrefs(body, meta, attempt) {
78
+ const next = { ...body }
79
+ const { upstream = null, orderRest = [], excludeList = [], strict = true, sort = null } = attempt ?? {}
80
+ const excluded = excludeList.filter((name) => name !== upstream)
81
+ const known = Array.isArray(meta?.upstreams) ? meta.upstreams : []
82
+ const allowList = excluded.length > 0 ? known.filter((name) => !excluded.includes(name)) : null
83
+ if (upstream === null && sort === null && (allowList === null || allowList.length === 0)) return next
84
+ const pipeline = meta?.pipeline ?? null
85
+ const useGateway = pipeline === 'planner' || pipeline === null
86
+ const useOpenRouter = pipeline === 'direct' || pipeline === null
87
+ if (useGateway) {
88
+ const gateway = {}
89
+ if (upstream !== null) {
90
+ if (strict) gateway.only = [upstream]
91
+ else {
92
+ gateway.order = [upstream, ...orderRest]
93
+ if (allowList !== null && allowList.length > 0) gateway.only = allowList
94
+ }
95
+ } else if (allowList !== null && allowList.length > 0) {
96
+ gateway.only = allowList
97
+ }
98
+ if (sort !== null) gateway.sort = sort
99
+ next.providerOptions = { ...(next.providerOptions ?? {}), gateway: { ...(next.providerOptions?.gateway ?? {}), ...gateway } }
100
+ }
101
+ if (useOpenRouter) {
102
+ const provider = { ...(next.provider ?? {}) }
103
+ if (upstream !== null) {
104
+ if (strict) provider.only = [upstream]
105
+ else {
106
+ provider.order = [upstream, ...orderRest]
107
+ if (allowList !== null && allowList.length > 0) provider.only = allowList
108
+ }
109
+ } else if (allowList !== null && allowList.length > 0) {
110
+ provider.only = allowList
111
+ }
112
+ if (sort !== null) provider.sort = OPENROUTER_SORT[sort] ?? sort
113
+ next.provider = provider
114
+ }
115
+ return next
116
+ }
117
+
118
+ /**
119
+ * Expand one model's pin configuration into the ordered failover candidates.
120
+ *
121
+ * A model with a non-empty (post-exclusion) pin list is tried upstream by
122
+ * upstream; a model without one is a single automatic candidate whose excludes
123
+ * become an allow-list.
124
+ *
125
+ * @param config - `{ upstreams, exclude, pinMode, sort }` for one model.
126
+ * @returns the candidate attempts, in try order.
127
+ */
128
+ export function buildAttempts(config) {
129
+ const listed = (config?.upstreams ?? []).filter((name) => typeof name === 'string' && name.length > 0)
130
+ const exclude = (config?.exclude ?? []).filter((name) => typeof name === 'string' && name.length > 0)
131
+ const excluded = new Set(exclude)
132
+ const wanted = listed.filter((name) => !excluded.has(name))
133
+ const strict = (config?.pinMode ?? 'strict') === 'strict'
134
+ const sort = config?.sort ?? null
135
+ const base = { strict, sort, excludeList: exclude }
136
+ if (wanted.length > 0) {
137
+ return wanted.map((upstream, index) => ({
138
+ ...base,
139
+ upstream,
140
+ orderRest: strict ? [] : wanted.filter((_, other) => other !== index),
141
+ }))
142
+ }
143
+ return [{ ...base, upstream: null, orderRest: [] }]
144
+ }
145
+
146
+ /**
147
+ * Classify a pinned-request failure into one upstream availability verdict.
148
+ * @param message - the gateway's error text.
149
+ * @returns `ok` (the channel answered), `limited`, `bad`, `auth`, or `unknown`.
150
+ */
151
+ export function classifyUpstreamError(message) {
152
+ const text = String(message ?? '')
153
+ if (/empty response content/i.test(text)) return 'ok'
154
+ if (/429|rate-?limited|temporarily rate/i.test(text)) return 'limited'
155
+ if (/invalid_request|modelid|no allowed providers|no available providers|not found|unsupported/i.test(text)) return 'bad'
156
+ if (/unauthorized|re-authenticate|401/i.test(text)) return 'auth'
157
+ return 'unknown'
158
+ }
159
+
160
+ /**
161
+ * Recover the gateway's own upstream list from a routing-layer error.
162
+ *
163
+ * The probe channels an impossible `only` value, so the router fails before
164
+ * spending a token and names every provider it could have used.
165
+ *
166
+ * @returns the upstream slugs, or null when the error carried none.
167
+ */
168
+ export function extractAvailableProviders(message, pipeline) {
169
+ const text = String(message ?? '')
170
+ if (pipeline === 'planner') {
171
+ const match = /Available providers are:\s*([^.]+)/.exec(text)
172
+ if (match === null) return null
173
+ const tokens = match[1].split(/,\s*/).map((token) => token.trim()).filter((token) => /^[a-z0-9][a-z0-9-]*$/.test(token))
174
+ return tokens.length > 0 ? tokens : null
175
+ }
176
+ if (pipeline === 'direct') {
177
+ const start = text.indexOf('{')
178
+ if (start < 0) return null
179
+ try {
180
+ const parsed = JSON.parse(text.slice(start))
181
+ const list = parsed?.error?.metadata?.available_providers
182
+ return Array.isArray(list) && list.length > 0 ? list.map(String) : null
183
+ } catch {
184
+ return null
185
+ }
186
+ }
187
+ return null
188
+ }
189
+
190
+ /**
191
+ * Read the tier-0 channel hint out of a planner's reasoning sentence.
192
+ * @param plan - `provider_metadata.gateway.routing.planningReasoning`.
193
+ * @returns the upstreams the planner considered tier 0.
194
+ */
195
+ export function parseTier0(plan) {
196
+ const match = /([\w-]+) won tier 0 over ([^."]+)/.exec(String(plan ?? ''))
197
+ if (match === null) return []
198
+ return [...new Set([match[1], ...match[2].split(/,\s*|\s+and\s+/).map((part) => part.trim()).filter(Boolean)])]
199
+ }
200
+
201
+ /**
202
+ * Merge the upstream channels discovered by several means, keeping order and
203
+ * dropping duplicates.
204
+ * @param lists - candidate lists, highest confidence first.
205
+ * @returns the merged, de-duplicated upstream slugs (capped at 25).
206
+ */
207
+ export function mergeUpstreams(...lists) {
208
+ const seen = []
209
+ for (const list of lists) {
210
+ for (const name of list ?? []) {
211
+ const value = String(name)
212
+ if (value.length > 0 && !seen.includes(value)) seen.push(value)
213
+ }
214
+ }
215
+ return seen.slice(0, 25)
216
+ }
217
+
218
+ /**
219
+ * Normalize a model id for fuzzy OpenRouter catalog matching (the gateway's
220
+ * canonical slug and OpenRouter's id disagree on hyphens: `zai/…` vs `z-ai/…`).
221
+ * @param value - either id.
222
+ * @returns the comparable form.
223
+ */
224
+ export function normalizeSlug(value) {
225
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, '')
226
+ }