dsh-cline-pass 0.2.7 → 0.2.9
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.en.md +38 -86
- package/README.md +35 -87
- package/lib/accounts.js +31 -0
- package/lib/adapter.js +7 -3
- package/lib/catalog.js +28 -1
- package/lib/client.js +24 -22
- package/lib/cline.js +93 -2
- package/lib/compat.js +28 -1
- package/lib/engine.js +22 -14
- package/lib/index.js +27 -24
- package/lib/panel.js +12 -15
- package/lib/protocol.js +17 -9
- package/lib/store.js +9 -3
- package/lib/tools.js +21 -19
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -505,7 +505,7 @@ window.__ModuleLoader__.load({
|
|
|
505
505
|
* in front of it once an entry is no longer from today — a long-lived host
|
|
506
506
|
* process would otherwise show times that look like they were all this hour.
|
|
507
507
|
*/
|
|
508
|
-
function
|
|
508
|
+
function historyStamp(value) {
|
|
509
509
|
const at = new Date(Number(value ?? 0))
|
|
510
510
|
if (!Number.isFinite(at.getTime())) return '-'
|
|
511
511
|
const pad = (part) => String(part).padStart(2, '0')
|
|
@@ -966,7 +966,7 @@ window.__ModuleLoader__.load({
|
|
|
966
966
|
action: { kind: 'validate', model, data: result },
|
|
967
967
|
notice: {
|
|
968
968
|
kind: (summary.ok ?? 0) > 0 ? 'ok' : 'warn',
|
|
969
|
-
text: `${model} · available=${summary.ok ?? 0} rate-limited=${summary.limited ?? 0} not-pinnable=${summary.bad ?? 0} auth-failed=${summary.auth ?? 0}`,
|
|
969
|
+
text: `${model} · available=${summary.ok ?? 0} rate-limited=${summary.limited ?? 0} not-pinnable=${summary.bad ?? 0} auth-failed=${summary.auth ?? 0} unknown=${summary.unknown ?? 0}`,
|
|
970
970
|
},
|
|
971
971
|
})
|
|
972
972
|
}
|
|
@@ -1163,12 +1163,8 @@ window.__ModuleLoader__.load({
|
|
|
1163
1163
|
* whether any picker offers this model, and the one action that matters. The
|
|
1164
1164
|
* finer pin actions and the channel list live behind the expander.
|
|
1165
1165
|
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
* (test), and dropping it (reset). Validating every channel is what
|
|
1169
|
-
* auto-configure already does before it pins, so it is not a separate button
|
|
1170
|
-
* here — running it alone costs one real request per channel to re-learn
|
|
1171
|
-
* verdicts the same click would have produced.
|
|
1166
|
+
* Discover channels, validate them, and choose a pin explicitly. Validation
|
|
1167
|
+
* costs one real request per channel; it does not change the stored pin.
|
|
1172
1168
|
*/
|
|
1173
1169
|
function ModelRow(props) {
|
|
1174
1170
|
const { t, model, disabled, actions } = props
|
|
@@ -1201,6 +1197,7 @@ window.__ModuleLoader__.load({
|
|
|
1201
1197
|
open ? h('div', { key: 'detail', className: 'cp-panel' }, [
|
|
1202
1198
|
h('div', { key: 'actions', className: 'cp-row' }, [
|
|
1203
1199
|
action('probe', t('probe'), actions.probeModel),
|
|
1200
|
+
action('validate', t('validate'), actions.validateModel),
|
|
1204
1201
|
action('test', t('testPin'), actions.testModel),
|
|
1205
1202
|
action('reset', t('reset'), actions.resetModel),
|
|
1206
1203
|
]),
|
|
@@ -1256,7 +1253,7 @@ window.__ModuleLoader__.load({
|
|
|
1256
1253
|
onClick: () => actions.testKey(account.apiKeyEnv, ''),
|
|
1257
1254
|
}, t('test')),
|
|
1258
1255
|
active || !pooled ? null : h('button', {
|
|
1259
|
-
key: 'use', type: 'button', className: 'cp-btn',
|
|
1256
|
+
key: 'use', type: 'button', className: 'cp-btn', disabled,
|
|
1260
1257
|
onClick: () => actions.setAccountMode({ active: account.key }),
|
|
1261
1258
|
}, t('accountsSetActive')),
|
|
1262
1259
|
h('button', {
|
|
@@ -1275,9 +1272,8 @@ window.__ModuleLoader__.load({
|
|
|
1275
1272
|
/**
|
|
1276
1273
|
* The accounts card.
|
|
1277
1274
|
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
* controls sit in the section body where they apply to the whole list.
|
|
1275
|
+
* The key editor belongs to the account selected for editing. It stays put
|
|
1276
|
+
* while requests rotate or cool down; quota follows the effective account.
|
|
1281
1277
|
*
|
|
1282
1278
|
* It folds like every other section, with one exception: before an account is
|
|
1283
1279
|
* usable this is the only thing left to act on, so it opens itself. A card
|
|
@@ -1292,7 +1288,7 @@ window.__ModuleLoader__.load({
|
|
|
1292
1288
|
const single = data.accountMode !== 'roundrobin'
|
|
1293
1289
|
const fallback = data.accounts.find((account) => account.enabled)?.key ?? ''
|
|
1294
1290
|
const activeKey = data.activeAccount === '' ? fallback : data.activeAccount
|
|
1295
|
-
//
|
|
1291
|
+
// Editing is an explicit account choice, independent of request rotation.
|
|
1296
1292
|
const target = data.accounts.find((account) => account.key === activeKey) ?? data.accounts[0]
|
|
1297
1293
|
// A row can be deleted only when an explicit `accounts` entry stands behind
|
|
1298
1294
|
// it; a lone implicit account is the top-level key, so deleting it would
|
|
@@ -1357,7 +1353,7 @@ window.__ModuleLoader__.load({
|
|
|
1357
1353
|
target === undefined ? null : h('div', { key: 'key', className: 'cp-row cp-acct-key' }, [
|
|
1358
1354
|
h('span', { key: 'l', className: 'cp-muted' }, fill(t('accountsFor'), { name: target.displayName })),
|
|
1359
1355
|
h(KeyField, {
|
|
1360
|
-
key:
|
|
1356
|
+
key: `field:${target.key}:${target.apiKeyEnv}`,
|
|
1361
1357
|
t,
|
|
1362
1358
|
account: target,
|
|
1363
1359
|
disabled,
|
|
@@ -1471,7 +1467,7 @@ window.__ModuleLoader__.load({
|
|
|
1471
1467
|
color: failed ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)',
|
|
1472
1468
|
title: failed ? entry.error : 'ok',
|
|
1473
1469
|
})),
|
|
1474
|
-
h('td', { key: 'when', className: 'cp-history-when' },
|
|
1470
|
+
h('td', { key: 'when', className: 'cp-history-when' }, historyStamp(entry.ts)),
|
|
1475
1471
|
// Model and its serving channel share a cell: the panel is ~480px wide and
|
|
1476
1472
|
// a column of its own squeezed the model id — the one identifier a reader
|
|
1477
1473
|
// scans for — down to a truncated stub.
|
|
@@ -1711,9 +1707,8 @@ window.__ModuleLoader__.load({
|
|
|
1711
1707
|
void props.loadUsage().then((result) => setReadFailed(result === undefined))
|
|
1712
1708
|
}, [state.data])
|
|
1713
1709
|
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
// of going stale the moment the first read completed.
|
|
1710
|
+
// Revisions keep advancing when retained rows have reached the cap.
|
|
1711
|
+
// Older hosts expose only the retained count.
|
|
1717
1712
|
React.useEffect(() => {
|
|
1718
1713
|
if (state.data === null) return
|
|
1719
1714
|
let cancelled = false
|
|
@@ -1722,7 +1717,7 @@ window.__ModuleLoader__.load({
|
|
|
1722
1717
|
setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
|
|
1723
1718
|
})
|
|
1724
1719
|
return () => { cancelled = true }
|
|
1725
|
-
}, [state.data?.historySize])
|
|
1720
|
+
}, [state.data?.historyRevision ?? state.data?.historySize])
|
|
1726
1721
|
|
|
1727
1722
|
|
|
1728
1723
|
|
|
@@ -1885,7 +1880,7 @@ window.__ModuleLoader__.load({
|
|
|
1885
1880
|
h('span', { key: 'n', className: 'cp-grow' }, data?.ready === true ? t('keyOk') : t('keyMissing')),
|
|
1886
1881
|
]),
|
|
1887
1882
|
account === undefined ? null : h(KeyField, {
|
|
1888
|
-
key:
|
|
1883
|
+
key: `key:${account.key}:${account.apiKeyEnv}`,
|
|
1889
1884
|
t,
|
|
1890
1885
|
account,
|
|
1891
1886
|
disabled: state.busy !== null || data?.settingsAvailable === false,
|
|
@@ -2105,6 +2100,7 @@ window.__ModuleLoader__.load({
|
|
|
2105
2100
|
/** A local timestamp, matching the panel's own reading of "updated at". */
|
|
2106
2101
|
function stamp(ms) {
|
|
2107
2102
|
const at = new Date(Number(ms ?? 0))
|
|
2103
|
+
if (!Number.isFinite(at.getTime())) return '-'
|
|
2108
2104
|
const pad = (n) => String(n).padStart(2, '0')
|
|
2109
2105
|
return `${at.getFullYear()}/${at.getMonth() + 1}/${at.getDate()} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
|
|
2110
2106
|
}
|
|
@@ -2181,12 +2177,12 @@ window.__ModuleLoader__.load({
|
|
|
2181
2177
|
// 3. The quota reading beside the model selector. It mounts for every
|
|
2182
2178
|
// session but renders nothing unless the selected model is this
|
|
2183
2179
|
// provider's, so no other model pays for a usage request.
|
|
2184
|
-
|
|
2180
|
+
const registerUsage = (usageCtx) => usageCtx.slots.inject('conversation.input.right', () => usageCtx.slots.register({
|
|
2185
2181
|
name: 'conversation.input.right',
|
|
2186
2182
|
id: `${NS}-usage`,
|
|
2187
2183
|
order: 100,
|
|
2188
2184
|
inject: (sessionId) => ({
|
|
2189
|
-
directory:
|
|
2185
|
+
directory: usageCtx.modelDirectories.directoryFor(sessionId).store,
|
|
2190
2186
|
// The route name is configuration, so it is only known once a state
|
|
2191
2187
|
// read has landed. Read it at render time rather than capturing it:
|
|
2192
2188
|
// the pill can mount before the first read completes.
|
|
@@ -2207,6 +2203,12 @@ window.__ModuleLoader__.load({
|
|
|
2207
2203
|
}),
|
|
2208
2204
|
}, UsagePill))
|
|
2209
2205
|
|
|
2206
|
+
// Newer Cordis service methods execute in the consumer's context. The
|
|
2207
|
+
// directory resolver reads remote.session, so give this slot its explicit
|
|
2208
|
+
// dependency without requiring that newer service on legacy hosts.
|
|
2209
|
+
if (ctx.get('remote.session') !== undefined) ctx.inject(['remote.session'], registerUsage)
|
|
2210
|
+
else registerUsage(ctx)
|
|
2211
|
+
|
|
2210
2212
|
// The first read is lazy (each component asks for it), because the RPC
|
|
2211
2213
|
// transport only exists once Connection has activated.
|
|
2212
2214
|
void controller.refresh()
|
package/lib/cline.js
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
* - `GET {baseURL}/users/{id}/usages` — the per-request usage history behind them
|
|
9
9
|
* - `GET api.cline.bot/.../recommended-models` — official subscription list
|
|
10
10
|
* - `GET models.dev/api.json` — community registry, a backstop
|
|
11
|
+
* - `GET ai-gateway.vercel.sh/v1/models` — the upstream's own catalog
|
|
11
12
|
* - `GET openrouter.ai/api/v1/...` — endpoint detail for direct-pipeline models
|
|
12
13
|
*
|
|
13
14
|
* @module dsh-cline-pass/cline
|
|
14
15
|
*/
|
|
16
|
+
|
|
17
|
+
import { REASONING_EFFORTS } from './catalog.js'
|
|
15
18
|
/** The public Cline endpoint that lists subscription models without auth. */
|
|
16
19
|
export const RECOMMENDED_MODELS_URL = 'https://api.cline.bot/api/v1/ai/cline/recommended-models'
|
|
17
20
|
/** The authenticated sub-path that reports quota consumption for the account. */
|
|
@@ -36,6 +39,17 @@ const DAY_MS = 86_400_000
|
|
|
36
39
|
export const MODEL_ROWS_MAX = 8
|
|
37
40
|
/** The community model registry. */
|
|
38
41
|
export const MODELS_DEV_URL = 'https://models.dev/api.json'
|
|
42
|
+
/**
|
|
43
|
+
* The upstream gateway's own model catalog.
|
|
44
|
+
*
|
|
45
|
+
* It is the only source that publishes the reasoning levels behind a Cline Pass
|
|
46
|
+
* model. Cline Pass does not: its `/models` carries ids only, and its gateway
|
|
47
|
+
* accepts every level in the harness vocabulary for every model, so an unsupported
|
|
48
|
+
* one cannot even be probed out — a level outside the vocabulary is the only
|
|
49
|
+
* rejection it makes. The route is named in the gateway's own failures
|
|
50
|
+
* (`… from Vercel`), and that catalog is open.
|
|
51
|
+
*/
|
|
52
|
+
export const VERCEL_MODELS_URL = 'https://ai-gateway.vercel.sh/v1/models'
|
|
39
53
|
/** OpenRouter's public API, used only for direct-pipeline endpoint detail. */
|
|
40
54
|
export const OPENROUTER_API = 'https://openrouter.ai/api/v1'
|
|
41
55
|
|
|
@@ -147,6 +161,38 @@ function modelMetadata(entry) {
|
|
|
147
161
|
return meta
|
|
148
162
|
}
|
|
149
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The reasoning levels one model offers, from the upstream's `reasoning_options`.
|
|
166
|
+
*
|
|
167
|
+
* An `effort` entry names the levels; a `toggle` entry is the model's on/off switch,
|
|
168
|
+
* which in the harness vocabulary is `none`. Returned in escalating order so the
|
|
169
|
+
* picker reads in one direction whatever order the catalog happened to list.
|
|
170
|
+
*
|
|
171
|
+
* Models published with only a toggle, or only a token budget, name no levels at
|
|
172
|
+
* all. Those return undefined rather than an invented list, and the caller keeps the
|
|
173
|
+
* full vocabulary — narrowing a model to a single `none` would take away the switch
|
|
174
|
+
* the catalog just described.
|
|
175
|
+
*
|
|
176
|
+
* @returns the level ids, or undefined when the entry names none.
|
|
177
|
+
*/
|
|
178
|
+
function effortsFromOptions(options) {
|
|
179
|
+
if (!Array.isArray(options)) return undefined
|
|
180
|
+
const levels = new Set()
|
|
181
|
+
let toggle = false
|
|
182
|
+
for (const option of options) {
|
|
183
|
+
if (option?.type === 'toggle') toggle = true
|
|
184
|
+
if (option?.type !== 'effort' || !Array.isArray(option.values)) continue
|
|
185
|
+
for (const value of option.values) if (typeof value === 'string' && value.length > 0) levels.add(value)
|
|
186
|
+
}
|
|
187
|
+
// A toggle on its own names nothing to choose from — it says the switch exists,
|
|
188
|
+
// not what to set it to — so it is not narrowed to a lone `none`, which would
|
|
189
|
+
// offer "off" and take away the switch the catalog just described.
|
|
190
|
+
if (levels.size === 0) return undefined
|
|
191
|
+
if (toggle) levels.add('none')
|
|
192
|
+
const ordered = REASONING_EFFORTS.map((effort) => effort.id).filter((id) => levels.has(id))
|
|
193
|
+
return ordered.length > 0 ? ordered : undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
150
196
|
/**
|
|
151
197
|
* List the gateway's own catalog ids.
|
|
152
198
|
* @returns the model ids; an empty array when the call fails or returns nothing.
|
|
@@ -307,11 +353,56 @@ export async function fetchOfficialModels({ signal, timeoutMs = 30000, fetchImpl
|
|
|
307
353
|
}
|
|
308
354
|
}
|
|
309
355
|
} catch { /* source unavailable */ }
|
|
356
|
+
// The upstream's own catalog, matched by the id's tail: `cline-pass/glm-5.3` and
|
|
357
|
+
// `zai/glm-5.3` are the same model reached through the same gateway. It is also
|
|
358
|
+
// the only source that describes a model published after this release — Cline Pass
|
|
359
|
+
// lists ids, names and descriptions, and says nothing about what a model can do,
|
|
360
|
+
// so without this a new model falls back to the route's conservative window.
|
|
361
|
+
//
|
|
362
|
+
// Its output cap is deliberately NOT taken. It disagrees with the published one
|
|
363
|
+
// (32k where the shipped table says 384k) and it moves: the same model reported a
|
|
364
|
+
// different figure a week later. Adopting it would silently resize every request's
|
|
365
|
+
// completion reserve, which is what decides where compaction fires.
|
|
366
|
+
const upstreamByTail = new Map()
|
|
367
|
+
try {
|
|
368
|
+
const response = await fetchImpl(VERCEL_MODELS_URL, { signal: withTimeout(signal, timeoutMs) })
|
|
369
|
+
if (response.ok) {
|
|
370
|
+
const json = await response.json()
|
|
371
|
+
const rows = Array.isArray(json?.data) ? json.data : Array.isArray(json?.models) ? json.models : []
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
if (typeof row?.id !== 'string') continue
|
|
374
|
+
// The shared extractor already reads the display name and `modalities.input`,
|
|
375
|
+
// and it deliberately misses this catalog's snake_case output cap — which is
|
|
376
|
+
// exactly the field that must not be adopted.
|
|
377
|
+
const meta = modelMetadata(row)
|
|
378
|
+
const contextWindow = row?.context_window ?? row?.contextWindow
|
|
379
|
+
if (Number.isSafeInteger(contextWindow) && contextWindow > 0) meta.contextWindow = contextWindow
|
|
380
|
+
const efforts = effortsFromOptions(row.reasoning_options)
|
|
381
|
+
if (efforts !== undefined) meta.efforts = efforts
|
|
382
|
+
if (Object.keys(meta).length > 0) upstreamByTail.set(row.id.split('/').pop(), meta)
|
|
383
|
+
}
|
|
384
|
+
if (upstreamByTail.size > 0) sources.push('vercel')
|
|
385
|
+
}
|
|
386
|
+
} catch { /* source unavailable */ }
|
|
310
387
|
const models = [...found.keys()].filter((id) => /^cline-pass\/[a-z0-9._-]+$/.test(id))
|
|
311
388
|
const catalog = {}
|
|
312
389
|
for (const id of models) {
|
|
313
|
-
const meta = found.get(id)
|
|
314
|
-
|
|
390
|
+
const meta = found.get(id) ?? {}
|
|
391
|
+
const upstream = upstreamByTail.get(id.slice('cline-pass/'.length))
|
|
392
|
+
if (upstream === undefined) {
|
|
393
|
+
if (Object.keys(meta).length > 0) catalog[id] = meta
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
// Fills what the other sources leave out, so an id list that carries only a name
|
|
397
|
+
// still yields a real window, real levels and real modalities. A source that did
|
|
398
|
+
// describe a field keeps it: this is a gap filler, not an override.
|
|
399
|
+
const entry = { ...upstream, ...meta }
|
|
400
|
+
// The exception is the name, where the id list's "name" is the id itself and
|
|
401
|
+
// says nothing — the upstream publishes the label a person reads.
|
|
402
|
+
if (upstream.name !== undefined && (entry.name === undefined || entry.name === id || entry.name === id.slice('cline-pass/'.length))) {
|
|
403
|
+
entry.name = upstream.name
|
|
404
|
+
}
|
|
405
|
+
catalog[id] = entry
|
|
315
406
|
}
|
|
316
407
|
return { models, catalog, sources }
|
|
317
408
|
}
|
package/lib/compat.js
CHANGED
|
@@ -145,10 +145,37 @@ export function installLiveSection(settings, owner, ns, schema, seed, hooks) {
|
|
|
145
145
|
* @param removals - key paths to delete, e.g. `[['accounts', 'name']]`.
|
|
146
146
|
*/
|
|
147
147
|
export async function updateLiveSection(settings, ns, patch, removals = []) {
|
|
148
|
+
if (removals.length > 0 && typeof settings?.mutate !== 'function') {
|
|
149
|
+
throw new Error('the settings service cannot remove configuration keys')
|
|
150
|
+
}
|
|
148
151
|
if (removals.length > 0 && typeof settings?.mutate === 'function') {
|
|
149
152
|
// Unset first, then merge the rest: the merge cannot resurrect a key the
|
|
150
153
|
// unset already removed, because the patch no longer mentions it.
|
|
151
154
|
await settings.mutate(ns, removals.map((path) => ({ op: 'unset', path: [...path] })))
|
|
152
155
|
}
|
|
153
156
|
if (Object.keys(patch).length > 0) await settings.update(ns, patch)
|
|
154
|
-
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The same recursive patch semantics for hosts without a settings service. */
|
|
160
|
+
export function patchConfig(config, patch, removals = []) {
|
|
161
|
+
const merge = (source, changes) => {
|
|
162
|
+
const next = { ...source }
|
|
163
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
164
|
+
next[key] = value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
165
|
+
? merge(source?.[key], value)
|
|
166
|
+
: value
|
|
167
|
+
}
|
|
168
|
+
return next
|
|
169
|
+
}
|
|
170
|
+
const unset = (source, path) => {
|
|
171
|
+
if (source === null || typeof source !== 'object' || path.length === 0) return source
|
|
172
|
+
const [key, ...rest] = path
|
|
173
|
+
const next = { ...source }
|
|
174
|
+
if (rest.length === 0) delete next[key]
|
|
175
|
+
else if (Object.hasOwn(next, key)) next[key] = unset(next[key], rest)
|
|
176
|
+
return next
|
|
177
|
+
}
|
|
178
|
+
let next = config
|
|
179
|
+
for (const path of removals) next = unset(next, path)
|
|
180
|
+
return merge(next, patch)
|
|
181
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
errorText,
|
|
26
26
|
extractAvailableProviders,
|
|
27
27
|
injectPrefs,
|
|
28
|
+
isAuthenticationError,
|
|
28
29
|
isAdherenceOk,
|
|
29
30
|
mergeUpstreams,
|
|
30
31
|
parseRouting,
|
|
@@ -210,8 +211,8 @@ export function createEngine({
|
|
|
210
211
|
})
|
|
211
212
|
const payload = unwrapEnvelope(result.json)
|
|
212
213
|
const ms = Date.now() - started
|
|
213
|
-
if (payload?.error !== undefined && payload?.choices === undefined) {
|
|
214
|
-
const message = errorText(payload
|
|
214
|
+
if (!result.ok || (payload?.error !== undefined && payload?.choices === undefined) || !Array.isArray(payload?.choices) || payload.choices.length === 0) {
|
|
215
|
+
const message = errorText(payload?.error) || String(payload?.message ?? '') || result.text.slice(0, 400) || (result.ok ? 'gateway returned no completion' : `HTTP ${result.status}`)
|
|
215
216
|
store.learn(model, { ok: false, lastError: message, lastMs: ms })
|
|
216
217
|
return { ok: false, ms, model, error: message }
|
|
217
218
|
}
|
|
@@ -342,15 +343,16 @@ export function createEngine({
|
|
|
342
343
|
const ms = Date.now() - started
|
|
343
344
|
let status = 'unknown'
|
|
344
345
|
let note = ''
|
|
345
|
-
if (payload?.error !== undefined && payload?.choices === undefined) {
|
|
346
|
-
note = errorText(payload
|
|
347
|
-
status = classifyUpstreamError(note)
|
|
346
|
+
if (!result.ok || (payload?.error !== undefined && payload?.choices === undefined)) {
|
|
347
|
+
note = errorText(payload?.error) || result.text || `HTTP ${result.status}`
|
|
348
|
+
status = isAuthenticationError(note, result.status) ? 'auth' : classifyUpstreamError(note)
|
|
348
349
|
} else if (payload?.choices !== undefined) {
|
|
349
350
|
const routing = parseRouting(payload)
|
|
350
351
|
const adherence = pinAdherence(attempt, routing)
|
|
351
|
-
if (
|
|
352
|
+
if (adherence === 'adopted') {
|
|
352
353
|
status = 'ok'
|
|
353
|
-
|
|
354
|
+
} else if (adherence === 'unresolved') {
|
|
355
|
+
note = 'the response did not name the serving channel; the pin could not be verified'
|
|
354
356
|
} else {
|
|
355
357
|
status = 'not-adopted'
|
|
356
358
|
note = `pin not adopted: served by ${routing.finalProvider ?? '(unknown)'}`
|
|
@@ -362,7 +364,7 @@ export function createEngine({
|
|
|
362
364
|
}
|
|
363
365
|
}))
|
|
364
366
|
for (const result of settled) {
|
|
365
|
-
store.learnUpstream(model, result.upstream, result.status, result.note, result.ms)
|
|
367
|
+
store.learnUpstream(model, result.upstream, result.status, result.note, result.ms, { includeUnknown: true })
|
|
366
368
|
results.push(result)
|
|
367
369
|
}
|
|
368
370
|
}
|
|
@@ -463,12 +465,18 @@ export function createEngine({
|
|
|
463
465
|
})
|
|
464
466
|
const payload = unwrapEnvelope(result.json)
|
|
465
467
|
const ms = Date.now() - started
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
468
|
+
const message = errorText(payload?.error) || String(payload?.message ?? '') || (result.ok ? '' : result.text.slice(0, 400)) || (result.ok ? '' : `HTTP ${result.status}`)
|
|
469
|
+
if (isAuthenticationError(message, result.status)) return { ok: false, authorized: false, ms, model, error: message || `HTTP ${result.status}` }
|
|
470
|
+
const hasCompletion = result.ok && Array.isArray(payload?.choices) && payload.choices.length > 0
|
|
471
|
+
// A structured model/quota refusal can establish authorization without a
|
|
472
|
+
// completion. Server failures, HTML and malformed success bodies cannot.
|
|
473
|
+
const modelError = /empty response content|\bmodel(?:id)?\b|context|max[_ -]?tokens|reasoning|no (?:allowed|available) providers/i.test(message)
|
|
474
|
+
const modelRefusal = payload?.error !== undefined && (
|
|
475
|
+
[402, 429].includes(result.status)
|
|
476
|
+
|| ((result.ok || [400, 404, 422].includes(result.status)) && modelError)
|
|
477
|
+
)
|
|
478
|
+
const authorized = hasCompletion || modelRefusal
|
|
479
|
+
return { ok: authorized, authorized, ms, model, error: authorized && hasCompletion ? '' : (message || 'gateway returned no completion; authorization could not be verified') }
|
|
472
480
|
} catch (error) {
|
|
473
481
|
return { ok: false, authorized: false, ms: Date.now() - started, model, error: errorText(error) }
|
|
474
482
|
}
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
29
29
|
} from './adapter.js'
|
|
30
30
|
import { catalogEntry, MODEL_CATALOG, REASONING_EFFORTS } from './catalog.js'
|
|
31
|
-
import { installLiveSection, liveConfigReader, markVolatile, resolveLiveRefs, updateLiveSection } from './compat.js'
|
|
31
|
+
import { installLiveSection, liveConfigReader, markVolatile, patchConfig, resolveLiveRefs, updateLiveSection } from './compat.js'
|
|
32
32
|
import { createEngine } from './engine.js'
|
|
33
33
|
import { createPanel, PANEL_PATH, registerPanel } from './panel.js'
|
|
34
34
|
import { BUILD_TAG } from './protocol.js'
|
|
@@ -294,18 +294,29 @@ export function apply(ctx, config) {
|
|
|
294
294
|
* avoid. Keeping one ordering is also what stops the cooldown rule from being
|
|
295
295
|
* applied in one place and forgotten in the other.
|
|
296
296
|
*/
|
|
297
|
-
const candidateAccounts = (exclude = []) => {
|
|
297
|
+
const candidateAccounts = async (exclude = []) => {
|
|
298
298
|
const skip = new Set(exclude.map(String))
|
|
299
|
-
const
|
|
299
|
+
const failures = []
|
|
300
|
+
const resolved = await Promise.all(accountProfiles().filter(([key, profile]) => profile.enabled && !skip.has(key)).map(async ([key, profile]) => {
|
|
301
|
+
try {
|
|
302
|
+
const value = await resolveKey(profile.apiKeyEnv)
|
|
303
|
+
if (value === undefined) throw new LlmError(`cline-pass: no API key for account "${key}"; store it at ${profile.apiKeyEnv} or configure another account`, 'MISSING_CREDENTIAL')
|
|
304
|
+
return [key, profile, assertUsableApiKey(value, 'cline-pass', profile.apiKeyEnv)]
|
|
305
|
+
} catch (error) {
|
|
306
|
+
failures.push(error)
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
}))
|
|
310
|
+
const enabled = resolved.filter((entry) => entry !== undefined)
|
|
300
311
|
// Accounts that just refused a request are stood down for a while, so a pool
|
|
301
312
|
// does not spend one failed round trip per call rediscovering the same
|
|
302
313
|
// exhausted key. When every candidate is cooling the oldest one is used
|
|
303
314
|
// anyway: a window can reset, and refusing to try at all would be worse.
|
|
304
315
|
const now = Date.now()
|
|
305
316
|
const eligible = enabled.filter(([key]) => !skip.has(key) && (cooling.get(key) ?? 0) <= now)
|
|
306
|
-
if (eligible.length > 0) return eligible
|
|
317
|
+
if (eligible.length > 0) return { ordered: eligible, error: failures[0] }
|
|
307
318
|
const pool = enabled.filter(([key]) => !skip.has(key))
|
|
308
|
-
return [...pool].sort((left, right) => (cooling.get(left[0]) ?? 0) - (cooling.get(right[0]) ?? 0))
|
|
319
|
+
return { ordered: [...pool].sort((left, right) => (cooling.get(left[0]) ?? 0) - (cooling.get(right[0]) ?? 0)), error: failures[0] }
|
|
309
320
|
}
|
|
310
321
|
|
|
311
322
|
/**
|
|
@@ -315,9 +326,9 @@ export function apply(ctx, config) {
|
|
|
315
326
|
* account is in line must not move it either — otherwise showing the user their
|
|
316
327
|
* quota would change which account answers them.
|
|
317
328
|
*/
|
|
318
|
-
const effectiveAccount = () => {
|
|
329
|
+
const effectiveAccount = async () => {
|
|
319
330
|
const cfg = live()
|
|
320
|
-
const ordered = candidateAccounts()
|
|
331
|
+
const { ordered } = await candidateAccounts()
|
|
321
332
|
if (ordered.length === 0) return ''
|
|
322
333
|
if (cfg.accountMode === 'roundrobin' && ordered.length > 1) return ordered[roundRobin % ordered.length][0]
|
|
323
334
|
return (ordered.find(([key]) => key === cfg.activeAccount) ?? ordered[0])[0]
|
|
@@ -330,8 +341,11 @@ export function apply(ctx, config) {
|
|
|
330
341
|
if (enabled.length === 0) {
|
|
331
342
|
throw new LlmError('cline-pass: every configured account is disabled; enable one or add another', 'MISSING_CREDENTIAL')
|
|
332
343
|
}
|
|
333
|
-
const ordered = candidateAccounts(exclude)
|
|
334
|
-
if (ordered.length === 0)
|
|
344
|
+
const { ordered, error } = await candidateAccounts(exclude)
|
|
345
|
+
if (ordered.length === 0) {
|
|
346
|
+
if (exclude.length > 0) return undefined
|
|
347
|
+
throw error ?? new LlmError('cline-pass: no usable account credentials', 'MISSING_CREDENTIAL')
|
|
348
|
+
}
|
|
335
349
|
let picked
|
|
336
350
|
if (cfg.accountMode === 'roundrobin' && ordered.length > 1) {
|
|
337
351
|
picked = ordered[roundRobin % ordered.length]
|
|
@@ -339,15 +353,8 @@ export function apply(ctx, config) {
|
|
|
339
353
|
} else {
|
|
340
354
|
picked = ordered.find(([key]) => key === cfg.activeAccount) ?? ordered[0]
|
|
341
355
|
}
|
|
342
|
-
const [key, profile] = picked
|
|
343
|
-
|
|
344
|
-
if (value === undefined) {
|
|
345
|
-
throw new LlmError(
|
|
346
|
-
`cline-pass: no API key for account "${key}"; store it at ${profile.apiKeyEnv} (cline_pass_accounts action="add" name=${key} key=… writes it, or use the dsh Models page), or export ${profile.apiKeyEnv} in the launching environment`,
|
|
347
|
-
'MISSING_CREDENTIAL',
|
|
348
|
-
)
|
|
349
|
-
}
|
|
350
|
-
return { name: key, key: assertUsableApiKey(value, 'cline-pass', profile.apiKeyEnv), baseURL: profile.baseURL }
|
|
356
|
+
const [key, profile, value] = picked
|
|
357
|
+
return { name: key, key: value, baseURL: profile.baseURL }
|
|
351
358
|
}
|
|
352
359
|
|
|
353
360
|
// ── connection facts ──────────────────────────────────────────────────────
|
|
@@ -524,12 +531,8 @@ export function apply(ctx, config) {
|
|
|
524
531
|
const updateConfig = async (patch, removals = []) => {
|
|
525
532
|
const settings = ctx.get('settings')
|
|
526
533
|
if (settings === undefined) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (path.length === 1) delete next[path[0]]
|
|
530
|
-
else if (path.length === 2 && next[path[0]] !== undefined) delete next[path[0]][path[1]]
|
|
531
|
-
}
|
|
532
|
-
liveConfig = next
|
|
534
|
+
liveConfig = patchConfig(live(), patch, removals)
|
|
535
|
+
current = () => liveConfig
|
|
533
536
|
return
|
|
534
537
|
}
|
|
535
538
|
await updateLiveSection(settings, DEFAULT_SETTINGS_NS, patch, removals)
|
package/lib/panel.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @module dsh-cline-pass/panel
|
|
15
15
|
*/
|
|
16
16
|
import { fetchUsageLimits } from './cline.js'
|
|
17
|
+
import { accountProfileFor } from './accounts.js'
|
|
17
18
|
import { normalizePinMode } from './protocol.js'
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -121,7 +122,8 @@ export function createPanel({ control, engine, store }) {
|
|
|
121
122
|
const accounts = (await control.accountsWithKeys()).map((account) => accountView(account, declaredNames.has(String(account.key))))
|
|
122
123
|
const hidden = new Set((config.hiddenModels ?? []).map(String))
|
|
123
124
|
const models = (config.knownModels ?? []).map((id) => projectModel(id, config.perModel?.[id] ?? {}, store.metaOf(id), hidden.has(String(id))))
|
|
124
|
-
const
|
|
125
|
+
const effectiveAccount = await control.effectiveAccount()
|
|
126
|
+
const usable = effectiveAccount !== '' && accounts.some((account) => account.keyConfigured && account.enabled)
|
|
125
127
|
const pinned = models.filter((model) => model.pinned.length > 0).length
|
|
126
128
|
return {
|
|
127
129
|
provider: control.providerName,
|
|
@@ -134,7 +136,7 @@ export function createPanel({ control, engine, store }) {
|
|
|
134
136
|
// one: `activeAccount` only decides single mode, and round-robin picks from
|
|
135
137
|
// the cursor. Usage is per account, so a reading has to name the account it
|
|
136
138
|
// belongs to or the surface showing it describes someone else's quota.
|
|
137
|
-
effectiveAccount
|
|
139
|
+
effectiveAccount,
|
|
138
140
|
accounts,
|
|
139
141
|
ready: usable,
|
|
140
142
|
models,
|
|
@@ -144,6 +146,7 @@ export function createPanel({ control, engine, store }) {
|
|
|
144
146
|
usage: store.usage(),
|
|
145
147
|
catalogCount: Number(store.catalog()?.ids?.length ?? 0),
|
|
146
148
|
historySize: store.historySize(),
|
|
149
|
+
historyRevision: store.historyRevision(),
|
|
147
150
|
// The stored value, not the running one: the switch states what the user
|
|
148
151
|
// wants, and reporting the running value would snap it back to where it
|
|
149
152
|
// started the moment they clicked it, which reads as a failed click. The
|
|
@@ -253,15 +256,13 @@ export function createPanel({ control, engine, store }) {
|
|
|
253
256
|
const ref = String(payload?.ref ?? '').trim()
|
|
254
257
|
const config = readConfig()
|
|
255
258
|
let key = typed
|
|
256
|
-
|
|
257
|
-
|
|
259
|
+
const account = ref === '' ? undefined : control.accounts().find((entry) => String(entry.apiKeyEnv) === ref)
|
|
260
|
+
const baseURL = String(account?.baseURL ?? '') || String(config.baseURL ?? '')
|
|
261
|
+
const label = String(account?.key ?? (typed.length > 0 ? '(typed key)' : ref))
|
|
258
262
|
if (key.length === 0) {
|
|
259
263
|
if (ref.length === 0) throw new Error('testing a key needs a `value` or the `ref` of a stored account')
|
|
260
|
-
const account = control.accounts().find((entry) => String(entry.apiKeyEnv) === ref)
|
|
261
264
|
key = await control.readCredential(ref)
|
|
262
265
|
if (key === '') throw new Error(`no key is stored at ${ref}`)
|
|
263
|
-
baseURL = String(account?.baseURL ?? '') || baseURL
|
|
264
|
-
label = String(account?.key ?? ref)
|
|
265
266
|
}
|
|
266
267
|
const model = (config.knownModels ?? [])[0] ?? 'cline-pass/glm-5.3-flash'
|
|
267
268
|
const result = await engine.testAccount({ key, baseURL, model })
|
|
@@ -289,14 +290,9 @@ export function createPanel({ control, engine, store }) {
|
|
|
289
290
|
baseURL: '',
|
|
290
291
|
}
|
|
291
292
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
accounts[name] =
|
|
295
|
-
displayName: String(payload?.displayName ?? '').trim() || name,
|
|
296
|
-
apiKeyEnv,
|
|
297
|
-
enabled: payload?.enabled !== false,
|
|
298
|
-
baseURL: '',
|
|
299
|
-
}
|
|
293
|
+
const profile = accountProfileFor(name, payload, accounts, config.apiKeyEnv)
|
|
294
|
+
const apiKeyEnv = profile.apiKeyEnv
|
|
295
|
+
accounts[name] = profile
|
|
300
296
|
await control.updateConfig({ accounts })
|
|
301
297
|
const value = String(payload?.key ?? '').trim()
|
|
302
298
|
if (value.length > 0) await control.setCredential(apiKeyEnv, value)
|
|
@@ -476,6 +472,7 @@ export function createPanel({ control, engine, store }) {
|
|
|
476
472
|
effort: String(entry.effort ?? ''),
|
|
477
473
|
stream: entry.stream === true,
|
|
478
474
|
error: firstLine(entry.error, 200),
|
|
475
|
+
code: String(entry.code ?? ''),
|
|
479
476
|
})),
|
|
480
477
|
}
|
|
481
478
|
}
|