dsh-cline-pass 0.2.9 → 0.2.11

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
@@ -22,7 +22,7 @@ Requires Node.js `>= 20.3.0`, dsh `>= 0.1.2-alpha.3 < 0.3.0`, and a valid Cline
22
22
  dsh plugin --profile web add dsh-cline-pass
23
23
  ```
24
24
 
25
- Restart dsh, open **Settings → Built-in Plugins → Cline Pass** to save and test your API key, then select a Cline Pass model in the model picker. Keys are stored in the dsh credential store and displayed only in masked form.
25
+ Restart dsh, open **Settings → Cline Pass** to save and test your API key, then select a Cline Pass model in the model picker. Keys are stored in the dsh credential store and displayed only in masked form.
26
26
 
27
27
  Alternatively, configure the key through an environment variable:
28
28
 
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  dsh plugin --profile web add dsh-cline-pass
23
23
  ```
24
24
 
25
- 安装后重启 dsh,在 **设置 → 内置插件 → Cline Pass** 中保存并测试 API Key,然后在模型选择器中选择 Cline Pass 模型。Key 存入 dsh 凭据库,界面仅显示掩码。
25
+ 安装后重启 dsh,在 **设置 → Cline Pass** 中保存并测试 API Key,然后在模型选择器中选择 Cline Pass 模型。Key 存入 dsh 凭据库,界面仅显示掩码。
26
26
 
27
27
  也可以通过环境变量配置:
28
28
 
package/lib/adapter.js CHANGED
@@ -992,7 +992,11 @@ export class ClinePassAdapter extends LlmAdapter {
992
992
  : 'ok',
993
993
  })
994
994
  this.config.record(options.model, {
995
- provider: observed.provider ?? (attempt.upstream ?? null),
995
+ // The upstream the gateway actually served from, never the one we asked
996
+ // for: a pin the gateway declined is exactly what this column exists to
997
+ // expose, and filling it with the requested name would report the pin as if
998
+ // it had been adopted. An unreported routing reads as unknown upstream.
999
+ provider: observed.provider,
996
1000
  canonical: observed.canonical,
997
1001
  attempts: trace.map((row) => row.upstream),
998
1002
  ms: Date.now() - startedAt,
package/lib/client.js CHANGED
@@ -84,6 +84,17 @@ window.__ModuleLoader__.load({
84
84
  '.cp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}',
85
85
  '.cp-table{display:flex;flex-direction:column;gap:6px}',
86
86
  '.cp-model{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:8px}',
87
+ // The bulk toolbar sits directly above the first row; the card's own 10px gap
88
+ // reads as none against two stacked borders, so this card opens it up.
89
+ '.cp-models{gap:14px}',
90
+ '.cp-models-body{flex-direction:column;gap:16px;display:flex}',
91
+ // One line per model. `.cp-row` wraps by default, which a long id plus a long
92
+ // pin order turns into a two-line row that reads as broken rather than dense;
93
+ // both texts ellipsis instead, and their full value stays in `title`.
94
+ '.cp-model-head{flex-wrap:nowrap;min-width:0}',
95
+ '.cp-model-head .cp-grow{min-width:0}',
96
+ '.cp-model-mode{flex:none;max-width:55%}',
97
+ '.cp-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
87
98
  // The account table is a real table for the same reason the history one
88
99
  // is: per-row grids size their `auto` tracks from their own content, so a
89
100
  // header cell never lines up with the data under it.
@@ -107,6 +118,11 @@ window.__ModuleLoader__.load({
107
118
  '.cp-acct-newname{flex:0 0 150px}',
108
119
  '.cp-acct-add,.cp-acct-key{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px;margin-top:2px}',
109
120
  '.cp-chips{display:flex;flex-wrap:wrap;gap:6px}',
121
+ '.cp-chip-manual{border-style:dashed;color:var(--dsw-alias-label-tertiary)}',
122
+ '.cp-small{font-size:11.5px}',
123
+ '.cp-caps{flex-direction:column;gap:8px;display:flex}',
124
+ '.cp-caps-boxes{flex-wrap:wrap;gap:10px}',
125
+ '.cp-cap{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}',
110
126
  '.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}',
111
127
  '.cp-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:600}',
112
128
  '.cp-chip-excluded{text-decoration:line-through;color:var(--dsw-alias-label-tertiary)}',
@@ -195,16 +211,10 @@ window.__ModuleLoader__.load({
195
211
  // Plugins-page card chrome, mirrored token for token from the host's own
196
212
  // `PluginCard`: that tab hands a plugin the whole card, so matching it —
197
213
  // rather than inventing a second look — is this bundle's job.
198
- '.cp-set-card{border:0.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}',
199
- '.cp-set-card:hover{border-color:var(--dsw-alias-label-dimmed)}',
200
- '.cp-set-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
201
- '.cp-set-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}',
202
- '.cp-set-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
203
- '.cp-set-head-text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
214
+ '.cp-set-card{border:0.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none}',
215
+ '.cp-set-head{flex-direction:column;gap:4px;display:flex;padding:14px 16px}',
204
216
  '.cp-set-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
205
217
  '.cp-set-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
206
- '.cp-set-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
207
- '.cp-set-chevron-open{transform:rotate(180deg)}',
208
218
  '.cp-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;display:inline-flex}',
209
219
  'details[open]>summary .cp-chevron{transform:rotate(180deg)}',
210
220
  // A summary laid out as a flex row keeps no native affordance, so the
@@ -288,8 +298,6 @@ window.__ModuleLoader__.load({
288
298
  title: 'Cline Pass',
289
299
  description: '订阅模型的渠道钉住与账号池配置',
290
300
  panelUnavailable: '浏览器通道不可用。',
291
- expand: '展开设置',
292
- collapse: '收起设置',
293
301
  keyOk: 'API Key 已配置',
294
302
  keyMissing: '尚未配置 API Key',
295
303
  keyValue: 'API Key',
@@ -317,9 +325,10 @@ window.__ModuleLoader__.load({
317
325
  active: '当前账号',
318
326
  addAccount: '添加',
319
327
  accountName: '账号名',
320
- models: '订阅模型',
328
+ models: 'cline-pass',
321
329
  modelsCount: '{n} 个模型',
322
- hiddenModels: '{n} 个已从选择列表隐藏',
330
+ hiddenModels: '{n} 隐藏',
331
+ hiddenModelsHint: '{n} 个已从选择列表隐藏',
323
332
  visible: '在模型选择列表中显示',
324
333
  hiddenTag: '已隐藏',
325
334
  selectAll: '全选',
@@ -357,15 +366,33 @@ window.__ModuleLoader__.load({
357
366
  historyTokensHint: '↓输入 ↑输出 ⚡缓存 🧠推理;— 表示网关本次未返回用量',
358
367
  historyEffort: '推理强度',
359
368
  historyAuto: '(自动)',
360
- noChannels: '未探测渠道',
369
+ historyUnknown: '(未上报)',
370
+ noChannels: '未探测到渠道,可手动输入',
361
371
  pinned: '已钉住',
362
372
  auto: '自动路由',
373
+ modeAuto: '智能路由',
374
+ modeStrict: '钉住',
375
+ modePreferred: '优先',
376
+ channelsCount: '{n} 个渠道',
363
377
  probe: '探测',
364
378
  validate: '校验',
365
379
  testPin: '测试',
366
380
  reset: '恢复自动',
367
381
  refresh: '刷新',
368
382
  channels: '渠道(点选=钉住顺序,⊘=排除)',
383
+ channelPlaceholder: '输入渠道名,回车添加',
384
+ channelAdd: '添加',
385
+ channelUnverified: '未验证',
386
+ channelsKnown: '已探测:{list}',
387
+ capabilities: '上游能力',
388
+ capabilitiesExtraPlaceholder: '如 quantization:fp8 或 !quantization:bf16',
389
+ capabilitiesDirect: '最近一次探测走 OpenRouter,该路由会忽略此筛选',
390
+ capFree: '免费',
391
+ capImplicitCaching: '隐式缓存',
392
+ capReasoning: '推理',
393
+ capStructuredOutput: '结构化输出',
394
+ capToolUse: '工具调用',
395
+ capVision: '视觉',
369
396
  history: '最近请求',
370
397
  noHistory: '暂无记录。',
371
398
  busy: '处理中…',
@@ -377,8 +404,6 @@ window.__ModuleLoader__.load({
377
404
  title: 'Cline Pass',
378
405
  description: 'Channel pins and the account pool for the subscription models',
379
406
  panelUnavailable: 'The browser channel is unavailable.',
380
- expand: 'Show settings',
381
- collapse: 'Hide settings',
382
407
  keyOk: 'API key configured',
383
408
  keyMissing: 'No API key',
384
409
  keyValue: 'API key',
@@ -406,9 +431,10 @@ window.__ModuleLoader__.load({
406
431
  active: 'Active',
407
432
  addAccount: 'Add',
408
433
  accountName: 'Name',
409
- models: 'Models',
434
+ models: 'cline-pass',
410
435
  modelsCount: '{n} model(s)',
411
- hiddenModels: '{n} hidden from the pickers',
436
+ hiddenModels: '{n} hidden',
437
+ hiddenModelsHint: '{n} hidden from the model pickers',
412
438
  visible: 'Show in the model pickers',
413
439
  hiddenTag: 'Hidden',
414
440
  selectAll: 'Check all',
@@ -446,15 +472,33 @@ window.__ModuleLoader__.load({
446
472
  historyTokensHint: '↓in ↑out ⚡cache 🧠reasoning; a dash means the gateway reported no usage',
447
473
  historyEffort: 'effort',
448
474
  historyAuto: '(auto)',
449
- noChannels: 'No channels probed',
475
+ historyUnknown: '(unreported)',
476
+ noChannels: 'Nothing probed yet — type a name',
450
477
  pinned: 'Pinned',
451
478
  auto: 'Auto',
479
+ modeAuto: 'Smart routing',
480
+ modeStrict: 'Pinned',
481
+ modePreferred: 'Preferred',
482
+ channelsCount: '{n} channels',
452
483
  probe: 'Probe',
453
484
  validate: 'Validate',
454
485
  testPin: 'Test',
455
486
  reset: 'Back to auto',
456
487
  refresh: 'Refresh',
457
488
  channels: 'Channels (click = pin order, ⊘ = exclude)',
489
+ channelPlaceholder: 'Channel name, Enter to add',
490
+ channelAdd: 'Add',
491
+ channelUnverified: 'unverified',
492
+ channelsKnown: 'Probed: {list}',
493
+ capabilities: 'Upstream capabilities',
494
+ capabilitiesExtraPlaceholder: 'e.g. quantization:fp8 or !quantization:bf16',
495
+ capabilitiesDirect: 'The last probe saw OpenRouter; that route ignores this filter',
496
+ capFree: 'Free',
497
+ capImplicitCaching: 'Implicit caching',
498
+ capReasoning: 'Reasoning',
499
+ capStructuredOutput: 'Structured output',
500
+ capToolUse: 'Tool use',
501
+ capVision: 'Vision',
458
502
  history: 'Recent requests',
459
503
  noHistory: 'Nothing recorded.',
460
504
  busy: 'Working…',
@@ -1112,47 +1156,188 @@ window.__ModuleLoader__.load({
1112
1156
  }
1113
1157
 
1114
1158
  /** The channel chooser for one model. */
1115
- function ChannelPicker(props) {
1159
+ /** The capability names the gateway accepts, in its own spelling. */
1160
+ const CAPABILITIES = [
1161
+ ['free', 'capFree'],
1162
+ ['implicit-caching', 'capImplicitCaching'],
1163
+ ['reasoning', 'capReasoning'],
1164
+ ['structured-output', 'capStructuredOutput'],
1165
+ ['tool-use', 'capToolUse'],
1166
+ ['vision', 'capVision'],
1167
+ ]
1168
+
1169
+ /**
1170
+ * The channel list, editable.
1171
+ *
1172
+ * The chips used to be exactly what a probe returned, which made an account whose
1173
+ * probe came back empty a dead end: the row said no channels were probed and
1174
+ * offered no way to name the upstream the user already knew served them. The list
1175
+ * is the user's data now — a probe fills it in, typing fills it in, and both are
1176
+ * the same list.
1177
+ */
1178
+ function ChannelEditor(props) {
1116
1179
  const { t, model, disabled, onPin } = props
1117
- if (model.upstreams.length === 0) return h('div', { className: 'cp-muted' }, t('noChannels'))
1180
+ const [draft, setDraft] = React.useState('')
1118
1181
  const verdicts = new Map((model.upstreamStatus ?? []).map((entry) => [entry.upstream, entry]))
1119
- return h('div', { className: 'cp-row' }, [
1120
- h('span', { key: 'label', className: 'cp-muted' }, t('channels')),
1121
- h('span', { key: 'chips', className: 'cp-chips' }, model.upstreams.map((upstream) => {
1122
- const pinned = model.pinned.includes(upstream)
1123
- const excluded = model.excluded.includes(upstream)
1124
- const verdict = verdicts.get(upstream)
1125
- const detail = verdict === undefined
1126
- ? ''
1127
- : ` · ${VERDICT[verdict.status] ?? verdict.status}${verdict.note ? `\n${verdict.note}` : ''}`
1128
- return h('span', { key: upstream, className: `cp-chip${pinned ? ' cp-chip-on' : ''}${excluded ? ' cp-chip-excluded' : ''}` }, [
1129
- h('span', {
1130
- key: 'pin',
1131
- role: 'button',
1132
- tabIndex: 0,
1133
- title: `${upstream}${detail}`,
1134
- onClick: () => {
1135
- if (disabled) return
1136
- const next = pinned ? model.pinned.filter((name) => name !== upstream) : [...model.pinned, upstream]
1137
- onPin({ model: model.id, upstreams: next })
1138
- },
1139
- }, [
1140
- h('span', { key: 'dot', style: { marginRight: 5 } }, h(Dot, { color: verdictColor(verdict?.status ?? 'unknown'), title: VERDICT[verdict?.status ?? 'unknown'] })),
1141
- upstream,
1142
- ]),
1143
- h('span', {
1144
- key: 'ex',
1145
- role: 'button',
1146
- tabIndex: 0,
1147
- title: excluded ? 'un-exclude' : 'exclude',
1148
- onClick: () => {
1149
- if (disabled) return
1150
- const next = excluded ? model.excluded.filter((name) => name !== upstream) : [...model.excluded, upstream]
1151
- onPin({ model: model.id, exclude: next })
1152
- },
1153
- }, '⊘'),
1154
- ])
1155
- })),
1182
+ const known = model.upstreams
1183
+ // A chosen name shows even when no probe reported it: a chip that disappears is
1184
+ // a chip that cannot be removed.
1185
+ const names = [...new Set([...known, ...model.pinned, ...model.excluded])]
1186
+ const pin = (next) => {
1187
+ if (disabled) return
1188
+ onPin({ model: model.id, upstreams: next })
1189
+ }
1190
+ const add = () => {
1191
+ const name = draft.trim()
1192
+ if (name === '') return
1193
+ setDraft('')
1194
+ const needle = name.toLowerCase()
1195
+ if (model.pinned.some((entry) => String(entry).toLowerCase() === needle)) return
1196
+ pin([...model.pinned, name])
1197
+ }
1198
+ return h('div', { className: 'cp-caps' }, [
1199
+ h('div', { key: 'row', className: 'cp-row' }, [
1200
+ h('span', { key: 'label', className: 'cp-muted' }, t('channels')),
1201
+ names.length === 0
1202
+ ? h('span', { key: 'none', className: 'cp-muted' }, t('noChannels'))
1203
+ : h('span', { key: 'chips', className: 'cp-chips' }, names.map((upstream) => {
1204
+ const pinned = model.pinned.includes(upstream)
1205
+ const excluded = model.excluded.includes(upstream)
1206
+ const probed = known.includes(upstream)
1207
+ const verdict = verdicts.get(upstream)
1208
+ const detail = verdict === undefined
1209
+ ? ''
1210
+ : ` · ${VERDICT[verdict.status] ?? verdict.status}${verdict.note ? `\n${verdict.note}` : ''}`
1211
+ return h('span', {
1212
+ key: upstream,
1213
+ className: `cp-chip${pinned ? ' cp-chip-on' : ''}${excluded ? ' cp-chip-excluded' : ''}${probed ? '' : ' cp-chip-manual'}`,
1214
+ }, [
1215
+ h('span', {
1216
+ key: 'pin',
1217
+ role: 'button',
1218
+ tabIndex: 0,
1219
+ title: `${upstream}${probed ? '' : ` · ${t('channelUnverified')}`}${detail}`,
1220
+ onClick: () => {
1221
+ if (disabled) return
1222
+ const next = pinned ? model.pinned.filter((name) => name !== upstream) : [...model.pinned, upstream]
1223
+ onPin({ model: model.id, upstreams: next })
1224
+ },
1225
+ }, [
1226
+ h('span', { key: 'dot', style: { marginRight: 5 } }, h(Dot, { color: verdictColor(verdict?.status ?? 'unknown'), title: VERDICT[verdict?.status ?? 'unknown'] })),
1227
+ upstream,
1228
+ ]),
1229
+ h('span', {
1230
+ key: 'ex',
1231
+ role: 'button',
1232
+ tabIndex: 0,
1233
+ title: excluded ? 'un-exclude' : 'exclude',
1234
+ onClick: () => {
1235
+ if (disabled) return
1236
+ const next = excluded ? model.excluded.filter((name) => name !== upstream) : [...model.excluded, upstream]
1237
+ onPin({ model: model.id, exclude: next })
1238
+ },
1239
+ }, '⊘'),
1240
+ // A name that only exists because someone typed it has no discovery
1241
+ // list to fall back on, so it needs an explicit way out: un-pinning
1242
+ // removes it, and this is what says so.
1243
+ probed ? null : h('span', {
1244
+ key: 'del',
1245
+ role: 'button',
1246
+ tabIndex: 0,
1247
+ title: 'remove',
1248
+ onClick: () => {
1249
+ if (disabled) return
1250
+ onPin({
1251
+ model: model.id,
1252
+ upstreams: model.pinned.filter((name) => name !== upstream),
1253
+ exclude: model.excluded.filter((name) => name !== upstream),
1254
+ })
1255
+ },
1256
+ }, '×'),
1257
+ ])
1258
+ })),
1259
+ ]),
1260
+ h('div', { key: 'add', className: 'cp-row' }, [
1261
+ h('input', {
1262
+ key: 'in',
1263
+ type: 'text',
1264
+ className: 'cp-input cp-grow',
1265
+ value: draft,
1266
+ disabled,
1267
+ placeholder: t('channelPlaceholder'),
1268
+ 'aria-label': `${t('channelAdd')}: ${model.id}`,
1269
+ onChange: (event) => setDraft(event.target.value),
1270
+ onKeyDown: (event) => {
1271
+ if (event.key !== 'Enter') return
1272
+ event.preventDefault()
1273
+ add()
1274
+ },
1275
+ }),
1276
+ h('button', { key: 'b', type: 'button', className: 'cp-btn', disabled, onClick: add }, t('channelAdd')),
1277
+ ]),
1278
+ known.length === 0 ? null : h('div', { key: 'known', className: 'cp-muted cp-small' }, fill(t('channelsKnown'), { list: known.join(' · ') })),
1279
+ ])
1280
+ }
1281
+
1282
+ /**
1283
+ * The capabilities an upstream must offer for this model.
1284
+ *
1285
+ * The gateway applies this itself and rejects a name it does not know, so the six
1286
+ * it documents are checkboxes and anything else — a weight format such as
1287
+ * `quantization:fp8`, or its negation — is typed. It only exists on the planner
1288
+ * pipeline: OpenRouter has no equivalent, and a model known to run there says so
1289
+ * rather than letting a filter look active while it is ignored.
1290
+ */
1291
+ function CapabilityFilter(props) {
1292
+ const { t, model, disabled, onPin } = props
1293
+ const [extra, setExtra] = React.useState('')
1294
+ const chosen = model.has ?? []
1295
+ const write = (next) => {
1296
+ if (disabled) return
1297
+ onPin({ model: model.id, has: next })
1298
+ }
1299
+ const toggle = (id) => write(chosen.includes(id) ? chosen.filter((name) => name !== id) : [...chosen, id])
1300
+ const addExtra = () => {
1301
+ const value = extra.trim()
1302
+ if (value === '') return
1303
+ setExtra('')
1304
+ if (chosen.includes(value)) return
1305
+ write([...chosen, value])
1306
+ }
1307
+ const extras = chosen.filter((name) => !CAPABILITIES.some(([id]) => id === name))
1308
+ return h('div', { className: 'cp-caps' }, [
1309
+ h('div', { key: 'head', className: 'cp-row' }, [
1310
+ h('span', { key: 'l', className: 'cp-muted' }, t('capabilities')),
1311
+ model.pipeline === 'direct' ? h('span', { key: 'w', className: 'cp-muted cp-small' }, t('capabilitiesDirect')) : null,
1312
+ ]),
1313
+ h('span', { key: 'boxes', className: 'cp-row cp-caps-boxes' }, CAPABILITIES.map(([id, key]) =>
1314
+ h('label', { key: id, className: 'cp-cap' }, [
1315
+ h('input', {
1316
+ key: 'c', type: 'checkbox', className: 'cp-check',
1317
+ checked: chosen.includes(id), disabled, onChange: () => toggle(id),
1318
+ }),
1319
+ h('span', { key: 't' }, t(key)),
1320
+ ]))),
1321
+ h('div', { key: 'extra', className: 'cp-row' }, [
1322
+ h('input', {
1323
+ key: 'in', type: 'text', className: 'cp-input cp-grow',
1324
+ value: extra, disabled,
1325
+ placeholder: t('capabilitiesExtraPlaceholder'),
1326
+ 'aria-label': `${t('capabilities')}: ${model.id}`,
1327
+ onChange: (event) => setExtra(event.target.value),
1328
+ onKeyDown: (event) => {
1329
+ if (event.key !== 'Enter') return
1330
+ event.preventDefault()
1331
+ addExtra()
1332
+ },
1333
+ }),
1334
+ h('button', { key: 'b', type: 'button', className: 'cp-btn', disabled, onClick: addExtra }, t('channelAdd')),
1335
+ ]),
1336
+ extras.length === 0 ? null : h('span', { key: 'list', className: 'cp-chips' }, extras.map((name) =>
1337
+ h('span', { key: name, className: 'cp-chip cp-chip-on' }, [
1338
+ h('span', { key: 'n' }, name),
1339
+ h('span', { key: 'x', role: 'button', tabIndex: 0, title: 'remove', onClick: () => toggle(name) }, '×'),
1340
+ ]))),
1156
1341
  ])
1157
1342
  }
1158
1343
 
@@ -1170,12 +1355,29 @@ window.__ModuleLoader__.load({
1170
1355
  const { t, model, disabled, actions } = props
1171
1356
  const [open, setOpen] = React.useState(false)
1172
1357
  const hidden = model.hidden === true
1173
- const pinnedLabel = model.pinned.length === 0 ? t('auto') : model.pinned.join(' › ')
1358
+ // What a request will actually do, readable without expanding the row. The
1359
+ // old line read `已钉住: 自动路由` — claiming a pin while saying nothing is
1360
+ // pinned, which is exactly how it was misread.
1361
+ // Every row is this route, and the card header already names it, so the prefix
1362
+ // is 11 characters of noise on each of nineteen rows. The full id stays in
1363
+ // `title` for anything that needs to copy it.
1364
+ const label = typeof model.displayName === 'string' && model.displayName !== ''
1365
+ ? model.displayName
1366
+ : String(model.id).replace(/^cline-pass\//, '')
1367
+ const mode = model.pinned.length === 0
1368
+ ? t('modeAuto')
1369
+ : `${t(model.pinMode === 'preferred' ? 'modePreferred' : 'modeStrict')} · ${model.pinned.join(' › ')}`
1370
+ // How many channels sit behind it and how many of those the order pins. After a
1371
+ // probe only: before that the list is empty and a count would be a guess.
1372
+ const channelCount = model.upstreams.length === 0 ? '' : fill(t('channelsCount'), { n: model.upstreams.length })
1174
1373
  const action = (key, label, run) => h('button', {
1175
1374
  key, type: 'button', className: 'cp-btn', disabled, onClick: () => run(model.id),
1176
1375
  }, label)
1177
1376
  return h('div', { className: `cp-model${hidden ? ' cp-model-hidden' : ''}` }, [
1178
- h('div', { key: 'head', className: 'cp-row' }, [
1377
+ // One line, always. The id and the mode are long enough to collide on a
1378
+ // narrow panel, so both ellipsis and keep their full text in `title`: a
1379
+ // second line here reads as a broken row, not as more information.
1380
+ h('div', { key: 'head', className: 'cp-row cp-model-head' }, [
1179
1381
  h('input', {
1180
1382
  key: 'vis',
1181
1383
  type: 'checkbox',
@@ -1186,11 +1388,12 @@ window.__ModuleLoader__.load({
1186
1388
  'aria-label': `${t('visible')}: ${model.id}`,
1187
1389
  onChange: (event) => actions.setModelVisible(model.id, event.target.checked),
1188
1390
  }),
1189
- h('span', { key: 'name', className: 'cp-mono cp-grow' }, model.id),
1391
+ h('span', { key: 'name', className: 'cp-mono cp-grow cp-ellipsis', title: model.id }, label),
1190
1392
  hidden ? h('span', { key: 'off', className: 'cp-muted' }, t('hiddenTag')) : null,
1191
- h('span', { key: 'pin', className: 'cp-muted cp-mono' }, `${t('pinned')}: ${pinnedLabel}`),
1393
+ h('span', { key: 'mode', className: 'cp-muted cp-mono cp-model-mode cp-ellipsis', title: mode }, mode),
1192
1394
  h('button', {
1193
- key: 'more', type: 'button', className: 'cp-btn', title: t('channels'),
1395
+ key: 'more', type: 'button', className: 'cp-btn',
1396
+ title: channelCount === '' ? t('channels') : `${t('channels')} · ${channelCount}`,
1194
1397
  onClick: () => setOpen(!open),
1195
1398
  }, open ? '▾' : '▸'),
1196
1399
  ]),
@@ -1201,9 +1404,8 @@ window.__ModuleLoader__.load({
1201
1404
  action('test', t('testPin'), actions.testModel),
1202
1405
  action('reset', t('reset'), actions.resetModel),
1203
1406
  ]),
1204
- model.upstreams.length > 0
1205
- ? h(ChannelPicker, { key: 'channels', t, model, disabled, onPin: (payload) => actions.pinModel(payload) })
1206
- : h('div', { key: 'none', className: 'cp-muted' }, t('noChannels')),
1407
+ h(ChannelEditor, { key: 'channels', t, model, disabled, onPin: (payload) => actions.pinModel(payload) }),
1408
+ h(CapabilityFilter, { key: 'caps', t, model, disabled, onPin: (payload) => actions.pinModel(payload) }),
1207
1409
  ]) : null,
1208
1410
  ])
1209
1411
  }
@@ -1392,8 +1594,8 @@ window.__ModuleLoader__.load({
1392
1594
  * which drops the browser's own disclosure marker — without it a folded
1393
1595
  * section looks like a plain card title, not something to open.
1394
1596
  */
1395
- function foldHeader(title) {
1396
- return h('summary', { key: 'sum', className: 'cp-card-title cp-fold' }, [
1597
+ function foldHeader(title, hint) {
1598
+ return h('summary', { key: 'sum', className: 'cp-card-title cp-fold', ...(hint === undefined || hint === '' ? {} : { title: hint }) }, [
1397
1599
  h('span', { key: 'l' }, title),
1398
1600
  h(Chevron, { key: 'chevron', className: 'cp-chevron' }),
1399
1601
  ])
@@ -1409,27 +1611,40 @@ window.__ModuleLoader__.load({
1409
1611
  function ModelsCard(props) {
1410
1612
  const { t, data, disabled, busy, actions } = props
1411
1613
  const hidden = Number(data.hiddenModels ?? 0)
1412
- return h('details', { className: 'cp-card' }, [
1413
- foldHeader(`${t('models')} · ${fill(t('modelsCount'), { n: data.models.length })}${hidden === 0 ? '' : ` · ${fill(t('hiddenModels'), { n: hidden })}`}`),
1614
+ // Open by default: the pins are what this page is for, and a collapsed card
1615
+ // makes a user click before they can see whether anything is pinned at all.
1616
+ // The fold stays available for the days a pool has nineteen models in it.
1617
+ return h('details', { className: 'cp-card cp-models', open: true }, [
1618
+ // Short enough for a fold header; the long spelling is the tooltip.
1619
+ foldHeader(
1620
+ `${t('models')} · ${fill(t('modelsCount'), { n: data.models.length })}${hidden === 0 ? '' : ` · ${fill(t('hiddenModels'), { n: hidden })}`}`,
1621
+ hidden === 0 ? '' : fill(t('hiddenModelsHint'), { n: hidden }),
1622
+ ),
1414
1623
  // The refresh and the bulk checkbox actions sit in the body, not the
1415
1624
  // header: a button inside a summary would also toggle the fold.
1416
- h('div', { key: 'bar', className: 'cp-row' }, [
1417
- h('button', {
1418
- key: 'all', type: 'button', className: 'cp-btn', disabled,
1419
- onClick: () => actions.setModelsVisibility('all'),
1420
- }, t('selectAll')),
1421
- h('button', {
1422
- key: 'invert', type: 'button', className: 'cp-btn', disabled,
1423
- onClick: () => actions.setModelsVisibility('invert'),
1424
- }, t('invertSelection')),
1425
- h('button', {
1426
- key: 'r', type: 'button', className: 'cp-btn cp-grow-end', disabled,
1427
- onClick: () => actions.refreshModels(),
1428
- }, t('refresh')),
1625
+ //
1626
+ // `details` wraps everything after its summary, so the card's own flex gap
1627
+ // stops at that wrapper and the toolbar sat flush against the first row. This
1628
+ // grouping carries the spacing itself, whatever the engine's `details` layout.
1629
+ h('div', { key: 'models', className: 'cp-models-body' }, [
1630
+ h('div', { key: 'bar', className: 'cp-row' }, [
1631
+ h('button', {
1632
+ key: 'all', type: 'button', className: 'cp-btn', disabled,
1633
+ onClick: () => actions.setModelsVisibility('all'),
1634
+ }, t('selectAll')),
1635
+ h('button', {
1636
+ key: 'invert', type: 'button', className: 'cp-btn', disabled,
1637
+ onClick: () => actions.setModelsVisibility('invert'),
1638
+ }, t('invertSelection')),
1639
+ h('button', {
1640
+ key: 'r', type: 'button', className: 'cp-btn cp-grow-end', disabled,
1641
+ onClick: () => actions.refreshModels(),
1642
+ }, t('refresh')),
1643
+ ]),
1644
+ h('div', { key: 'rows', className: 'cp-table' }, data.models.map((model) => h(ModelRow, {
1645
+ key: model.id, t, model, disabled, busy, actions,
1646
+ }))),
1429
1647
  ]),
1430
- h('div', { key: 'rows', className: 'cp-table' }, data.models.map((model) => h(ModelRow, {
1431
- key: model.id, t, model, disabled, busy, actions,
1432
- }))),
1433
1648
  ])
1434
1649
  }
1435
1650
 
@@ -1445,7 +1660,7 @@ window.__ModuleLoader__.load({
1445
1660
  */
1446
1661
  function historyRows(t, entry, showAccount) {
1447
1662
  const failed = String(entry.error ?? '') !== ''
1448
- const upstream = `${entry.provider || t('historyAuto')}${showAccount && entry.account !== '' ? ` · ${entry.account}` : ''}`
1663
+ const upstream = `${entry.provider || t('historyUnknown')}${showAccount && entry.account !== '' ? ` · ${entry.account}` : ''}`
1449
1664
  // The route prefix is constant across every row of this list, so it is
1450
1665
  // carried in the tooltip instead of eating the width the model name needs
1451
1666
  // — centred text that overflows loses BOTH ends, not just the tail.
@@ -1474,7 +1689,7 @@ window.__ModuleLoader__.load({
1474
1689
  h('td', { key: 'model', className: 'cp-history-model-cell' }, h('div', { className: 'cp-history-model-stack' }, [
1475
1690
  h('span', { key: 'name', className: 'cp-history-model', title: entry.model }, label),
1476
1691
  h('span', { key: 'upstream', className: 'cp-history-meta' }, [
1477
- h('span', { key: 'provider', className: 'cp-tag' }, entry.provider || t('historyAuto')),
1692
+ h('span', { key: 'provider', className: 'cp-tag' }, entry.provider || t('historyUnknown')),
1478
1693
  showAccount && entry.account !== '' ? h('span', { key: 'account', className: 'cp-tag cp-tag-quiet' }, entry.account) : null,
1479
1694
  ]),
1480
1695
  ])),
@@ -1837,31 +2052,21 @@ window.__ModuleLoader__.load({
1837
2052
  * host's own cards — and because the panel only mounts once opened, a folded
1838
2053
  * card costs no round trip.
1839
2054
  */
2055
+ /**
2056
+ * The section's page: the panel itself, under a static heading.
2057
+ *
2058
+ * It used to fold, because it lived inside the Plugins tab where a long panel
2059
+ * had to stay out of the way. As a first-level page the fold only asks for a
2060
+ * click on the thing the user just navigated to.
2061
+ */
1840
2062
  function PluginSettingsCard(props) {
1841
2063
  const t = props.t
1842
- const [open, setOpen] = React.useState(false)
1843
- const title = t('title')
1844
- return h('div', { className: `cp-set-card${open ? ' cp-set-card-open' : ''}` }, [
1845
- h('button', {
1846
- key: 'head',
1847
- type: 'button',
1848
- className: 'cp-set-header',
1849
- 'aria-expanded': open,
1850
- 'aria-label': `${t(open ? 'collapse' : 'expand')}: ${title}`,
1851
- onClick: () => setOpen(!open),
1852
- }, [
1853
- h('span', { key: 'text', className: 'cp-set-head-text' }, [
1854
- h('span', { key: 'name', className: 'cp-set-name' }, title),
1855
- h('span', { key: 'desc', className: 'cp-set-desc' }, t('description')),
1856
- ]),
1857
- h(Chevron, {
1858
- key: 'chevron',
1859
- className: `cp-set-chevron${open ? ' cp-set-chevron-open' : ''}`,
1860
- }),
2064
+ return h('div', { className: 'cp-set-card' }, [
2065
+ h('div', { key: 'head', className: 'cp-set-head' }, [
2066
+ h('span', { key: 'name', className: 'cp-set-name' }, t('title')),
2067
+ h('span', { key: 'desc', className: 'cp-set-desc' }, t('description')),
1861
2068
  ]),
1862
- open
1863
- ? h('div', { key: 'body', className: 'cp-set-body' }, h(Panel, { ...props, hideTitle: true }))
1864
- : null,
2069
+ h('div', { key: 'body', className: 'cp-set-body' }, h(Panel, { ...props, hideTitle: true })),
1865
2070
  ])
1866
2071
  }
1867
2072
 
@@ -2147,19 +2352,20 @@ window.__ModuleLoader__.load({
2147
2352
  const renderPluginCard = (props) => h(PluginSettingsCard, { ...props, ...face, t: translate })
2148
2353
  const renderQuick = (props) => h(QuickCard, { ...props, ...face, t: translate })
2149
2354
 
2150
- // 1. The one configuration surface: a tab inside the Plugins settings
2151
- // section, which already enumerates this namespace. No
2152
- // `settings.section` alongside it — a second page in the Settings nav
2153
- // would only duplicate this panel, and the nav is for surfaces that are
2154
- // not already listed there.
2355
+ // 1. The one configuration surface: a first-level page in the Settings nav.
2356
+ //
2357
+ // It used to be a tab under Plugins, which put the channel pins five or six
2358
+ // clicks deep — Settings, Plugins, the tab, the collapsed card, the model
2359
+ // list, the row. The pins are what this plugin is for, so they get the same
2360
+ // standing as the shell's own pages: `order: 12` sits between Models (10) and
2361
+ // Plugins (15), which is also where a reader looks for a provider's routing.
2155
2362
  //
2156
- // `settings.plugins.tab` is a list slot: its key is `id`, its position
2157
- // is `order` (the host's own inventory tab sits at 10), and its tab
2158
- // label is a thunk so it follows the active locale.
2159
- ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
2160
- name: 'settings.plugins.tab',
2363
+ // `settings.section` is a list slot: its key is `id`, its position is
2364
+ // `order`, and its nav label is a thunk so it follows the active locale.
2365
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2366
+ name: 'settings.section',
2161
2367
  id: NS,
2162
- order: 20,
2368
+ order: 12,
2163
2369
  label: () => translate('title'),
2164
2370
  locale: NS,
2165
2371
  inject: face,
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
  /**
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 ?? ''),
@@ -377,6 +378,9 @@ export function createPanel({ control, engine, store }) {
377
378
  sort: payload?.sort === undefined
378
379
  ? (current.sort ?? '')
379
380
  : (payload.sort === 'none' || payload.sort === '' ? '' : String(payload.sort)),
381
+ has: Array.isArray(payload?.has)
382
+ ? [...new Set(payload.has.map((name) => String(name).trim()).filter((name) => name.length > 0))]
383
+ : (current.has ?? []).map(String),
380
384
  }
381
385
  await control.updateConfig({ perModel: { ...(config.perModel ?? {}), [model]: next } })
382
386
  return { model, pin: next, ...(await state()) }
package/lib/protocol.js CHANGED
@@ -184,6 +184,9 @@ function knownChannels(meta, pipeline) {
184
184
  export function injectPrefs(body, meta, attempt) {
185
185
  const next = { ...body }
186
186
  const { upstream = null, orderRest = [], excludeList = [], strict = true } = attempt ?? {}
187
+ // `has` names the capabilities an upstream must offer. There is no equivalent on
188
+ // the OpenRouter spelling, so a direct-pipeline model simply does not carry it.
189
+ const required = (Array.isArray(attempt?.has) ? attempt.has : []).map((name) => String(name).trim()).filter((name) => name.length > 0)
187
190
  // Normalized here as well as in buildAttempts: this function is exported, so
188
191
  // a caller can hand it an attempt straight from configuration.
189
192
  const sort = normalizeSort(attempt?.sort)
@@ -197,7 +200,7 @@ export function injectPrefs(body, meta, attempt) {
197
200
  const both = attempt?.spelling !== 'detected'
198
201
  const useGateway = both || pipeline === 'planner' || pipeline === null
199
202
  const useOpenRouter = both || pipeline === 'direct' || pipeline === null
200
- const gatewayUsable = useGateway && (upstream !== null || sort !== null || (gatewayAllow !== null && gatewayAllow.length > 0))
203
+ const gatewayUsable = useGateway && (upstream !== null || sort !== null || required.length > 0 || (gatewayAllow !== null && gatewayAllow.length > 0))
201
204
  // `ignore` is OpenRouter's own exclusion field: written alongside the
202
205
  // allow-list so an exclusion still bites when the allow-list cannot be
203
206
  // computed (no channel list yet) or has gone stale.
@@ -215,6 +218,7 @@ export function injectPrefs(body, meta, attempt) {
215
218
  gateway.only = spell(gatewayAllow, 'planner')
216
219
  }
217
220
  if (sort !== null) gateway.sort = sort
221
+ if (required.length > 0) gateway.has = required
218
222
  next.providerOptions = { ...(next.providerOptions ?? {}), gateway: { ...(next.providerOptions?.gateway ?? {}), ...gateway } }
219
223
  }
220
224
  if (openRouterUsable) {
@@ -316,7 +320,12 @@ export function buildAttempts(config) {
316
320
  const wanted = listed.filter((name) => !excluded.has(normalizeSlug(name)))
317
321
  const strict = normalizePinMode(config?.pinMode) === 'strict'
318
322
  const sort = normalizeSort(config?.sort)
319
- const base = { strict, sort, excludeList: exclude }
323
+ // The capability filter is gateway-only and applies to every attempt, so it rides
324
+ // in the shared base rather than being threaded through each candidate.
325
+ const has = (Array.isArray(config?.has) ? config.has : [])
326
+ .map((name) => String(name).trim())
327
+ .filter((name) => name.length > 0)
328
+ const base = { strict, sort, excludeList: exclude, ...(has.length > 0 ? { has } : {}) }
320
329
  // Carried through only when the caller configured it, so an attempt built from
321
330
  // a bare pin profile keeps its historical shape.
322
331
  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.9",
3
+ "version": "0.2.11",
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",