dsh-cline-pass 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.9",
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",