dsh-cline-pass 0.2.5 → 0.2.7

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/adapter.js CHANGED
@@ -54,11 +54,16 @@ export const DEFAULT_MAX_CONCURRENT_REQUESTS = 16
54
54
  */
55
55
  export const DEFAULT_CONTEXT_WINDOW = 128000
56
56
  /**
57
- * Conservative per-request output cap for a model the catalog does not
58
- * describe. It must stay well below the smallest published window, because
59
- * the default is materialized into requests whose callers omit `maxTokens`.
57
+ * Per-request output budget for a model whose caller names no `maxTokens`.
58
+ *
59
+ * A budget, not a ceiling: the harness materializes it into requests that omit
60
+ * `maxTokens` and reserves the same number as completion room before it compacts,
61
+ * so this is the number that decides how much of the window stays available to the
62
+ * conversation. 64k is far past any single response a tool-calling agent emits
63
+ * while keeping the reserve well under the point where it would start displacing
64
+ * the compaction ratio. The published ceiling clamps it per model.
60
65
  */
61
- export const DEFAULT_MAX_TOKENS = 32000
66
+ export const DEFAULT_MAX_TOKENS = 64000
62
67
  /**
63
68
  * Request-image budget applied when the caller names none.
64
69
  *
@@ -469,9 +474,14 @@ function closeBlock(block) {
469
474
  /** Map a gateway error payload to a stable harness code. */
470
475
  export function streamErrorCode(message, status) {
471
476
  const detail = String(message ?? '')
472
- if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
477
+ // The harness vocabulary covers balances, but Cline also refuses spent
478
+ // subscription windows. Require exhaustion, not just a window name, so a
479
+ // temporary error fetching weekly usage does not stand an account down.
480
+ const windowDetail = detail.replace(/[_-]/g, ' ')
481
+ 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
+ if (status === 402 || isQuotaExceededError(detail) || spentWindow) return QUOTA_EXCEEDED_CODE
473
483
  if (/unauthorized|re-authenticate|invalid api key|401|403/i.test(detail)) return 'AUTH'
474
- if (/429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
484
+ if (status === 429 || /429|rate limit|rate-?limited/i.test(detail)) return 'RATE_LIMIT'
475
485
  if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
476
486
  if (status === 400) return 'INVALID_REQUEST'
477
487
  if (status !== undefined && status >= 500) return 'SERVER'
@@ -804,7 +814,9 @@ export class ClinePassAdapter extends LlmAdapter {
804
814
  * nothing is left, which is when the real error is raised below.
805
815
  */
806
816
  const refused = new Set()
807
- for (const attempt of attempts) {
817
+ let attemptIndex = 0
818
+ while (attemptIndex < attempts.length) {
819
+ const attempt = attempts[attemptIndex++]
808
820
  const account = await this.config.resolveAccount({ exclude: [...refused] })
809
821
  if (account === undefined) break
810
822
  const body = injectPrefs(buildRequestBody(options, connection, images), meta, attempt)
@@ -860,12 +872,15 @@ export class ClinePassAdapter extends LlmAdapter {
860
872
  // nothing left and this error is raised unchanged.
861
873
  if (lastError.code === 'AUTH' || lastError.code === QUOTA_EXCEEDED_CODE) {
862
874
  refused.add(account.name)
875
+ // A spent account says nothing about this channel. Give the next
876
+ // account the full chain, including a lone auto route or strict pin.
877
+ attemptIndex = 0
863
878
  this.config.record(options.model, {
864
879
  provider: null,
865
880
  attempts: trace.map((row) => row.upstream),
866
881
  ms: Date.now() - startedAt,
867
882
  stream: true,
868
- error: firstLine(lastError.message, 200),
883
+ error: lastError.message.replace(/\s+/g, ' ').trim().slice(0, 200),
869
884
  code: lastError.code,
870
885
  account: account.name,
871
886
  })
@@ -934,6 +949,7 @@ export class ClinePassAdapter extends LlmAdapter {
934
949
  }
935
950
  if (code === 'AUTH' || code === QUOTA_EXCEEDED_CODE) {
936
951
  refused.add(account.name)
952
+ attemptIndex = 0
937
953
  this.config.record(options.model, {
938
954
  provider: observed.provider,
939
955
  canonical: observed.canonical,
package/lib/catalog.js CHANGED
@@ -165,11 +165,44 @@ function pick(configured, published, fallback) {
165
165
  */
166
166
  const SEAM_MODALITIES = new Set(['text', 'image'])
167
167
 
168
+ /**
169
+ * The output budget to advertise for one model.
170
+ *
171
+ * `defaultMaxTokens` is what a request asks for, not what the model can emit, and
172
+ * the harness spends it twice: it fills a request's `max_tokens` from it, and it
173
+ * reserves the same number as completion room before deciding to compact. So the
174
+ * published output ceiling clamps the configured budget rather than becoming it.
175
+ * Reporting the ceiling instead reserves that much of every window — at the
176
+ * shipped 384k ceiling compaction moves from 80% of a 1M window down to 55% — and
177
+ * a ceiling equal to the window leaves the compaction policy no message budget at
178
+ * all, which it answers with an error rather than a reading.
179
+ *
180
+ * A budget is also never taken past half the window: reserving more than half of it
181
+ * leaves the conversation less room than a single response, which is the state the
182
+ * policy refuses. Every shipped model still clears the harness's headroom at that
183
+ * bound, so the guard only ever catches a hand-set value.
184
+ *
185
+ * @param budget - the configured per-request budget, when one is set.
186
+ * @param ceiling - the model's published output ceiling, when one is known.
187
+ * @param contextWindow - the resolved window, when one is known.
188
+ * @returns the tokens to advertise, or undefined when nothing constrains it.
189
+ */
190
+ function resolveOutputBudget(budget, ceiling, contextWindow) {
191
+ const wanted = Number.isFinite(budget) && budget > 0 ? budget : undefined
192
+ const most = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : undefined
193
+ const chosen = wanted === undefined ? most : most === undefined ? wanted : Math.min(wanted, most)
194
+ if (chosen === undefined) return undefined
195
+ const half = Number.isFinite(contextWindow) && contextWindow > 0 ? Math.floor(contextWindow / 2) : undefined
196
+ return half === undefined ? chosen : Math.min(chosen, half)
197
+ }
198
+
168
199
  /**
169
200
  * Resolve the metadata seam fields for one model.
170
201
  *
171
202
  * Precedence per field: the configured override, then the shipped catalog, then
172
- * what the last official scan published, then the route-wide fallback.
203
+ * what the last official scan published, then the route-wide fallback. The output
204
+ * budget is the exception — there the published figure is a ceiling that bounds the
205
+ * configured budget, which is why it goes through {@link resolveOutputBudget}.
173
206
  *
174
207
  * The live scan sits below the shipped table rather than above it because the
175
208
  * shipped entry is curated: models.dev reports `audio`/`video`/`pdf`, and the
@@ -185,7 +218,11 @@ export function resolveModelMetadata(provider, model, override, fallback, discov
185
218
  const published = { ...(discovered ?? {}), ...(catalogEntry(model) ?? {}) }
186
219
  const name = pick(override?.name, published.name, model)
187
220
  const contextWindow = pick(override?.contextWindow, published.contextWindow, fallback.contextWindow)
188
- const maxTokens = pick(override?.maxTokens, published.maxTokens, fallback.maxTokens)
221
+ const maxTokens = resolveOutputBudget(
222
+ fallback.maxTokens,
223
+ pick(override?.maxTokens, published.maxTokens, undefined),
224
+ contextWindow,
225
+ )
189
226
  const modality = pick(override?.input, published.input ?? published.inputModalities, undefined)
190
227
  const reasoning = override?.reasoning ?? published.reasoning ?? fallback.reasoning
191
228
  const offered = (modality ?? ['text']).filter((entry) => SEAM_MODALITIES.has(entry))
package/lib/client.js CHANGED
@@ -250,6 +250,14 @@ window.__ModuleLoader__.load({
250
250
  '.cp-pill-pop{position:absolute;bottom:calc(100% + 8px);right:0;z-index:40;min-width:236px}',
251
251
  '.cp-pill-card{background:var(--dsw-alias-bg-layer-1);border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:12px 14px;display:flex;flex-direction:column;gap:9px}',
252
252
  '.cp-pill-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
253
+ // The heading row carries the account switcher in a pool, so the title and
254
+ // the arrows sit on one line and the reading keeps the width below them.
255
+ '.cp-pill-head{display:flex;align-items:center;justify-content:space-between;gap:10px}',
256
+ '.cp-pill-nav{display:flex;align-items:center;gap:5px;min-width:0}',
257
+ '.cp-pill-arrow{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:6px;width:18px;height:18px;line-height:1;font:inherit;font-size:12px;cursor:pointer;padding:0;flex:none}',
258
+ '.cp-pill-arrow:hover{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}',
259
+ '.cp-pill-who{font-size:11.5px;color:var(--dsw-alias-label-secondary);max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
260
+ '.cp-pill-pos{font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;flex:none}',
253
261
  '.cp-pill-row{display:flex;flex-direction:column;gap:4px}',
254
262
  '.cp-pill-row-head{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;color:var(--dsw-alias-label-primary)}',
255
263
  '.cp-pill-percent{font-variant-numeric:tabular-nums;font-weight:600}',
@@ -1536,6 +1544,19 @@ window.__ModuleLoader__.load({
1536
1544
  ])
1537
1545
  }
1538
1546
 
1547
+ /** Select cached quota without substituting another account's reading. */
1548
+ function usageIndex(accounts, effectiveAccount, picked) {
1549
+ if (accounts.length === 0) return -1
1550
+ if (picked !== null) return Math.min(Math.max(picked, 0), accounts.length - 1)
1551
+ // Undefined is an older host; an explicit empty string means no account.
1552
+ if (effectiveAccount !== undefined) {
1553
+ if (effectiveAccount === '') return -1
1554
+ return accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount))
1555
+ }
1556
+ const usable = accounts.findIndex((entry) => entry.ok === true)
1557
+ return Math.max(0, usable)
1558
+ }
1559
+
1539
1560
  /**
1540
1561
  * The official quota card.
1541
1562
  *
@@ -1546,16 +1567,23 @@ window.__ModuleLoader__.load({
1546
1567
  * One reading per window: the official percentage, plus when it resets.
1547
1568
  */
1548
1569
  function UsageCard(props) {
1549
- const { t, usage, disabled, actions, unavailable, activeAccount, accountMode } = props
1570
+ const { t, usage, disabled, actions, unavailable, activeAccount, accountMode, effectiveAccount } = props
1550
1571
  const fetched = Number(usage?.fetchedAt ?? 0) > 0
1551
1572
  const accounts = usage?.accounts ?? []
1552
1573
  // A pool is shown one account at a time. Stacking every account turned the
1553
1574
  // card into a wall of bars and pushed the one reading a user came for below
1554
1575
  // the fold, so the arrows — not a scroll — are what moves between accounts.
1555
- const [picked, setPicked] = React.useState(0)
1576
+ //
1577
+ // `null` means "still following the account a request would use". Opening on
1578
+ // the first entry puts the reading a user came for behind an arrow, and the
1579
+ // reading is per account, so the first entry answers a question about a key
1580
+ // that may not be about to be spent. Once they page, their choice wins: the
1581
+ // cursor moving underneath must not yank the card away mid-review.
1582
+ const [picked, setPicked] = React.useState(null)
1556
1583
  // The selection is an index, so a shorter list — an account removed, or its
1557
1584
  // key deleted — can leave it past the end. Clamp instead of rendering nothing.
1558
- const index = accounts.length === 0 ? 0 : Math.min(Math.max(picked, 0), accounts.length - 1)
1585
+ const index = usageIndex(accounts, effectiveAccount, picked)
1586
+ const account = accounts[index]
1559
1587
  const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
1560
1588
  const windowRow = (type, key, limit) => {
1561
1589
  const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
@@ -1626,13 +1654,17 @@ window.__ModuleLoader__.load({
1626
1654
  }
1627
1655
  let body
1628
1656
  if (!fetched) body = h('div', { key: 'wait', className: 'cp-muted' }, unavailable === true ? t('usageUnavailable') : t('usageLoading'))
1657
+ else if (account === undefined && effectiveAccount !== undefined) {
1658
+ body = h('div', { key: 'wait', className: 'cp-muted' }, effectiveAccount === '' || unavailable === true ? t('usageUnavailable') : t('usageLoading'))
1659
+ }
1629
1660
  else if (accounts.length === 0) body = h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1630
1661
  // `index` is the account on screen; its own key remounts the block so the
1631
1662
  // windows never animate between two accounts' readings.
1632
- else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accountGroup(accounts[index], index))
1663
+ else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accountGroup(account, index))
1664
+ const name = account?.account ?? effectiveAccount
1633
1665
  const title = accounts.length > 1
1634
1666
  ? `${t('usageTitle')} · ${fill(t('accountsCount'), { n: accounts.length })}`
1635
- : `${t('usageTitle')}${accounts[0] ? ` · ${fill(t('usageAccount'), { name: accounts[0].account })}` : ''}`
1667
+ : `${t('usageTitle')}${name ? ` · ${fill(t('usageAccount'), { name })}` : ''}`
1636
1668
  return h('div', { className: 'cp-card' }, [
1637
1669
  h('div', { key: 'title', className: 'cp-card-title' }, [
1638
1670
  h('span', { key: 'l' }, title),
@@ -1750,12 +1782,16 @@ window.__ModuleLoader__.load({
1750
1782
  key: 'usage',
1751
1783
  t,
1752
1784
  usage: data.usage,
1753
- // Which account the pool would use, resolved the same way the accounts
1754
- // card resolves it: an empty `activeAccount` means "first enabled", not
1755
- // "none", so the badge has to name that account rather than nobody.
1756
- activeAccount: data.activeAccount !== ''
1757
- ? data.activeAccount
1758
- : (data.accounts.find((account) => account.enabled)?.key ?? ''),
1785
+ // Which account the pool would use. The host resolves it, so round-robin
1786
+ // is covered too: `activeAccount` only decides single mode, and the badge
1787
+ // claims to mark the account a request would actually spend. The fallback
1788
+ // covers a state read from a host that predates the field.
1789
+ activeAccount: data.effectiveAccount !== undefined && data.effectiveAccount !== ''
1790
+ ? data.effectiveAccount
1791
+ : (data.activeAccount !== ''
1792
+ ? data.activeAccount
1793
+ : (data.accounts.find((account) => account.enabled)?.key ?? '')),
1794
+ effectiveAccount: data.effectiveAccount,
1759
1795
  accountMode: data.accountMode,
1760
1796
  unavailable: readFailed,
1761
1797
  disabled,
@@ -1891,13 +1927,28 @@ window.__ModuleLoader__.load({
1891
1927
  /** The pill itself: a compact reading, expanding into the window breakdown. */
1892
1928
  function UsagePillActive(props) {
1893
1929
  const t = props.t
1930
+ // The panel's live snapshot, which an account switch updates at once. The
1931
+ // reading below arrives on a timer, so the account is taken from here: a
1932
+ // pill keyed to its own reading would keep naming the previous account for
1933
+ // up to a minute after the user changed it.
1934
+ const panel = React.useSyncExternalStore(props.store.subscribe, props.store.getSnapshot, props.store.getSnapshot)
1894
1935
  const [snapshot, setSnapshot] = React.useState(null)
1895
1936
  const [failed, setFailed] = React.useState(false)
1896
1937
  const [open, setOpen] = React.useState(false)
1938
+ // Which account the popover reads. `null` keeps following the account a
1939
+ // request would use; paging sets it and wins while the popover is open.
1940
+ const [picked, setPicked] = React.useState(null)
1897
1941
  const root = React.useRef(null)
1898
1942
  // Kept across renders so the timer's callback always sees the latest one.
1899
1943
  const read = React.useRef(() => {})
1900
1944
 
1945
+ // Closing forgets the page, so the next look starts from the account a
1946
+ // request would use — which is what someone reopening after switching
1947
+ // accounts expects to see, rather than the page they left behind.
1948
+ React.useEffect(() => {
1949
+ if (!open) setPicked(null)
1950
+ }, [open])
1951
+
1901
1952
  React.useEffect(() => {
1902
1953
  let alive = true
1903
1954
  let busy = false
@@ -1948,8 +1999,28 @@ window.__ModuleLoader__.load({
1948
1999
  }, [open])
1949
2000
 
1950
2001
  const accounts = snapshot?.value?.usage?.accounts ?? []
1951
- const account = accounts.find((entry) => entry.ok === true) ?? accounts[0]
1952
- const limits = account?.limits ?? []
2002
+ // The account a request would use, not merely the first one that answered.
2003
+ // Quota is per account, so showing a healthy reading from an account the
2004
+ // next request will not touch is worse than showing nothing: it answers a
2005
+ // question about a key that is not about to be spent. The host names the
2006
+ // effective account because only it knows the cursor. A reading missing
2007
+ // that account must not borrow another key's quota.
2008
+ //
2009
+ // The account is read from the panel's snapshot, not from this reading: a
2010
+ // switch lands there at once, while a reading only arrives on a timer. The
2011
+ // reading's own copy is the fallback for a state taken before the store had
2012
+ // one.
2013
+ //
2014
+ // The selection is by index so the arrows can move it, and a cursor that
2015
+ // advances underneath only moves the reading while the popover is closed:
2016
+ // once the user pages, their choice holds for as long as it is open.
2017
+ const effectiveAccount = panel?.data?.effectiveAccount !== undefined
2018
+ ? panel.data.effectiveAccount
2019
+ : snapshot?.value?.effectiveAccount
2020
+ const index = usageIndex(accounts, effectiveAccount, picked)
2021
+ const account = accounts[index]
2022
+ const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
2023
+ const limits = account?.ok === true ? account.limits ?? [] : []
1953
2024
  const byType = new Map(limits.map((limit) => [String(limit.type), limit]))
1954
2025
  const percentOf = (type) => {
1955
2026
  const limit = byType.get(type)
@@ -1978,7 +2049,26 @@ window.__ModuleLoader__.load({
1978
2049
  h('button', { key: 'r', type: 'button', className: 'cp-pill-retry', onClick: () => read.current() }, t('usagePillRetry')),
1979
2050
  ])
1980
2051
  : h('div', { key: 'card', className: 'cp-pill-card' }, [
1981
- h('div', { key: 'title', className: 'cp-pill-title' }, t('usagePillTitle')),
2052
+ h('div', { key: 'head', className: 'cp-pill-head' }, [
2053
+ h('span', { key: 't', className: 'cp-pill-title' }, t('usagePillTitle')),
2054
+ // A pool is read one account at a time, the way the settings card reads
2055
+ // it: the quota is per account, so the arrows move between them rather
2056
+ // than stacking every window at once. Absent with a single account,
2057
+ // where there is nowhere to go.
2058
+ accounts.length <= 1 || account === undefined ? null : h('span', { key: 'nav', className: 'cp-pill-nav' }, [
2059
+ h('button', {
2060
+ key: 'prev', type: 'button', className: 'cp-pill-arrow',
2061
+ title: t('usagePrev'), 'aria-label': t('usagePrev'), onClick: () => step(-1),
2062
+ }, '‹'),
2063
+ h('span', { key: 'who', className: 'cp-pill-who', title: account?.displayName ?? account?.account ?? '' },
2064
+ account?.displayName ?? account?.account ?? ''),
2065
+ h('span', { key: 'pos', className: 'cp-pill-pos' }, `${index + 1} / ${accounts.length}`),
2066
+ h('button', {
2067
+ key: 'next', type: 'button', className: 'cp-pill-arrow',
2068
+ title: t('usageNext'), 'aria-label': t('usageNext'), onClick: () => step(1),
2069
+ }, '›'),
2070
+ ]),
2071
+ ]),
1982
2072
  rows === null
1983
2073
  ? h('div', { key: 'empty', className: 'cp-pill-fail' }, t('usagePillUnavailable'))
1984
2074
  : rows.map(([type, key, limit]) => {
@@ -2101,7 +2191,18 @@ window.__ModuleLoader__.load({
2101
2191
  // read has landed. Read it at render time rather than capturing it:
2102
2192
  // the pill can mount before the first read completes.
2103
2193
  route: () => controller.store.getSnapshot().data?.provider ?? NS,
2104
- readUsage: async () => await controller.call('usage', {}),
2194
+ // The panel's own snapshot. Switching accounts is a panel action that
2195
+ // updates this store at once, whereas a usage reading is only taken on
2196
+ // mount and on a timer — so the account is read from here, and a pill
2197
+ // keyed to its own reading names the previous account until the poll.
2198
+ store: controller.store,
2199
+ readUsage: async () => {
2200
+ const value = await controller.call('usage', {})
2201
+ // Keep the panel's snapshot as fresh as this reading, so whatever the
2202
+ // host has just named is visible everywhere without waiting a minute.
2203
+ controller.applyState(value)
2204
+ return value
2205
+ },
2105
2206
  t: translate,
2106
2207
  }),
2107
2208
  }, UsagePill))
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import z from '@deepseek-ai/schemastery'
19
19
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
- import { assertUsableApiKey, errorChain, LlmError } from '@deepseek-ai/dsh-llm'
20
+ import { assertUsableApiKey, errorChain, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
21
21
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
22
22
  import {
23
23
  ClinePassAdapter,
@@ -89,6 +89,12 @@ export const AccountProfile = z.object({
89
89
  export const ModelProfile = z.object({
90
90
  name: z.string().default(''),
91
91
  contextWindow: z.number().default(0),
92
+ /**
93
+ * This model's output ceiling, overriding the published one. It bounds what a
94
+ * request may ask for; it is not itself the ask, because the harness reserves the
95
+ * asked-for budget as completion room before it compacts. Raise `defaultMaxTokens`
96
+ * to spend more of the window on output.
97
+ */
92
98
  maxTokens: z.number().default(0),
93
99
  reasoning: z.union([z.boolean(), z.const(undefined)]).default(undefined),
94
100
  input: z.array(z.string()).default([]),
@@ -135,6 +141,15 @@ export const Config = z.object({
135
141
  perModel: markVolatile(z.dict(PinProfile).default({})),
136
142
  reasoningModels: z.boolean().default(true),
137
143
  defaultContextWindow: z.number().default(DEFAULT_CONTEXT_WINDOW),
144
+ /**
145
+ * What one request asks for as output, and therefore how much of the window the
146
+ * harness holds back as completion room before it compacts.
147
+ *
148
+ * A policy rather than a ceiling: the model's published output capacity is much
149
+ * larger, and reporting that here reserves it on every request — at the shipped
150
+ * 384k figure a 1M window compacts at 55% instead of the 80% the threshold ratio
151
+ * asks for. Each model's published ceiling clamps whatever is set here.
152
+ */
138
153
  defaultMaxTokens: z.number().default(DEFAULT_MAX_TOKENS),
139
154
  streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
140
155
  /**
@@ -227,7 +242,7 @@ export function apply(ctx, config) {
227
242
  * by the channel retry already, and standing an account down for it would cost
228
243
  * capacity the request never proved was gone.
229
244
  */
230
- const COOLDOWN_MS = { QUOTA_EXCEEDED: 15 * 60_000, AUTH: 30 * 60_000 }
245
+ const COOLDOWN_MS = { [QUOTA_EXCEEDED_CODE]: 15 * 60_000, AUTH: 30 * 60_000 }
231
246
 
232
247
  /**
233
248
  * Resolve the configured accounts.
@@ -270,27 +285,53 @@ export function apply(ctx, config) {
270
285
  return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
271
286
  }
272
287
 
273
- /** Pick the account for the next request and resolve its key. */
274
- const resolveAccount = async ({ exclude = [] } = {}) => {
275
- const cfg = live()
288
+ /**
289
+ * The enabled accounts in the order the router would consider them.
290
+ *
291
+ * Shared by the pick and by the read-only peek the panel uses, because the two
292
+ * have to agree: the peek is what the usage pill shows, and a pill that named
293
+ * an account the next request would not use is exactly the bug it exists to
294
+ * avoid. Keeping one ordering is also what stops the cooldown rule from being
295
+ * applied in one place and forgotten in the other.
296
+ */
297
+ const candidateAccounts = (exclude = []) => {
276
298
  const skip = new Set(exclude.map(String))
277
299
  const enabled = accountProfiles().filter(([, profile]) => profile.enabled)
278
- if (enabled.length === 0) {
279
- throw new LlmError('cline-pass: every configured account is disabled; enable one or add another', 'MISSING_CREDENTIAL')
280
- }
281
300
  // Accounts that just refused a request are stood down for a while, so a pool
282
301
  // does not spend one failed round trip per call rediscovering the same
283
302
  // exhausted key. When every candidate is cooling the oldest one is used
284
303
  // anyway: a window can reset, and refusing to try at all would be worse.
285
304
  const now = Date.now()
286
305
  const eligible = enabled.filter(([key]) => !skip.has(key) && (cooling.get(key) ?? 0) <= now)
287
- const pool = eligible.length > 0
288
- ? eligible
289
- : enabled.filter(([key]) => !skip.has(key))
290
- if (pool.length === 0) return undefined
291
- const ordered = eligible.length > 0
292
- ? pool
293
- : [...pool].sort((left, right) => (cooling.get(left[0]) ?? 0) - (cooling.get(right[0]) ?? 0))
306
+ if (eligible.length > 0) return eligible
307
+ const pool = enabled.filter(([key]) => !skip.has(key))
308
+ return [...pool].sort((left, right) => (cooling.get(left[0]) ?? 0) - (cooling.get(right[0]) ?? 0))
309
+ }
310
+
311
+ /**
312
+ * The account the next request would use, without consuming a round-robin turn.
313
+ *
314
+ * Reading quota is not a request that advances the cursor, so asking which
315
+ * account is in line must not move it either — otherwise showing the user their
316
+ * quota would change which account answers them.
317
+ */
318
+ const effectiveAccount = () => {
319
+ const cfg = live()
320
+ const ordered = candidateAccounts()
321
+ if (ordered.length === 0) return ''
322
+ if (cfg.accountMode === 'roundrobin' && ordered.length > 1) return ordered[roundRobin % ordered.length][0]
323
+ return (ordered.find(([key]) => key === cfg.activeAccount) ?? ordered[0])[0]
324
+ }
325
+
326
+ /** Pick the account for the next request and resolve its key. */
327
+ const resolveAccount = async ({ exclude = [] } = {}) => {
328
+ const cfg = live()
329
+ const enabled = accountProfiles().filter(([, profile]) => profile.enabled)
330
+ if (enabled.length === 0) {
331
+ throw new LlmError('cline-pass: every configured account is disabled; enable one or add another', 'MISSING_CREDENTIAL')
332
+ }
333
+ const ordered = candidateAccounts(exclude)
334
+ if (ordered.length === 0) return undefined
294
335
  let picked
295
336
  if (cfg.accountMode === 'roundrobin' && ordered.length > 1) {
296
337
  picked = ordered[roundRobin % ordered.length]
@@ -556,6 +597,11 @@ export function apply(ctx, config) {
556
597
  }
557
598
  })),
558
599
  readCredential: async (ref) => (await resolveKey(credentialRef(ref))) ?? '',
600
+ // The account a request would use right now. The panel reads this so its
601
+ // usage surfaces can name the account a request would actually spend, which
602
+ // is what a pool makes non-obvious: `activeAccount` is only the single-mode
603
+ // choice, and in round-robin it is not the account in line at all.
604
+ effectiveAccount,
559
605
  usageAccounts,
560
606
  setCredential: async (ref, value) => {
561
607
  const credentials = ctx.get('credentials')
package/lib/panel.js CHANGED
@@ -130,6 +130,11 @@ export function createPanel({ control, engine, store }) {
130
130
  settingsAvailable: control.settingsAvailable(),
131
131
  accountMode: config.accountMode === 'roundrobin' ? 'roundrobin' : 'single',
132
132
  activeAccount: String(config.activeAccount ?? ''),
133
+ // The account a request would use, which in a pool is not the configured
134
+ // one: `activeAccount` only decides single mode, and round-robin picks from
135
+ // the cursor. Usage is per account, so a reading has to name the account it
136
+ // belongs to or the surface showing it describes someone else's quota.
137
+ effectiveAccount: control.effectiveAccount(),
133
138
  accounts,
134
139
  ready: usable,
135
140
  models,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Self-contained Cline Pass provider for dsh: register Cline Pass subscription models on the harness LLM seam, enumerate and validate the upstream channels behind each model, pin or exclude them with failover, and drive an account pool. No proxy process and no external cline-pass-switcher install required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,7 +18,7 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "scripts": {
21
- "test": "node test/smoke.mjs && node test/client.mjs && node test/pin-adherence.mjs",
21
+ "test": "node test/smoke.mjs && node test/client.mjs && node test/pin-adherence.mjs && node test/account-failover.mjs",
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",