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/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/cline.js CHANGED
@@ -8,10 +8,13 @@
8
8
  * - `GET {baseURL}/users/{id}/usages` — the per-request usage history behind them
9
9
  * - `GET api.cline.bot/.../recommended-models` — official subscription list
10
10
  * - `GET models.dev/api.json` — community registry, a backstop
11
+ * - `GET ai-gateway.vercel.sh/v1/models` — the upstream's own catalog
11
12
  * - `GET openrouter.ai/api/v1/...` — endpoint detail for direct-pipeline models
12
13
  *
13
14
  * @module dsh-cline-pass/cline
14
15
  */
16
+
17
+ import { REASONING_EFFORTS } from './catalog.js'
15
18
  /** The public Cline endpoint that lists subscription models without auth. */
16
19
  export const RECOMMENDED_MODELS_URL = 'https://api.cline.bot/api/v1/ai/cline/recommended-models'
17
20
  /** The authenticated sub-path that reports quota consumption for the account. */
@@ -36,6 +39,17 @@ const DAY_MS = 86_400_000
36
39
  export const MODEL_ROWS_MAX = 8
37
40
  /** The community model registry. */
38
41
  export const MODELS_DEV_URL = 'https://models.dev/api.json'
42
+ /**
43
+ * The upstream gateway's own model catalog.
44
+ *
45
+ * It is the only source that publishes the reasoning levels behind a Cline Pass
46
+ * model. Cline Pass does not: its `/models` carries ids only, and its gateway
47
+ * accepts every level in the harness vocabulary for every model, so an unsupported
48
+ * one cannot even be probed out — a level outside the vocabulary is the only
49
+ * rejection it makes. The route is named in the gateway's own failures
50
+ * (`… from Vercel`), and that catalog is open.
51
+ */
52
+ export const VERCEL_MODELS_URL = 'https://ai-gateway.vercel.sh/v1/models'
39
53
  /** OpenRouter's public API, used only for direct-pipeline endpoint detail. */
40
54
  export const OPENROUTER_API = 'https://openrouter.ai/api/v1'
41
55
 
@@ -147,6 +161,38 @@ function modelMetadata(entry) {
147
161
  return meta
148
162
  }
149
163
 
164
+ /**
165
+ * The reasoning levels one model offers, from the upstream's `reasoning_options`.
166
+ *
167
+ * An `effort` entry names the levels; a `toggle` entry is the model's on/off switch,
168
+ * which in the harness vocabulary is `none`. Returned in escalating order so the
169
+ * picker reads in one direction whatever order the catalog happened to list.
170
+ *
171
+ * Models published with only a toggle, or only a token budget, name no levels at
172
+ * all. Those return undefined rather than an invented list, and the caller keeps the
173
+ * full vocabulary — narrowing a model to a single `none` would take away the switch
174
+ * the catalog just described.
175
+ *
176
+ * @returns the level ids, or undefined when the entry names none.
177
+ */
178
+ function effortsFromOptions(options) {
179
+ if (!Array.isArray(options)) return undefined
180
+ const levels = new Set()
181
+ let toggle = false
182
+ for (const option of options) {
183
+ if (option?.type === 'toggle') toggle = true
184
+ if (option?.type !== 'effort' || !Array.isArray(option.values)) continue
185
+ for (const value of option.values) if (typeof value === 'string' && value.length > 0) levels.add(value)
186
+ }
187
+ // A toggle on its own names nothing to choose from — it says the switch exists,
188
+ // not what to set it to — so it is not narrowed to a lone `none`, which would
189
+ // offer "off" and take away the switch the catalog just described.
190
+ if (levels.size === 0) return undefined
191
+ if (toggle) levels.add('none')
192
+ const ordered = REASONING_EFFORTS.map((effort) => effort.id).filter((id) => levels.has(id))
193
+ return ordered.length > 0 ? ordered : undefined
194
+ }
195
+
150
196
  /**
151
197
  * List the gateway's own catalog ids.
152
198
  * @returns the model ids; an empty array when the call fails or returns nothing.
@@ -307,11 +353,56 @@ export async function fetchOfficialModels({ signal, timeoutMs = 30000, fetchImpl
307
353
  }
308
354
  }
309
355
  } catch { /* source unavailable */ }
356
+ // The upstream's own catalog, matched by the id's tail: `cline-pass/glm-5.3` and
357
+ // `zai/glm-5.3` are the same model reached through the same gateway. It is also
358
+ // the only source that describes a model published after this release — Cline Pass
359
+ // lists ids, names and descriptions, and says nothing about what a model can do,
360
+ // so without this a new model falls back to the route's conservative window.
361
+ //
362
+ // Its output cap is deliberately NOT taken. It disagrees with the published one
363
+ // (32k where the shipped table says 384k) and it moves: the same model reported a
364
+ // different figure a week later. Adopting it would silently resize every request's
365
+ // completion reserve, which is what decides where compaction fires.
366
+ const upstreamByTail = new Map()
367
+ try {
368
+ const response = await fetchImpl(VERCEL_MODELS_URL, { signal: withTimeout(signal, timeoutMs) })
369
+ if (response.ok) {
370
+ const json = await response.json()
371
+ const rows = Array.isArray(json?.data) ? json.data : Array.isArray(json?.models) ? json.models : []
372
+ for (const row of rows) {
373
+ if (typeof row?.id !== 'string') continue
374
+ // The shared extractor already reads the display name and `modalities.input`,
375
+ // and it deliberately misses this catalog's snake_case output cap — which is
376
+ // exactly the field that must not be adopted.
377
+ const meta = modelMetadata(row)
378
+ const contextWindow = row?.context_window ?? row?.contextWindow
379
+ if (Number.isSafeInteger(contextWindow) && contextWindow > 0) meta.contextWindow = contextWindow
380
+ const efforts = effortsFromOptions(row.reasoning_options)
381
+ if (efforts !== undefined) meta.efforts = efforts
382
+ if (Object.keys(meta).length > 0) upstreamByTail.set(row.id.split('/').pop(), meta)
383
+ }
384
+ if (upstreamByTail.size > 0) sources.push('vercel')
385
+ }
386
+ } catch { /* source unavailable */ }
310
387
  const models = [...found.keys()].filter((id) => /^cline-pass\/[a-z0-9._-]+$/.test(id))
311
388
  const catalog = {}
312
389
  for (const id of models) {
313
- const meta = found.get(id)
314
- if (meta !== undefined && Object.keys(meta).length > 0) catalog[id] = meta
390
+ const meta = found.get(id) ?? {}
391
+ const upstream = upstreamByTail.get(id.slice('cline-pass/'.length))
392
+ if (upstream === undefined) {
393
+ if (Object.keys(meta).length > 0) catalog[id] = meta
394
+ continue
395
+ }
396
+ // Fills what the other sources leave out, so an id list that carries only a name
397
+ // still yields a real window, real levels and real modalities. A source that did
398
+ // describe a field keeps it: this is a gap filler, not an override.
399
+ const entry = { ...upstream, ...meta }
400
+ // The exception is the name, where the id list's "name" is the id itself and
401
+ // says nothing — the upstream publishes the label a person reads.
402
+ if (upstream.name !== undefined && (entry.name === undefined || entry.name === id || entry.name === id.slice('cline-pass/'.length))) {
403
+ entry.name = upstream.name
404
+ }
405
+ catalog[id] = entry
315
406
  }
316
407
  return { models, catalog, sources }
317
408
  }
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
  }