dsh-cline-pass 0.2.10 → 0.2.12

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,17 +54,7 @@ export const DEFAULT_MAX_CONCURRENT_REQUESTS = 16
54
54
  * unknown ones; raise it per model in configuration when a new model ships.
55
55
  */
56
56
  export const DEFAULT_CONTEXT_WINDOW = 128000
57
- /**
58
- * Per-request output budget for a model whose caller names no `maxTokens`.
59
- *
60
- * A budget, not a ceiling: the harness materializes it into requests that omit
61
- * `maxTokens` and reserves the same number as completion room before it compacts,
62
- * so this is the number that decides how much of the window stays available to the
63
- * conversation. 64k is far past any single response a tool-calling agent emits
64
- * while keeping the reserve well under the point where it would start displacing
65
- * the compaction ratio. The published ceiling clamps it per model.
66
- */
67
- export const DEFAULT_MAX_TOKENS = 64000
57
+ export { DEFAULT_MAX_TOKENS } from './catalog.js'
68
58
  /**
69
59
  * Request-image budget applied when the caller names none.
70
60
  *
@@ -992,7 +982,11 @@ export class ClinePassAdapter extends LlmAdapter {
992
982
  : 'ok',
993
983
  })
994
984
  this.config.record(options.model, {
995
- provider: observed.provider ?? (attempt.upstream ?? null),
985
+ // The upstream the gateway actually served from, never the one we asked
986
+ // for: a pin the gateway declined is exactly what this column exists to
987
+ // expose, and filling it with the requested name would report the pin as if
988
+ // it had been adopted. An unreported routing reads as unknown upstream.
989
+ provider: observed.provider,
996
990
  canonical: observed.canonical,
997
991
  attempts: trace.map((row) => row.upstream),
998
992
  ms: Date.now() - startedAt,
package/lib/catalog.js CHANGED
@@ -167,6 +167,22 @@ function pick(configured, published, fallback) {
167
167
  return fallback
168
168
  }
169
169
 
170
+ /**
171
+ * Per-request output budget used when nothing else names one.
172
+ *
173
+ * A budget, not a ceiling: the harness materializes it into requests that omit
174
+ * `maxTokens`, and reserves the same number as completion room before it compacts, so
175
+ * this is the number that decides how much of the window stays available to the
176
+ * conversation.
177
+ *
178
+ * 128k doubles the room a long reasoning pass gets without moving that point on a 1M
179
+ * window: the 80% rule stays the binding one until the reserve passes ~144k. It is
180
+ * not applied flat, though — a 128k reserve on a 256k window would leave almost
181
+ * nothing to compact into — so the shipped default scales to the window while a
182
+ * configured value does not, because that one is a choice rather than a default.
183
+ */
184
+ export const DEFAULT_MAX_TOKENS = 131072
185
+
170
186
  /**
171
187
  * The seam's whole `ModelModality` vocabulary. A catalog entry may list more
172
188
  * than this (models.dev reports `audio` for mimo-v2.5); the seam would reject
@@ -219,8 +235,11 @@ function resolveOutputBudget(budget, ceiling, contextWindow) {
219
235
  const most = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : undefined
220
236
  const chosen = wanted === undefined ? most : most === undefined ? wanted : Math.min(wanted, most)
221
237
  if (chosen === undefined) return undefined
222
- const half = Number.isFinite(contextWindow) && contextWindow > 0 ? Math.floor(contextWindow / 2) : undefined
223
- return half === undefined ? chosen : Math.min(chosen, half)
238
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return chosen
239
+ // The shipped default follows the window; a configured value is the user's choice
240
+ // and is only held back from reserving the window outright.
241
+ const share = wanted === DEFAULT_MAX_TOKENS ? 8 : 2
242
+ return Math.min(chosen, Math.floor(contextWindow / share))
224
243
  }
225
244
 
226
245
  /**
package/lib/client.js CHANGED
@@ -77,6 +77,7 @@ window.__ModuleLoader__.load({
77
77
  '.cp-input,.cp-select{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:0.5px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px;font-size:13px;min-width:0}',
78
78
  '.cp-input:focus-visible,.cp-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
79
79
  '.cp-btn{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer}',
80
+ '.cp-btn-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
80
81
  '.cp-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary)}',
81
82
  '.cp-btn:disabled{opacity:.5;cursor:default}',
82
83
  '.cp-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);font-weight:600}',
@@ -118,6 +119,11 @@ window.__ModuleLoader__.load({
118
119
  '.cp-acct-newname{flex:0 0 150px}',
119
120
  '.cp-acct-add,.cp-acct-key{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px;margin-top:2px}',
120
121
  '.cp-chips{display:flex;flex-wrap:wrap;gap:6px}',
122
+ '.cp-chip-manual{border-style:dashed;color:var(--dsw-alias-label-tertiary)}',
123
+ '.cp-small{font-size:11.5px}',
124
+ '.cp-caps{flex-direction:column;gap:8px;display:flex}',
125
+ '.cp-caps-boxes{flex-wrap:wrap;gap:10px}',
126
+ '.cp-cap{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}',
121
127
  '.cp-chip{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:2px 8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}',
122
128
  '.cp-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:600}',
123
129
  '.cp-chip-excluded{text-decoration:line-through;color:var(--dsw-alias-label-tertiary)}',
@@ -194,6 +200,7 @@ window.__ModuleLoader__.load({
194
200
  '.cp-history-load-row>span{white-space:nowrap}',
195
201
  '.cp-history-load-row>span:first-child{flex:none}',
196
202
  '.cp-history-load-row>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;text-align:right}',
203
+ '.cp-history-token-stack{display:flex;flex-direction:column;gap:1px;align-items:center}',
197
204
  '.cp-history-meta{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden}',
198
205
  // A failure gets its own row under the entry rather than widening it: the
199
206
  // gateway's messages are long enough to wreck a one-line layout.
@@ -355,13 +362,15 @@ window.__ModuleLoader__.load({
355
362
  historyUpstream: '上游',
356
363
  historyLoad: '延迟',
357
364
  historyTokens: 'TOKEN',
365
+ historyCacheHit: '缓存',
358
366
  historyFirstChunk: '首字',
359
367
  historyTotal: '总耗时',
360
368
  historyRate: '输出速率',
361
369
  historyTokensHint: '↓输入 ↑输出 ⚡缓存 🧠推理;— 表示网关本次未返回用量',
362
370
  historyEffort: '推理强度',
363
371
  historyAuto: '(自动)',
364
- noChannels: '未探测渠道',
372
+ historyUnknown: '(未上报)',
373
+ noChannels: '未探测到渠道,可手动输入',
365
374
  pinned: '已钉住',
366
375
  auto: '自动路由',
367
376
  modeAuto: '智能路由',
@@ -374,6 +383,24 @@ window.__ModuleLoader__.load({
374
383
  reset: '恢复自动',
375
384
  refresh: '刷新',
376
385
  channels: '渠道(点选=钉住顺序,⊘=排除)',
386
+ channelPlaceholder: '输入渠道名,回车添加',
387
+ budget: '输出预算',
388
+ budgetCurrent: '{n} tok',
389
+ budgetRecommended: '128K(推荐)',
390
+ budgetOfficial: '256K(对齐官方)',
391
+ budgetCustom: '自定义 token 数',
392
+ channelAdd: '添加',
393
+ channelUnverified: '未验证',
394
+ channelsKnown: '已探测:{list}',
395
+ capabilities: '上游能力',
396
+ capabilitiesExtraPlaceholder: '如 quantization:fp8 或 !quantization:bf16',
397
+ capabilitiesDirect: '最近一次探测走 OpenRouter,该路由会忽略此筛选',
398
+ capFree: '免费',
399
+ capImplicitCaching: '隐式缓存',
400
+ capReasoning: '推理',
401
+ capStructuredOutput: '结构化输出',
402
+ capToolUse: '工具调用',
403
+ capVision: '视觉',
377
404
  history: '最近请求',
378
405
  noHistory: '暂无记录。',
379
406
  busy: '处理中…',
@@ -447,13 +474,15 @@ window.__ModuleLoader__.load({
447
474
  historyUpstream: 'Upstream',
448
475
  historyLoad: 'Latency',
449
476
  historyTokens: 'Tokens',
477
+ historyCacheHit: 'cache',
450
478
  historyFirstChunk: 'first',
451
479
  historyTotal: 'total',
452
480
  historyRate: 'rate',
453
481
  historyTokensHint: '↓in ↑out ⚡cache 🧠reasoning; a dash means the gateway reported no usage',
454
482
  historyEffort: 'effort',
455
483
  historyAuto: '(auto)',
456
- noChannels: 'No channels probed',
484
+ historyUnknown: '(unreported)',
485
+ noChannels: 'Nothing probed yet — type a name',
457
486
  pinned: 'Pinned',
458
487
  auto: 'Auto',
459
488
  modeAuto: 'Smart routing',
@@ -466,6 +495,24 @@ window.__ModuleLoader__.load({
466
495
  reset: 'Back to auto',
467
496
  refresh: 'Refresh',
468
497
  channels: 'Channels (click = pin order, ⊘ = exclude)',
498
+ channelPlaceholder: 'Channel name, Enter to add',
499
+ budget: 'Output budget',
500
+ budgetCurrent: '{n} tok',
501
+ budgetRecommended: '128K (recommended)',
502
+ budgetOfficial: '256K (matches official)',
503
+ budgetCustom: 'Custom token count',
504
+ channelAdd: 'Add',
505
+ channelUnverified: 'unverified',
506
+ channelsKnown: 'Probed: {list}',
507
+ capabilities: 'Upstream capabilities',
508
+ capabilitiesExtraPlaceholder: 'e.g. quantization:fp8 or !quantization:bf16',
509
+ capabilitiesDirect: 'The last probe saw OpenRouter; that route ignores this filter',
510
+ capFree: 'Free',
511
+ capImplicitCaching: 'Implicit caching',
512
+ capReasoning: 'Reasoning',
513
+ capStructuredOutput: 'Structured output',
514
+ capToolUse: 'Tool use',
515
+ capVision: 'Vision',
469
516
  history: 'Recent requests',
470
517
  noHistory: 'Nothing recorded.',
471
518
  busy: 'Working…',
@@ -751,6 +798,7 @@ window.__ModuleLoader__.load({
751
798
  pinModel: (payload) => controller.pinModel(payload),
752
799
  setModelVisible: (model, visible) => controller.setModelVisible(model, visible),
753
800
  setToolsExposed: (value) => controller.setToolsExposed(value),
801
+ setBudget: (value) => controller.setBudget(value),
754
802
  setModelsVisibility: (mode) => controller.setModelsVisibility(mode),
755
803
  probeModel: (model) => controller.probeModel(model),
756
804
  validateModel: (model) => controller.validateModel(model),
@@ -941,6 +989,13 @@ window.__ModuleLoader__.load({
941
989
  return result
942
990
  }
943
991
 
992
+ /** Set the per-request output budget, in tokens. */
993
+ async setBudget(value) {
994
+ const result = await this.run('budget', async () => await this.call('budget.set', { value }))
995
+ if (result !== undefined) this.applyState(result)
996
+ return result
997
+ }
998
+
944
999
  /**
945
1000
  * Show, hide or swap every subscription model at once.
946
1001
  *
@@ -1123,47 +1178,188 @@ window.__ModuleLoader__.load({
1123
1178
  }
1124
1179
 
1125
1180
  /** The channel chooser for one model. */
1126
- function ChannelPicker(props) {
1181
+ /** The capability names the gateway accepts, in its own spelling. */
1182
+ const CAPABILITIES = [
1183
+ ['free', 'capFree'],
1184
+ ['implicit-caching', 'capImplicitCaching'],
1185
+ ['reasoning', 'capReasoning'],
1186
+ ['structured-output', 'capStructuredOutput'],
1187
+ ['tool-use', 'capToolUse'],
1188
+ ['vision', 'capVision'],
1189
+ ]
1190
+
1191
+ /**
1192
+ * The channel list, editable.
1193
+ *
1194
+ * The chips used to be exactly what a probe returned, which made an account whose
1195
+ * probe came back empty a dead end: the row said no channels were probed and
1196
+ * offered no way to name the upstream the user already knew served them. The list
1197
+ * is the user's data now — a probe fills it in, typing fills it in, and both are
1198
+ * the same list.
1199
+ */
1200
+ function ChannelEditor(props) {
1127
1201
  const { t, model, disabled, onPin } = props
1128
- if (model.upstreams.length === 0) return h('div', { className: 'cp-muted' }, t('noChannels'))
1202
+ const [draft, setDraft] = React.useState('')
1129
1203
  const verdicts = new Map((model.upstreamStatus ?? []).map((entry) => [entry.upstream, entry]))
1130
- return h('div', { className: 'cp-row' }, [
1131
- h('span', { key: 'label', className: 'cp-muted' }, t('channels')),
1132
- h('span', { key: 'chips', className: 'cp-chips' }, model.upstreams.map((upstream) => {
1133
- const pinned = model.pinned.includes(upstream)
1134
- const excluded = model.excluded.includes(upstream)
1135
- const verdict = verdicts.get(upstream)
1136
- const detail = verdict === undefined
1137
- ? ''
1138
- : ` · ${VERDICT[verdict.status] ?? verdict.status}${verdict.note ? `\n${verdict.note}` : ''}`
1139
- return h('span', { key: upstream, className: `cp-chip${pinned ? ' cp-chip-on' : ''}${excluded ? ' cp-chip-excluded' : ''}` }, [
1140
- h('span', {
1141
- key: 'pin',
1142
- role: 'button',
1143
- tabIndex: 0,
1144
- title: `${upstream}${detail}`,
1145
- onClick: () => {
1146
- if (disabled) return
1147
- const next = pinned ? model.pinned.filter((name) => name !== upstream) : [...model.pinned, upstream]
1148
- onPin({ model: model.id, upstreams: next })
1149
- },
1150
- }, [
1151
- h('span', { key: 'dot', style: { marginRight: 5 } }, h(Dot, { color: verdictColor(verdict?.status ?? 'unknown'), title: VERDICT[verdict?.status ?? 'unknown'] })),
1152
- upstream,
1153
- ]),
1154
- h('span', {
1155
- key: 'ex',
1156
- role: 'button',
1157
- tabIndex: 0,
1158
- title: excluded ? 'un-exclude' : 'exclude',
1159
- onClick: () => {
1160
- if (disabled) return
1161
- const next = excluded ? model.excluded.filter((name) => name !== upstream) : [...model.excluded, upstream]
1162
- onPin({ model: model.id, exclude: next })
1163
- },
1164
- }, '⊘'),
1165
- ])
1166
- })),
1204
+ const known = model.upstreams
1205
+ // A chosen name shows even when no probe reported it: a chip that disappears is
1206
+ // a chip that cannot be removed.
1207
+ const names = [...new Set([...known, ...model.pinned, ...model.excluded])]
1208
+ const pin = (next) => {
1209
+ if (disabled) return
1210
+ onPin({ model: model.id, upstreams: next })
1211
+ }
1212
+ const add = () => {
1213
+ const name = draft.trim()
1214
+ if (name === '') return
1215
+ setDraft('')
1216
+ const needle = name.toLowerCase()
1217
+ if (model.pinned.some((entry) => String(entry).toLowerCase() === needle)) return
1218
+ pin([...model.pinned, name])
1219
+ }
1220
+ return h('div', { className: 'cp-caps' }, [
1221
+ h('div', { key: 'row', className: 'cp-row' }, [
1222
+ h('span', { key: 'label', className: 'cp-muted' }, t('channels')),
1223
+ names.length === 0
1224
+ ? h('span', { key: 'none', className: 'cp-muted' }, t('noChannels'))
1225
+ : h('span', { key: 'chips', className: 'cp-chips' }, names.map((upstream) => {
1226
+ const pinned = model.pinned.includes(upstream)
1227
+ const excluded = model.excluded.includes(upstream)
1228
+ const probed = known.includes(upstream)
1229
+ const verdict = verdicts.get(upstream)
1230
+ const detail = verdict === undefined
1231
+ ? ''
1232
+ : ` · ${VERDICT[verdict.status] ?? verdict.status}${verdict.note ? `\n${verdict.note}` : ''}`
1233
+ return h('span', {
1234
+ key: upstream,
1235
+ className: `cp-chip${pinned ? ' cp-chip-on' : ''}${excluded ? ' cp-chip-excluded' : ''}${probed ? '' : ' cp-chip-manual'}`,
1236
+ }, [
1237
+ h('span', {
1238
+ key: 'pin',
1239
+ role: 'button',
1240
+ tabIndex: 0,
1241
+ title: `${upstream}${probed ? '' : ` · ${t('channelUnverified')}`}${detail}`,
1242
+ onClick: () => {
1243
+ if (disabled) return
1244
+ const next = pinned ? model.pinned.filter((name) => name !== upstream) : [...model.pinned, upstream]
1245
+ onPin({ model: model.id, upstreams: next })
1246
+ },
1247
+ }, [
1248
+ h('span', { key: 'dot', style: { marginRight: 5 } }, h(Dot, { color: verdictColor(verdict?.status ?? 'unknown'), title: VERDICT[verdict?.status ?? 'unknown'] })),
1249
+ upstream,
1250
+ ]),
1251
+ h('span', {
1252
+ key: 'ex',
1253
+ role: 'button',
1254
+ tabIndex: 0,
1255
+ title: excluded ? 'un-exclude' : 'exclude',
1256
+ onClick: () => {
1257
+ if (disabled) return
1258
+ const next = excluded ? model.excluded.filter((name) => name !== upstream) : [...model.excluded, upstream]
1259
+ onPin({ model: model.id, exclude: next })
1260
+ },
1261
+ }, '⊘'),
1262
+ // A name that only exists because someone typed it has no discovery
1263
+ // list to fall back on, so it needs an explicit way out: un-pinning
1264
+ // removes it, and this is what says so.
1265
+ probed ? null : h('span', {
1266
+ key: 'del',
1267
+ role: 'button',
1268
+ tabIndex: 0,
1269
+ title: 'remove',
1270
+ onClick: () => {
1271
+ if (disabled) return
1272
+ onPin({
1273
+ model: model.id,
1274
+ upstreams: model.pinned.filter((name) => name !== upstream),
1275
+ exclude: model.excluded.filter((name) => name !== upstream),
1276
+ })
1277
+ },
1278
+ }, '×'),
1279
+ ])
1280
+ })),
1281
+ ]),
1282
+ h('div', { key: 'add', className: 'cp-row' }, [
1283
+ h('input', {
1284
+ key: 'in',
1285
+ type: 'text',
1286
+ className: 'cp-input cp-grow',
1287
+ value: draft,
1288
+ disabled,
1289
+ placeholder: t('channelPlaceholder'),
1290
+ 'aria-label': `${t('channelAdd')}: ${model.id}`,
1291
+ onChange: (event) => setDraft(event.target.value),
1292
+ onKeyDown: (event) => {
1293
+ if (event.key !== 'Enter') return
1294
+ event.preventDefault()
1295
+ add()
1296
+ },
1297
+ }),
1298
+ h('button', { key: 'b', type: 'button', className: 'cp-btn', disabled, onClick: add }, t('channelAdd')),
1299
+ ]),
1300
+ known.length === 0 ? null : h('div', { key: 'known', className: 'cp-muted cp-small' }, fill(t('channelsKnown'), { list: known.join(' · ') })),
1301
+ ])
1302
+ }
1303
+
1304
+ /**
1305
+ * The capabilities an upstream must offer for this model.
1306
+ *
1307
+ * The gateway applies this itself and rejects a name it does not know, so the six
1308
+ * it documents are checkboxes and anything else — a weight format such as
1309
+ * `quantization:fp8`, or its negation — is typed. It only exists on the planner
1310
+ * pipeline: OpenRouter has no equivalent, and a model known to run there says so
1311
+ * rather than letting a filter look active while it is ignored.
1312
+ */
1313
+ function CapabilityFilter(props) {
1314
+ const { t, model, disabled, onPin } = props
1315
+ const [extra, setExtra] = React.useState('')
1316
+ const chosen = model.has ?? []
1317
+ const write = (next) => {
1318
+ if (disabled) return
1319
+ onPin({ model: model.id, has: next })
1320
+ }
1321
+ const toggle = (id) => write(chosen.includes(id) ? chosen.filter((name) => name !== id) : [...chosen, id])
1322
+ const addExtra = () => {
1323
+ const value = extra.trim()
1324
+ if (value === '') return
1325
+ setExtra('')
1326
+ if (chosen.includes(value)) return
1327
+ write([...chosen, value])
1328
+ }
1329
+ const extras = chosen.filter((name) => !CAPABILITIES.some(([id]) => id === name))
1330
+ return h('div', { className: 'cp-caps' }, [
1331
+ h('div', { key: 'head', className: 'cp-row' }, [
1332
+ h('span', { key: 'l', className: 'cp-muted' }, t('capabilities')),
1333
+ model.pipeline === 'direct' ? h('span', { key: 'w', className: 'cp-muted cp-small' }, t('capabilitiesDirect')) : null,
1334
+ ]),
1335
+ h('span', { key: 'boxes', className: 'cp-row cp-caps-boxes' }, CAPABILITIES.map(([id, key]) =>
1336
+ h('label', { key: id, className: 'cp-cap' }, [
1337
+ h('input', {
1338
+ key: 'c', type: 'checkbox', className: 'cp-check',
1339
+ checked: chosen.includes(id), disabled, onChange: () => toggle(id),
1340
+ }),
1341
+ h('span', { key: 't' }, t(key)),
1342
+ ]))),
1343
+ h('div', { key: 'extra', className: 'cp-row' }, [
1344
+ h('input', {
1345
+ key: 'in', type: 'text', className: 'cp-input cp-grow',
1346
+ value: extra, disabled,
1347
+ placeholder: t('capabilitiesExtraPlaceholder'),
1348
+ 'aria-label': `${t('capabilities')}: ${model.id}`,
1349
+ onChange: (event) => setExtra(event.target.value),
1350
+ onKeyDown: (event) => {
1351
+ if (event.key !== 'Enter') return
1352
+ event.preventDefault()
1353
+ addExtra()
1354
+ },
1355
+ }),
1356
+ h('button', { key: 'b', type: 'button', className: 'cp-btn', disabled, onClick: addExtra }, t('channelAdd')),
1357
+ ]),
1358
+ extras.length === 0 ? null : h('span', { key: 'list', className: 'cp-chips' }, extras.map((name) =>
1359
+ h('span', { key: name, className: 'cp-chip cp-chip-on' }, [
1360
+ h('span', { key: 'n' }, name),
1361
+ h('span', { key: 'x', role: 'button', tabIndex: 0, title: 'remove', onClick: () => toggle(name) }, '×'),
1362
+ ]))),
1167
1363
  ])
1168
1364
  }
1169
1365
 
@@ -1230,9 +1426,8 @@ window.__ModuleLoader__.load({
1230
1426
  action('test', t('testPin'), actions.testModel),
1231
1427
  action('reset', t('reset'), actions.resetModel),
1232
1428
  ]),
1233
- model.upstreams.length > 0
1234
- ? h(ChannelPicker, { key: 'channels', t, model, disabled, onPin: (payload) => actions.pinModel(payload) })
1235
- : h('div', { key: 'none', className: 'cp-muted' }, t('noChannels')),
1429
+ h(ChannelEditor, { key: 'channels', t, model, disabled, onPin: (payload) => actions.pinModel(payload) }),
1430
+ h(CapabilityFilter, { key: 'caps', t, model, disabled, onPin: (payload) => actions.pinModel(payload) }),
1236
1431
  ]) : null,
1237
1432
  ])
1238
1433
  }
@@ -1428,6 +1623,60 @@ window.__ModuleLoader__.load({
1428
1623
  ])
1429
1624
  }
1430
1625
 
1626
+ /**
1627
+ * The per-request output budget.
1628
+ *
1629
+ * One number for the whole route: the harness reserves it as completion room on
1630
+ * every request, so it decides where compaction fires as much as how long one
1631
+ * response may be. The two presets are the shipped default and the figure
1632
+ * DeepSeek's own provider uses; anything else is typed.
1633
+ */
1634
+ function BudgetCard(props) {
1635
+ const { t, data, disabled, actions } = props
1636
+ const current = Number(data.defaultMaxTokens ?? 0)
1637
+ const [draft, setDraft] = React.useState('')
1638
+ const write = (value) => {
1639
+ if (disabled) return
1640
+ actions.setBudget(value)
1641
+ }
1642
+ const custom = () => {
1643
+ const value = Number(draft.trim())
1644
+ if (!Number.isSafeInteger(value) || value < 1024) return
1645
+ setDraft('')
1646
+ write(value)
1647
+ }
1648
+ return h('div', { className: 'cp-card' }, [
1649
+ h('div', { key: 'title', className: 'cp-card-title' }, `${t('budget')} · ${fill(t('budgetCurrent'), { n: current.toLocaleString('en-US') })}`),
1650
+ h('div', { key: 'row', className: 'cp-row' }, [
1651
+ h('button', {
1652
+ key: 'recommended', type: 'button',
1653
+ className: `cp-btn${current === 131072 ? ' cp-btn-on' : ''}`,
1654
+ disabled, onClick: () => write(131072),
1655
+ }, t('budgetRecommended')),
1656
+ h('button', {
1657
+ key: 'official', type: 'button',
1658
+ className: `cp-btn${current === 256000 ? ' cp-btn-on' : ''}`,
1659
+ disabled, onClick: () => write(256000),
1660
+ }, t('budgetOfficial')),
1661
+ ]),
1662
+ h('div', { key: 'custom', className: 'cp-row' }, [
1663
+ h('input', {
1664
+ key: 'in', type: 'text', className: 'cp-input cp-grow',
1665
+ value: draft, disabled,
1666
+ placeholder: t('budgetCustom'),
1667
+ 'aria-label': t('budget'),
1668
+ onChange: (event) => setDraft(event.target.value),
1669
+ onKeyDown: (event) => {
1670
+ if (event.key !== 'Enter') return
1671
+ event.preventDefault()
1672
+ custom()
1673
+ },
1674
+ }),
1675
+ h('button', { key: 'b', type: 'button', className: 'cp-btn', disabled, onClick: custom }, t('channelAdd')),
1676
+ ]),
1677
+ ])
1678
+ }
1679
+
1431
1680
  /**
1432
1681
  * The models card: a fold-away section, like the request history.
1433
1682
  *
@@ -1487,12 +1736,13 @@ window.__ModuleLoader__.load({
1487
1736
  */
1488
1737
  function historyRows(t, entry, showAccount) {
1489
1738
  const failed = String(entry.error ?? '') !== ''
1490
- const upstream = `${entry.provider || t('historyAuto')}${showAccount && entry.account !== '' ? ` · ${entry.account}` : ''}`
1739
+ const upstream = `${entry.provider || t('historyUnknown')}${showAccount && entry.account !== '' ? ` · ${entry.account}` : ''}`
1491
1740
  // The route prefix is constant across every row of this list, so it is
1492
1741
  // carried in the tooltip instead of eating the width the model name needs
1493
1742
  // — centred text that overflows loses BOTH ends, not just the tail.
1494
1743
  const label = String(entry.model).replace(/^cline-pass\//, '')
1495
1744
  const figures = latency(entry)
1745
+ const cacheHit = entry.cacheHit === undefined ? null : entry.cacheHit
1496
1746
  const row = h('tr', {
1497
1747
  key: 'row',
1498
1748
  // The whole row restates itself on hover, including the figures the three
@@ -1516,7 +1766,7 @@ window.__ModuleLoader__.load({
1516
1766
  h('td', { key: 'model', className: 'cp-history-model-cell' }, h('div', { className: 'cp-history-model-stack' }, [
1517
1767
  h('span', { key: 'name', className: 'cp-history-model', title: entry.model }, label),
1518
1768
  h('span', { key: 'upstream', className: 'cp-history-meta' }, [
1519
- h('span', { key: 'provider', className: 'cp-tag' }, entry.provider || t('historyAuto')),
1769
+ h('span', { key: 'provider', className: 'cp-tag' }, entry.provider || t('historyUnknown')),
1520
1770
  showAccount && entry.account !== '' ? h('span', { key: 'account', className: 'cp-tag cp-tag-quiet' }, entry.account) : null,
1521
1771
  ]),
1522
1772
  ])),
@@ -1526,8 +1776,11 @@ window.__ModuleLoader__.load({
1526
1776
  h('td', {
1527
1777
  key: 'tokens',
1528
1778
  className: 'cp-history-tokens',
1529
- title: `${tokenSummaryExact(entry)}\n${t('historyTokensHint')}`,
1530
- }, tokenSummary(entry)),
1779
+ title: `${tokenSummaryExact(entry)}\n${cacheHit === null ? '' : `${t('historyCacheHit')} ${cacheHit.toFixed(2)}%\n`}${t('historyTokensHint')}`,
1780
+ }, h('div', { className: 'cp-history-token-stack' }, [
1781
+ h('span', { key: 'sum' }, tokenSummary(entry)),
1782
+ cacheHit === null ? null : h('span', { key: 'cache', className: 'cp-muted cp-small' }, `${t('historyCacheHit')} ${cacheHit.toFixed(2)}%`),
1783
+ ])),
1531
1784
  h('td', { key: 'load', className: 'cp-history-load' }, [
1532
1785
  h('span', { key: 'first', className: 'cp-history-load-row' }, [
1533
1786
  h('span', { key: 'k', className: 'cp-muted' }, t('historyFirstChunk')),
@@ -1780,6 +2033,7 @@ window.__ModuleLoader__.load({
1780
2033
  pinModel: props.pinModel,
1781
2034
  setModelVisible: props.setModelVisible,
1782
2035
  setToolsExposed: props.setToolsExposed,
2036
+ setBudget: props.setBudget,
1783
2037
  setModelsVisibility: props.setModelsVisibility,
1784
2038
  probeModel: props.probeModel,
1785
2039
  validateModel: props.validateModel,
@@ -1837,8 +2091,9 @@ window.__ModuleLoader__.load({
1837
2091
  // Everything below the accounts card reads through the key that card
1838
2092
  // stores, so none of it exists until there is one.
1839
2093
  data === null || !ready ? null : h(ModelsCard, { key: 'models', t, data, disabled, busy: state.busy, actions }),
1840
- // Route-wide switches last: they are not about one model or account, and
2094
+ // Route-wide settings last: they are not about one model or account, and
1841
2095
  // nothing below them depends on their value.
2096
+ data === null ? null : h(BudgetCard, { key: 'budget', t, data, disabled: state.busy !== null || data.settingsAvailable === false, actions }),
1842
2097
  data === null ? null : h('div', { key: 'tools', className: 'cp-card' }, [
1843
2098
  h('label', { key: 'head', className: 'cp-row cp-tools-label' }, [
1844
2099
  h('input', {
package/lib/index.js CHANGED
@@ -106,6 +106,17 @@ export const PinProfile = z.object({
106
106
  exclude: z.array(z.string()).default([]),
107
107
  pinMode: z.string().default('strict'),
108
108
  sort: z.string().default(''),
109
+ /**
110
+ * Capabilities an upstream must offer for this model, in the gateway's own
111
+ * vocabulary: `free`, `implicit-caching`, `reasoning`, `structured-output`,
112
+ * `tool-use`, `vision`, or a weight condition such as `quantization:fp8` /
113
+ * `!quantization:bf16`.
114
+ *
115
+ * The gateway applies this server-side and rejects a name it does not know, so
116
+ * the panel checks the list before it is written. It only exists on the planner
117
+ * pipeline — OpenRouter has no equivalent — and a model routed there ignores it.
118
+ */
119
+ has: z.array(z.string()).default([]),
109
120
  })
110
121
 
111
122
  /**
@@ -149,6 +160,11 @@ export const Config = z.object({
149
160
  * larger, and reporting that here reserves it on every request — at the shipped
150
161
  * 384k figure a 1M window compacts at 55% instead of the 80% the threshold ratio
151
162
  * asks for. Each model's published ceiling clamps whatever is set here.
163
+ *
164
+ * Nothing here may reserve more than half the window. The shipped default goes
165
+ * further and scales to an eighth on a small window, because a flat 128k reserve
166
+ * leaves a 256k model almost nothing to compact into; an explicit value counts as a
167
+ * choice and only gets the half-window limit.
152
168
  */
153
169
  defaultMaxTokens: z.number().default(DEFAULT_MAX_TOKENS),
154
170
  streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
package/lib/panel.js CHANGED
@@ -71,6 +71,7 @@ export function projectModel(model, pin, meta, hidden = false) {
71
71
  sort: String(pin?.sort ?? ''),
72
72
  pinned: Array.isArray(pin?.upstreams) ? pin.upstreams.map(String) : [],
73
73
  excluded: Array.isArray(pin?.exclude) ? pin.exclude.map(String) : [],
74
+ has: Array.isArray(pin?.has) ? pin.has.map(String) : [],
74
75
  upstreams: Array.isArray(meta?.upstreams) ? meta.upstreams.map(String) : [],
75
76
  upstreamStatus,
76
77
  lastProvider: String(meta?.lastProvider ?? ''),
@@ -129,6 +130,9 @@ export function createPanel({ control, engine, store }) {
129
130
  provider: control.providerName,
130
131
  displayName: control.displayName,
131
132
  baseURL: String(config.baseURL ?? ''),
133
+ // The per-request output budget: one number for the whole route, because it
134
+ // decides how much of every window the harness holds back as completion room.
135
+ defaultMaxTokens: Number(config.defaultMaxTokens ?? 0),
132
136
  settingsAvailable: control.settingsAvailable(),
133
137
  accountMode: config.accountMode === 'roundrobin' ? 'roundrobin' : 'single',
134
138
  activeAccount: String(config.activeAccount ?? ''),
@@ -377,6 +381,9 @@ export function createPanel({ control, engine, store }) {
377
381
  sort: payload?.sort === undefined
378
382
  ? (current.sort ?? '')
379
383
  : (payload.sort === 'none' || payload.sort === '' ? '' : String(payload.sort)),
384
+ has: Array.isArray(payload?.has)
385
+ ? [...new Set(payload.has.map((name) => String(name).trim()).filter((name) => name.length > 0))]
386
+ : (current.has ?? []).map(String),
380
387
  }
381
388
  await control.updateConfig({ perModel: { ...(config.perModel ?? {}), [model]: next } })
382
389
  return { model, pin: next, ...(await state()) }
@@ -445,6 +452,21 @@ export function createPanel({ control, engine, store }) {
445
452
  return await modelPin({ model: modelOf(payload), upstreams: [], exclude: [], sort: 'none', pinMode: 'preferred' })
446
453
  }
447
454
 
455
+ /**
456
+ * Cache reads as a share of billed prompt tokens, or null when nothing was measured.
457
+ *
458
+ * A ratio over zero is not a reading, and 0% would claim "nothing cached" for a
459
+ * request the gateway never reported usage for.
460
+ */
461
+ function cacheHitOf(usage) {
462
+ if (usage === null || usage === undefined) return null
463
+ const cached = Number(usage.cacheReadTokens ?? 0)
464
+ const billed = cached + Number(usage.inputTokens ?? 0)
465
+ // Two decimals: an integer round turns 99.6% into a flat 100%, which reads as a
466
+ // claim the numbers do not support.
467
+ return billed <= 0 ? null : Math.round((cached / billed) * 10000) / 100
468
+ }
469
+
448
470
  /** The live request history, newest first. */
449
471
  async function history(payload) {
450
472
  const limit = Number.isSafeInteger(payload?.limit) && payload.limit > 0 ? Math.min(payload.limit, 100) : 25
@@ -469,6 +491,7 @@ export function createPanel({ control, engine, store }) {
469
491
  cacheReadTokens: Number(entry.usage?.cacheReadTokens ?? 0),
470
492
  reasoningTokens: Number(entry.usage?.reasoningTokens ?? 0),
471
493
  },
494
+ cacheHit: cacheHitOf(entry.usage),
472
495
  effort: String(entry.effort ?? ''),
473
496
  stream: entry.stream === true,
474
497
  error: firstLine(entry.error, 200),
@@ -486,6 +509,23 @@ export function createPanel({ control, engine, store }) {
486
509
  * The response carries `restartRequired` so the panel can say so instead of
487
510
  * implying the switch already took effect.
488
511
  */
512
+ /**
513
+ * Set the per-request output budget.
514
+ *
515
+ * The panel offers the two numbers that matter — the shipped default, and the one
516
+ * DeepSeek's own provider uses — plus whatever a user types. It is a route-wide
517
+ * setting rather than a per-model one because it is the same policy for every
518
+ * model: how much of a window to hold back for one response.
519
+ */
520
+ async function budgetSet(payload) {
521
+ const value = Number(payload?.value)
522
+ if (!Number.isSafeInteger(value) || value < 1024 || value > 2000000) {
523
+ throw new Error(`the output budget must be a whole number of tokens between 1024 and 2000000 (got ${JSON.stringify(payload?.value)})`)
524
+ }
525
+ await control.updateConfig({ defaultMaxTokens: value })
526
+ return await state()
527
+ }
528
+
489
529
  async function toolsExpose(payload) {
490
530
  if (typeof payload?.value !== 'boolean') throw new Error('this action needs a boolean `value`')
491
531
  await control.updateConfig({ exposeTools: payload.value })
@@ -510,6 +550,7 @@ export function createPanel({ control, engine, store }) {
510
550
  'model.reset': modelReset,
511
551
  'models.refresh': modelsRefresh,
512
552
  'tools.expose': toolsExpose,
553
+ 'budget.set': budgetSet,
513
554
  history,
514
555
  }
515
556
  }
package/lib/protocol.js CHANGED
@@ -59,7 +59,15 @@ export function isAuthenticationError(message, status) {
59
59
  export function parseRouting(json) {
60
60
  const payload = unwrapEnvelope(json)
61
61
  const message = payload?.choices?.[0]?.message
62
- const routing = message?.provider_metadata?.gateway?.routing ?? payload?.provider_metadata?.gateway?.routing ?? {}
62
+ // A non-streaming answer carries routing on the message; a stream carries the same
63
+ // object on the delta instead. Reading only the message left every streamed request
64
+ // with no upstream at all, which the history then filled in with the pinned name —
65
+ // reporting a pin the gateway may never have adopted.
66
+ const delta = payload?.choices?.[0]?.delta
67
+ const routing = message?.provider_metadata?.gateway?.routing
68
+ ?? delta?.provider_metadata?.gateway?.routing
69
+ ?? payload?.provider_metadata?.gateway?.routing
70
+ ?? {}
63
71
  const direct = typeof payload?.provider === 'string' ? payload.provider : null
64
72
  return {
65
73
  content: typeof message?.content === 'string' ? message.content : null,
@@ -184,6 +192,9 @@ function knownChannels(meta, pipeline) {
184
192
  export function injectPrefs(body, meta, attempt) {
185
193
  const next = { ...body }
186
194
  const { upstream = null, orderRest = [], excludeList = [], strict = true } = attempt ?? {}
195
+ // `has` names the capabilities an upstream must offer. There is no equivalent on
196
+ // the OpenRouter spelling, so a direct-pipeline model simply does not carry it.
197
+ const required = (Array.isArray(attempt?.has) ? attempt.has : []).map((name) => String(name).trim()).filter((name) => name.length > 0)
187
198
  // Normalized here as well as in buildAttempts: this function is exported, so
188
199
  // a caller can hand it an attempt straight from configuration.
189
200
  const sort = normalizeSort(attempt?.sort)
@@ -197,7 +208,7 @@ export function injectPrefs(body, meta, attempt) {
197
208
  const both = attempt?.spelling !== 'detected'
198
209
  const useGateway = both || pipeline === 'planner' || pipeline === null
199
210
  const useOpenRouter = both || pipeline === 'direct' || pipeline === null
200
- const gatewayUsable = useGateway && (upstream !== null || sort !== null || (gatewayAllow !== null && gatewayAllow.length > 0))
211
+ const gatewayUsable = useGateway && (upstream !== null || sort !== null || required.length > 0 || (gatewayAllow !== null && gatewayAllow.length > 0))
201
212
  // `ignore` is OpenRouter's own exclusion field: written alongside the
202
213
  // allow-list so an exclusion still bites when the allow-list cannot be
203
214
  // computed (no channel list yet) or has gone stale.
@@ -215,6 +226,7 @@ export function injectPrefs(body, meta, attempt) {
215
226
  gateway.only = spell(gatewayAllow, 'planner')
216
227
  }
217
228
  if (sort !== null) gateway.sort = sort
229
+ if (required.length > 0) gateway.has = required
218
230
  next.providerOptions = { ...(next.providerOptions ?? {}), gateway: { ...(next.providerOptions?.gateway ?? {}), ...gateway } }
219
231
  }
220
232
  if (openRouterUsable) {
@@ -316,7 +328,12 @@ export function buildAttempts(config) {
316
328
  const wanted = listed.filter((name) => !excluded.has(normalizeSlug(name)))
317
329
  const strict = normalizePinMode(config?.pinMode) === 'strict'
318
330
  const sort = normalizeSort(config?.sort)
319
- const base = { strict, sort, excludeList: exclude }
331
+ // The capability filter is gateway-only and applies to every attempt, so it rides
332
+ // in the shared base rather than being threaded through each candidate.
333
+ const has = (Array.isArray(config?.has) ? config.has : [])
334
+ .map((name) => String(name).trim())
335
+ .filter((name) => name.length > 0)
336
+ const base = { strict, sort, excludeList: exclude, ...(has.length > 0 ? { has } : {}) }
320
337
  // Carried through only when the caller configured it, so an attempt built from
321
338
  // a bare pin profile keeps its historical shape.
322
339
  if (typeof config?.spelling === 'string' && config.spelling.length > 0) base.spelling = config.spelling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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",