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 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 **Auto-configure** in the panel to probe, validate, and configure channels. Expand a model row to adjust channel order or exclude a channel.
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
 
@@ -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
- if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
478
- if (/unauthorized|re-authenticate|invalid api key|401|403/i.test(detail)) return 'AUTH'
479
- if (/429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
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
- for (const attempt of attempts) {
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: firstLine(lastError.message, 200),
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 stamp(value) {
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
- * The expander holds the three steps that are not the one-click path:
1167
- * discovering channels (probe), verifying what actually serves the stored pin
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
- * One table: the key field belongs to the account a request would actually
1279
- * use, instead of to whichever account happened to be first, and the pool
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
- // The key field follows the account requests will actually use.
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: 'field',
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' }, stamp(entry.ts)),
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.length === 0 ? 0 : Math.min(Math.max(at, 0), accounts.length - 1)
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(accounts[index], index))
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')}${accounts[0] ? ` · ${fill(t('usageAccount'), { name: accounts[0].account })}` : ''}`
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
- // The history section follows the host's retained count: every panel read
1699
- // carries it, so the rows are re-read whenever new requests landed instead
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: '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; the fallbacks cover
1991
- // a reading taken before that account existed.
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
- : (snapshot?.value?.effectiveAccount ?? '')
2004
- const followed = accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount))
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
- ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
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: ctx.modelDirectories.directoryFor(sessionId).store,
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.error)
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.error)
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 (isAdherenceOk(adherence)) {
352
+ if (adherence === 'adopted') {
352
353
  status = 'ok'
353
- if (adherence === 'fallback') note = `answered by ${routing.finalProvider ?? 'another channel'}`
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
- if (payload?.error !== undefined && payload?.choices === undefined) {
467
- const message = errorText(payload.error)
468
- const authFailed = /unauthorized|re-authenticate|invalid\s*api|401/i.test(message)
469
- return { ok: !authFailed, authorized: !authFailed, ms, model, error: message }
470
- }
471
- return { ok: true, authorized: true, ms, model, error: '' }
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 = { QUOTA_EXCEEDED: 15 * 60_000, AUTH: 30 * 60_000 }
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 enabled = accountProfiles().filter(([, profile]) => profile.enabled)
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) return undefined
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
- const value = await resolveKey(profile.apiKeyEnv)
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
- const next = { ...liveConfig, ...patch }
528
- for (const path of removals) {
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 usable = accounts.some((account) => account.keyConfigured && account.enabled)
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: control.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
- let baseURL = String(config.baseURL ?? '')
257
- let label = typed.length > 0 ? '(typed key)' : ref
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 apiKeyEnv = String(payload?.apiKeyEnv ?? '').trim()
293
- || `CLINE_PASS_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_KEY`
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-3'
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) => !excluded.includes(name)) : null
188
- const openRouterAllow = excluded.length > 0 ? knownChannels(meta, 'direct').filter((name) => !excluded.includes(name)) : null
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) => !excluded.includes(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 (/unauthorized|re-authenticate|401/i.test(text)) return 'auth'
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 one upstream's availability verdict; an inconclusive one is dropped. */
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
- let baseURL = readConfig().baseURL
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[key] = {
697
- displayName: String(entry?.displayName ?? key),
698
- apiKeyEnv: String(entry?.apiKeyEnv ?? `CLINE_PASS_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_KEY`),
699
- enabled: entry?.enabled !== false,
700
- baseURL: '',
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
- await control.updateConfig({ accounts: next })
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 apiKeyEnv = String(args.apiKeyEnv ?? `CLINE_PASS_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_KEY`)
723
- accounts[key] = {
724
- displayName: String(args.displayName ?? key),
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: true,
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.6",
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",