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/LICENSE +28 -0
- package/README.en.md +124 -0
- package/README.md +124 -0
- package/cordis.patch.yml +27 -0
- package/lib/adapter.js +620 -0
- package/lib/catalog.js +193 -0
- package/lib/client.js +945 -0
- package/lib/cline.js +194 -0
- package/lib/engine.js +405 -0
- package/lib/index.js +409 -0
- package/lib/panel.js +464 -0
- package/lib/protocol.js +226 -0
- package/lib/store.js +92 -0
- package/lib/tools.js +756 -0
- package/package.json +72 -0
package/lib/cline.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport for the Cline gateway and its public catalogs. Transport only: no
|
|
3
|
+
* pinning, retry, or failover policy lives here (see `adapter.js`).
|
|
4
|
+
*
|
|
5
|
+
* - `POST {baseURL}/chat/completions` — the gateway
|
|
6
|
+
* - `GET {baseURL}/models` — the gateway's own catalog
|
|
7
|
+
* - `GET api.cline.bot/.../recommended-models` — official subscription list
|
|
8
|
+
* - `GET models.dev/api.json` — community registry, a backstop
|
|
9
|
+
* - `GET openrouter.ai/api/v1/...` — endpoint detail for direct-pipeline models
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-cline-pass/cline
|
|
12
|
+
*/
|
|
13
|
+
/** The public Cline endpoint that lists subscription models without auth. */
|
|
14
|
+
export const RECOMMENDED_MODELS_URL = 'https://api.cline.bot/api/v1/ai/cline/recommended-models'
|
|
15
|
+
/** The community model registry. */
|
|
16
|
+
export const MODELS_DEV_URL = 'https://models.dev/api.json'
|
|
17
|
+
/** OpenRouter's public API, used only for direct-pipeline endpoint detail. */
|
|
18
|
+
export const OPENROUTER_API = 'https://openrouter.ai/api/v1'
|
|
19
|
+
|
|
20
|
+
/** Combine a caller signal with a timeout signal. */
|
|
21
|
+
function withTimeout(signal, timeoutMs) {
|
|
22
|
+
const signals = []
|
|
23
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) signals.push(AbortSignal.timeout(timeoutMs))
|
|
24
|
+
if (signal !== undefined && signal !== null) signals.push(signal)
|
|
25
|
+
if (signals.length === 0) return undefined
|
|
26
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Strip a trailing slash so paths can be appended literally. */
|
|
30
|
+
export function trimBase(baseURL) {
|
|
31
|
+
return String(baseURL ?? '').trim().replace(/\/+$/, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The gateway's own chat-completions endpoint for one base URL.
|
|
36
|
+
* @param baseURL - e.g. `https://api.cline.bot/api/v1`.
|
|
37
|
+
*/
|
|
38
|
+
export function chatURL(baseURL) {
|
|
39
|
+
return `${trimBase(baseURL)}/chat/completions`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send one JSON request and decode whatever comes back.
|
|
44
|
+
* @returns `{ status, ok, json, text }`; never throws for an HTTP error status.
|
|
45
|
+
*/
|
|
46
|
+
export async function sendJSON(url, { method = 'GET', apiKey = '', body, signal, timeoutMs = 60000, headers = {} } = {}) {
|
|
47
|
+
const outgoing = {
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
Accept: 'application/json',
|
|
51
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
52
|
+
...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }),
|
|
53
|
+
...headers,
|
|
54
|
+
},
|
|
55
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
56
|
+
signal: withTimeout(signal, timeoutMs),
|
|
57
|
+
}
|
|
58
|
+
const response = await fetch(url, outgoing)
|
|
59
|
+
const text = await response.text()
|
|
60
|
+
let json = null
|
|
61
|
+
if (text.length > 0) {
|
|
62
|
+
try {
|
|
63
|
+
json = JSON.parse(text)
|
|
64
|
+
} catch {
|
|
65
|
+
json = null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { status: response.status, ok: response.ok, json, text }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Send one chat completion, non-streaming.
|
|
73
|
+
* @returns `{ status, ok, json, text }` with the gateway envelope already unwrapped by the caller.
|
|
74
|
+
*/
|
|
75
|
+
export function chatCompletion(options) {
|
|
76
|
+
const { baseURL, apiKey, body, signal, timeoutMs = 180000 } = options
|
|
77
|
+
return sendJSON(chatURL(baseURL), { method: 'POST', apiKey, body, signal, timeoutMs })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Open a streaming chat completion and hand back the live response.
|
|
82
|
+
* @returns the raw `fetch` Response so the caller owns SSE iteration and abort.
|
|
83
|
+
*/
|
|
84
|
+
export async function openChatStream({ baseURL, apiKey, body, signal, headers = {} }) {
|
|
85
|
+
return await fetch(chatURL(baseURL), {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
Accept: 'text/event-stream',
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }),
|
|
91
|
+
...headers,
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
signal,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List the gateway's own catalog ids.
|
|
100
|
+
* @returns the model ids; an empty array when the call fails or returns nothing.
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchGatewayCatalog({ baseURL, apiKey, signal, timeoutMs = 30000 }) {
|
|
103
|
+
try {
|
|
104
|
+
const result = await sendJSON(`${trimBase(baseURL)}/models`, { apiKey, signal, timeoutMs })
|
|
105
|
+
const ids = (result.json?.data ?? []).map((model) => model?.id).filter((id) => typeof id === 'string')
|
|
106
|
+
return ids
|
|
107
|
+
} catch {
|
|
108
|
+
return []
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collect `cline-pass/*` ids from the official endpoints and the community registry.
|
|
114
|
+
*
|
|
115
|
+
* Additive by design: every source that answers contributes, and a source that
|
|
116
|
+
* fails is skipped rather than failing the scan.
|
|
117
|
+
*
|
|
118
|
+
* @returns `{ models, sources }` — deduplicated ids plus the sources that answered.
|
|
119
|
+
*/
|
|
120
|
+
export async function fetchOfficialModels({ signal, timeoutMs = 30000, fetchImpl = fetch } = {}) {
|
|
121
|
+
const found = new Set()
|
|
122
|
+
const sources = []
|
|
123
|
+
const add = (value) => {
|
|
124
|
+
const id = typeof value === 'string' ? value : value?.id
|
|
125
|
+
if (typeof id !== 'string') return
|
|
126
|
+
const normalized = id.trim().toLowerCase()
|
|
127
|
+
if (normalized.startsWith('cline-pass/')) found.add(normalized)
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetchImpl(RECOMMENDED_MODELS_URL, { signal: withTimeout(signal, timeoutMs) })
|
|
131
|
+
if (response.ok) {
|
|
132
|
+
const json = await response.json()
|
|
133
|
+
const list = json?.clinePass ?? json?.data?.clinePass
|
|
134
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
135
|
+
list.forEach(add)
|
|
136
|
+
sources.push('cline.api')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch { /* source unavailable */ }
|
|
140
|
+
try {
|
|
141
|
+
const response = await fetchImpl(MODELS_DEV_URL, { signal: withTimeout(signal, timeoutMs) })
|
|
142
|
+
if (response.ok) {
|
|
143
|
+
const json = await response.json()
|
|
144
|
+
const provider = json?.providers?.['cline-pass'] ?? json?.['cline-pass']
|
|
145
|
+
if (provider?.models !== undefined && provider.models !== null) {
|
|
146
|
+
Object.keys(provider.models).forEach((id) => add(id.startsWith('cline-pass/') ? id : `cline-pass/${id}`))
|
|
147
|
+
sources.push('models.dev')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch { /* source unavailable */ }
|
|
151
|
+
return { models: [...found].filter((id) => /^cline-pass\/[a-z0-9._-]+$/.test(id)), sources }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a canonical slug against OpenRouter's public catalog, tolerating the
|
|
156
|
+
* hyphen differences between the gateway's slug and OpenRouter's id.
|
|
157
|
+
* @returns the real OpenRouter id, or null.
|
|
158
|
+
*/
|
|
159
|
+
export async function resolveOpenRouterSlug(slug, { signal, timeoutMs = 30000 } = {}) {
|
|
160
|
+
try {
|
|
161
|
+
const result = await sendJSON(`${OPENROUTER_API}/models`, { signal, timeoutMs })
|
|
162
|
+
const ids = (result.json?.data ?? []).map((model) => model?.id).filter((id) => typeof id === 'string')
|
|
163
|
+
const normalize = (value) => String(value).toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
164
|
+
return ids.find((id) => id === slug) ?? ids.find((id) => normalize(id) === normalize(slug)) ?? null
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-upstream endpoint detail (context length, recent uptime) for one
|
|
172
|
+
* OpenRouter model. Only meaningful for direct-pipeline models.
|
|
173
|
+
* @returns `{ slug, endpoints }`, where endpoints is `[{ slug, name, endpoints, context, uptime }]`.
|
|
174
|
+
*/
|
|
175
|
+
export async function openRouterEndpoints(slug, { signal, timeoutMs = 30000 } = {}) {
|
|
176
|
+
const real = await resolveOpenRouterSlug(slug, { signal, timeoutMs })
|
|
177
|
+
if (real === null) return { slug, endpoints: [] }
|
|
178
|
+
try {
|
|
179
|
+
const result = await sendJSON(`${OPENROUTER_API}/models/${real}/endpoints`, { signal, timeoutMs })
|
|
180
|
+
const detail = new Map()
|
|
181
|
+
for (const entry of result.json?.data?.endpoints ?? []) {
|
|
182
|
+
const providerSlug = String(entry?.tag ?? '').split('/')[0] || String(entry?.provider_name ?? '').toLowerCase().replace(/\s+/g, '-')
|
|
183
|
+
if (providerSlug === '') continue
|
|
184
|
+
const current = detail.get(providerSlug) ?? { slug: providerSlug, name: String(entry?.provider_name ?? providerSlug), endpoints: 0, context: 0, uptime: 0 }
|
|
185
|
+
current.endpoints += 1
|
|
186
|
+
current.context = Math.max(current.context, Number(entry?.context_length ?? 0))
|
|
187
|
+
current.uptime = Math.max(current.uptime, Math.round(Number(entry?.uptime_last_30m ?? 0)))
|
|
188
|
+
detail.set(providerSlug, current)
|
|
189
|
+
}
|
|
190
|
+
return { slug: real, endpoints: [...detail.values()] }
|
|
191
|
+
} catch {
|
|
192
|
+
return { slug: real, endpoints: [] }
|
|
193
|
+
}
|
|
194
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine: account selection, pinned request chains with failover, probing,
|
|
3
|
+
* validation, and request recording. It owns every network call, so the adapter
|
|
4
|
+
* and the management tools apply identical pinning semantics.
|
|
5
|
+
*
|
|
6
|
+
* Failover contract: a candidate is abandoned only while nothing has been
|
|
7
|
+
* delivered yet. A non-2xx, a transport failure, or an error payload before the
|
|
8
|
+
* first content delta moves to the next candidate; once a chunk has been
|
|
9
|
+
* produced the stream is the answer, and a later failure is reported as such.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-cline-pass/engine
|
|
12
|
+
*/
|
|
13
|
+
import { LlmError } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import {
|
|
15
|
+
chatCompletion,
|
|
16
|
+
fetchGatewayCatalog,
|
|
17
|
+
fetchOfficialModels,
|
|
18
|
+
openChatStream,
|
|
19
|
+
openRouterEndpoints,
|
|
20
|
+
} from './cline.js'
|
|
21
|
+
import {
|
|
22
|
+
buildAttempts,
|
|
23
|
+
classifyUpstreamError,
|
|
24
|
+
errorText,
|
|
25
|
+
extractAvailableProviders,
|
|
26
|
+
injectPrefs,
|
|
27
|
+
mergeUpstreams,
|
|
28
|
+
parseRouting,
|
|
29
|
+
parseTier0,
|
|
30
|
+
unwrapEnvelope,
|
|
31
|
+
} from './protocol.js'
|
|
32
|
+
|
|
33
|
+
/** How many upstreams are validated concurrently. */
|
|
34
|
+
const VALIDATE_CONCURRENCY = 5
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse an SSE body into decoded JSON frames.
|
|
38
|
+
* @param body - a web ReadableStream of UTF-8 bytes.
|
|
39
|
+
* @returns every `data:` payload up to `[DONE]`.
|
|
40
|
+
*/
|
|
41
|
+
export async function* parseServerSentEvents(body) {
|
|
42
|
+
const decoder = new TextDecoder()
|
|
43
|
+
let buffer = ''
|
|
44
|
+
for await (const bytes of body) {
|
|
45
|
+
buffer += decoder.decode(bytes, { stream: true })
|
|
46
|
+
let index = buffer.indexOf('\n')
|
|
47
|
+
while (index >= 0) {
|
|
48
|
+
const line = buffer.slice(0, index).replace(/\r$/, '')
|
|
49
|
+
buffer = buffer.slice(index + 1)
|
|
50
|
+
index = buffer.indexOf('\n')
|
|
51
|
+
if (line.length === 0 || line.startsWith(':')) continue
|
|
52
|
+
if (!line.startsWith('data:')) continue
|
|
53
|
+
const payload = line.slice(5).trim()
|
|
54
|
+
if (payload === '[DONE]') {
|
|
55
|
+
yield '[DONE]'
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
yield JSON.parse(payload)
|
|
60
|
+
} catch { /* an unparseable frame carries nothing to deliver */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const tail = buffer.trim()
|
|
64
|
+
if (tail.startsWith('data:')) {
|
|
65
|
+
const payload = tail.slice(5).trim()
|
|
66
|
+
if (payload === '[DONE]') {
|
|
67
|
+
yield '[DONE]'
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (payload.length > 0) {
|
|
71
|
+
try {
|
|
72
|
+
yield JSON.parse(payload)
|
|
73
|
+
} catch { /* see above */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new LlmError('stream ended without [DONE]', 'STREAM_CLOSED')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create the engine.
|
|
81
|
+
*
|
|
82
|
+
* @param options.resolveAccount - async `() => { name, key, baseURL }`; called
|
|
83
|
+
* once per attempt, so round-robin pools rotate per attempt.
|
|
84
|
+
* @param options.store - the observation store (probe results, history).
|
|
85
|
+
* @param options.logger - optional Cordis logger.
|
|
86
|
+
* @param options.attemptTimeoutMs - per-attempt budget for a chat request.
|
|
87
|
+
* @param options.probeTimeoutMs - budget for probe/validate requests.
|
|
88
|
+
*/
|
|
89
|
+
export function createEngine({
|
|
90
|
+
resolveAccount,
|
|
91
|
+
store,
|
|
92
|
+
logger,
|
|
93
|
+
attemptTimeoutMs = 180000,
|
|
94
|
+
probeTimeoutMs = 60000,
|
|
95
|
+
}) {
|
|
96
|
+
/**
|
|
97
|
+
* One pinned request, non-streaming.
|
|
98
|
+
* @returns `{ status, ok, json, text, routing, attempt, account }`.
|
|
99
|
+
*/
|
|
100
|
+
async function attemptChat(model, body, attempt, { signal, timeoutMs = attemptTimeoutMs } = {}) {
|
|
101
|
+
const account = await resolveAccount()
|
|
102
|
+
const pinned = injectPrefs(body, store.metaOf(model), attempt)
|
|
103
|
+
const started = Date.now()
|
|
104
|
+
const result = await chatCompletion({
|
|
105
|
+
baseURL: account.baseURL,
|
|
106
|
+
apiKey: account.key,
|
|
107
|
+
body: pinned,
|
|
108
|
+
signal,
|
|
109
|
+
timeoutMs,
|
|
110
|
+
})
|
|
111
|
+
const payload = unwrapEnvelope(result.json)
|
|
112
|
+
const failed = result.ok !== true || (payload?.error !== undefined && payload?.choices === undefined)
|
|
113
|
+
return {
|
|
114
|
+
attempt,
|
|
115
|
+
account,
|
|
116
|
+
status: failed ? (payload?.error === undefined ? result.status : 502) : 200,
|
|
117
|
+
ok: !failed,
|
|
118
|
+
json: payload,
|
|
119
|
+
text: result.text,
|
|
120
|
+
ms: Date.now() - started,
|
|
121
|
+
routing: failed ? {} : parseRouting(payload),
|
|
122
|
+
error: failed ? (errorText(payload?.error) || result.text.slice(0, 400) || `HTTP ${result.status}`) : '',
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run one model's ordered candidate chain until an attempt succeeds.
|
|
128
|
+
* @returns `{ ok, result?, trace, error }` — `result` is the winning attempt.
|
|
129
|
+
*/
|
|
130
|
+
async function runChain(model, body, pinConfig, { signal, timeoutMs } = {}) {
|
|
131
|
+
const trace = []
|
|
132
|
+
let last = null
|
|
133
|
+
for (const attempt of buildAttempts(pinConfig)) {
|
|
134
|
+
const outcome = await attemptChat(model, body, attempt, { signal, timeoutMs })
|
|
135
|
+
trace.push({ upstream: attempt.upstream ?? '(auto)', status: outcome.status, ms: outcome.ms, note: outcome.error === '' ? 'ok' : outcome.error.slice(0, 160) })
|
|
136
|
+
last = outcome
|
|
137
|
+
if (outcome.ok) return { ok: true, result: outcome, trace, error: '' }
|
|
138
|
+
// A channel that refuses a strict pin is learned as unusable for it.
|
|
139
|
+
if (attempt.upstream !== null && outcome.error !== '') store.learnUpstream(model, attempt.upstream, classifyUpstreamError(outcome.error), outcome.error, outcome.ms)
|
|
140
|
+
if (attempt.upstream === null && (attempt.excludeList ?? []).length > 0 && outcome.error !== '') learnFromError(model, outcome.error)
|
|
141
|
+
}
|
|
142
|
+
return { ok: false, result: last, trace, error: last?.error ?? 'no candidate succeeded' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Merge a gateway error's own provider list into the model's channel list. */
|
|
146
|
+
function learnFromError(model, message) {
|
|
147
|
+
const match = /Available providers are:\s*([^.]+)/.exec(String(message ?? ''))
|
|
148
|
+
if (match === null) return
|
|
149
|
+
const tokens = match[1].split(/,\s*/).map((token) => token.trim()).filter((token) => /^[a-z0-9][a-z0-9-]*$/.test(token))
|
|
150
|
+
if (tokens.length === 0) return
|
|
151
|
+
const meta = store.metaOf(model)
|
|
152
|
+
const merged = mergeUpstreams(meta.upstreams ?? [], tokens)
|
|
153
|
+
if (merged.length !== (meta.upstreams ?? []).length) store.learn(model, { upstreams: merged })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Probe one model: detect its pipeline, read back the upstream it used, and
|
|
158
|
+
* harvest the precise channel list for that pipeline. Costs one tiny request.
|
|
159
|
+
*/
|
|
160
|
+
async function probe(model, { signal, timeoutMs = attemptTimeoutMs } = {}) {
|
|
161
|
+
const account = await resolveAccount()
|
|
162
|
+
const started = Date.now()
|
|
163
|
+
const result = await chatCompletion({
|
|
164
|
+
baseURL: account.baseURL,
|
|
165
|
+
apiKey: account.key,
|
|
166
|
+
body: { model, messages: [{ role: 'user', content: 'Reply with the word OK' }], max_tokens: 256 },
|
|
167
|
+
signal,
|
|
168
|
+
timeoutMs,
|
|
169
|
+
})
|
|
170
|
+
const payload = unwrapEnvelope(result.json)
|
|
171
|
+
const ms = Date.now() - started
|
|
172
|
+
if (payload?.error !== undefined && payload?.choices === undefined) {
|
|
173
|
+
const message = errorText(payload.error)
|
|
174
|
+
store.learn(model, { ok: false, lastError: message, lastMs: ms })
|
|
175
|
+
return { ok: false, ms, model, error: message }
|
|
176
|
+
}
|
|
177
|
+
const routing = parseRouting(payload)
|
|
178
|
+
const pipeline = routing.pipeline
|
|
179
|
+
let harvested = null
|
|
180
|
+
if (pipeline !== null) harvested = await harvest(model, pipeline, account, { signal, timeoutMs: probeTimeoutMs })
|
|
181
|
+
let endpoints = []
|
|
182
|
+
let openrouterSlug = null
|
|
183
|
+
if (pipeline !== 'planner' && typeof routing.canonicalSlug === 'string') {
|
|
184
|
+
const detail = await openRouterEndpoints(routing.canonicalSlug, { signal, timeoutMs: probeTimeoutMs })
|
|
185
|
+
endpoints = detail.endpoints
|
|
186
|
+
openrouterSlug = detail.slug
|
|
187
|
+
}
|
|
188
|
+
const previous = store.metaOf(model)
|
|
189
|
+
const upstreamDetail = { ...(previous.upstreamDetail ?? {}) }
|
|
190
|
+
for (const entry of endpoints) upstreamDetail[entry.slug] = entry
|
|
191
|
+
const upstreams = pipeline === 'planner'
|
|
192
|
+
? mergeUpstreams(harvested, routing.fallbacks)
|
|
193
|
+
: mergeUpstreams(routing.fallbacks, harvested, Object.keys(upstreamDetail))
|
|
194
|
+
const tier0 = [...new Set([...(previous.tier0 ?? []), ...parseTier0(routing.plan)])]
|
|
195
|
+
const meta = store.learn(model, {
|
|
196
|
+
ok: true,
|
|
197
|
+
pipeline,
|
|
198
|
+
pinnable: pipeline !== null,
|
|
199
|
+
availableProviders: harvested ?? previous.availableProviders ?? [],
|
|
200
|
+
canonicalSlug: routing.canonicalSlug ?? previous.canonicalSlug ?? null,
|
|
201
|
+
openrouterSlug: openrouterSlug ?? previous.openrouterSlug ?? null,
|
|
202
|
+
upstreamDetail,
|
|
203
|
+
upstreams,
|
|
204
|
+
tier0,
|
|
205
|
+
lastProvider: routing.finalProvider ?? previous.lastProvider ?? null,
|
|
206
|
+
lastMs: ms,
|
|
207
|
+
probedAt: Date.now(),
|
|
208
|
+
lastError: '',
|
|
209
|
+
})
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
ms,
|
|
213
|
+
model,
|
|
214
|
+
error: '',
|
|
215
|
+
pipeline: meta.pipeline,
|
|
216
|
+
pinnable: meta.pinnable === true,
|
|
217
|
+
canonicalSlug: meta.canonicalSlug ?? '',
|
|
218
|
+
lastProvider: meta.lastProvider ?? '',
|
|
219
|
+
upstreams: meta.upstreams ?? [],
|
|
220
|
+
availableProviders: meta.availableProviders ?? [],
|
|
221
|
+
tier0: meta.tier0 ?? [],
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Harvest the exact channel list for one pipeline by pinning a channel that
|
|
227
|
+
* cannot exist: the router fails before spending a token and names every
|
|
228
|
+
* provider it could have used.
|
|
229
|
+
*/
|
|
230
|
+
async function harvest(model, pipeline, account, { signal, timeoutMs = probeTimeoutMs } = {}) {
|
|
231
|
+
const base = { model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 16 }
|
|
232
|
+
const body = pipeline === 'planner'
|
|
233
|
+
? { ...base, providerOptions: { gateway: { only: ['__probe__'] } } }
|
|
234
|
+
: { ...base, provider: { only: ['__probe__'] } }
|
|
235
|
+
try {
|
|
236
|
+
const result = await chatCompletion({ baseURL: account.baseURL, apiKey: account.key, body, signal, timeoutMs })
|
|
237
|
+
const payload = unwrapEnvelope(result.json)
|
|
238
|
+
const message = errorText(payload?.error) || result.text
|
|
239
|
+
return extractAvailableProviders(message, pipeline)
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Test every known channel of a model once and record the verdicts.
|
|
247
|
+
* @returns `{ results: [{ upstream, status, ms, note }], summary }`.
|
|
248
|
+
*/
|
|
249
|
+
async function validate(model, { signal, timeoutMs = probeTimeoutMs } = {}) {
|
|
250
|
+
const meta = store.metaOf(model)
|
|
251
|
+
const list = Array.isArray(meta.upstreams) ? meta.upstreams : []
|
|
252
|
+
const pipeline = meta.pipeline ?? null
|
|
253
|
+
const results = []
|
|
254
|
+
for (let index = 0; index < list.length; index += VALIDATE_CONCURRENCY) {
|
|
255
|
+
const batch = list.slice(index, index + VALIDATE_CONCURRENCY)
|
|
256
|
+
const settled = await Promise.all(batch.map(async (upstream) => {
|
|
257
|
+
const started = Date.now()
|
|
258
|
+
const base = { model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 16 }
|
|
259
|
+
const body = pipeline === 'planner'
|
|
260
|
+
? { ...base, providerOptions: { gateway: { only: [upstream] } } }
|
|
261
|
+
: { ...base, provider: { only: [upstream] } }
|
|
262
|
+
try {
|
|
263
|
+
const account = await resolveAccount()
|
|
264
|
+
const result = await chatCompletion({ baseURL: account.baseURL, apiKey: account.key, body, signal, timeoutMs })
|
|
265
|
+
const payload = unwrapEnvelope(result.json)
|
|
266
|
+
const ms = Date.now() - started
|
|
267
|
+
let status = 'unknown'
|
|
268
|
+
let note = ''
|
|
269
|
+
if (payload?.error !== undefined && payload?.choices === undefined) {
|
|
270
|
+
note = errorText(payload.error)
|
|
271
|
+
status = classifyUpstreamError(note)
|
|
272
|
+
} else if (payload?.choices !== undefined) {
|
|
273
|
+
status = 'ok'
|
|
274
|
+
}
|
|
275
|
+
return { upstream, status, ms, note: note.slice(0, 160) }
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return { upstream, status: 'unknown', ms: Date.now() - started, note: errorText(error).slice(0, 160) }
|
|
278
|
+
}
|
|
279
|
+
}))
|
|
280
|
+
for (const result of settled) {
|
|
281
|
+
store.learnUpstream(model, result.upstream, result.status, result.note, result.ms)
|
|
282
|
+
results.push(result)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const summary = { ok: 0, limited: 0, bad: 0, auth: 0, unknown: 0 }
|
|
286
|
+
for (const result of results) summary[result.status] = (summary[result.status] ?? 0) + 1
|
|
287
|
+
store.learn(model, { validatedAt: Date.now() })
|
|
288
|
+
return { results, summary }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Send one small chat through a (possibly temporary) pin configuration and
|
|
293
|
+
* report what served it. Nothing is persisted.
|
|
294
|
+
*/
|
|
295
|
+
async function test(model, { upstreams, exclude, signal } = {}) {
|
|
296
|
+
const pin = currentPin(model, { upstreams, exclude })
|
|
297
|
+
const started = Date.now()
|
|
298
|
+
const outcome = await runChain(model, { model, messages: [{ role: 'user', content: 'Reply with the word OK' }], max_tokens: 256 }, pin, { signal, timeoutMs: attemptTimeoutMs })
|
|
299
|
+
const ms = Date.now() - started
|
|
300
|
+
if (!outcome.ok) {
|
|
301
|
+
record(model, { provider: null, attempts: outcome.trace.map((row) => row.upstream), ms, stream: false, error: outcome.error, account: outcome.result?.account?.name ?? null })
|
|
302
|
+
return { ok: false, ms, model, error: outcome.error, targets: pin.upstreams ?? [], excluded: pin.exclude ?? [], trace: outcome.trace, actual: '', content: '', account: outcome.result?.account?.name ?? '' }
|
|
303
|
+
}
|
|
304
|
+
const routing = outcome.result.routing
|
|
305
|
+
record(model, { provider: routing.finalProvider, canonical: routing.canonicalSlug, attempts: outcome.trace.map((row) => row.upstream), ms, stream: false, error: null, account: outcome.result.account?.name ?? null })
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
ms,
|
|
309
|
+
model,
|
|
310
|
+
error: '',
|
|
311
|
+
targets: pin.upstreams ?? [],
|
|
312
|
+
excluded: pin.exclude ?? [],
|
|
313
|
+
trace: outcome.trace,
|
|
314
|
+
actual: routing.finalProvider ?? '',
|
|
315
|
+
actualName: routing.finalProviderName ?? '',
|
|
316
|
+
pipeline: routing.pipeline ?? '',
|
|
317
|
+
pinnable: routing.pipeline !== null,
|
|
318
|
+
canonicalSlug: routing.canonicalSlug ?? '',
|
|
319
|
+
account: outcome.result.account?.name ?? '',
|
|
320
|
+
content: String(routing.content ?? '').slice(0, 120),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check one account key against the gateway with a single small request.
|
|
326
|
+
*
|
|
327
|
+
* A key that authenticates but whose model still errors (a reasoning model
|
|
328
|
+
* burning `max_tokens` into an empty completion, for example) counts as
|
|
329
|
+
* authorized: only a real authentication failure is reported as one.
|
|
330
|
+
*/
|
|
331
|
+
async function testAccount({ key, baseURL, model, signal, timeoutMs = probeTimeoutMs }) {
|
|
332
|
+
const started = Date.now()
|
|
333
|
+
try {
|
|
334
|
+
const result = await chatCompletion({
|
|
335
|
+
baseURL,
|
|
336
|
+
apiKey: key,
|
|
337
|
+
body: { model, messages: [{ role: 'user', content: 'Say OK' }], max_tokens: 512 },
|
|
338
|
+
signal,
|
|
339
|
+
timeoutMs,
|
|
340
|
+
})
|
|
341
|
+
const payload = unwrapEnvelope(result.json)
|
|
342
|
+
const ms = Date.now() - started
|
|
343
|
+
if (payload?.error !== undefined && payload?.choices === undefined) {
|
|
344
|
+
const message = errorText(payload.error)
|
|
345
|
+
const authFailed = /unauthorized|re-authenticate|invalid\s*api|401/i.test(message)
|
|
346
|
+
return { ok: !authFailed, authorized: !authFailed, ms, model, error: message }
|
|
347
|
+
}
|
|
348
|
+
return { ok: true, authorized: true, ms, model, error: '' }
|
|
349
|
+
} catch (error) {
|
|
350
|
+
return { ok: false, authorized: false, ms: Date.now() - started, model, error: errorText(error) }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Resolve a per-call pin override against the stored configuration. */
|
|
355
|
+
function currentPin(model, override = {}) {
|
|
356
|
+
const stored = pinOf(model)
|
|
357
|
+
return {
|
|
358
|
+
upstreams: Array.isArray(override.upstreams) ? override.upstreams.map(String) : stored.upstreams,
|
|
359
|
+
exclude: Array.isArray(override.exclude) ? override.exclude.map(String) : stored.exclude,
|
|
360
|
+
pinMode: stored.pinMode,
|
|
361
|
+
sort: stored.sort,
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The configured pin for one model, read through the plugin's live config. */
|
|
366
|
+
let pinReader = () => ({ upstreams: [], exclude: [], pinMode: 'strict', sort: null })
|
|
367
|
+
const pinOf = (model) => pinReader(model)
|
|
368
|
+
|
|
369
|
+
/** Record one request in the observation store. */
|
|
370
|
+
function record(model, info) {
|
|
371
|
+
store.record({ model, ...info })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
/** Replace the function that reads a model's configured pin. */
|
|
376
|
+
setPinReader(reader) {
|
|
377
|
+
pinReader = reader
|
|
378
|
+
},
|
|
379
|
+
/** Refresh the gateway's own catalog ids. */
|
|
380
|
+
async gatewayCatalog(options = {}) {
|
|
381
|
+
const account = await resolveAccount()
|
|
382
|
+
return await fetchGatewayCatalog({ baseURL: account.baseURL, apiKey: account.key, ...options })
|
|
383
|
+
},
|
|
384
|
+
/** Refresh the official subscription model list. */
|
|
385
|
+
async officialModels(options = {}) {
|
|
386
|
+
return await fetchOfficialModels(options)
|
|
387
|
+
},
|
|
388
|
+
/** The resolved account for the next request (round-robin aware). */
|
|
389
|
+
resolveAccount,
|
|
390
|
+
attemptChat,
|
|
391
|
+
runChain,
|
|
392
|
+
probe,
|
|
393
|
+
validate,
|
|
394
|
+
test,
|
|
395
|
+
testAccount,
|
|
396
|
+
currentPin,
|
|
397
|
+
record,
|
|
398
|
+
learnFromError,
|
|
399
|
+
chatCompletion,
|
|
400
|
+
openChatStream,
|
|
401
|
+
parseServerSentEvents,
|
|
402
|
+
buildAttempts,
|
|
403
|
+
injectPrefs,
|
|
404
|
+
}
|
|
405
|
+
}
|