dsh-cline-pass 0.2.7 → 0.2.8
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 +1 -1
- package/README.md +1 -1
- package/lib/accounts.js +31 -0
- package/lib/adapter.js +7 -3
- package/lib/client.js +24 -22
- 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/README.en.md
CHANGED
|
@@ -90,7 +90,7 @@ The provider route and the setup panel are unaffected — the panel is a browser
|
|
|
90
90
|
|
|
91
91
|
## Upstream channels
|
|
92
92
|
|
|
93
|
-
For a new model, click **
|
|
93
|
+
For a new model, expand its row and click **Probe** to discover channels, then **Validate** to check which channels actually honor a pin. Choose a channel order or exclusions as needed. Validation sends one real request per channel and does not change the stored pin; **Test** checks the saved pin rules.
|
|
94
94
|
|
|
95
95
|
You can also run the tools in this order:
|
|
96
96
|
|
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ cline_pass_accounts action=mode mode=roundrobin
|
|
|
90
90
|
|
|
91
91
|
## 上游渠道
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
首次使用模型时,展开面板中的模型行,先点击 **探测** 获取渠道,再点击 **校验** 检查各渠道是否确实采用了钉住规则,最后按需要选择渠道顺序或排除渠道。校验会向每个渠道发出一次真实请求,不会自动修改钉住配置;**测试** 可验证已保存的钉住规则。
|
|
94
94
|
|
|
95
95
|
也可以按以下顺序调用工具:
|
|
96
96
|
|
package/lib/accounts.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Account edits shared by the panel and management tools. */
|
|
2
|
+
|
|
3
|
+
/** Allocate a stable reference without folding distinct account names together. */
|
|
4
|
+
function credentialName(name, accounts) {
|
|
5
|
+
const stem = name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')
|
|
6
|
+
const base = `CLINE_PASS_${stem}_KEY`
|
|
7
|
+
const used = new Set(Object.entries(accounts).filter(([key]) => key !== name).map(([, entry]) => String(entry?.apiKeyEnv ?? '')))
|
|
8
|
+
// Keep the familiar name for simple lowercase ids. Other ids carry their
|
|
9
|
+
// exact bytes, so case and punctuation remain distinct even on concurrent adds.
|
|
10
|
+
const encoded = `${base}__${Buffer.from(name).toString('hex').toUpperCase()}`
|
|
11
|
+
let ref = /^[a-z0-9_]+$/.test(name) && !used.has(base) ? base : encoded
|
|
12
|
+
for (let suffix = 2; used.has(ref); suffix += 1) ref = `${encoded}_${suffix}`
|
|
13
|
+
return ref
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Apply only supplied fields when updating; allocate defaults on creation. */
|
|
17
|
+
export function accountProfileFor(name, changes, accounts, defaultRef = '') {
|
|
18
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '__proto__') {
|
|
19
|
+
throw new Error('the account name may use letters, digits, dot, dash and underscore only (except __proto__)')
|
|
20
|
+
}
|
|
21
|
+
const existing = Object.hasOwn(accounts, name) ? accounts[name] : undefined
|
|
22
|
+
const previous = existing ?? {}
|
|
23
|
+
const suppliedRef = String(changes?.apiKeyEnv ?? '').trim()
|
|
24
|
+
const priorRef = String(previous.apiKeyEnv ?? '').trim() || (existing === undefined ? '' : String(defaultRef))
|
|
25
|
+
return {
|
|
26
|
+
displayName: changes?.displayName === undefined ? (String(previous.displayName ?? '') || name) : (String(changes.displayName).trim() || name),
|
|
27
|
+
apiKeyEnv: suppliedRef || priorRef || credentialName(name, accounts),
|
|
28
|
+
enabled: changes?.enabled === undefined ? previous.enabled !== false : changes.enabled !== false,
|
|
29
|
+
baseURL: changes?.baseURL === undefined ? String(previous.baseURL ?? '') : String(changes.baseURL ?? '').trim(),
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/adapter.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
classifyUpstreamError,
|
|
30
30
|
errorText,
|
|
31
31
|
injectPrefs,
|
|
32
|
+
isAuthenticationError,
|
|
32
33
|
parseRouting,
|
|
33
34
|
pinAdherence,
|
|
34
35
|
pinWarnings,
|
|
@@ -480,7 +481,7 @@ export function streamErrorCode(message, status) {
|
|
|
480
481
|
const windowDetail = detail.replace(/[_-]/g, ' ')
|
|
481
482
|
const spentWindow = /\b(?:weekly|monthly|daily|(?:five|5)\s*hour|plan)\b[\s\S]{0,80}?\b(?:exceed\w*|exhaust\w*|reach\w*|used\s+up|out\s+of|deplet\w*|spent)\b|\b(?:exceed\w*|exhaust\w*|reach\w*|used\s+up|out\s+of|deplet\w*)\b[\s\S]{0,80}?\b(?:weekly|monthly|daily|(?:five|5)\s*hour|plan)\b[\s\S]{0,40}?\b(?:limit|cap|quota|credits|budget|allowance)\b/i.test(windowDetail)
|
|
482
483
|
if (status === 402 || isQuotaExceededError(detail) || spentWindow) return QUOTA_EXCEEDED_CODE
|
|
483
|
-
if (
|
|
484
|
+
if (isAuthenticationError(detail, status)) return 'AUTH'
|
|
484
485
|
if (status === 429 || /429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
|
|
485
486
|
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
|
|
486
487
|
if (status === 400) return 'INVALID_REQUEST'
|
|
@@ -909,11 +910,13 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
909
910
|
* cannot see, because this gateway does not stream it.
|
|
910
911
|
*/
|
|
911
912
|
let usage = null
|
|
913
|
+
let finishFailure = null
|
|
912
914
|
try {
|
|
913
915
|
for (;;) {
|
|
914
916
|
const next = await watchdog.next(iterator)
|
|
915
917
|
if (next.done === true) break
|
|
916
918
|
if (next.value?.type === 'usage') usage = next.value.usage
|
|
919
|
+
if (next.value?.type === 'finish' && next.value.reason?.kind === 'error') finishFailure = next.value.reason.failure
|
|
917
920
|
if (firstChunkMs === 0) firstChunkMs = Date.now() - startedAt
|
|
918
921
|
yielded = true
|
|
919
922
|
yield next.value
|
|
@@ -996,7 +999,8 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
996
999
|
ttfb: firstByteMs,
|
|
997
1000
|
ttft: firstChunkMs,
|
|
998
1001
|
stream: true,
|
|
999
|
-
error: null,
|
|
1002
|
+
error: finishFailure?.message ?? null,
|
|
1003
|
+
code: finishFailure?.code ?? '',
|
|
1000
1004
|
account: account.name,
|
|
1001
1005
|
adherence,
|
|
1002
1006
|
warnings,
|
|
@@ -1009,7 +1013,7 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
1009
1013
|
// Marking it usable no matter who answered is how a false "available"
|
|
1010
1014
|
// reached the verdict map the exclusion allow-list is built from — the
|
|
1011
1015
|
// map then vouched for the very channels it was meant to exclude.
|
|
1012
|
-
if (attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
|
|
1016
|
+
if (finishFailure === null && attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
|
|
1013
1017
|
return
|
|
1014
1018
|
} finally {
|
|
1015
1019
|
// Held until the attempt ends, response body included, so the slot
|
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/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
|
}
|
package/lib/protocol.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* log instead, which turns "did the patched code actually load?" into a
|
|
20
20
|
* question with an answer.
|
|
21
21
|
*/
|
|
22
|
-
export const BUILD_TAG = 'dshfix-
|
|
22
|
+
export const BUILD_TAG = 'dshfix-4'
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The gateway wraps some answers in a `{ data: … }` envelope; unwrap it.
|
|
@@ -45,6 +45,12 @@ export function errorText(value) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Account authentication refusals, including responses with no useful body. */
|
|
49
|
+
export function isAuthenticationError(message, status) {
|
|
50
|
+
const detail = String(message ?? '').replace(/[_-]/g, ' ')
|
|
51
|
+
return status === 401 || status === 403 || /unauthorized|re\s*authenticate|invalid\s*(?:api\s*key|token)|\b401\b|\b403\b/i.test(detail)
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
/**
|
|
49
55
|
* Read the routing facts out of one completed gateway response.
|
|
50
56
|
* @param json - raw decoded response body (enveloped or not).
|
|
@@ -181,11 +187,12 @@ export function injectPrefs(body, meta, attempt) {
|
|
|
181
187
|
// Normalized here as well as in buildAttempts: this function is exported, so
|
|
182
188
|
// a caller can hand it an attempt straight from configuration.
|
|
183
189
|
const sort = normalizeSort(attempt?.sort)
|
|
184
|
-
const excluded = excludeList.filter((name) => name !== upstream)
|
|
190
|
+
const excluded = excludeList.filter((name) => normalizeSlug(name) !== normalizeSlug(upstream ?? ''))
|
|
191
|
+
const excludedIds = new Set(excluded.map(normalizeSlug))
|
|
185
192
|
// Each spelling is written in the vocabulary its own router understands.
|
|
186
193
|
const spell = (names, pipeline) => names.map((name) => channelNameFor(name, pipeline))
|
|
187
|
-
const gatewayAllow = excluded.length > 0 ? knownChannels(meta, 'planner').filter((name) => !
|
|
188
|
-
const openRouterAllow = excluded.length > 0 ? knownChannels(meta, 'direct').filter((name) => !
|
|
194
|
+
const gatewayAllow = excluded.length > 0 ? knownChannels(meta, 'planner').filter((name) => !excludedIds.has(normalizeSlug(name))) : null
|
|
195
|
+
const openRouterAllow = excluded.length > 0 ? knownChannels(meta, 'direct').filter((name) => !excludedIds.has(normalizeSlug(name))) : null
|
|
189
196
|
const pipeline = meta?.pipeline ?? null
|
|
190
197
|
const both = attempt?.spelling !== 'detected'
|
|
191
198
|
const useGateway = both || pipeline === 'planner' || pipeline === null
|
|
@@ -278,13 +285,14 @@ export function isAdherenceOk(adherence) {
|
|
|
278
285
|
*/
|
|
279
286
|
export function pinWarnings(attempt, meta) {
|
|
280
287
|
const upstream = attempt?.upstream ?? null
|
|
281
|
-
const excluded = (attempt?.excludeList ?? []).filter((name) => name !== upstream)
|
|
288
|
+
const excluded = (attempt?.excludeList ?? []).filter((name) => normalizeSlug(name) !== normalizeSlug(upstream ?? ''))
|
|
289
|
+
const excludedIds = new Set(excluded.map(normalizeSlug))
|
|
282
290
|
if (excluded.length === 0) return []
|
|
283
291
|
const known = Array.isArray(meta?.upstreams) ? meta.upstreams : []
|
|
284
292
|
if (known.length === 0) {
|
|
285
293
|
return [`excludeUnresolved: this model has no channel list yet, so ${excluded.join(', ')} could not be compiled into an allow-list — run cline_pass_probe for it`]
|
|
286
294
|
}
|
|
287
|
-
const remaining = known.filter((name) => !
|
|
295
|
+
const remaining = known.filter((name) => !excludedIds.has(normalizeSlug(name)))
|
|
288
296
|
if (remaining.length === 0 && upstream === null) {
|
|
289
297
|
return [`PIN_UNRESOLVED: every known channel is excluded (${known.join(', ')}), so the request falls back to the gateway's own routing`]
|
|
290
298
|
}
|
|
@@ -304,8 +312,8 @@ export function pinWarnings(attempt, meta) {
|
|
|
304
312
|
export function buildAttempts(config) {
|
|
305
313
|
const listed = (config?.upstreams ?? []).filter((name) => typeof name === 'string' && name.length > 0)
|
|
306
314
|
const exclude = (config?.exclude ?? []).filter((name) => typeof name === 'string' && name.length > 0)
|
|
307
|
-
const excluded = new Set(exclude)
|
|
308
|
-
const wanted = listed.filter((name) => !excluded.has(name))
|
|
315
|
+
const excluded = new Set(exclude.map(normalizeSlug))
|
|
316
|
+
const wanted = listed.filter((name) => !excluded.has(normalizeSlug(name)))
|
|
309
317
|
const strict = normalizePinMode(config?.pinMode) === 'strict'
|
|
310
318
|
const sort = normalizeSort(config?.sort)
|
|
311
319
|
const base = { strict, sort, excludeList: exclude }
|
|
@@ -332,7 +340,7 @@ export function classifyUpstreamError(message) {
|
|
|
332
340
|
if (/empty response content/i.test(text)) return 'ok'
|
|
333
341
|
if (/429|rate-?limited|temporarily rate/i.test(text)) return 'limited'
|
|
334
342
|
if (/invalid_request|modelid|no allowed providers|no available providers|not found|unsupported/i.test(text)) return 'bad'
|
|
335
|
-
if (
|
|
343
|
+
if (isAuthenticationError(text)) return 'auth'
|
|
336
344
|
return 'unknown'
|
|
337
345
|
}
|
|
338
346
|
|
package/lib/store.js
CHANGED
|
@@ -26,6 +26,7 @@ export function createStore({ historyLimit = DEFAULT_HISTORY_LIMIT } = {}) {
|
|
|
26
26
|
const models = new Map()
|
|
27
27
|
/** @type {object[]} newest first */
|
|
28
28
|
const history = []
|
|
29
|
+
let historyRevision = 0
|
|
29
30
|
let catalog = { ids: [], sources: [], fetchedAt: 0 }
|
|
30
31
|
/** Last quota reading, one entry per enabled account. */
|
|
31
32
|
let usage = { ok: false, accounts: [], fetchedAt: 0 }
|
|
@@ -55,10 +56,10 @@ export function createStore({ historyLimit = DEFAULT_HISTORY_LIMIT } = {}) {
|
|
|
55
56
|
models.set(model, next)
|
|
56
57
|
return next
|
|
57
58
|
},
|
|
58
|
-
/** Record
|
|
59
|
-
learnUpstream(model, upstream, status, note, ms) {
|
|
59
|
+
/** Record a verdict; explicit validation also replaces stale results with unknown. */
|
|
60
|
+
learnUpstream(model, upstream, status, note, ms, { includeUnknown = false } = {}) {
|
|
60
61
|
if (upstream === undefined || upstream === null || upstream === '') return
|
|
61
|
-
if (status === 'unknown') return
|
|
62
|
+
if (status === 'unknown' && !includeUnknown) return
|
|
62
63
|
const current = models.get(model) ?? {}
|
|
63
64
|
models.set(model, {
|
|
64
65
|
...current,
|
|
@@ -67,6 +68,7 @@ export function createStore({ historyLimit = DEFAULT_HISTORY_LIMIT } = {}) {
|
|
|
67
68
|
},
|
|
68
69
|
/** Append one request-history row (newest first, bounded). */
|
|
69
70
|
record(entry) {
|
|
71
|
+
historyRevision += 1
|
|
70
72
|
history.unshift({ ts: Date.now(), ...entry })
|
|
71
73
|
if (history.length > historyLimit) history.length = historyLimit
|
|
72
74
|
},
|
|
@@ -85,6 +87,10 @@ export function createStore({ historyLimit = DEFAULT_HISTORY_LIMIT } = {}) {
|
|
|
85
87
|
historySize() {
|
|
86
88
|
return history.length
|
|
87
89
|
},
|
|
90
|
+
/** Monotonic change counter, including replacements at the retention cap. */
|
|
91
|
+
historyRevision() {
|
|
92
|
+
return historyRevision
|
|
93
|
+
},
|
|
88
94
|
/** The last cached official-catalog scan. */
|
|
89
95
|
catalog() {
|
|
90
96
|
return catalog
|
package/lib/tools.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module dsh-cline-pass/tools
|
|
8
8
|
*/
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
|
+
import { accountProfileFor } from './accounts.js'
|
|
10
11
|
import { BUILD_TAG, normalizePinMode, pinWarnings } from './protocol.js'
|
|
11
12
|
|
|
12
13
|
/** One model-facing text block. */
|
|
@@ -625,6 +626,7 @@ export function registerTools(ctx, { control, engine, store }) {
|
|
|
625
626
|
apiKeyEnv: { type: 'string', description: 'Credential name holding this account key.' },
|
|
626
627
|
displayName: { type: 'string', description: 'Human-readable label.' },
|
|
627
628
|
enabled: { type: 'boolean', description: 'Whether the account takes part in routing.' },
|
|
629
|
+
baseURL: { type: 'string', description: 'Override the gateway address for this account; empty uses the provider address.' },
|
|
628
630
|
mode: { type: 'string', enum: ['single', 'roundrobin'], description: 'Pool mode.' },
|
|
629
631
|
active: { type: 'string', description: 'Account key used in single mode.' },
|
|
630
632
|
accounts: {
|
|
@@ -638,6 +640,7 @@ export function registerTools(ctx, { control, engine, store }) {
|
|
|
638
640
|
apiKeyEnv: { type: 'string' },
|
|
639
641
|
displayName: { type: 'string' },
|
|
640
642
|
enabled: { type: 'boolean' },
|
|
643
|
+
baseURL: { type: 'string' },
|
|
641
644
|
key: { type: 'string' },
|
|
642
645
|
},
|
|
643
646
|
},
|
|
@@ -667,21 +670,22 @@ export function registerTools(ctx, { control, engine, store }) {
|
|
|
667
670
|
async execute(args, exec) {
|
|
668
671
|
const action = String(args.action)
|
|
669
672
|
let note = ''
|
|
673
|
+
let ok = true
|
|
670
674
|
const nameOf = (value) => String(value ?? '').trim()
|
|
671
675
|
if (action === 'test') {
|
|
672
676
|
let key = String(args.key ?? '').trim()
|
|
673
677
|
let label = args.name === undefined ? '(supplied key)' : nameOf(args.name)
|
|
674
|
-
|
|
678
|
+
const account = args.name === undefined ? undefined : control.accounts().find((entry) => entry.key === nameOf(args.name))
|
|
679
|
+
if (args.name !== undefined && account === undefined) throw new Error(`no account named ${JSON.stringify(nameOf(args.name))}`)
|
|
680
|
+
const baseURL = account?.baseURL || readConfig().baseURL
|
|
675
681
|
if (key === '') {
|
|
676
682
|
if (args.name === undefined) throw new Error('cline_pass_accounts action="test" needs `key` or an existing `name`')
|
|
677
|
-
const account = control.accounts().find((entry) => entry.key === nameOf(args.name))
|
|
678
|
-
if (account === undefined) throw new Error(`no account named ${JSON.stringify(nameOf(args.name))}`)
|
|
679
683
|
key = await control.readCredential(account.apiKeyEnv)
|
|
680
684
|
if (key === '') throw new Error(`account ${JSON.stringify(account.key)} has no stored key at ${account.apiKeyEnv}; store one with action="add" key=… or through the dsh Models page`)
|
|
681
|
-
baseURL = account.baseURL || baseURL
|
|
682
685
|
}
|
|
683
686
|
const model = (readConfig().knownModels ?? [])[0] ?? 'cline-pass/glm-5.3-flash'
|
|
684
687
|
const result = await engine.testAccount({ key, baseURL, model, signal: exec.signal })
|
|
688
|
+
ok = result.authorized === true
|
|
685
689
|
note = result.authorized
|
|
686
690
|
? `key for ${label} authorized in ${result.ms}ms`
|
|
687
691
|
: `key for ${label} FAILED: ${firstLine(result.error, 200)}`
|
|
@@ -693,18 +697,20 @@ export function registerTools(ctx, { control, engine, store }) {
|
|
|
693
697
|
const next = {}
|
|
694
698
|
for (const [index, entry] of args.accounts.entries()) {
|
|
695
699
|
const key = nameOf(entry?.name) || `account-${index + 1}`
|
|
696
|
-
next
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
700
|
+
if (Object.hasOwn(next, key)) throw new Error(`duplicate account name ${JSON.stringify(key)}`)
|
|
701
|
+
next[key] = accountProfileFor(key, entry, { ...accounts, ...next }, config.apiKeyEnv)
|
|
702
|
+
}
|
|
703
|
+
// Validate every entry before any credential or configuration write.
|
|
704
|
+
for (const [index, entry] of args.accounts.entries()) {
|
|
705
|
+
const key = nameOf(entry?.name) || `account-${index + 1}`
|
|
702
706
|
if (typeof entry?.key === 'string' && entry.key.trim() !== '') {
|
|
703
707
|
await control.setCredential(next[key].apiKeyEnv, entry.key.trim())
|
|
704
708
|
note = `stored the key for ${key} in the credential store`
|
|
705
709
|
}
|
|
706
710
|
}
|
|
707
|
-
|
|
711
|
+
const removals = Object.keys(accounts).filter((key) => !Object.hasOwn(next, key)).map((key) => ['accounts', key])
|
|
712
|
+
const activeAccount = Object.hasOwn(next, String(config.activeAccount ?? '')) ? config.activeAccount : ''
|
|
713
|
+
await control.updateConfig({ accounts: next, activeAccount }, removals)
|
|
708
714
|
} else if (action === 'add') {
|
|
709
715
|
const key = nameOf(args.name)
|
|
710
716
|
if (!/^[A-Za-z0-9._-]+$/.test(key)) throw new Error('cline_pass_accounts action="add" needs a `name` of letters, digits, dot, dash or underscore')
|
|
@@ -719,13 +725,9 @@ export function registerTools(ctx, { control, engine, store }) {
|
|
|
719
725
|
baseURL: '',
|
|
720
726
|
}
|
|
721
727
|
}
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
apiKeyEnv,
|
|
726
|
-
enabled: args.enabled !== false,
|
|
727
|
-
baseURL: '',
|
|
728
|
-
}
|
|
728
|
+
const profile = accountProfileFor(key, args, accounts, config.apiKeyEnv)
|
|
729
|
+
const apiKeyEnv = profile.apiKeyEnv
|
|
730
|
+
accounts[key] = profile
|
|
729
731
|
await control.updateConfig({ accounts })
|
|
730
732
|
if (typeof args.key === 'string' && args.key.trim() !== '') {
|
|
731
733
|
await control.setCredential(apiKeyEnv, args.key.trim())
|
|
@@ -762,7 +764,7 @@ export function registerTools(ctx, { control, engine, store }) {
|
|
|
762
764
|
}
|
|
763
765
|
const config = readConfig()
|
|
764
766
|
return {
|
|
765
|
-
ok
|
|
767
|
+
ok,
|
|
766
768
|
action,
|
|
767
769
|
note,
|
|
768
770
|
accountMode: config.accountMode === 'roundrobin' ? 'roundrobin' : 'single',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cline-pass",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Self-contained Cline Pass provider for dsh: register Cline Pass subscription models on the harness LLM seam, enumerate and validate the upstream channels behind each model, pin or exclude them with failover, and drive an account pool. No proxy process and no external cline-pass-switcher install required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"LICENSE"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node test/smoke.mjs && node test/client.mjs && node test/pin-adherence.mjs && node test/account-failover.mjs",
|
|
21
|
+
"test": "node test/smoke.mjs && node test/client.mjs && node test/pin-adherence.mjs && node test/account-failover.mjs && node test/review-regressions.mjs",
|
|
22
22
|
"test:protocol": "node test/pin-adherence.mjs",
|
|
23
23
|
"test:client": "node test/client.mjs",
|
|
24
24
|
"test:mount": "node test/mount.mjs",
|