dsh-cline-pass 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md 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,
@@ -480,7 +481,7 @@ export function streamErrorCode(message, status) {
480
481
  const windowDetail = detail.replace(/[_-]/g, ' ')
481
482
  const spentWindow = /\b(?:weekly|monthly|daily|(?:five|5)\s*hour|plan)\b[\s\S]{0,80}?\b(?:exceed\w*|exhaust\w*|reach\w*|used\s+up|out\s+of|deplet\w*|spent)\b|\b(?:exceed\w*|exhaust\w*|reach\w*|used\s+up|out\s+of|deplet\w*)\b[\s\S]{0,80}?\b(?:weekly|monthly|daily|(?:five|5)\s*hour|plan)\b[\s\S]{0,40}?\b(?:limit|cap|quota|credits|budget|allowance)\b/i.test(windowDetail)
482
483
  if (status === 402 || isQuotaExceededError(detail) || spentWindow) return QUOTA_EXCEEDED_CODE
483
- if (/unauthorized|re-authenticate|invalid api key|401|403/i.test(detail)) return 'AUTH'
484
+ if (isAuthenticationError(detail, status)) return 'AUTH'
484
485
  if (status === 429 || /429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
485
486
  if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
486
487
  if (status === 400) return 'INVALID_REQUEST'
@@ -909,11 +910,13 @@ export class ClinePassAdapter extends LlmAdapter {
909
910
  * cannot see, because this gateway does not stream it.
910
911
  */
911
912
  let usage = null
913
+ let finishFailure = null
912
914
  try {
913
915
  for (;;) {
914
916
  const next = await watchdog.next(iterator)
915
917
  if (next.done === true) break
916
918
  if (next.value?.type === 'usage') usage = next.value.usage
919
+ if (next.value?.type === 'finish' && next.value.reason?.kind === 'error') finishFailure = next.value.reason.failure
917
920
  if (firstChunkMs === 0) firstChunkMs = Date.now() - startedAt
918
921
  yielded = true
919
922
  yield next.value
@@ -996,7 +999,8 @@ export class ClinePassAdapter extends LlmAdapter {
996
999
  ttfb: firstByteMs,
997
1000
  ttft: firstChunkMs,
998
1001
  stream: true,
999
- error: null,
1002
+ error: finishFailure?.message ?? null,
1003
+ code: finishFailure?.code ?? '',
1000
1004
  account: account.name,
1001
1005
  adherence,
1002
1006
  warnings,
@@ -1009,7 +1013,7 @@ export class ClinePassAdapter extends LlmAdapter {
1009
1013
  // Marking it usable no matter who answered is how a false "available"
1010
1014
  // reached the verdict map the exclusion allow-list is built from — the
1011
1015
  // map then vouched for the very channels it was meant to exclude.
1012
- if (attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
1016
+ if (finishFailure === null && attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
1013
1017
  return
1014
1018
  } finally {
1015
1019
  // Held until the attempt ends, response body included, so the slot
package/lib/client.js CHANGED
@@ -505,7 +505,7 @@ window.__ModuleLoader__.load({
505
505
  * in front of it once an entry is no longer from today — a long-lived host
506
506
  * process would otherwise show times that look like they were all this hour.
507
507
  */
508
- function 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.
@@ -1711,9 +1707,8 @@ window.__ModuleLoader__.load({
1711
1707
  void props.loadUsage().then((result) => setReadFailed(result === undefined))
1712
1708
  }, [state.data])
1713
1709
 
1714
- // The history section follows the host's retained count: every panel read
1715
- // carries it, so the rows are re-read whenever new requests landed instead
1716
- // of going stale the moment the first read completed.
1710
+ // Revisions keep advancing when retained rows have reached the cap.
1711
+ // Older hosts expose only the retained count.
1717
1712
  React.useEffect(() => {
1718
1713
  if (state.data === null) return
1719
1714
  let cancelled = false
@@ -1722,7 +1717,7 @@ window.__ModuleLoader__.load({
1722
1717
  setHistory({ entries: result.entries ?? [], total: Number(result.total ?? 0) })
1723
1718
  })
1724
1719
  return () => { cancelled = true }
1725
- }, [state.data?.historySize])
1720
+ }, [state.data?.historyRevision ?? state.data?.historySize])
1726
1721
 
1727
1722
 
1728
1723
 
@@ -1885,7 +1880,7 @@ window.__ModuleLoader__.load({
1885
1880
  h('span', { key: 'n', className: 'cp-grow' }, data?.ready === true ? t('keyOk') : t('keyMissing')),
1886
1881
  ]),
1887
1882
  account === undefined ? null : h(KeyField, {
1888
- key: 'key',
1883
+ key: `key:${account.key}:${account.apiKeyEnv}`,
1889
1884
  t,
1890
1885
  account,
1891
1886
  disabled: state.busy !== null || data?.settingsAvailable === false,
@@ -2105,6 +2100,7 @@ window.__ModuleLoader__.load({
2105
2100
  /** A local timestamp, matching the panel's own reading of "updated at". */
2106
2101
  function stamp(ms) {
2107
2102
  const at = new Date(Number(ms ?? 0))
2103
+ if (!Number.isFinite(at.getTime())) return '-'
2108
2104
  const pad = (n) => String(n).padStart(2, '0')
2109
2105
  return `${at.getFullYear()}/${at.getMonth() + 1}/${at.getDate()} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
2110
2106
  }
@@ -2181,12 +2177,12 @@ window.__ModuleLoader__.load({
2181
2177
  // 3. The quota reading beside the model selector. It mounts for every
2182
2178
  // session but renders nothing unless the selected model is this
2183
2179
  // provider's, so no other model pays for a usage request.
2184
- ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
2180
+ const registerUsage = (usageCtx) => usageCtx.slots.inject('conversation.input.right', () => usageCtx.slots.register({
2185
2181
  name: 'conversation.input.right',
2186
2182
  id: `${NS}-usage`,
2187
2183
  order: 100,
2188
2184
  inject: (sessionId) => ({
2189
- directory: ctx.modelDirectories.directoryFor(sessionId).store,
2185
+ directory: usageCtx.modelDirectories.directoryFor(sessionId).store,
2190
2186
  // The route name is configuration, so it is only known once a state
2191
2187
  // read has landed. Read it at render time rather than capturing it:
2192
2188
  // the pill can mount before the first read completes.
@@ -2207,6 +2203,12 @@ window.__ModuleLoader__.load({
2207
2203
  }),
2208
2204
  }, UsagePill))
2209
2205
 
2206
+ // Newer Cordis service methods execute in the consumer's context. The
2207
+ // directory resolver reads remote.session, so give this slot its explicit
2208
+ // dependency without requiring that newer service on legacy hosts.
2209
+ if (ctx.get('remote.session') !== undefined) ctx.inject(['remote.session'], registerUsage)
2210
+ else registerUsage(ctx)
2211
+
2210
2212
  // The first read is lazy (each component asks for it), because the RPC
2211
2213
  // transport only exists once Connection has activated.
2212
2214
  void controller.refresh()
package/lib/compat.js CHANGED
@@ -145,10 +145,37 @@ export function installLiveSection(settings, owner, ns, schema, seed, hooks) {
145
145
  * @param removals - key paths to delete, e.g. `[['accounts', 'name']]`.
146
146
  */
147
147
  export async function updateLiveSection(settings, ns, patch, removals = []) {
148
+ if (removals.length > 0 && typeof settings?.mutate !== 'function') {
149
+ throw new Error('the settings service cannot remove configuration keys')
150
+ }
148
151
  if (removals.length > 0 && typeof settings?.mutate === 'function') {
149
152
  // Unset first, then merge the rest: the merge cannot resurrect a key the
150
153
  // unset already removed, because the patch no longer mentions it.
151
154
  await settings.mutate(ns, removals.map((path) => ({ op: 'unset', path: [...path] })))
152
155
  }
153
156
  if (Object.keys(patch).length > 0) await settings.update(ns, patch)
154
- }
157
+ }
158
+
159
+ /** The same recursive patch semantics for hosts without a settings service. */
160
+ export function patchConfig(config, patch, removals = []) {
161
+ const merge = (source, changes) => {
162
+ const next = { ...source }
163
+ for (const [key, value] of Object.entries(changes)) {
164
+ next[key] = value !== null && typeof value === 'object' && !Array.isArray(value)
165
+ ? merge(source?.[key], value)
166
+ : value
167
+ }
168
+ return next
169
+ }
170
+ const unset = (source, path) => {
171
+ if (source === null || typeof source !== 'object' || path.length === 0) return source
172
+ const [key, ...rest] = path
173
+ const next = { ...source }
174
+ if (rest.length === 0) delete next[key]
175
+ else if (Object.hasOwn(next, key)) next[key] = unset(next[key], rest)
176
+ return next
177
+ }
178
+ let next = config
179
+ for (const path of removals) next = unset(next, path)
180
+ return merge(next, patch)
181
+ }
package/lib/engine.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  errorText,
26
26
  extractAvailableProviders,
27
27
  injectPrefs,
28
+ isAuthenticationError,
28
29
  isAdherenceOk,
29
30
  mergeUpstreams,
30
31
  parseRouting,
@@ -210,8 +211,8 @@ export function createEngine({
210
211
  })
211
212
  const payload = unwrapEnvelope(result.json)
212
213
  const ms = Date.now() - started
213
- if (payload?.error !== undefined && payload?.choices === undefined) {
214
- const message = errorText(payload.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
@@ -28,7 +28,7 @@ import {
28
28
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
29
29
  } from './adapter.js'
30
30
  import { catalogEntry, MODEL_CATALOG, REASONING_EFFORTS } from './catalog.js'
31
- import { installLiveSection, liveConfigReader, markVolatile, resolveLiveRefs, updateLiveSection } from './compat.js'
31
+ import { installLiveSection, liveConfigReader, markVolatile, patchConfig, resolveLiveRefs, updateLiveSection } from './compat.js'
32
32
  import { createEngine } from './engine.js'
33
33
  import { createPanel, PANEL_PATH, registerPanel } from './panel.js'
34
34
  import { BUILD_TAG } from './protocol.js'
@@ -294,18 +294,29 @@ export function apply(ctx, config) {
294
294
  * avoid. Keeping one ordering is also what stops the cooldown rule from being
295
295
  * applied in one place and forgotten in the other.
296
296
  */
297
- const candidateAccounts = (exclude = []) => {
297
+ const candidateAccounts = async (exclude = []) => {
298
298
  const skip = new Set(exclude.map(String))
299
- const 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.7",
3
+ "version": "0.2.8",
4
4
  "description": "Self-contained Cline Pass provider for dsh: register Cline Pass subscription models on the harness LLM seam, enumerate and validate the upstream channels behind each model, pin or exclude them with failover, and drive an account pool. No proxy process and no external cline-pass-switcher install required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,7 +18,7 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "scripts": {
21
- "test": "node test/smoke.mjs && node test/client.mjs && node test/pin-adherence.mjs && node test/account-failover.mjs",
21
+ "test": "node test/smoke.mjs && node test/client.mjs && node test/pin-adherence.mjs && node test/account-failover.mjs && node test/review-regressions.mjs",
22
22
  "test:protocol": "node test/pin-adherence.mjs",
23
23
  "test:client": "node test/client.mjs",
24
24
  "test:mount": "node test/mount.mjs",