@goodandready/dsh-clinebot 0.4.3 → 0.4.5

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/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.5] - 2026-09-25
9
+
10
+ ### Fixed
11
+ - **Obsolete Model ID Migration & Canonical Normalization (#103)**: Automatically migrate historical model identifiers lacking dots (e.g., `deepseek-v41-flash` -> `deepseek-v4.1-flash`, `qwen38-max` -> `qwen3.8-max`, `glm-53` -> `glm-5.3`, `glm-53-flash` -> `glm-5.3-flash`, `muse-spark-13-contributor` -> `muse-spark-1.3-contributor`) across disk cache, DSH settings, and auto-discovery, eliminating stuck legacy IDs.
12
+ - **Config Schema Test Assertion for Schemastery 3.18.4 (#112)**: Aligned `test/config-schema.test.js` with Schemastery 3.18.4 volatile getter behavior using `plainConfig(validated).enabled`.
13
+ - **UI Color Compliance & Token Hardening (#116)**: Replaced hardcoded `rgba()` fallbacks in `accounts-section.js` and `models-section.js` with canonical DSH `color-mix()` and design tokens.
14
+ - **Decomposition Compliance (#117)**: Extracted credentials helpers into `lib/credential-refs.js`, reducing `lib/cline-client.js` from 590 to 511 lines (well below the 600-line ceiling).
15
+ - **Client Localization Pruning (#115)**: Removed 6 obsolete dead locale keys from `src/client/locales.js` in both `en` and `zh`.
16
+ - **Route Policy & Packaging Contract (#113)**: Documented mandatory «Политика маршрутов» (16 registered endpoints) and «Что публикуется» sections in `docs/design/DESIGN.md`.
17
+ - **Visual Verification Evidence (#114)**: Added dual-theme production visual verification artifact `media/visual-verification.png` and embedded under `## Visual verification` in `README.md`.
18
+
19
+ ## [0.4.4] - 2026-09-25
20
+
21
+ ### Added
22
+ - **Model Context Customization & Authentic Provider Specs (#110, GitHub #8)**: User-controlled context length configuration allowing custom context windows per model, resetting to gateway defaults (128K/200K), or one-click application of authentic original model specifications (Moonshot Kimi 2M, Alibaba Qwen 1M, MiniMax 1M, Xiaomi MiMo 1M, DeepSeek 128K, Zhipu GLM 128K).
23
+ - **Batch Context Controls (#110)**: Global 1-click batch actions in the models management header ("⚡ Original Specs" and "↺ Reset Contexts") to instantly apply or reset context windows across all models simultaneously.
24
+ - **Immediate DSH Provider Synchronization (#110)**: Dynamic synchronization of overridden context windows directly to `llm-pi-ai` provider settings, preventing chat sessions from prematurely compacting or truncating context at 128k/200k when larger context is selected.
25
+ - **Context Management Endpoint (`POST /dsh-clinebot/models/context`) (#110)**: Full REST route supporting single model updates (`modelId`, `contextLength`, `maxTokens`), single model resets (`reset: true`), batch mode `all-original`, batch mode `all-default`, and array bulk overrides.
26
+ - **Interactive UI & Localization (#110)**: Inline context badges, quick edit popover, clean token input, and bilingual English / Chinese localization across all context actions.
27
+
8
28
  ## [0.4.3] - 2026-09-24
9
29
 
10
30
  ### Fixed
package/README.md CHANGED
@@ -182,6 +182,7 @@ dsh-clinebot:
182
182
  | `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | ClinePass OpenAI-compatible base URL |
183
183
  | `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | Environment variable / credentials key name |
184
184
  | `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | Default selected model ID |
185
+ | `modelContextOverrides` | `array` | `[]` | User-defined model context length and max token overrides |
185
186
  | `timeoutMs` | `number` | `15000` | HTTP request timeout in milliseconds |
186
187
  | `smokeTimeoutMs` | `number` | `25000` | Smoke test latency ping timeout |
187
188
  | `enabledModels` | `array` | `[...]` | List of models exposed in the DSH chat picker |
@@ -205,6 +206,14 @@ All endpoints are registered under `/dsh-clinebot/*` and protected against untru
205
206
 
206
207
  ---
207
208
 
209
+ ## Visual verification
210
+
211
+ Settings card in DeepSeek Harness, Dark and Light themes side by side:
212
+
213
+ ![dsh-clinebot visual verification](media/visual-verification.png)
214
+
215
+ ---
216
+
208
217
  ## 🧪 Testing
209
218
 
210
219
  Run the automated test suite:
package/README.ru.md CHANGED
@@ -177,6 +177,7 @@ dsh-clinebot:
177
177
  | `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | Базовый URL OpenAI-совместимого API ClinePass |
178
178
  | `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | Имя переменной / ключа в хранилище credentials |
179
179
  | `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | Модель, выбираемая по умолчанию |
180
+ | `modelContextOverrides` | `array` | `[]` | Пользовательские переопределения размера контекста и лимита токенов |
180
181
  | `timeoutMs` | `number` | `15000` | Таймаут HTTP-запросов (мс) |
181
182
  | `smokeTimeoutMs` | `number` | `25000` | Таймаут тестового пинга (мс) |
182
183
  | `enabledModels` | `array` | `[...]` | Список моделей, активных в селекторе чата |
package/README.zh.md CHANGED
@@ -179,6 +179,7 @@ dsh-clinebot:
179
179
  | `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | ClinePass OpenAI 兼容接口地址 |
180
180
  | `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | 凭据管理系统中的密钥名称 |
181
181
  | `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | 默认选中的模型 ID |
182
+ | `modelContextOverrides` | `array` | `[]` | 用户自定义模型上下文窗口与最大 Token 数量覆盖 |
182
183
  | `timeoutMs` | `number` | `15000` | HTTP 请求超时时间(毫秒) |
183
184
  | `smokeTimeoutMs` | `number` | `25000` | 探活测试超时时间(毫秒) |
184
185
  | `enabledModels` | `array` | `[...]` | 允许在聊天下拉框中显示的可用模型列表 |
package/lib/client.js CHANGED
@@ -48,7 +48,6 @@ const en = {
48
48
  'key.save': 'Save Key',
49
49
  'key.saving': 'Saving…',
50
50
  'key.login_fast': '🔑 Open API Keys ↗',
51
- 'key.login_waiting': 'Paste key below for instant verification.',
52
51
  'key.verifying': 'Validating key with Cline…',
53
52
  'key.verified': '✓ Valid key for {email} ({plan})',
54
53
  'key.invalid': '✗ Key validation failed: {error}',
@@ -66,10 +65,8 @@ const en = {
66
65
  'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
67
66
  'accounts.delete_btn': 'Delete',
68
67
  'accounts.delete_confirm': 'Are you sure you want to delete account "{label}"?',
69
- 'accounts.delete_secret': 'Delete secret from credentials',
70
68
  'accounts.save_btn': 'Save Account',
71
69
  'accounts.cancel_btn': 'Cancel',
72
- 'accounts.key_label': 'API Key',
73
70
  'accounts.quota_used': '{pct}% used',
74
71
  'accounts.last_failover': 'Last failover: {from} → {to} ({reason})',
75
72
  'quota.title': '📊 ClinePass Quota & Rate Limits',
@@ -81,7 +78,6 @@ const en = {
81
78
  'quota.window_5h': '⏱ 5-Hour Rolling Limit',
82
79
  'quota.window_weekly': '📅 Weekly Window',
83
80
  'quota.used': '{pct}% used',
84
- 'quota.remaining': 'Remaining: {pct}%',
85
81
  'quota.reset_at': 'Reset: {time}',
86
82
  'models.title': '🎯 Model Picker in DSH ({enabled} of {total} active)',
87
83
  'models.desc': 'Catalog synchronizes directly from ClinePass subscription. New plan models are enabled automatically. Uncheck models to hide them from DSH chat picker. Thinking effort is selectable in the composer model menu.',
@@ -109,6 +105,17 @@ const en = {
109
105
  'models.th_caps': 'Capabilities',
110
106
  'models.star': 'Star',
111
107
  'models.auto_new': 'New',
108
+ 'models.apply_original_all': '⚡ Original Specs',
109
+ 'models.reset_contexts_all': '↺ Reset Contexts',
110
+ 'models.context_updated_msg': 'Model context limits updated and synchronized to DSH.',
111
+ 'models.ctx_default': 'Default ({ctx})',
112
+ 'models.ctx_original': 'Original ({ctx} · {provider})',
113
+ 'models.ctx_edit': 'Edit Context Window',
114
+ 'models.ctx_custom_placeholder': 'Tokens (e.g. 2000000)',
115
+ 'models.ctx_save': 'Save',
116
+ 'models.ctx_cancel': 'Cancel',
117
+ 'models.ctx_tag_original': 'Original',
118
+ 'models.ctx_tag_custom': 'Custom',
112
119
  'stats.title': '📈 Current DSH Session Telemetry',
113
120
  'stats.desc': 'Local monitoring of requests, network latency, and estimated token usage via ClinePass.',
114
121
  'stats.requests': 'Successful / Total Requests',
@@ -119,7 +126,6 @@ const en = {
119
126
  'diag.desc': 'ClineBot registers into DSH Models automatically when key is configured. You can run connection smoke test or force re-sync.',
120
127
  'diag.smoke_btn': 'Run Smoke Test (Ping)',
121
128
  'diag.smoke_testing': 'Testing…',
122
- 'diag.smoke_ok': 'Smoke test passed! Latency: {latency} ms',
123
129
  'diag.resync_btn': 'Force Re-sync to DSH',
124
130
  'diag.resyncing': 'Syncing…',
125
131
  'diag.resynced_msg': 'Provider re-synced to DSH Models ({count} models)',
@@ -158,7 +164,6 @@ const en = {
158
164
  'key.save': '保存密钥',
159
165
  'key.saving': '正在保存…',
160
166
  'key.login_fast': '🔑 打开 API 密钥页 ↗',
161
- 'key.login_waiting': '请在下方粘贴密钥,系统将自动进行有效性校验。',
162
167
  'key.verifying': '正在连接 Cline 验证密钥…',
163
168
  'key.verified': '✓ 密钥有效:{email} ({plan})',
164
169
  'key.invalid': '✗ 密钥验证未通过:{error}',
@@ -176,10 +181,8 @@ const en = {
176
181
  'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
177
182
  'accounts.delete_btn': '删除',
178
183
  'accounts.delete_confirm': '确定要删除账号“{label}”吗?',
179
- 'accounts.delete_secret': '同时删除凭据密钥',
180
184
  'accounts.save_btn': '保存账号',
181
185
  'accounts.cancel_btn': '取消',
182
- 'accounts.key_label': 'API 密钥',
183
186
  'accounts.quota_used': '已用 {pct}%',
184
187
  'accounts.last_failover': '最近故障转移:{from} → {to} ({reason})',
185
188
  'quota.title': '📊 ClinePass 用量配额与限额监控',
@@ -191,7 +194,6 @@ const en = {
191
194
  'quota.window_5h': '⏱ 5 小时滚动限额',
192
195
  'quota.window_weekly': '📅 每周滚动窗口',
193
196
  'quota.used': '已使用 {pct}%',
194
- 'quota.remaining': '剩余可用:{pct}%',
195
197
  'quota.reset_at': '重置时间:{time}',
196
198
  'models.title': '🎯 DSH 模型选择列表(已启用 {enabled}/{total})',
197
199
  'models.desc': '模型目录与 ClinePass 官方订阅实时同步。新订阅模型默认自动启用。取消勾选可在 DSH 对话模型列表中隐藏不需要的模型。支持在对话输入框上方切换推理思考强度(Effort)。',
@@ -219,6 +221,17 @@ const en = {
219
221
  'models.th_caps': '特性能力',
220
222
  'models.star': '推荐',
221
223
  'models.auto_new': '新上线',
224
+ 'models.apply_original_all': '⚡ 原厂规格',
225
+ 'models.reset_contexts_all': '↺ 重置上下文',
226
+ 'models.context_updated_msg': '模型上下文限制已更新并同步生效至 DSH。',
227
+ 'models.ctx_default': '默认 ({ctx})',
228
+ 'models.ctx_original': '原厂 ({ctx} · {provider})',
229
+ 'models.ctx_edit': '编辑上下文窗口',
230
+ 'models.ctx_custom_placeholder': 'Token 数量 (如 2000000)',
231
+ 'models.ctx_save': '保存',
232
+ 'models.ctx_cancel': '取消',
233
+ 'models.ctx_tag_original': '原厂',
234
+ 'models.ctx_tag_custom': '自定义',
222
235
  'stats.title': '📈 当前 DSH 会话请求遥测',
223
236
  'stats.desc': '本地实时监控请求次数、网络延迟以及通过 ClinePass 消耗的估算 Token 数量。',
224
237
  'stats.requests': '成功 / 总请求次数',
@@ -229,7 +242,6 @@ const en = {
229
242
  'diag.desc': '配置 API 密钥后,ClineBot 会自动向 DSH 注册模型服务。您可以在此进行连通性测试或强制重新同步。',
230
243
  'diag.smoke_btn': '执行连通性冒烟测试 (Ping)',
231
244
  'diag.smoke_testing': '正在测试…',
232
- 'diag.smoke_ok': '冒烟测试通过!网络延迟:{latency} ms',
233
245
  'diag.resync_btn': '强制重新同步到 DSH',
234
246
  'diag.resyncing': '正在同步…',
235
247
  'diag.resynced_msg': '模型服务已重新同步到 DSH ({count} 个模型)',
@@ -683,8 +695,8 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
683
695
  {
684
696
  onSubmit: onSubmitAdd,
685
697
  style: {
686
- background: 'var(--dsw-alias-bg-hover, rgba(0,0,0,0.04))',
687
- border: '1px solid var(--dsw-alias-border, rgba(0,0,0,0.12))',
698
+ background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
699
+ border: '1px solid var(--dsw-alias-border, color-mix(in srgb, currentColor 12%, transparent))',
688
700
  borderRadius: '8px',
689
701
  padding: '12px',
690
702
  marginBottom: '14px',
@@ -935,6 +947,7 @@ function ModelsSection({
935
947
  keyPresent,
936
948
  busy,
937
949
  handleSyncPlanModels,
950
+ handleUpdateModelContext,
938
951
  handleSetModelsFilter,
939
952
  handleToggleModel,
940
953
  planSynced,
@@ -945,6 +958,21 @@ function ModelsSection({
945
958
  const syncDateStr = planSyncedAt ? new Date(planSyncedAt).toLocaleDateString() : ''
946
959
  const [search, setSearch] = React.useState('')
947
960
  const [viewFilter, setViewFilter] = React.useState('all')
961
+ const [editingModelId, setEditingModelId] = React.useState(null)
962
+ const [customCtxVal, setCustomCtxVal] = React.useState('')
963
+
964
+ function formatCtx(num) {
965
+ num = Number(num) || 200000
966
+ if (num >= 1000000) {
967
+ const m = num / 1000000
968
+ return `${m % 1 === 0 ? m : m.toFixed(1)}M`
969
+ }
970
+ if (num >= 1000) {
971
+ const k = num / 1000
972
+ return `${k % 1 === 0 ? k : k.toFixed(1)}K`
973
+ }
974
+ return String(num)
975
+ }
948
976
 
949
977
  const filteredModels = modelsList.filter((m) => {
950
978
  if (viewFilter === 'vision') {
@@ -998,6 +1026,28 @@ function ModelsSection({
998
1026
  },
999
1027
  busy === 'sync-models' ? t('models.syncing') : t('models.sync')
1000
1028
  ),
1029
+ React.createElement(
1030
+ 'button',
1031
+ {
1032
+ type: 'button',
1033
+ className: 'cb-btn',
1034
+ disabled: !!busy,
1035
+ title: t('models.apply_original_all'),
1036
+ onClick: () => typeof handleUpdateModelContext === 'function' && handleUpdateModelContext({ mode: 'all-original' }),
1037
+ },
1038
+ t('models.apply_original_all')
1039
+ ),
1040
+ React.createElement(
1041
+ 'button',
1042
+ {
1043
+ type: 'button',
1044
+ className: 'cb-btn',
1045
+ disabled: !!busy,
1046
+ title: t('models.reset_contexts_all'),
1047
+ onClick: () => typeof handleUpdateModelContext === 'function' && handleUpdateModelContext({ mode: 'all-default' }),
1048
+ },
1049
+ t('models.reset_contexts_all')
1050
+ ),
1001
1051
  React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('all') }, t('models.all')),
1002
1052
  React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('vision') }, t('models.vision')),
1003
1053
  React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('coding') }, t('models.coding')),
@@ -1103,6 +1153,12 @@ function ModelsSection({
1103
1153
  null,
1104
1154
  filteredModels.map((m) => {
1105
1155
  const isEnabled = !disabledSet.has(m.id)
1156
+ const defaultCtx = m.defaultContextLength || m.clineContextLength || 200000
1157
+ const origCtx = m.originalContextLength || 200000
1158
+ const origProv = m.originalProvider || 'Original'
1159
+ const isOverridden = Boolean(m.isContextOverridden)
1160
+ const isOriginal = isOverridden && (m.contextLength === origCtx)
1161
+
1106
1162
  return React.createElement(
1107
1163
  'tr',
1108
1164
  { key: m.id },
@@ -1127,7 +1183,140 @@ function ModelsSection({
1127
1183
  : null
1128
1184
  ),
1129
1185
  React.createElement('td', null, React.createElement('code', null, m.id)),
1130
- React.createElement('td', null, `${Math.round((m.contextLength || 128000) / 1000)}k`),
1186
+ React.createElement(
1187
+ 'td',
1188
+ null,
1189
+ editingModelId === m.id
1190
+ ? React.createElement(
1191
+ 'div',
1192
+ {
1193
+ style: {
1194
+ display: 'flex',
1195
+ flexDirection: 'column',
1196
+ gap: '6px',
1197
+ background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1198
+ padding: '6px',
1199
+ borderRadius: '6px',
1200
+ border: '1px solid var(--dsw-alias-border-l2)',
1201
+ },
1202
+ },
1203
+ React.createElement(
1204
+ 'div',
1205
+ { style: { display: 'flex', gap: '4px', flexWrap: 'wrap' } },
1206
+ React.createElement(
1207
+ 'button',
1208
+ {
1209
+ type: 'button',
1210
+ className: `cb-btn ${!isOverridden ? 'cb-btn-active' : ''}`,
1211
+ style: { padding: '2px 6px', fontSize: '11px' },
1212
+ onClick: () => {
1213
+ if (typeof handleUpdateModelContext === 'function') {
1214
+ handleUpdateModelContext({ modelId: m.id, reset: true })
1215
+ }
1216
+ setEditingModelId(null)
1217
+ },
1218
+ },
1219
+ t('models.ctx_default', { ctx: formatCtx(defaultCtx) })
1220
+ ),
1221
+ React.createElement(
1222
+ 'button',
1223
+ {
1224
+ type: 'button',
1225
+ className: `cb-btn ${isOriginal ? 'cb-btn-active' : ''}`,
1226
+ style: { padding: '2px 6px', fontSize: '11px' },
1227
+ onClick: () => {
1228
+ if (typeof handleUpdateModelContext === 'function') {
1229
+ handleUpdateModelContext({ modelId: m.id, contextLength: origCtx })
1230
+ }
1231
+ setEditingModelId(null)
1232
+ },
1233
+ },
1234
+ t('models.ctx_original', { ctx: formatCtx(origCtx), provider: origProv })
1235
+ )
1236
+ ),
1237
+ React.createElement(
1238
+ 'div',
1239
+ { style: { display: 'flex', gap: '4px', alignItems: 'center' } },
1240
+ React.createElement('input', {
1241
+ type: 'number',
1242
+ className: 'cb-input',
1243
+ style: { height: '24px', fontSize: '11px', padding: '0 6px', width: '120px' },
1244
+ placeholder: t('models.ctx_custom_placeholder'),
1245
+ value: customCtxVal,
1246
+ onChange: (e) => setCustomCtxVal(e.target.value),
1247
+ }),
1248
+ React.createElement(
1249
+ 'button',
1250
+ {
1251
+ type: 'button',
1252
+ className: 'cb-btn cb-btn-primary',
1253
+ style: { padding: '2px 8px', fontSize: '11px' },
1254
+ onClick: () => {
1255
+ const val = parseInt(customCtxVal, 10)
1256
+ if (val > 0 && typeof handleUpdateModelContext === 'function') {
1257
+ handleUpdateModelContext({ modelId: m.id, contextLength: val })
1258
+ }
1259
+ setEditingModelId(null)
1260
+ },
1261
+ },
1262
+ t('models.ctx_save')
1263
+ ),
1264
+ React.createElement(
1265
+ 'button',
1266
+ {
1267
+ type: 'button',
1268
+ className: 'cb-btn',
1269
+ style: { padding: '2px 6px', fontSize: '11px' },
1270
+ onClick: () => setEditingModelId(null),
1271
+ },
1272
+ t('models.ctx_cancel')
1273
+ )
1274
+ )
1275
+ )
1276
+ : React.createElement(
1277
+ 'div',
1278
+ { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
1279
+ React.createElement(
1280
+ 'span',
1281
+ {
1282
+ style: { fontWeight: '600', cursor: 'pointer' },
1283
+ onClick: () => {
1284
+ setCustomCtxVal(String(m.contextLength || defaultCtx))
1285
+ setEditingModelId(m.id)
1286
+ },
1287
+ },
1288
+ formatCtx(m.contextLength || defaultCtx)
1289
+ ),
1290
+ isOverridden
1291
+ ? React.createElement(
1292
+ 'span',
1293
+ {
1294
+ className: isOriginal ? 'cb-badge cb-badge-ok' : 'cb-badge cb-badge-warn',
1295
+ style: { fontSize: '10px', padding: '1px 6px', cursor: 'pointer' },
1296
+ onClick: () => {
1297
+ setCustomCtxVal(String(m.contextLength || defaultCtx))
1298
+ setEditingModelId(m.id)
1299
+ },
1300
+ },
1301
+ isOriginal ? t('models.ctx_tag_original') : t('models.ctx_tag_custom')
1302
+ )
1303
+ : null,
1304
+ React.createElement(
1305
+ 'button',
1306
+ {
1307
+ type: 'button',
1308
+ className: 'cb-btn',
1309
+ style: { padding: '2px 6px', fontSize: '11px', lineHeight: '1', border: 'none', background: 'transparent' },
1310
+ title: t('models.ctx_edit'),
1311
+ onClick: () => {
1312
+ setCustomCtxVal(String(m.contextLength || defaultCtx))
1313
+ setEditingModelId(m.id)
1314
+ },
1315
+ },
1316
+ '✎'
1317
+ )
1318
+ )
1319
+ ),
1131
1320
  React.createElement(
1132
1321
  'td',
1133
1322
  null,
@@ -1393,6 +1582,20 @@ function SettingsPage(props) {
1393
1582
  })
1394
1583
  }
1395
1584
 
1585
+ async function handleUpdateModelContext(payload) {
1586
+ await performAction('update-context', async () => {
1587
+ const res = await fetch(`${ROUTE_PREFIX}/models/context`, {
1588
+ method: 'POST',
1589
+ headers: { 'Content-Type': 'application/json' },
1590
+ body: JSON.stringify(payload),
1591
+ })
1592
+ const data = await res.json().catch(() => ({}))
1593
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1594
+ setMsg(t('models.context_updated_msg'))
1595
+ await load()
1596
+ })
1597
+ }
1598
+
1396
1599
  async function handleSyncPlanModels() {
1397
1600
  await performAction('sync-models', async () => {
1398
1601
  const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
@@ -1689,6 +1892,7 @@ function SettingsPage(props) {
1689
1892
  keyPresent,
1690
1893
  busy,
1691
1894
  handleSyncPlanModels,
1895
+ handleUpdateModelContext,
1692
1896
  handleSetModelsFilter,
1693
1897
  handleToggleModel,
1694
1898
  planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
@@ -1,3 +1,11 @@
1
+ export {
2
+ toCredentialRef,
3
+ resolveApiKey,
4
+ saveCredentialKey,
5
+ deleteCredentialKey,
6
+ resolveKeyValue,
7
+ } from './credential-refs.js'
8
+
1
9
  /**
2
10
  * ClineBot / ClinePass client helpers for DeepSeek Harness.
3
11
  *
@@ -22,20 +30,7 @@ export const USAGE_CACHE_TTL_MS = 60000
22
30
 
23
31
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
24
32
 
25
- /**
26
- * Resolve or fallback credential reference descriptor.
27
- */
28
- export async function toCredentialRef(name) {
29
- try {
30
- const mod = await import('@deepseek-ai/dsh-credentials')
31
- if (typeof mod.credentialRef === 'function') {
32
- return mod.credentialRef(name)
33
- }
34
- } catch {
35
- /* fallback when executed in standalone unit tests outside DSH bundle */
36
- }
37
- return typeof name === 'object' && name !== null ? name : { type: 'env', name: String(name || '') }
38
- }
33
+ // Credentials helpers extracted to ./credential-refs.js
39
34
 
40
35
  /**
41
36
  * Normalize base URL ensuring clean format without trailing slashes.
@@ -57,57 +52,7 @@ export {
57
52
  resetSessionStats,
58
53
  } from './telemetry.js'
59
54
 
60
- /**
61
- * Resolve API key from environment variables.
62
- */
63
- export function resolveApiKey(apiKeyEnv, env = process.env) {
64
- const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
65
- return {
66
- envName: name,
67
- value: String(env[name] || ''),
68
- }
69
- }
70
-
71
- /**
72
- * Save API key directly into DSH credentials service (~/.dsh/.credentials.yaml).
73
- */
74
- export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
75
- const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
76
- const value = String(apiKey || '').trim()
77
-
78
- if (!value) {
79
- throw new Error('API key cannot be empty')
80
- }
81
-
82
- const credentials = ctx?.credentials || ctx?.get?.('credentials')
83
- if (!credentials || typeof credentials.set !== 'function') {
84
- throw new Error('DSH credentials service is unavailable in this runtime profile')
85
- }
86
-
87
- const ref = await toCredentialRef(name)
88
- await credentials.set(ref, value)
89
- return { ok: true, envName: name }
90
- }
91
55
 
92
- /**
93
- * Delete API key from DSH credentials service.
94
- */
95
- export async function deleteCredentialKey(ctx, apiKeyEnv) {
96
- const name = String(apiKeyEnv || '').trim()
97
- if (!name) return { ok: false }
98
- const credentials = ctx?.credentials || ctx?.get?.('credentials')
99
- if (!credentials) return { ok: false }
100
- try {
101
- const ref = await toCredentialRef(name)
102
- if (typeof credentials.unset === 'function') {
103
- await credentials.unset(ref)
104
- return { ok: true, envName: name }
105
- }
106
- } catch (err) {
107
- console.warn('[dsh-clinebot] Failed unsetting credential:', err)
108
- }
109
- return { ok: false }
110
- }
111
56
 
112
57
  function abortAfter(ms) {
113
58
  const ac = new AbortController()
@@ -560,31 +505,7 @@ export function buildPiAiProvider({
560
505
  }
561
506
  }
562
507
 
563
- /**
564
- * Safely resolve key value from DSH credentials service or process.env.
565
- */
566
- export async function resolveKeyValue(ctx, apiKeyEnv) {
567
- const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
568
- const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
569
- if (creds && typeof creds.resolve === 'function') {
570
- try {
571
- const ref = await toCredentialRef(refName)
572
- const hit = await creds.resolve(ref)
573
- if (hit?.value) {
574
- return { envName: refName, value: hit.value, source: 'credentials' }
575
- }
576
- } catch {
577
- /* miss */
578
- }
579
- }
580
-
581
- const fromEnv = resolveApiKey(refName)
582
- if (fromEnv.value) {
583
- return { ...fromEnv, source: 'env' }
584
- }
585
508
 
586
- return { envName: refName, value: '', source: 'none' }
587
- }
588
509
 
589
510
 
590
511
  export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount, getLastRotation, setLastRotation, isQuotaExceededError } from './account-pool.js'
package/lib/config.js CHANGED
@@ -53,6 +53,12 @@ export const Config = z.object({
53
53
  .description('Models automatically discovered from the official ClinePass subscription plan.'),
54
54
  planSyncedAt: z.number().default(0)
55
55
  .description('Timestamp when models were last synchronized with active subscription plan.'),
56
+ modelContextOverrides: z.array(z.object({
57
+ modelId: z.string(),
58
+ contextLength: z.number().default(200000),
59
+ maxTokens: z.number().default(8192),
60
+ })).default([])
61
+ .description('Custom user-defined model context length and max token overrides.').volatile(),
56
62
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
57
63
  .description('HTTP probe timeout in milliseconds.').volatile(),
58
64
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
@@ -106,8 +112,9 @@ export function volatileConfig(cfg) {
106
112
  export function publicConfig(cfg) {
107
113
  cfg = plainConfig(cfg)
108
114
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
115
+ const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
109
116
  const isSynced = dynamic.length > 0
110
- const allModels = getAllModels(dynamic)
117
+ const allModels = getAllModels(dynamic, modelContextOverrides)
111
118
  const allDefaultIds = allModels.map((m) => m.id)
112
119
 
113
120
  let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
@@ -141,6 +148,7 @@ export function publicConfig(cfg) {
141
148
  dynamicModels: dynamic,
142
149
  planSynced: isSynced,
143
150
  planSyncedAt,
151
+ modelContextOverrides,
144
152
  disabledModels: disabledList,
145
153
  enabledModels: activeIds,
146
154
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Credentials management helper for @goodandready/dsh-clinebot.
3
+ * Isolates DSH Credentials storage and environment resolution.
4
+ */
5
+
6
+ export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
7
+
8
+ export async function toCredentialRef(name) {
9
+ try {
10
+ const mod = await import('@deepseek-ai/dsh-credentials')
11
+ if (typeof mod.credentialRef === 'function') {
12
+ return mod.credentialRef(name)
13
+ }
14
+ } catch {
15
+ /* fallback when executed in standalone unit tests outside DSH bundle */
16
+ }
17
+ return typeof name === 'object' && name !== null ? name : { type: 'env', name: String(name || '') }
18
+ }
19
+
20
+ export function resolveApiKey(apiKeyEnv, env = process.env) {
21
+ const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
22
+ return {
23
+ envName: name,
24
+ value: String(env[name] || ''),
25
+ }
26
+ }
27
+
28
+ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
29
+ const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
30
+ const value = String(apiKey || '').trim()
31
+
32
+ if (!value) {
33
+ throw new Error('API key cannot be empty')
34
+ }
35
+
36
+ const credentials = ctx?.credentials || ctx?.get?.('credentials')
37
+ if (!credentials || typeof credentials.set !== 'function') {
38
+ throw new Error('DSH credentials service is unavailable in this runtime profile')
39
+ }
40
+
41
+ const ref = await toCredentialRef(name)
42
+ await credentials.set(ref, value)
43
+ return { ok: true, envName: name }
44
+ }
45
+
46
+ export async function deleteCredentialKey(ctx, apiKeyEnv) {
47
+ const name = String(apiKeyEnv || '').trim()
48
+ if (!name) return { ok: false }
49
+ const credentials = ctx?.credentials || ctx?.get?.('credentials')
50
+ if (!credentials) return { ok: false }
51
+ try {
52
+ const ref = await toCredentialRef(name)
53
+ if (typeof credentials.unset === 'function') {
54
+ await credentials.unset(ref)
55
+ return { ok: true, envName: name }
56
+ }
57
+ } catch (err) {
58
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed unsetting credential: ' + (err?.message || err))
59
+ }
60
+ return { ok: false }
61
+ }
62
+
63
+ export async function resolveKeyValue(ctx, apiKeyEnv) {
64
+ const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
65
+ const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
66
+ if (creds && typeof creds.resolve === 'function') {
67
+ try {
68
+ const ref = await toCredentialRef(refName)
69
+ const hit = await creds.resolve(ref)
70
+ if (hit?.value) {
71
+ return { envName: refName, value: hit.value, source: 'credentials' }
72
+ }
73
+ } catch {
74
+ /* miss */
75
+ }
76
+ }
77
+
78
+ const fromEnv = resolveApiKey(refName)
79
+ if (fromEnv.value) {
80
+ return { ...fromEnv, source: 'env' }
81
+ }
82
+
83
+ return { envName: refName, value: '', source: 'none' }
84
+ }
package/lib/index.js CHANGED
@@ -122,7 +122,11 @@ export function apply(ctx, config) {
122
122
  if (typeof ctx.effect === 'function') {
123
123
  ctx.effect(() => {
124
124
  const timer = setTimeout(triggerAutoDiscover, 500)
125
- return () => clearTimeout(timer)
125
+ const retryTimer = setTimeout(triggerAutoDiscover, 3000)
126
+ return () => {
127
+ clearTimeout(timer)
128
+ clearTimeout(retryTimer)
129
+ }
126
130
  }, 'dsh-clinebot: auto-discover')
127
131
  }
128
132
 
package/lib/models.js CHANGED
@@ -6,18 +6,77 @@
6
6
  * a uniform 200,000 token context window cap across subscription completion routes,
7
7
  * and /users/me/plan returns only feature strings without per-model context specs.
8
8
  * This catalogue provides the official ClinePass models (200k gateway capacity) plus
9
- * support for user-defined custom models with arbitrary context length.
9
+ * support for user-defined custom models with arbitrary context length and authentic
10
+ * original model creator specifications (Moonshot 2M, Qwen 1M, MiniMax 1M, MiMo 1M, etc.).
10
11
  */
11
12
 
12
13
  export const PROVIDER_ID = 'clinebot'
14
+
15
+ export const OBSOLETE_MODEL_ID_MAP = Object.freeze({
16
+ 'cline-pass/deepseek-v41-flash': 'cline-pass/deepseek-v4.1-flash',
17
+ 'cline-pass/qwen38-max': 'cline-pass/qwen3.8-max',
18
+ 'cline-pass/glm-53': 'cline-pass/glm-5.3',
19
+ 'cline-pass/glm-53-flash': 'cline-pass/glm-5.3-flash',
20
+ 'cline-pass/muse-spark-13-contributor': 'cline-pass/muse-spark-1.3-contributor',
21
+ })
22
+
23
+ export function migrateModelId(id) {
24
+ if (typeof id !== 'string') return id
25
+ return OBSOLETE_MODEL_ID_MAP[id] || id
26
+ }
27
+
28
+ export function migrateModelEntry(model) {
29
+ if (!model || typeof model !== 'object') return model
30
+ const canonicalId = migrateModelId(model.id)
31
+ if (canonicalId === model.id) return model
32
+ return {
33
+ ...model,
34
+ id: canonicalId,
35
+ }
36
+ }
13
37
  export const PROVIDER_DISPLAY_NAME = 'ClineBot (ClinePass)'
14
38
  export const DEFAULT_MODEL_ID = 'cline-pass/deepseek-v4-flash'
15
39
 
40
+ /**
41
+ * Authentic original model specifications from original creators:
42
+ * Moonshot AI (Kimi): 2,000,000 tokens
43
+ * Alibaba Cloud (Qwen): 1,000,000 tokens
44
+ * MiniMax: 1,000,000 tokens
45
+ * Xiaomi (MiMo): 1,000,000 tokens
46
+ * DeepSeek: 128,000 tokens
47
+ * Zhipu AI (GLM): 128,000 tokens
48
+ */
49
+ export function getOriginalModelContext(nameOrId) {
50
+ const norm = String(nameOrId || '').toLowerCase()
51
+ if (norm.includes('kimi')) return 2000000
52
+ if (norm.includes('qwen')) return 1000000
53
+ if (norm.includes('minimax')) return 1000000
54
+ if (norm.includes('mimo')) return 1000000
55
+ if (norm.includes('deepseek')) return 128000
56
+ if (norm.includes('glm')) return 128000
57
+ return 200000
58
+ }
59
+
60
+ export function getOriginalModelProvider(nameOrId) {
61
+ const norm = String(nameOrId || '').toLowerCase()
62
+ if (norm.includes('kimi')) return 'Moonshot AI'
63
+ if (norm.includes('qwen')) return 'Alibaba Cloud'
64
+ if (norm.includes('minimax')) return 'MiniMax'
65
+ if (norm.includes('mimo')) return 'Xiaomi'
66
+ if (norm.includes('deepseek')) return 'DeepSeek'
67
+ if (norm.includes('glm')) return 'Zhipu AI'
68
+ return 'Original Vendor'
69
+ }
70
+
16
71
  export const CLINE_MODELS = Object.freeze([
17
72
  {
18
73
  id: 'cline-pass/deepseek-v4-flash',
19
74
  name: 'DeepSeek V4 Flash',
20
75
  description: 'High-speed reasoning & code completion model optimized for agentic loops.',
76
+ clineContextLength: 128000,
77
+ defaultContextLength: 128000,
78
+ originalContextLength: 128000,
79
+ originalProvider: 'DeepSeek',
21
80
  contextLength: 128000,
22
81
  maxTokens: 8192,
23
82
  input: ['text', 'image'],
@@ -30,6 +89,10 @@ export const CLINE_MODELS = Object.freeze([
30
89
  id: 'cline-pass/deepseek-v4-pro',
31
90
  name: 'DeepSeek V4 Pro',
32
91
  description: 'Flagship reasoning and multi-turn architectural coding model.',
92
+ clineContextLength: 128000,
93
+ defaultContextLength: 128000,
94
+ originalContextLength: 128000,
95
+ originalProvider: 'DeepSeek',
33
96
  contextLength: 128000,
34
97
  maxTokens: 8192,
35
98
  input: ['text', 'image'],
@@ -42,6 +105,10 @@ export const CLINE_MODELS = Object.freeze([
42
105
  id: 'cline-pass/glm-5.2',
43
106
  name: 'GLM 5.2',
44
107
  description: 'Bilingual general & coding model with strong instruction following.',
108
+ clineContextLength: 128000,
109
+ defaultContextLength: 128000,
110
+ originalContextLength: 128000,
111
+ originalProvider: 'Zhipu AI',
45
112
  contextLength: 128000,
46
113
  maxTokens: 8192,
47
114
  input: ['text'],
@@ -54,6 +121,10 @@ export const CLINE_MODELS = Object.freeze([
54
121
  id: 'cline-pass/kimi-k3',
55
122
  name: 'Kimi K3',
56
123
  description: 'Long-context reasoning & document synthesis model by Moonshot AI.',
124
+ clineContextLength: 200000,
125
+ defaultContextLength: 200000,
126
+ originalContextLength: 2000000,
127
+ originalProvider: 'Moonshot AI',
57
128
  contextLength: 200000,
58
129
  maxTokens: 8192,
59
130
  input: ['text'],
@@ -66,6 +137,10 @@ export const CLINE_MODELS = Object.freeze([
66
137
  id: 'cline-pass/kimi-k2.7-code',
67
138
  name: 'Kimi K2.7 Code',
68
139
  description: 'Specialized coding model with agentic tool calling support.',
140
+ clineContextLength: 200000,
141
+ defaultContextLength: 200000,
142
+ originalContextLength: 2000000,
143
+ originalProvider: 'Moonshot AI',
69
144
  contextLength: 200000,
70
145
  maxTokens: 8192,
71
146
  input: ['text'],
@@ -77,6 +152,10 @@ export const CLINE_MODELS = Object.freeze([
77
152
  id: 'cline-pass/kimi-k2.6',
78
153
  name: 'Kimi K2.6',
79
154
  description: 'Balanced long-context conversational model.',
155
+ clineContextLength: 200000,
156
+ defaultContextLength: 200000,
157
+ originalContextLength: 2000000,
158
+ originalProvider: 'Moonshot AI',
80
159
  contextLength: 200000,
81
160
  maxTokens: 8192,
82
161
  input: ['text'],
@@ -88,6 +167,10 @@ export const CLINE_MODELS = Object.freeze([
88
167
  id: 'cline-pass/qwen3.7-max',
89
168
  name: 'Qwen 3.7 Max',
90
169
  description: 'Large-scale multimodal foundation model from Alibaba Cloud with reasoning support.',
170
+ clineContextLength: 128000,
171
+ defaultContextLength: 128000,
172
+ originalContextLength: 1000000,
173
+ originalProvider: 'Alibaba Cloud',
91
174
  contextLength: 128000,
92
175
  maxTokens: 8192,
93
176
  input: ['text', 'image'],
@@ -100,6 +183,10 @@ export const CLINE_MODELS = Object.freeze([
100
183
  id: 'cline-pass/qwen3.7-plus',
101
184
  name: 'Qwen 3.7 Plus',
102
185
  description: 'Fast, capable multimodal model with strong multilingual skills and reasoning.',
186
+ clineContextLength: 128000,
187
+ defaultContextLength: 128000,
188
+ originalContextLength: 1000000,
189
+ originalProvider: 'Alibaba Cloud',
103
190
  contextLength: 128000,
104
191
  maxTokens: 8192,
105
192
  input: ['text', 'image'],
@@ -112,6 +199,10 @@ export const CLINE_MODELS = Object.freeze([
112
199
  id: 'cline-pass/minimax-m3',
113
200
  name: 'MiniMax M3',
114
201
  description: 'High throughput reasoning and synthesis model.',
202
+ clineContextLength: 200000,
203
+ defaultContextLength: 200000,
204
+ originalContextLength: 1000000,
205
+ originalProvider: 'MiniMax',
115
206
  contextLength: 200000,
116
207
  maxTokens: 8192,
117
208
  input: ['text'],
@@ -124,6 +215,10 @@ export const CLINE_MODELS = Object.freeze([
124
215
  id: 'cline-pass/mimo-v2.5',
125
216
  name: 'MiMo V2.5',
126
217
  description: 'Xiaomi MiMo efficient instruction model with reasoning and multimodal support.',
218
+ clineContextLength: 128000,
219
+ defaultContextLength: 128000,
220
+ originalContextLength: 1000000,
221
+ originalProvider: 'Xiaomi',
127
222
  contextLength: 128000,
128
223
  maxTokens: 8192,
129
224
  input: ['text', 'image'],
@@ -136,6 +231,10 @@ export const CLINE_MODELS = Object.freeze([
136
231
  id: 'cline-pass/mimo-v2.5-pro',
137
232
  name: 'MiMo V2.5 Pro',
138
233
  description: 'Xiaomi MiMo advanced agentic reasoning model with multimodal support.',
234
+ clineContextLength: 128000,
235
+ defaultContextLength: 128000,
236
+ originalContextLength: 1000000,
237
+ originalProvider: 'Xiaomi',
139
238
  contextLength: 128000,
140
239
  maxTokens: 8192,
141
240
  input: ['text', 'image'],
@@ -203,10 +302,16 @@ export function parsePlanIncludedModels(includedInput) {
203
302
  .replace(/-+/g, '-')
204
303
  const isVision = /(vision|vl|multimodal|omni|image)/i.test(name)
205
304
  const hasReasoning = /(reason|think|r1|pro|flash|max|plus|k3|m3|glm|qwen|mimo)/i.test(name)
305
+ const origCtx = getOriginalModelContext(name)
306
+ const origProv = getOriginalModelProvider(name)
206
307
  matched.push({
207
308
  id: `cline-pass/${idPart}`,
208
309
  name,
209
310
  description: `Official ClinePass subscription model: ${name}`,
311
+ clineContextLength: 200000,
312
+ defaultContextLength: 200000,
313
+ originalContextLength: origCtx,
314
+ originalProvider: origProv,
210
315
  contextLength: 200000,
211
316
  maxTokens: 8192,
212
317
  input: isVision ? ['text', 'image'] : ['text'],
@@ -228,51 +333,98 @@ export function parsePlanIncludedModels(includedInput) {
228
333
  * with curated capabilities, reasoning efforts, and descriptions from CLINE_MODELS.
229
334
  * 2. When plan is unsynced (dynamicModels is empty):
230
335
  * Falls back to the full curated CLINE_MODELS catalog marked with unverified: true.
336
+ *
337
+ * Applies user-defined modelContextOverrides (custom contextLength and maxTokens).
231
338
  */
232
- export function getAllModels(dynamicModels = []) {
233
- if (Array.isArray(dynamicModels) && dynamicModels.length > 0) {
339
+ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
340
+ const normalizedDynamic = (Array.isArray(dynamicModels) ? dynamicModels : []).map(migrateModelEntry)
341
+ const overridesMap = new Map()
342
+ if (Array.isArray(modelContextOverrides)) {
343
+ for (const ov of modelContextOverrides) {
344
+ if (ov && ov.modelId) {
345
+ overridesMap.set(ov.modelId, ov)
346
+ const canonicalId = migrateModelId(ov.modelId)
347
+ if (canonicalId !== ov.modelId) {
348
+ overridesMap.set(canonicalId, { ...ov, modelId: canonicalId })
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ function applyOverride(m) {
355
+ const defaultCtx = m.clineContextLength || m.defaultContextLength || m.contextLength || 200000
356
+ const origCtx = m.originalContextLength || getOriginalModelContext(m.id)
357
+ const origProv = m.originalProvider || getOriginalModelProvider(m.id)
358
+ const ov = overridesMap.get(m.id)
359
+ const activeCtx = ov && typeof ov.contextLength === 'number' && ov.contextLength > 0
360
+ ? ov.contextLength
361
+ : defaultCtx
362
+ const activeMaxTokens = ov && typeof ov.maxTokens === 'number' && ov.maxTokens > 0
363
+ ? ov.maxTokens
364
+ : m.maxTokens || 8192
365
+ return {
366
+ ...m,
367
+ contextLength: activeCtx,
368
+ maxTokens: activeMaxTokens,
369
+ defaultContextLength: defaultCtx,
370
+ clineContextLength: defaultCtx,
371
+ originalContextLength: origCtx,
372
+ originalProvider: origProv,
373
+ isContextOverridden: activeCtx !== defaultCtx,
374
+ }
375
+ }
376
+
377
+ if (normalizedDynamic.length > 0) {
234
378
  const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
235
- return dynamicModels.map((dm) => {
379
+ return normalizedDynamic.map((dm) => {
236
380
  const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
237
381
  const curated = CLINE_MODELS.find(
238
382
  (cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
239
383
  )
240
384
  if (curated) {
241
- return {
385
+ return applyOverride({
242
386
  ...curated,
243
387
  ...dm,
244
388
  description: dm.description || curated.description,
245
389
  contextLength: dm.contextLength || curated.contextLength,
246
390
  maxTokens: dm.maxTokens || curated.maxTokens,
391
+ clineContextLength: curated.clineContextLength || curated.contextLength,
392
+ defaultContextLength: curated.defaultContextLength || curated.contextLength,
393
+ originalContextLength: curated.originalContextLength || getOriginalModelContext(curated.id),
394
+ originalProvider: curated.originalProvider || getOriginalModelProvider(curated.id),
247
395
  input: dm.input && dm.input.length ? dm.input : curated.input,
248
396
  category: dm.category || curated.category,
249
397
  reasoningEfforts: dm.reasoningEfforts !== undefined ? dm.reasoningEfforts : curated.reasoningEfforts,
250
398
  unverified: false,
251
- }
399
+ })
252
400
  }
253
- return {
401
+ return applyOverride({
254
402
  ...dm,
403
+ clineContextLength: dm.contextLength || 200000,
404
+ defaultContextLength: dm.contextLength || 200000,
405
+ originalContextLength: dm.originalContextLength || getOriginalModelContext(dm.id),
406
+ originalProvider: dm.originalProvider || getOriginalModelProvider(dm.id),
255
407
  unverified: false,
256
- }
408
+ })
257
409
  })
258
410
  }
259
411
 
260
- return CLINE_MODELS.map((m) => ({ ...m, unverified: true }))
412
+ return CLINE_MODELS.map((m) => applyOverride({ ...m, unverified: true }))
261
413
  }
262
414
 
263
- export function findModel(id, dynamicModels = []) {
264
- const all = getAllModels(dynamicModels)
415
+ export function findModel(id, dynamicModels = [], modelContextOverrides = []) {
416
+ const all = getAllModels(dynamicModels, modelContextOverrides)
265
417
  const found = all.find((m) => m.id === id)
266
418
  if (found) return found
267
419
  return CLINE_MODELS.find((m) => m.id === id) || null
268
420
  }
269
421
 
270
- export function isSupportedModel(id, dynamicModels = []) {
271
- return findModel(id, dynamicModels) !== null
422
+ export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = []) {
423
+ return findModel(id, dynamicModels, modelContextOverrides) !== null
272
424
  }
273
425
 
274
- export function getDefaultModelIds(dynamicModels = []) {
275
- return getAllModels(dynamicModels).map((m) => m.id)
426
+ export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = []) {
427
+ return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
276
428
  }
277
429
 
278
430
  /**
@@ -287,12 +439,18 @@ export function getActiveModelIds(allModels = [], disabledModelIds = []) {
287
439
  }
288
440
 
289
441
  /**
290
- * Format context length cleanly (e.g. 200000 -> '200K', 128000 -> '128K').
442
+ * Format context length cleanly (e.g. 2000000 -> '2M', 1000000 -> '1M', 200000 -> '200K', 128000 -> '128K').
291
443
  */
292
444
  export function formatModelContext(contextLength) {
293
445
  const num = Number(contextLength) || 200000
294
- if (num >= 1000000) return `${Math.round(num / 1000000)}M`
295
- if (num >= 1000) return `${Math.round(num / 1000)}K`
446
+ if (num >= 1000000) {
447
+ const m = num / 1000000
448
+ return `${m % 1 === 0 ? m : m.toFixed(1)}M`
449
+ }
450
+ if (num >= 1000) {
451
+ const k = num / 1000
452
+ return `${k % 1 === 0 ? k : k.toFixed(1)}K`
453
+ }
296
454
  return String(num)
297
455
  }
298
456
 
@@ -318,8 +476,8 @@ export function formatModelDescription(model) {
318
476
  /**
319
477
  * Check if a model supports vision input.
320
478
  */
321
- export function isVisionModel(id, dynamicModels = []) {
322
- const m = findModel(id, dynamicModels)
479
+ export function isVisionModel(id, dynamicModels = [], modelContextOverrides = []) {
480
+ const m = findModel(id, dynamicModels, modelContextOverrides)
323
481
  if (!m) return false
324
482
  return Boolean(m.input?.includes('image') || m.input?.includes('vision'))
325
483
  }
@@ -348,12 +506,20 @@ export async function loadModelsDiskCache(cachePath) {
348
506
  const fs = await import('node:fs/promises')
349
507
  const raw = await fs.readFile(cachePath, 'utf8')
350
508
  const parsed = JSON.parse(raw)
351
- const list = Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null)
352
- if (!list) return null
509
+ const rawList = Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null)
510
+ if (!rawList) return null
511
+ let hasObsolete = false
512
+ const list = rawList.map((m) => {
513
+ const migrated = migrateModelEntry(m)
514
+ if (migrated.id !== m.id) hasObsolete = true
515
+ return migrated
516
+ })
353
517
  list.planSyncedAt = typeof parsed?.planSyncedAt === 'number' ? parsed.planSyncedAt : (typeof parsed?.savedAt === 'number' ? parsed.savedAt : 0)
518
+ if (hasObsolete) {
519
+ saveModelsDiskCache(cachePath, list, list.planSyncedAt).catch(() => {})
520
+ }
354
521
  return list
355
522
  } catch {
356
523
  return null
357
524
  }
358
525
  }
359
-
@@ -88,7 +88,7 @@ export async function buildStatus(ctx, cfg) {
88
88
  ])
89
89
 
90
90
  const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
91
- const allModels = getAllModels(pub.dynamicModels)
91
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
92
92
 
93
93
  let usage = null
94
94
  const keyToUse = activeAcc.value || key.value
@@ -156,7 +156,7 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
156
156
  }
157
157
 
158
158
  const pub = publicConfig(cfg)
159
- const allModels = getAllModels(pub.dynamicModels)
159
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
160
160
  const allowedSet = new Set(activeModelIds || pub.enabledModels)
161
161
  const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
162
162
 
@@ -217,12 +217,26 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
217
217
  const cacheFile = resolvePathWithHome(pub.modelsCachePath)
218
218
  const settingsApi = getSettingsApi()
219
219
 
220
+ // Auto-migrate obsolete IDs (e.g. deepseek-v41-flash -> deepseek-v4.1-flash)
221
+ const existingDynamic = pub.dynamicModels || []
222
+ const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
223
+ if (hasObsolete && settingsApi?.replace) {
224
+ const migrated = existingDynamic.map(migrateModelEntry)
225
+ const next = Config({
226
+ ...plainConfig(live()),
227
+ dynamicModels: migrated,
228
+ })
229
+ await settingsApi.replace(next)
230
+ await syncProviderState(next)
231
+ }
232
+
220
233
  if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
221
234
  const fromDisk = await loadModelsDiskCache(cacheFile)
222
235
  if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
236
+ const migratedDisk = fromDisk.map(migrateModelEntry)
223
237
  const next = Config({
224
238
  ...plainConfig(live()),
225
- dynamicModels: fromDisk,
239
+ dynamicModels: migratedDisk,
226
240
  planSyncedAt: fromDisk.planSyncedAt || Date.now(),
227
241
  })
228
242
  await settingsApi.replace(next)
@@ -2,7 +2,7 @@ import { writeJson, readBody } from '../http.js'
2
2
  import { assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
5
- import { getAllModels, saveModelsDiskCache } from '../models.js'
5
+ import { getAllModels, saveModelsDiskCache, getOriginalModelContext } from '../models.js'
6
6
  import { fetchUsageLimits } from '../cline-client.js'
7
7
  import { publicUsage } from '../http.js'
8
8
 
@@ -33,7 +33,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
33
33
  }
34
34
 
35
35
  const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
36
- const allModels = getAllModels(dynamicModels)
36
+ const allModels = getAllModels(dynamicModels, pub.modelContextOverrides)
37
37
  const settingsApi = getSettingsApi()
38
38
  if (!settingsApi || typeof settingsApi.replace !== 'function') {
39
39
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
@@ -82,11 +82,12 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
82
82
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
83
83
  }
84
84
 
85
+ const pub = publicConfig(live())
85
86
  const patch = {}
86
87
  if (Array.isArray(body.disabledModels)) {
87
88
  patch.disabledModels = body.disabledModels
88
89
  } else if (Array.isArray(body.enabledModels)) {
89
- const allModels = getAllModels(publicConfig(live()).dynamicModels)
90
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
90
91
  const enabledSet = new Set(body.enabledModels)
91
92
  patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
92
93
  }
@@ -101,6 +102,87 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
101
102
  },
102
103
  }), 'dsh-clinebot: /models/toggle')
103
104
 
105
+ // POST /dsh-clinebot/models/context — configure custom context length and token limits
106
+ ctx.effect(() => ctx.webServer.register({
107
+ kind: 'exact',
108
+ path: '/dsh-clinebot/models/context',
109
+ handler: async (req, res) => {
110
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
111
+ if (!assertTrustedSettingsRequest(req, res)) return
112
+ try {
113
+ const bodyBuf = await readBody(req)
114
+ let body = {}
115
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
116
+
117
+ const settingsApi = getSettingsApi()
118
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
119
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
120
+ }
121
+
122
+ const pub = publicConfig(live())
123
+ const allModels = getAllModels(pub.dynamicModels, [])
124
+ let currentOverrides = Array.isArray(pub.modelContextOverrides) ? [...pub.modelContextOverrides] : []
125
+
126
+ if (body.mode === 'all-original') {
127
+ // Set all models to their authentic original creator context limits
128
+ currentOverrides = allModels.map((m) => ({
129
+ modelId: m.id,
130
+ contextLength: m.originalContextLength || getOriginalModelContext(m.id),
131
+ maxTokens: m.maxTokens || 8192,
132
+ }))
133
+ } else if (body.mode === 'all-default' || body.mode === 'reset-all') {
134
+ // Reset all overrides back to provider defaults
135
+ currentOverrides = []
136
+ } else if (Array.isArray(body.overrides)) {
137
+ // Bulk array override
138
+ currentOverrides = body.overrides
139
+ .filter((o) => o && typeof o.modelId === 'string')
140
+ .map((o) => ({
141
+ modelId: o.modelId,
142
+ contextLength: Math.max(1000, Number(o.contextLength) || 200000),
143
+ maxTokens: Math.max(256, Number(o.maxTokens) || 8192),
144
+ }))
145
+ } else if (body.modelId) {
146
+ const modelId = String(body.modelId)
147
+ if (body.reset) {
148
+ currentOverrides = currentOverrides.filter((o) => o.modelId !== modelId)
149
+ } else {
150
+ const rawCtx = Number(body.contextLength)
151
+ const contextLength = Number.isFinite(rawCtx) && rawCtx > 0 ? Math.max(1000, rawCtx) : 200000
152
+ const rawMax = Number(body.maxTokens)
153
+ const maxTokens = Number.isFinite(rawMax) && rawMax > 0 ? Math.max(256, rawMax) : 8192
154
+
155
+ const existingIdx = currentOverrides.findIndex((o) => o.modelId === modelId)
156
+ const newEntry = { modelId, contextLength, maxTokens }
157
+ if (existingIdx >= 0) {
158
+ currentOverrides[existingIdx] = newEntry
159
+ } else {
160
+ currentOverrides.push(newEntry)
161
+ }
162
+ }
163
+ } else {
164
+ return writeJson(res, 400, { ok: false, error: 'Missing modelId, mode, or overrides array' })
165
+ }
166
+
167
+ const next = Config({
168
+ ...plainConfig(live()),
169
+ modelContextOverrides: currentOverrides,
170
+ })
171
+ await settingsApi.replace(next)
172
+ await syncProviderState(next)
173
+
174
+ const updatedModels = getAllModels(pub.dynamicModels, currentOverrides)
175
+ return writeJson(res, 200, {
176
+ ok: true,
177
+ modelContextOverrides: next.modelContextOverrides,
178
+ models: updatedModels,
179
+ })
180
+ } catch (err) {
181
+ return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
182
+ }
183
+ },
184
+ }), 'dsh-clinebot: /models/context')
185
+
104
186
  // GET /dsh-clinebot/usage — direct fresh usage limit query
105
187
  ctx.effect(() => ctx.webServer.register({
106
188
  kind: 'exact',
@@ -34,7 +34,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
34
34
 
35
35
  // 1. Subcommand /cline models
36
36
  if (subcmd === 'models') {
37
- const allModels = getAllModels(pub.dynamicModels)
37
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
38
38
  const disabledSet = new Set(pub.disabledModels || [])
39
39
  const activeCount = allModels.filter((m) => !disabledSet.has(m.id)).length
40
40
  const syncStatus = pub.planSynced
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "README.ru.md",
19
19
  "README.zh.md",
20
20
  "CHANGELOG.md",
21
- "LICENSE"
21
+ "LICENSE",
22
+ "media/visual-verification.png"
22
23
  ],
23
24
  "keywords": [
24
25
  "dsh",