dsh-cline-pass 0.2.0 → 0.2.1

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/README.en.md CHANGED
@@ -68,7 +68,7 @@ To override defaults, add a `cline-pass` section to your profile's `$DSH_HOME/pr
68
68
  apiKeyEnv: CLINE_PASS_BACKUP_KEY
69
69
  ```
70
70
 
71
- Common settings include `baseURL`, `knownModels`, `models`, `perModel`, `exposeCatalog`, and `historyLimit`. The default gateway is `https://api.cline.bot/api/v1`.
71
+ Common settings include `baseURL`, `knownModels`, `models`, `perModel`, `exposeCatalog`, and `historyLimit`. The default gateway is `https://api.cline.bot/api/v1`. `maxConcurrentRequests` caps how many streams each account keeps open to the gateway at once (default 16); further requests wait for a free slot, and `0` lifts the cap.
72
72
 
73
73
  ## Upstream channels
74
74
 
package/README.md CHANGED
@@ -68,7 +68,7 @@ cline_pass_accounts action=mode mode=roundrobin
68
68
  apiKeyEnv: CLINE_PASS_BACKUP_KEY
69
69
  ```
70
70
 
71
- 常用设置包括 `baseURL`、`knownModels`、`models`、`perModel`、`exposeCatalog` 和 `historyLimit`。默认网关地址为 `https://api.cline.bot/api/v1`。
71
+ 常用设置包括 `baseURL`、`knownModels`、`models`、`perModel`、`exposeCatalog` 和 `historyLimit`。默认网关地址为 `https://api.cline.bot/api/v1`。`maxConcurrentRequests` 限制每个账号同时发往网关的流数,默认 16,超出的请求排队等待空位,设为 `0` 表示不限。
72
72
 
73
73
  ## 上游渠道
74
74
 
package/lib/adapter.js CHANGED
@@ -38,6 +38,15 @@ import { parseServerSentEvents } from './engine.js'
38
38
 
39
39
  /** Default maximum idle interval while a stream read is outstanding. */
40
40
  export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300000
41
+ /**
42
+ * Default number of streams one account keeps open to the gateway at once.
43
+ *
44
+ * Twice dsh 0.1.7's default `maxActiveSubagents` (8): a main agent with a full
45
+ * set of subagents, plus a title or compaction call, never queues, while a
46
+ * runaway fan-out still cannot open an unbounded number of upstream streams
47
+ * (CWE-770). `0` lifts the cap.
48
+ */
49
+ export const DEFAULT_MAX_CONCURRENT_REQUESTS = 16
41
50
  /**
42
51
  * Conservative context capacity for a model the catalog does not describe.
43
52
  * Shipped models carry their published window, so this only bounds the
@@ -558,6 +567,81 @@ async function* tapRouting(payloads, observed) {
558
567
  }
559
568
  }
560
569
 
570
+ //#region upstream concurrency
571
+
572
+ /**
573
+ * Per-account slots for streams open to the gateway.
574
+ *
575
+ * A slot is held for a whole attempt — from before `fetch` until the response
576
+ * body is closed — because the stream, not the wait for headers, is what keeps
577
+ * a socket and the account's concurrency busy. Accounts are counted apart, so a
578
+ * stalled account cannot hold up requests routed to a healthy one; absorbing
579
+ * exactly that is what the pool is for.
580
+ *
581
+ * Waiters are served first in, first out, and a freed slot is handed to the
582
+ * next waiter and counted in the same step, so a request arriving in between
583
+ * cannot take it as well. A waiter whose signal aborts leaves the queue at
584
+ * once; a held slot is also freed when its signal aborts, so a consumer that
585
+ * stops reading without closing the stream does not keep it.
586
+ *
587
+ * @returns `{ acquire(key, limit, signal) }`, resolving to a release function
588
+ * that is safe to call more than once. A `limit` below 1 means no limit.
589
+ */
590
+ export function createUpstreamSlots() {
591
+ const pools = new Map()
592
+ const admit = (pool) => {
593
+ while (pool.waiting.length > 0 && pool.active < pool.limit) {
594
+ pool.active += 1
595
+ pool.waiting.shift()()
596
+ }
597
+ }
598
+ const hold = (pool, signal) => {
599
+ let held = true
600
+ const release = () => {
601
+ if (!held) return
602
+ held = false
603
+ signal?.removeEventListener('abort', release)
604
+ pool.active -= 1
605
+ admit(pool)
606
+ }
607
+ signal?.addEventListener('abort', release, { once: true })
608
+ return release
609
+ }
610
+ return {
611
+ async acquire(key, limit, signal) {
612
+ signal?.throwIfAborted()
613
+ let pool = pools.get(key)
614
+ if (pool === undefined) {
615
+ pool = { active: 0, limit: Infinity, waiting: [] }
616
+ pools.set(key, pool)
617
+ }
618
+ // Read on every call, so a changed setting reaches the next request; a
619
+ // raised limit admits the queue before this caller joins it.
620
+ pool.limit = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : Infinity
621
+ admit(pool)
622
+ if (pool.waiting.length === 0 && pool.active < pool.limit) {
623
+ pool.active += 1
624
+ return hold(pool, signal)
625
+ }
626
+ return await new Promise((resolve, reject) => {
627
+ const grant = () => {
628
+ signal?.removeEventListener('abort', abandon)
629
+ resolve(hold(pool, signal))
630
+ }
631
+ const abandon = () => {
632
+ const at = pool.waiting.indexOf(grant)
633
+ if (at !== -1) pool.waiting.splice(at, 1)
634
+ reject(signal.reason)
635
+ }
636
+ pool.waiting.push(grant)
637
+ signal?.addEventListener('abort', abandon, { once: true })
638
+ })
639
+ },
640
+ }
641
+ }
642
+
643
+ //#endregion
644
+
561
645
  /**
562
646
  * The Cline Pass adapter. One instance serves the plugin's provider route(s);
563
647
  * the harness model id IS the gateway's wire model id.
@@ -566,6 +650,7 @@ export class ClinePassAdapter extends LlmAdapter {
566
650
  constructor(config) {
567
651
  super()
568
652
  this.config = config
653
+ this.upstreamSlots = createUpstreamSlots()
569
654
  }
570
655
 
571
656
  providerInfo(provider) {
@@ -668,9 +753,18 @@ export class ClinePassAdapter extends LlmAdapter {
668
753
  const account = await this.config.resolveAccount({ exclude: [...refused] })
669
754
  if (account === undefined) break
670
755
  const body = injectPrefs(buildRequestBody(options, connection, images), meta, attempt)
671
- const attemptStarted = Date.now()
672
756
  const consumer = new AbortController()
673
757
  const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal])
758
+ // The account's slot is taken before the attempt's clock starts: the wait
759
+ // is local, and counting it would teach the channel a latency it never had.
760
+ let release
761
+ try {
762
+ release = await this.upstreamSlots.acquire(account.name, connection.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS, upstream)
763
+ } catch (error) {
764
+ if (options.signal?.aborted) throw new LlmError('Cline Pass request aborted by caller', 'ABORTED', { cause: error })
765
+ throw error
766
+ }
767
+ const attemptStarted = Date.now()
674
768
  const watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
675
769
  try {
676
770
  let response
@@ -847,6 +941,9 @@ export class ClinePassAdapter extends LlmAdapter {
847
941
  if (attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
848
942
  return
849
943
  } finally {
944
+ // Held until the attempt ends, response body included, so the slot
945
+ // bounds open streams rather than requests waiting for headers.
946
+ release()
850
947
  watchdog[Symbol.dispose]()
851
948
  }
852
949
  }
package/lib/client.js CHANGED
@@ -119,16 +119,43 @@ window.__ModuleLoader__.load({
119
119
  // track is resolved from that row's own content, so a row whose upstream
120
120
  // is a long pill and a row whose upstream is "(auto)" end up with
121
121
  // different column widths — and nothing lines up with the header.
122
- '.cp-history-table{width:100%;border-collapse:collapse;table-layout:fixed}',
122
+ //
123
+ // `table-layout:auto`, i.e. the browser's own algorithm, and no `min-width`:
124
+ // the table always fits its container, so the log never needs sideways
125
+ // scrolling, and the sizing decision is left to the engine that knows each
126
+ // cell's real content width.
127
+ //
128
+ // That decision matters because the content genuinely does not fit. Measured
129
+ // at this card's real width (about 490px of table inside the settings card,
130
+ // which caps at 760px): the columns need roughly 85 + 208 + 234 + 111 =
131
+ // 638px, so something has to give. Under `table-layout:fixed` plus shares,
132
+ // what gave was the right-hand figures — they were ellipsised to "13…" and
133
+ // "4…", losing the number the reader came for. Left to itself the engine
134
+ // gives way on the token column instead, which simply wraps to a second
135
+ // line: the figures stay whole and nothing is lost.
136
+ '.cp-history-table{width:100%;border-collapse:collapse;table-layout:auto}',
123
137
  // Header and data share one alignment: a table whose labels sit left of
124
138
  // their values is the thing this panel keeps getting asked to fix.
125
139
  '.cp-history-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-1);text-align:center;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);padding:2px 0 6px;border-bottom:0.5px solid var(--dsw-alias-border-l3)}',
126
140
  '.cp-history-table td{padding:6px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);font-size:12px;vertical-align:middle;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
127
141
  '.cp-history-table tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
128
- '.cp-history-col-dot{width:18px}',
129
- '.cp-history-col-when{width:62px}',
130
- '.cp-history-col-tokens{width:120px}',
131
- '.cp-history-col-load{width:108px}',
142
+ // Only two columns are pinned, and each to its measured need:
143
+ // 点 16px — the status dot
144
+ // 时间 82px — a clock, or a dated stamp like "09-24 04:48" (needs 85px,
145
+ // but the engine measures a little differently than the
146
+ // intrinsic probe; 82px is where the stamp stops clipping)
147
+ // TOKEN and 延迟 are left to the engine. 延迟 holds single-line figures, so
148
+ // it always gets what it needs — which is the point: previously the model
149
+ // column took every spare pixel while the figures on the right were clipped,
150
+ // and that is the "left too loose, right too tight" shape being fixed.
151
+ '.cp-history-col-dot{width:16px}',
152
+ '.cp-history-col-when{width:82px}',
153
+ // The columns used to sit flush against one another at `padding:0`, which is
154
+ // what made the right-hand figures read as cramped. A little padding on each
155
+ // side of the three content columns separates label from neighbour.
156
+ '.cp-history-table th:nth-child(2),.cp-history-table td:nth-child(2){padding-left:10px;padding-right:10px}',
157
+ '.cp-history-table th:nth-child(4),.cp-history-table td:nth-child(4){padding-left:10px;padding-right:10px}',
158
+ '.cp-history-table th:nth-child(5),.cp-history-table td:nth-child(5){padding-left:10px;padding-right:10px}',
132
159
  '.cp-history-when{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
133
160
  '.cp-history-model-cell{min-width:0}',
134
161
  // The flex stack lives INSIDE the cell, never on the `<td>` itself: a td
@@ -142,7 +169,20 @@ window.__ModuleLoader__.load({
142
169
  '.cp-history-table td.cp-history-tokens{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip;line-height:1.5}',
143
170
  // Three stacked figures read like a request log: label left, value right.
144
171
  '.cp-history-table td.cp-history-load{font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip}',
145
- '.cp-history-load-row{display:flex;justify-content:space-between;gap:6px;font-size:11.5px;line-height:1.5}',
172
+ // `space-between` is what keeps the three figures right-aligned with each
173
+ // other across rows, but it also turns every spare pixel of the column into
174
+ // a gap between label and value. Capping the row and centring it makes that
175
+ // surplus invisible instead, so the column can be given generous width at a
176
+ // wide panel without the figures drifting apart.
177
+ '.cp-history-load-row{display:flex;justify-content:space-between;gap:5px;max-width:112px;margin:0 auto;font-size:11.5px;line-height:1.5}',
178
+ // With the table no longer allowed to scroll sideways, a narrow pane can
179
+ // leave this column short of its need. The label must not wrap then — a
180
+ // taller cell would change the row height — so the label stays whole and
181
+ // the figure is the part that gives way, ellipsised with its exact value
182
+ // still available in the row tooltip.
183
+ '.cp-history-load-row>span{white-space:nowrap}',
184
+ '.cp-history-load-row>span:first-child{flex:none}',
185
+ '.cp-history-load-row>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;text-align:right}',
146
186
  '.cp-history-meta{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden}',
147
187
  // A failure gets its own row under the entry rather than widening it: the
148
188
  // gateway's messages are long enough to wreck a one-line layout.
@@ -173,8 +213,15 @@ window.__ModuleLoader__.load({
173
213
  '.cp-fold:hover .cp-chevron{color:var(--dsw-alias-label-primary)}',
174
214
  '.cp-set-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px}',
175
215
  '.cp-usage-groups{display:flex;flex-direction:column;gap:12px}',
176
- '.cp-usage-group+.cp-usage-group{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px}',
177
- '.cp-usage-account{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
216
+ // A pool shows one account at a time: the arrows are the only way to reach
217
+ // the others, so they sit on the same row as the name they act on.
218
+ '.cp-usage-pager{display:flex;align-items:center;gap:8px}',
219
+ '.cp-usage-nav{box-sizing:border-box;width:24px;height:24px;flex:none;padding:0;border:0.5px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:14px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}',
220
+ '.cp-usage-nav:hover{border-color:var(--dsw-alias-brand-primary)}',
221
+ '.cp-usage-name{display:flex;align-items:center;gap:8px;min-width:0}',
222
+ '.cp-usage-acct{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
223
+ '.cp-usage-active{border:0.5px solid var(--dsw-alias-brand-primary);border-radius:999px;padding:1px 7px;font-size:11px;font-weight:600;flex:none}',
224
+ '.cp-usage-pos{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
178
225
  '.cp-usage{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}',
179
226
  '.cp-usage-item{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
180
227
  '.cp-usage-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}',
@@ -262,6 +309,9 @@ window.__ModuleLoader__.load({
262
309
  usageUnavailable: '暂时读不到官方额度。',
263
310
  usageEmpty: '官方未返回额度窗口。',
264
311
  usageAccount: '账号 {name}',
312
+ usagePrev: '上一个账号',
313
+ usageNext: '下一个账号',
314
+ usagePosition: '第 {i} / {n} 个',
265
315
  historyWhen: '时间',
266
316
  historyModel: '模型',
267
317
  historyUpstream: '上游',
@@ -341,6 +391,9 @@ window.__ModuleLoader__.load({
341
391
  usageUnavailable: 'The official quota is unavailable right now.',
342
392
  usageEmpty: 'The gateway reported no quota windows.',
343
393
  usageAccount: 'account {name}',
394
+ usagePrev: 'Previous account',
395
+ usageNext: 'Next account',
396
+ usagePosition: '{i} of {n}',
344
397
  historyWhen: 'Time',
345
398
  historyModel: 'Model',
346
399
  historyUpstream: 'Upstream',
@@ -1443,10 +1496,17 @@ window.__ModuleLoader__.load({
1443
1496
  * One reading per window: the official percentage, plus when it resets.
1444
1497
  */
1445
1498
  function UsageCard(props) {
1446
- const { t, usage, disabled, actions, unavailable } = props
1499
+ const { t, usage, disabled, actions, unavailable, activeAccount, accountMode } = props
1447
1500
  const fetched = Number(usage?.fetchedAt ?? 0) > 0
1448
1501
  const accounts = usage?.accounts ?? []
1449
- const anyWindow = accounts.some((entry) => (entry.limits ?? []).length > 0)
1502
+ // A pool is shown one account at a time. Stacking every account turned the
1503
+ // card into a wall of bars and pushed the one reading a user came for below
1504
+ // the fold, so the arrows — not a scroll — are what moves between accounts.
1505
+ const [picked, setPicked] = React.useState(0)
1506
+ // The selection is an index, so a shorter list — an account removed, or its
1507
+ // key deleted — can leave it past the end. Clamp instead of rendering nothing.
1508
+ const index = accounts.length === 0 ? 0 : Math.min(Math.max(picked, 0), accounts.length - 1)
1509
+ const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
1450
1510
  const windowRow = (type, key, limit) => {
1451
1511
  const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
1452
1512
  const left = countdown(t, limit.resetsAt)
@@ -1464,8 +1524,14 @@ window.__ModuleLoader__.load({
1464
1524
  left === '' ? null : h('div', { key: 'note', className: 'cp-usage-note' }, fill(t('usageResetsIn'), { t: left })),
1465
1525
  ])
1466
1526
  }
1467
- /** One account's block: its name when there is more than one, then its windows. */
1468
- const accountGroup = (account) => {
1527
+ /**
1528
+ * One account's block: a name row that doubles as the account switcher,
1529
+ * then its windows.
1530
+ *
1531
+ * The arrows only exist in a pool. With a single account there is nowhere
1532
+ * to go, so they would be two controls that cannot do anything.
1533
+ */
1534
+ const accountGroup = (account, at) => {
1469
1535
  const limits = account.limits ?? []
1470
1536
  const known = new Map(limits.map((limit) => [String(limit.type), limit]))
1471
1537
  const windows = [
@@ -1479,15 +1545,41 @@ window.__ModuleLoader__.load({
1479
1545
  : windows.length === 0
1480
1546
  ? h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1481
1547
  : h('div', { key: 'bars', className: 'cp-usage' }, windows.map(([type, key, limit]) => windowRow(type, key, limit)))
1482
- return h('div', { key: account.account, className: 'cp-usage-group' }, [
1483
- accounts.length > 1 ? h('div', { key: 'name', className: 'cp-usage-account' }, account.account) : null,
1484
- inner,
1485
- ])
1548
+ const pooled = accounts.length > 1
1549
+ const label = account.displayName || account.account
1550
+ // Only a single-account pool has a "current" account: in round-robin every
1551
+ // request rotates, so a badge here would name a fact that keeps changing.
1552
+ const isActive = accountMode !== 'roundrobin' && activeAccount !== '' && account.account === activeAccount
1553
+ const head = pooled
1554
+ ? h('div', { key: 'name', className: 'cp-usage-pager' }, [
1555
+ // Switching accounts re-reads nothing: the whole pool was read in one
1556
+ // request, so paging is local and stays available while the panel is
1557
+ // busy or the settings service is read-only.
1558
+ h('button', {
1559
+ key: 'prev', type: 'button', className: 'cp-usage-nav',
1560
+ title: t('usagePrev'), 'aria-label': t('usagePrev'),
1561
+ onClick: () => step(-1),
1562
+ }, '‹'),
1563
+ h('div', { key: 'who', className: 'cp-usage-name' }, [
1564
+ h('span', { key: 'n', className: 'cp-usage-acct', title: label }, label),
1565
+ isActive ? h('span', { key: 'a', className: 'cp-usage-active' }, t('accountsActive')) : null,
1566
+ h('span', { key: 'p', className: 'cp-usage-pos' }, fill(t('usagePosition'), { i: at + 1, n: accounts.length })),
1567
+ ]),
1568
+ h('button', {
1569
+ key: 'next', type: 'button', className: 'cp-usage-nav',
1570
+ title: t('usageNext'), 'aria-label': t('usageNext'),
1571
+ onClick: () => step(1),
1572
+ }, '›'),
1573
+ ])
1574
+ : null
1575
+ return h('div', { key: String(account.account), className: 'cp-usage-group' }, [head, inner])
1486
1576
  }
1487
1577
  let body
1488
1578
  if (!fetched) body = h('div', { key: 'wait', className: 'cp-muted' }, unavailable === true ? t('usageUnavailable') : t('usageLoading'))
1489
1579
  else if (accounts.length === 0) body = h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1490
- else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accounts.map(accountGroup))
1580
+ // `index` is the account on screen; its own key remounts the block so the
1581
+ // windows never animate between two accounts' readings.
1582
+ else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accountGroup(accounts[index], index))
1491
1583
  const title = accounts.length > 1
1492
1584
  ? `${t('usageTitle')} · ${fill(t('accountsCount'), { n: accounts.length })}`
1493
1585
  : `${t('usageTitle')}${accounts[0] ? ` · ${fill(t('usageAccount'), { name: accounts[0].account })}` : ''}`
@@ -1607,6 +1699,13 @@ window.__ModuleLoader__.load({
1607
1699
  key: 'usage',
1608
1700
  t,
1609
1701
  usage: data.usage,
1702
+ // Which account the pool would use, resolved the same way the accounts
1703
+ // card resolves it: an empty `activeAccount` means "first enabled", not
1704
+ // "none", so the badge has to name that account rather than nobody.
1705
+ activeAccount: data.activeAccount !== ''
1706
+ ? data.activeAccount
1707
+ : (data.accounts.find((account) => account.enabled)?.key ?? ''),
1708
+ accountMode: data.accountMode,
1610
1709
  unavailable: readFailed,
1611
1710
  disabled,
1612
1711
  actions,
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
22
22
  import {
23
23
  ClinePassAdapter,
24
24
  DEFAULT_CONTEXT_WINDOW,
25
+ DEFAULT_MAX_CONCURRENT_REQUESTS,
25
26
  DEFAULT_MAX_TOKENS,
26
27
  DEFAULT_REQUEST_IMAGE_POLICY,
27
28
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
@@ -34,7 +35,7 @@ import { BUILD_TAG } from './protocol.js'
34
35
  import { createStore } from './store.js'
35
36
  import { registerTools } from './tools.js'
36
37
 
37
- export { ClinePassAdapter, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_REQUEST_IMAGE_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS }
38
+ export { ClinePassAdapter, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_CONCURRENT_REQUESTS, DEFAULT_MAX_TOKENS, DEFAULT_REQUEST_IMAGE_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS }
38
39
  export { collectImageRefs, prepareRequestImages, projectImageDimensions, requestImageTarget } from './adapter.js'
39
40
  export { MODEL_CATALOG, REASONING_EFFORTS } from './catalog.js'
40
41
  export { createEngine } from './engine.js'
@@ -136,6 +137,13 @@ export const Config = z.object({
136
137
  defaultContextWindow: z.number().default(DEFAULT_CONTEXT_WINDOW),
137
138
  defaultMaxTokens: z.number().default(DEFAULT_MAX_TOKENS),
138
139
  streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
140
+ /**
141
+ * Streams one account may keep open to the gateway at once.
142
+ *
143
+ * A request past the cap waits for a slot (and can still be cancelled while it
144
+ * waits) instead of opening another stream. `0` lifts the cap.
145
+ */
146
+ maxConcurrentRequests: z.number().default(DEFAULT_MAX_CONCURRENT_REQUESTS),
139
147
  exposeCatalog: z.boolean().default(false),
140
148
  historyLimit: z.number().default(100),
141
149
  /**
@@ -303,6 +311,8 @@ export function apply(ctx, config) {
303
311
  const key = await resolveKey(profile.apiKeyEnv).catch(() => undefined)
304
312
  return {
305
313
  name,
314
+ // The name a person recognizes, for the panel's account switcher.
315
+ displayName: profile.displayName,
306
316
  key: key ?? '',
307
317
  keyConfigured: key !== undefined,
308
318
  baseURL: profile.baseURL || cfg.baseURL,
@@ -342,6 +352,7 @@ export function apply(ctx, config) {
342
352
  maxTokens: cfg.defaultMaxTokens,
343
353
  reasoningModels: cfg.reasoningModels,
344
354
  streamIdleTimeoutMs: cfg.streamIdleTimeoutMs,
355
+ maxConcurrentRequests: cfg.maxConcurrentRequests,
345
356
  }
346
357
  }
347
358
 
package/lib/panel.js CHANGED
@@ -155,12 +155,18 @@ export function createPanel({ control, engine, store }) {
155
155
  const accounts = await control.usageAccounts()
156
156
  if (accounts.length === 0) throw new Error('every configured Cline Pass account is disabled; enable one first')
157
157
  const readings = await Promise.all(accounts.map(async (account) => {
158
- if (account.keyConfigured !== true) return { account: account.name, ok: false, limits: [], error: `no API key stored for ${account.name}` }
158
+ // The reading is keyed by the configuration name, but the panel's switcher
159
+ // shows the name the accounts card shows, so both travel together.
160
+ const label = {
161
+ account: account.name,
162
+ displayName: String(account.displayName ?? '') || String(account.name),
163
+ }
164
+ if (account.keyConfigured !== true) return { ...label, ok: false, limits: [], error: `no API key stored for ${account.name}` }
159
165
  const result = await fetchUsageLimits({
160
166
  baseURL: account.baseURL || String(config.baseURL ?? ''),
161
167
  apiKey: account.key,
162
168
  })
163
- return { account: account.name, ...result }
169
+ return { ...label, ...result }
164
170
  }))
165
171
  const reading = store.setUsage({ accounts: readings })
166
172
  return { ...(await state()), usage: reading }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",