dsh-cline-pass 0.2.6 → 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 +22 -7
- package/lib/client.js +51 -36
- package/lib/compat.js +28 -1
- package/lib/engine.js +22 -14
- package/lib/index.js +29 -26
- 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,
|
|
@@ -474,9 +475,14 @@ function closeBlock(block) {
|
|
|
474
475
|
/** Map a gateway error payload to a stable harness code. */
|
|
475
476
|
export function streamErrorCode(message, status) {
|
|
476
477
|
const detail = String(message ?? '')
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
478
|
+
// The harness vocabulary covers balances, but Cline also refuses spent
|
|
479
|
+
// subscription windows. Require exhaustion, not just a window name, so a
|
|
480
|
+
// temporary error fetching weekly usage does not stand an account down.
|
|
481
|
+
const windowDetail = detail.replace(/[_-]/g, ' ')
|
|
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)
|
|
483
|
+
if (status === 402 || isQuotaExceededError(detail) || spentWindow) return QUOTA_EXCEEDED_CODE
|
|
484
|
+
if (isAuthenticationError(detail, status)) return 'AUTH'
|
|
485
|
+
if (status === 429 || /429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
|
|
480
486
|
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
|
|
481
487
|
if (status === 400) return 'INVALID_REQUEST'
|
|
482
488
|
if (status !== undefined && status >= 500) return 'SERVER'
|
|
@@ -809,7 +815,9 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
809
815
|
* nothing is left, which is when the real error is raised below.
|
|
810
816
|
*/
|
|
811
817
|
const refused = new Set()
|
|
812
|
-
|
|
818
|
+
let attemptIndex = 0
|
|
819
|
+
while (attemptIndex < attempts.length) {
|
|
820
|
+
const attempt = attempts[attemptIndex++]
|
|
813
821
|
const account = await this.config.resolveAccount({ exclude: [...refused] })
|
|
814
822
|
if (account === undefined) break
|
|
815
823
|
const body = injectPrefs(buildRequestBody(options, connection, images), meta, attempt)
|
|
@@ -865,12 +873,15 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
865
873
|
// nothing left and this error is raised unchanged.
|
|
866
874
|
if (lastError.code === 'AUTH' || lastError.code === QUOTA_EXCEEDED_CODE) {
|
|
867
875
|
refused.add(account.name)
|
|
876
|
+
// A spent account says nothing about this channel. Give the next
|
|
877
|
+
// account the full chain, including a lone auto route or strict pin.
|
|
878
|
+
attemptIndex = 0
|
|
868
879
|
this.config.record(options.model, {
|
|
869
880
|
provider: null,
|
|
870
881
|
attempts: trace.map((row) => row.upstream),
|
|
871
882
|
ms: Date.now() - startedAt,
|
|
872
883
|
stream: true,
|
|
873
|
-
error:
|
|
884
|
+
error: lastError.message.replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
874
885
|
code: lastError.code,
|
|
875
886
|
account: account.name,
|
|
876
887
|
})
|
|
@@ -899,11 +910,13 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
899
910
|
* cannot see, because this gateway does not stream it.
|
|
900
911
|
*/
|
|
901
912
|
let usage = null
|
|
913
|
+
let finishFailure = null
|
|
902
914
|
try {
|
|
903
915
|
for (;;) {
|
|
904
916
|
const next = await watchdog.next(iterator)
|
|
905
917
|
if (next.done === true) break
|
|
906
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
|
|
907
920
|
if (firstChunkMs === 0) firstChunkMs = Date.now() - startedAt
|
|
908
921
|
yielded = true
|
|
909
922
|
yield next.value
|
|
@@ -939,6 +952,7 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
939
952
|
}
|
|
940
953
|
if (code === 'AUTH' || code === QUOTA_EXCEEDED_CODE) {
|
|
941
954
|
refused.add(account.name)
|
|
955
|
+
attemptIndex = 0
|
|
942
956
|
this.config.record(options.model, {
|
|
943
957
|
provider: observed.provider,
|
|
944
958
|
canonical: observed.canonical,
|
|
@@ -985,7 +999,8 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
985
999
|
ttfb: firstByteMs,
|
|
986
1000
|
ttft: firstChunkMs,
|
|
987
1001
|
stream: true,
|
|
988
|
-
error: null,
|
|
1002
|
+
error: finishFailure?.message ?? null,
|
|
1003
|
+
code: finishFailure?.code ?? '',
|
|
989
1004
|
account: account.name,
|
|
990
1005
|
adherence,
|
|
991
1006
|
warnings,
|
|
@@ -998,7 +1013,7 @@ export class ClinePassAdapter extends LlmAdapter {
|
|
|
998
1013
|
// Marking it usable no matter who answered is how a false "available"
|
|
999
1014
|
// reached the verdict map the exclusion allow-list is built from — the
|
|
1000
1015
|
// map then vouched for the very channels it was meant to exclude.
|
|
1001
|
-
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)
|
|
1002
1017
|
return
|
|
1003
1018
|
} finally {
|
|
1004
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.
|
|
@@ -1544,6 +1540,19 @@ window.__ModuleLoader__.load({
|
|
|
1544
1540
|
])
|
|
1545
1541
|
}
|
|
1546
1542
|
|
|
1543
|
+
/** Select cached quota without substituting another account's reading. */
|
|
1544
|
+
function usageIndex(accounts, effectiveAccount, picked) {
|
|
1545
|
+
if (accounts.length === 0) return -1
|
|
1546
|
+
if (picked !== null) return Math.min(Math.max(picked, 0), accounts.length - 1)
|
|
1547
|
+
// Undefined is an older host; an explicit empty string means no account.
|
|
1548
|
+
if (effectiveAccount !== undefined) {
|
|
1549
|
+
if (effectiveAccount === '') return -1
|
|
1550
|
+
return accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount))
|
|
1551
|
+
}
|
|
1552
|
+
const usable = accounts.findIndex((entry) => entry.ok === true)
|
|
1553
|
+
return Math.max(0, usable)
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1547
1556
|
/**
|
|
1548
1557
|
* The official quota card.
|
|
1549
1558
|
*
|
|
@@ -1567,11 +1576,10 @@ window.__ModuleLoader__.load({
|
|
|
1567
1576
|
// that may not be about to be spent. Once they page, their choice wins: the
|
|
1568
1577
|
// cursor moving underneath must not yank the card away mid-review.
|
|
1569
1578
|
const [picked, setPicked] = React.useState(null)
|
|
1570
|
-
const followed = accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount ?? ''))
|
|
1571
|
-
const at = picked === null ? Math.max(0, followed) : picked
|
|
1572
1579
|
// The selection is an index, so a shorter list — an account removed, or its
|
|
1573
1580
|
// key deleted — can leave it past the end. Clamp instead of rendering nothing.
|
|
1574
|
-
const index = accounts
|
|
1581
|
+
const index = usageIndex(accounts, effectiveAccount, picked)
|
|
1582
|
+
const account = accounts[index]
|
|
1575
1583
|
const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
|
|
1576
1584
|
const windowRow = (type, key, limit) => {
|
|
1577
1585
|
const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
|
|
@@ -1642,13 +1650,17 @@ window.__ModuleLoader__.load({
|
|
|
1642
1650
|
}
|
|
1643
1651
|
let body
|
|
1644
1652
|
if (!fetched) body = h('div', { key: 'wait', className: 'cp-muted' }, unavailable === true ? t('usageUnavailable') : t('usageLoading'))
|
|
1653
|
+
else if (account === undefined && effectiveAccount !== undefined) {
|
|
1654
|
+
body = h('div', { key: 'wait', className: 'cp-muted' }, effectiveAccount === '' || unavailable === true ? t('usageUnavailable') : t('usageLoading'))
|
|
1655
|
+
}
|
|
1645
1656
|
else if (accounts.length === 0) body = h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
|
|
1646
1657
|
// `index` is the account on screen; its own key remounts the block so the
|
|
1647
1658
|
// windows never animate between two accounts' readings.
|
|
1648
|
-
else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accountGroup(
|
|
1659
|
+
else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accountGroup(account, index))
|
|
1660
|
+
const name = account?.account ?? effectiveAccount
|
|
1649
1661
|
const title = accounts.length > 1
|
|
1650
1662
|
? `${t('usageTitle')} · ${fill(t('accountsCount'), { n: accounts.length })}`
|
|
1651
|
-
: `${t('usageTitle')}${
|
|
1663
|
+
: `${t('usageTitle')}${name ? ` · ${fill(t('usageAccount'), { name })}` : ''}`
|
|
1652
1664
|
return h('div', { className: 'cp-card' }, [
|
|
1653
1665
|
h('div', { key: 'title', className: 'cp-card-title' }, [
|
|
1654
1666
|
h('span', { key: 'l' }, title),
|
|
@@ -1695,9 +1707,8 @@ window.__ModuleLoader__.load({
|
|
|
1695
1707
|
void props.loadUsage().then((result) => setReadFailed(result === undefined))
|
|
1696
1708
|
}, [state.data])
|
|
1697
1709
|
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
// 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.
|
|
1701
1712
|
React.useEffect(() => {
|
|
1702
1713
|
if (state.data === null) return
|
|
1703
1714
|
let cancelled = false
|
|
@@ -1706,7 +1717,7 @@ window.__ModuleLoader__.load({
|
|
|
1706
1717
|
setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
|
|
1707
1718
|
})
|
|
1708
1719
|
return () => { cancelled = true }
|
|
1709
|
-
}, [state.data?.historySize])
|
|
1720
|
+
}, [state.data?.historyRevision ?? state.data?.historySize])
|
|
1710
1721
|
|
|
1711
1722
|
|
|
1712
1723
|
|
|
@@ -1869,7 +1880,7 @@ window.__ModuleLoader__.load({
|
|
|
1869
1880
|
h('span', { key: 'n', className: 'cp-grow' }, data?.ready === true ? t('keyOk') : t('keyMissing')),
|
|
1870
1881
|
]),
|
|
1871
1882
|
account === undefined ? null : h(KeyField, {
|
|
1872
|
-
key:
|
|
1883
|
+
key: `key:${account.key}:${account.apiKeyEnv}`,
|
|
1873
1884
|
t,
|
|
1874
1885
|
account,
|
|
1875
1886
|
disabled: state.busy !== null || data?.settingsAvailable === false,
|
|
@@ -1987,8 +1998,8 @@ window.__ModuleLoader__.load({
|
|
|
1987
1998
|
// Quota is per account, so showing a healthy reading from an account the
|
|
1988
1999
|
// next request will not touch is worse than showing nothing: it answers a
|
|
1989
2000
|
// question about a key that is not about to be spent. The host names the
|
|
1990
|
-
// effective account because only it knows the cursor
|
|
1991
|
-
//
|
|
2001
|
+
// effective account because only it knows the cursor. A reading missing
|
|
2002
|
+
// that account must not borrow another key's quota.
|
|
1992
2003
|
//
|
|
1993
2004
|
// The account is read from the panel's snapshot, not from this reading: a
|
|
1994
2005
|
// switch lands there at once, while a reading only arrives on a timer. The
|
|
@@ -2000,14 +2011,11 @@ window.__ModuleLoader__.load({
|
|
|
2000
2011
|
// once the user pages, their choice holds for as long as it is open.
|
|
2001
2012
|
const effectiveAccount = panel?.data?.effectiveAccount !== undefined
|
|
2002
2013
|
? panel.data.effectiveAccount
|
|
2003
|
-
:
|
|
2004
|
-
const
|
|
2005
|
-
const usable = accounts.findIndex((entry) => entry.ok === true)
|
|
2006
|
-
const at = picked === null ? (followed >= 0 ? followed : Math.max(0, usable)) : picked
|
|
2007
|
-
const index = accounts.length === 0 ? 0 : Math.min(Math.max(at, 0), accounts.length - 1)
|
|
2014
|
+
: snapshot?.value?.effectiveAccount
|
|
2015
|
+
const index = usageIndex(accounts, effectiveAccount, picked)
|
|
2008
2016
|
const account = accounts[index]
|
|
2009
2017
|
const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
|
|
2010
|
-
const limits = account?.limits ?? []
|
|
2018
|
+
const limits = account?.ok === true ? account.limits ?? [] : []
|
|
2011
2019
|
const byType = new Map(limits.map((limit) => [String(limit.type), limit]))
|
|
2012
2020
|
const percentOf = (type) => {
|
|
2013
2021
|
const limit = byType.get(type)
|
|
@@ -2042,7 +2050,7 @@ window.__ModuleLoader__.load({
|
|
|
2042
2050
|
// it: the quota is per account, so the arrows move between them rather
|
|
2043
2051
|
// than stacking every window at once. Absent with a single account,
|
|
2044
2052
|
// where there is nowhere to go.
|
|
2045
|
-
accounts.length <= 1 ? null : h('span', { key: 'nav', className: 'cp-pill-nav' }, [
|
|
2053
|
+
accounts.length <= 1 || account === undefined ? null : h('span', { key: 'nav', className: 'cp-pill-nav' }, [
|
|
2046
2054
|
h('button', {
|
|
2047
2055
|
key: 'prev', type: 'button', className: 'cp-pill-arrow',
|
|
2048
2056
|
title: t('usagePrev'), 'aria-label': t('usagePrev'), onClick: () => step(-1),
|
|
@@ -2092,6 +2100,7 @@ window.__ModuleLoader__.load({
|
|
|
2092
2100
|
/** A local timestamp, matching the panel's own reading of "updated at". */
|
|
2093
2101
|
function stamp(ms) {
|
|
2094
2102
|
const at = new Date(Number(ms ?? 0))
|
|
2103
|
+
if (!Number.isFinite(at.getTime())) return '-'
|
|
2095
2104
|
const pad = (n) => String(n).padStart(2, '0')
|
|
2096
2105
|
return `${at.getFullYear()}/${at.getMonth() + 1}/${at.getDate()} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
|
|
2097
2106
|
}
|
|
@@ -2168,12 +2177,12 @@ window.__ModuleLoader__.load({
|
|
|
2168
2177
|
// 3. The quota reading beside the model selector. It mounts for every
|
|
2169
2178
|
// session but renders nothing unless the selected model is this
|
|
2170
2179
|
// provider's, so no other model pays for a usage request.
|
|
2171
|
-
|
|
2180
|
+
const registerUsage = (usageCtx) => usageCtx.slots.inject('conversation.input.right', () => usageCtx.slots.register({
|
|
2172
2181
|
name: 'conversation.input.right',
|
|
2173
2182
|
id: `${NS}-usage`,
|
|
2174
2183
|
order: 100,
|
|
2175
2184
|
inject: (sessionId) => ({
|
|
2176
|
-
directory:
|
|
2185
|
+
directory: usageCtx.modelDirectories.directoryFor(sessionId).store,
|
|
2177
2186
|
// The route name is configuration, so it is only known once a state
|
|
2178
2187
|
// read has landed. Read it at render time rather than capturing it:
|
|
2179
2188
|
// the pill can mount before the first read completes.
|
|
@@ -2194,6 +2203,12 @@ window.__ModuleLoader__.load({
|
|
|
2194
2203
|
}),
|
|
2195
2204
|
}, UsagePill))
|
|
2196
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
|
+
|
|
2197
2212
|
// The first read is lazy (each component asks for it), because the RPC
|
|
2198
2213
|
// transport only exists once Connection has activated.
|
|
2199
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
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import z from '@deepseek-ai/schemastery'
|
|
19
19
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
20
|
-
import { assertUsableApiKey, errorChain, LlmError } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
import { assertUsableApiKey, errorChain, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
|
21
21
|
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
|
22
22
|
import {
|
|
23
23
|
ClinePassAdapter,
|
|
@@ -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'
|
|
@@ -242,7 +242,7 @@ export function apply(ctx, config) {
|
|
|
242
242
|
* by the channel retry already, and standing an account down for it would cost
|
|
243
243
|
* capacity the request never proved was gone.
|
|
244
244
|
*/
|
|
245
|
-
const COOLDOWN_MS = {
|
|
245
|
+
const COOLDOWN_MS = { [QUOTA_EXCEEDED_CODE]: 15 * 60_000, AUTH: 30 * 60_000 }
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
248
|
* Resolve the configured accounts.
|
|
@@ -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",
|
|
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",
|