dsh-cline-pass 0.2.4 → 0.2.6
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 +78 -18
- package/lib/catalog.js +39 -2
- package/lib/client.js +100 -12
- package/lib/index.js +59 -13
- package/lib/panel.js +5 -0
- package/package.json +1 -1
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
|
*
|
|
@@ -249,31 +254,88 @@ function collectImageParts(blocks, images, target = []) {
|
|
|
249
254
|
/**
|
|
250
255
|
* Convert the harness conversation to wire messages.
|
|
251
256
|
*
|
|
252
|
-
* Tool results become standalone `{ role: 'tool' }` messages
|
|
253
|
-
*
|
|
254
|
-
*
|
|
257
|
+
* Tool results become standalone `{ role: 'tool' }` messages, in both harness
|
|
258
|
+
* shapes: the current one, where the result IS a `tool`-role message carrying
|
|
259
|
+
* `toolCallId`, and the one dsh < 0.2.0 produced, a user message wrapping a
|
|
260
|
+
* `tool-result` block. A user message carrying an image switches to the OpenAI
|
|
261
|
+
* content-part array; a text-only one keeps the plain-string form.
|
|
255
262
|
*
|
|
256
263
|
* An image nested in a tool result (read_image, a screenshot tool) has no
|
|
257
264
|
* OpenAI `tool` wire spelling, so it rides a following user message instead —
|
|
258
|
-
* the same shape the shipped adapters use.
|
|
265
|
+
* the same shape the shipped adapters use. Deferring that message until the
|
|
266
|
+
* next non-tool message keeps a parallel group's answers contiguous.
|
|
267
|
+
*
|
|
268
|
+
* `developer` becomes `system`. The harness uses that role for its own
|
|
269
|
+
* system-level notices — the tool-registry change it appends when the tool set
|
|
270
|
+
* moves — and the newer OpenAI spelling is not accepted by every
|
|
271
|
+
* OpenAI-compatible gateway, while `system` is. Sending it as a user turn (what
|
|
272
|
+
* an unhandled role fell through to) also misrepresents it as something the user
|
|
273
|
+
* said.
|
|
274
|
+
*
|
|
275
|
+
* The group guard below is the same rule stated once: an assistant message
|
|
276
|
+
* carrying `tool_calls` must be followed immediately by a `tool` message per id.
|
|
277
|
+
* Any other message that would land inside that run — a notice, a user turn, the
|
|
278
|
+
* image message a tool-result image needs — is held back until the group closes,
|
|
279
|
+
* because a gateway that validates the rule rejects the whole request with
|
|
280
|
+
* "insufficient tool messages following tool_calls message".
|
|
259
281
|
*/
|
|
260
282
|
function serializeMessages(messages, images) {
|
|
261
283
|
const wire = []
|
|
262
284
|
let pendingToolImages = []
|
|
285
|
+
/** Call ids of the most recent assistant tool_calls still awaiting an answer. */
|
|
286
|
+
let awaiting = []
|
|
287
|
+
/** Messages held back so they cannot split the group; emitted in order. */
|
|
288
|
+
let deferred = []
|
|
289
|
+
|
|
290
|
+
const flushDeferred = () => {
|
|
291
|
+
if (deferred.length === 0) return
|
|
292
|
+
for (const held of deferred) wire.push(held)
|
|
293
|
+
deferred = []
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Emit a non-tool message.
|
|
297
|
+
*
|
|
298
|
+
* A message that is not a tool answer cannot go out while a group is open, so
|
|
299
|
+
* it waits. With no group open this is a plain push, which is every ordinary
|
|
300
|
+
* conversation and keeps that output unchanged.
|
|
301
|
+
*/
|
|
302
|
+
const emit = (message) => {
|
|
303
|
+
if (awaiting.length > 0) deferred.push(message)
|
|
304
|
+
else wire.push(message)
|
|
305
|
+
}
|
|
263
306
|
const flushToolImages = () => {
|
|
264
307
|
if (pendingToolImages.length === 0) return
|
|
265
|
-
|
|
308
|
+
const message = { role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] }
|
|
266
309
|
pendingToolImages = []
|
|
310
|
+
emit(message)
|
|
311
|
+
}
|
|
312
|
+
/** A tool answer: it closes its own id, and the group when it was the last. */
|
|
313
|
+
const answer = (callId, content) => {
|
|
314
|
+
wire.push({ role: 'tool', tool_call_id: callId, content: flattenText(content) || '(no output)' })
|
|
315
|
+
collectImageParts(content, images, pendingToolImages)
|
|
316
|
+
awaiting = awaiting.filter((id) => id !== callId)
|
|
317
|
+
if (awaiting.length === 0) flushDeferred()
|
|
267
318
|
}
|
|
319
|
+
|
|
268
320
|
for (const message of messages) {
|
|
269
|
-
if (message.role === 'system') {
|
|
321
|
+
if (message.role === 'system' || message.role === 'developer') {
|
|
270
322
|
flushToolImages()
|
|
271
|
-
|
|
323
|
+
emit({ role: 'system', content: flattenText(message.content) })
|
|
272
324
|
continue
|
|
273
325
|
}
|
|
274
326
|
if (message.role === 'assistant') {
|
|
275
327
|
flushToolImages()
|
|
328
|
+
// A turn boundary. Anything still held belongs before the new turn, and an
|
|
329
|
+
// unanswered group from the previous turn can no longer be answered.
|
|
330
|
+
flushDeferred()
|
|
276
331
|
wire.push(serializeAssistant(message))
|
|
332
|
+
awaiting = (message.content ?? []).filter((block) => block.type === 'tool-call').map((block) => block.id)
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
// A harness tool result: the call identity is on the message. No flush here,
|
|
336
|
+
// so the answers to one assistant message's parallel calls stay adjacent.
|
|
337
|
+
if (message.role === 'tool') {
|
|
338
|
+
answer(message.toolCallId, message.content)
|
|
277
339
|
continue
|
|
278
340
|
}
|
|
279
341
|
const toolResults = (message.content ?? []).filter((block) => block.type === 'tool-result')
|
|
@@ -286,18 +348,16 @@ function serializeMessages(messages, images) {
|
|
|
286
348
|
const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
|
|
287
349
|
if (hasImage || text.length > 0 || toolResults.length === 0) {
|
|
288
350
|
flushToolImages()
|
|
289
|
-
|
|
351
|
+
emit({ role: 'user', content: hasImage ? parts : text })
|
|
290
352
|
}
|
|
291
353
|
for (const result of toolResults) {
|
|
292
|
-
|
|
293
|
-
role: 'tool',
|
|
294
|
-
tool_call_id: result.toolCallId,
|
|
295
|
-
content: flattenText(result.content) || '(no output)',
|
|
296
|
-
})
|
|
297
|
-
collectImageParts(result.content, images, pendingToolImages)
|
|
354
|
+
answer(result.toolCallId, result.content)
|
|
298
355
|
}
|
|
299
356
|
}
|
|
300
357
|
flushToolImages()
|
|
358
|
+
// A group the history never closed leaves nothing to wait for; whatever was
|
|
359
|
+
// held still belongs in the request, just after the answers that do exist.
|
|
360
|
+
flushDeferred()
|
|
301
361
|
return wire
|
|
302
362
|
}
|
|
303
363
|
|
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}',
|
|
@@ -1546,16 +1554,24 @@ window.__ModuleLoader__.load({
|
|
|
1546
1554
|
* One reading per window: the official percentage, plus when it resets.
|
|
1547
1555
|
*/
|
|
1548
1556
|
function UsageCard(props) {
|
|
1549
|
-
const { t, usage, disabled, actions, unavailable, activeAccount, accountMode } = props
|
|
1557
|
+
const { t, usage, disabled, actions, unavailable, activeAccount, accountMode, effectiveAccount } = props
|
|
1550
1558
|
const fetched = Number(usage?.fetchedAt ?? 0) > 0
|
|
1551
1559
|
const accounts = usage?.accounts ?? []
|
|
1552
1560
|
// A pool is shown one account at a time. Stacking every account turned the
|
|
1553
1561
|
// card into a wall of bars and pushed the one reading a user came for below
|
|
1554
1562
|
// the fold, so the arrows — not a scroll — are what moves between accounts.
|
|
1555
|
-
|
|
1563
|
+
//
|
|
1564
|
+
// `null` means "still following the account a request would use". Opening on
|
|
1565
|
+
// the first entry puts the reading a user came for behind an arrow, and the
|
|
1566
|
+
// reading is per account, so the first entry answers a question about a key
|
|
1567
|
+
// that may not be about to be spent. Once they page, their choice wins: the
|
|
1568
|
+
// cursor moving underneath must not yank the card away mid-review.
|
|
1569
|
+
const [picked, setPicked] = React.useState(null)
|
|
1570
|
+
const followed = accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount ?? ''))
|
|
1571
|
+
const at = picked === null ? Math.max(0, followed) : picked
|
|
1556
1572
|
// The selection is an index, so a shorter list — an account removed, or its
|
|
1557
1573
|
// key deleted — can leave it past the end. Clamp instead of rendering nothing.
|
|
1558
|
-
const index = accounts.length === 0 ? 0 : Math.min(Math.max(
|
|
1574
|
+
const index = accounts.length === 0 ? 0 : Math.min(Math.max(at, 0), accounts.length - 1)
|
|
1559
1575
|
const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
|
|
1560
1576
|
const windowRow = (type, key, limit) => {
|
|
1561
1577
|
const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
|
|
@@ -1750,12 +1766,16 @@ window.__ModuleLoader__.load({
|
|
|
1750
1766
|
key: 'usage',
|
|
1751
1767
|
t,
|
|
1752
1768
|
usage: data.usage,
|
|
1753
|
-
// Which account the pool would use
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1769
|
+
// Which account the pool would use. The host resolves it, so round-robin
|
|
1770
|
+
// is covered too: `activeAccount` only decides single mode, and the badge
|
|
1771
|
+
// claims to mark the account a request would actually spend. The fallback
|
|
1772
|
+
// covers a state read from a host that predates the field.
|
|
1773
|
+
activeAccount: data.effectiveAccount !== undefined && data.effectiveAccount !== ''
|
|
1774
|
+
? data.effectiveAccount
|
|
1775
|
+
: (data.activeAccount !== ''
|
|
1776
|
+
? data.activeAccount
|
|
1777
|
+
: (data.accounts.find((account) => account.enabled)?.key ?? '')),
|
|
1778
|
+
effectiveAccount: data.effectiveAccount,
|
|
1759
1779
|
accountMode: data.accountMode,
|
|
1760
1780
|
unavailable: readFailed,
|
|
1761
1781
|
disabled,
|
|
@@ -1891,13 +1911,28 @@ window.__ModuleLoader__.load({
|
|
|
1891
1911
|
/** The pill itself: a compact reading, expanding into the window breakdown. */
|
|
1892
1912
|
function UsagePillActive(props) {
|
|
1893
1913
|
const t = props.t
|
|
1914
|
+
// The panel's live snapshot, which an account switch updates at once. The
|
|
1915
|
+
// reading below arrives on a timer, so the account is taken from here: a
|
|
1916
|
+
// pill keyed to its own reading would keep naming the previous account for
|
|
1917
|
+
// up to a minute after the user changed it.
|
|
1918
|
+
const panel = React.useSyncExternalStore(props.store.subscribe, props.store.getSnapshot, props.store.getSnapshot)
|
|
1894
1919
|
const [snapshot, setSnapshot] = React.useState(null)
|
|
1895
1920
|
const [failed, setFailed] = React.useState(false)
|
|
1896
1921
|
const [open, setOpen] = React.useState(false)
|
|
1922
|
+
// Which account the popover reads. `null` keeps following the account a
|
|
1923
|
+
// request would use; paging sets it and wins while the popover is open.
|
|
1924
|
+
const [picked, setPicked] = React.useState(null)
|
|
1897
1925
|
const root = React.useRef(null)
|
|
1898
1926
|
// Kept across renders so the timer's callback always sees the latest one.
|
|
1899
1927
|
const read = React.useRef(() => {})
|
|
1900
1928
|
|
|
1929
|
+
// Closing forgets the page, so the next look starts from the account a
|
|
1930
|
+
// request would use — which is what someone reopening after switching
|
|
1931
|
+
// accounts expects to see, rather than the page they left behind.
|
|
1932
|
+
React.useEffect(() => {
|
|
1933
|
+
if (!open) setPicked(null)
|
|
1934
|
+
}, [open])
|
|
1935
|
+
|
|
1901
1936
|
React.useEffect(() => {
|
|
1902
1937
|
let alive = true
|
|
1903
1938
|
let busy = false
|
|
@@ -1948,7 +1983,30 @@ window.__ModuleLoader__.load({
|
|
|
1948
1983
|
}, [open])
|
|
1949
1984
|
|
|
1950
1985
|
const accounts = snapshot?.value?.usage?.accounts ?? []
|
|
1951
|
-
|
|
1986
|
+
// The account a request would use, not merely the first one that answered.
|
|
1987
|
+
// Quota is per account, so showing a healthy reading from an account the
|
|
1988
|
+
// next request will not touch is worse than showing nothing: it answers a
|
|
1989
|
+
// question about a key that is not about to be spent. The host names the
|
|
1990
|
+
// effective account because only it knows the cursor; the fallbacks cover
|
|
1991
|
+
// a reading taken before that account existed.
|
|
1992
|
+
//
|
|
1993
|
+
// The account is read from the panel's snapshot, not from this reading: a
|
|
1994
|
+
// switch lands there at once, while a reading only arrives on a timer. The
|
|
1995
|
+
// reading's own copy is the fallback for a state taken before the store had
|
|
1996
|
+
// one.
|
|
1997
|
+
//
|
|
1998
|
+
// The selection is by index so the arrows can move it, and a cursor that
|
|
1999
|
+
// advances underneath only moves the reading while the popover is closed:
|
|
2000
|
+
// once the user pages, their choice holds for as long as it is open.
|
|
2001
|
+
const effectiveAccount = panel?.data?.effectiveAccount !== undefined
|
|
2002
|
+
? panel.data.effectiveAccount
|
|
2003
|
+
: (snapshot?.value?.effectiveAccount ?? '')
|
|
2004
|
+
const followed = accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount))
|
|
2005
|
+
const usable = accounts.findIndex((entry) => entry.ok === true)
|
|
2006
|
+
const at = picked === null ? (followed >= 0 ? followed : Math.max(0, usable)) : picked
|
|
2007
|
+
const index = accounts.length === 0 ? 0 : Math.min(Math.max(at, 0), accounts.length - 1)
|
|
2008
|
+
const account = accounts[index]
|
|
2009
|
+
const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
|
|
1952
2010
|
const limits = account?.limits ?? []
|
|
1953
2011
|
const byType = new Map(limits.map((limit) => [String(limit.type), limit]))
|
|
1954
2012
|
const percentOf = (type) => {
|
|
@@ -1978,7 +2036,26 @@ window.__ModuleLoader__.load({
|
|
|
1978
2036
|
h('button', { key: 'r', type: 'button', className: 'cp-pill-retry', onClick: () => read.current() }, t('usagePillRetry')),
|
|
1979
2037
|
])
|
|
1980
2038
|
: h('div', { key: 'card', className: 'cp-pill-card' }, [
|
|
1981
|
-
h('div', { key: '
|
|
2039
|
+
h('div', { key: 'head', className: 'cp-pill-head' }, [
|
|
2040
|
+
h('span', { key: 't', className: 'cp-pill-title' }, t('usagePillTitle')),
|
|
2041
|
+
// A pool is read one account at a time, the way the settings card reads
|
|
2042
|
+
// it: the quota is per account, so the arrows move between them rather
|
|
2043
|
+
// than stacking every window at once. Absent with a single account,
|
|
2044
|
+
// where there is nowhere to go.
|
|
2045
|
+
accounts.length <= 1 ? null : h('span', { key: 'nav', className: 'cp-pill-nav' }, [
|
|
2046
|
+
h('button', {
|
|
2047
|
+
key: 'prev', type: 'button', className: 'cp-pill-arrow',
|
|
2048
|
+
title: t('usagePrev'), 'aria-label': t('usagePrev'), onClick: () => step(-1),
|
|
2049
|
+
}, '‹'),
|
|
2050
|
+
h('span', { key: 'who', className: 'cp-pill-who', title: account?.displayName ?? account?.account ?? '' },
|
|
2051
|
+
account?.displayName ?? account?.account ?? ''),
|
|
2052
|
+
h('span', { key: 'pos', className: 'cp-pill-pos' }, `${index + 1} / ${accounts.length}`),
|
|
2053
|
+
h('button', {
|
|
2054
|
+
key: 'next', type: 'button', className: 'cp-pill-arrow',
|
|
2055
|
+
title: t('usageNext'), 'aria-label': t('usageNext'), onClick: () => step(1),
|
|
2056
|
+
}, '›'),
|
|
2057
|
+
]),
|
|
2058
|
+
]),
|
|
1982
2059
|
rows === null
|
|
1983
2060
|
? h('div', { key: 'empty', className: 'cp-pill-fail' }, t('usagePillUnavailable'))
|
|
1984
2061
|
: rows.map(([type, key, limit]) => {
|
|
@@ -2101,7 +2178,18 @@ window.__ModuleLoader__.load({
|
|
|
2101
2178
|
// read has landed. Read it at render time rather than capturing it:
|
|
2102
2179
|
// the pill can mount before the first read completes.
|
|
2103
2180
|
route: () => controller.store.getSnapshot().data?.provider ?? NS,
|
|
2104
|
-
|
|
2181
|
+
// The panel's own snapshot. Switching accounts is a panel action that
|
|
2182
|
+
// updates this store at once, whereas a usage reading is only taken on
|
|
2183
|
+
// mount and on a timer — so the account is read from here, and a pill
|
|
2184
|
+
// keyed to its own reading names the previous account until the poll.
|
|
2185
|
+
store: controller.store,
|
|
2186
|
+
readUsage: async () => {
|
|
2187
|
+
const value = await controller.call('usage', {})
|
|
2188
|
+
// Keep the panel's snapshot as fresh as this reading, so whatever the
|
|
2189
|
+
// host has just named is visible everywhere without waiting a minute.
|
|
2190
|
+
controller.applyState(value)
|
|
2191
|
+
return value
|
|
2192
|
+
},
|
|
2105
2193
|
t: translate,
|
|
2106
2194
|
}),
|
|
2107
2195
|
}, UsagePill))
|
package/lib/index.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -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.6",
|
|
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",
|