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 +1 -1
- package/README.md +1 -1
- package/lib/adapter.js +5 -1
- package/lib/client.js +326 -120
- package/lib/index.js +11 -0
- package/lib/panel.js +4 -0
- package/lib/protocol.js +11 -2
- package/package.json +1 -1
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 →
|
|
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,在 **设置 →
|
|
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
|
-
|
|
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
|
|
199
|
-
'.cp-set-
|
|
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
|
-
|
|
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: '
|
|
434
|
+
models: 'cline-pass',
|
|
410
435
|
modelsCount: '{n} model(s)',
|
|
411
|
-
hiddenModels: '{n} hidden
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1180
|
+
const [draft, setDraft] = React.useState('')
|
|
1118
1181
|
const verdicts = new Map((model.upstreamStatus ?? []).map((entry) => [entry.upstream, entry]))
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
},
|
|
1140
|
-
|
|
1141
|
-
upstream
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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: '
|
|
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',
|
|
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.
|
|
1205
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
h('
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
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
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
//
|
|
2154
|
-
//
|
|
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.
|
|
2157
|
-
//
|
|
2158
|
-
|
|
2159
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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",
|