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 +24 -8
- package/lib/catalog.js +39 -2
- package/lib/client.js +116 -15
- package/lib/index.js +61 -15
- package/lib/panel.js +5 -0
- package/package.json +2 -2
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* the
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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')}${
|
|
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
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
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: '
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
/**
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
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",
|