@goodandready/dsh-clinebot 0.3.24 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -23,12 +23,12 @@ const SNAPSHOT_READY = Object.freeze({ status: 'ready', view: null })
23
23
  // --- src/client/locales.js ---
24
24
  const en = {
25
25
  title: 'ClineBot',
26
- subtitle: 'ClinePass subscription ($9.99/mo) with curated & custom models.',
26
+ subtitle: 'ClinePass subscription with dynamic plan models.',
27
27
  'settings.loading': 'Loading ClineBot settings…',
28
28
  'settings.retry': 'Retry',
29
29
  'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
30
30
  'header.title': 'ClineBot (ClinePass) Provider',
31
- 'header.sub': 'Connect models under ClinePass subscription ($9.99/mo). OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
31
+ 'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
32
32
  'badge.online': 'Host online ({latency} ms)',
33
33
  'badge.offline': 'Host unreachable',
34
34
  'badge.key_ok': 'Key ✓ ({source})',
@@ -43,8 +43,11 @@ const en = {
43
43
  'key.hide': 'Hide',
44
44
  'key.save': 'Save Key',
45
45
  'key.saving': 'Saving…',
46
- 'key.login_fast': '🚀 Quick Web Login',
47
- 'key.login_waiting': 'Waiting for authorization in browser…',
46
+ 'key.login_fast': '🔑 Open API Keys ↗',
47
+ 'key.login_waiting': 'Paste key below for instant verification.',
48
+ 'key.verifying': 'Validating key with Cline…',
49
+ 'key.verified': '✓ Valid key for {email} ({plan})',
50
+ 'key.invalid': '✗ Key validation failed: {error}',
48
51
  'key.env_label': 'Credential environment name: ',
49
52
  'key.get_key': 'Get key in app.cline.bot console ↗',
50
53
  'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
@@ -74,6 +77,9 @@ const en = {
74
77
  'models.sync': '🔄 Check Plan Models',
75
78
  'models.syncing': 'Checking…',
76
79
  'models.synced_msg': 'Synchronized subscription models: {total} ({discovered} discovered from ClinePass)',
80
+ 'models.verified': 'Verified with plan',
81
+ 'models.unverified': 'Not synced (fallback catalog)',
82
+ 'models.synced_at': 'Synced: {date}',
77
83
  'models.all': 'All',
78
84
  'models.vision': 'Vision Only',
79
85
  'models.coding': 'Coding Only',
@@ -113,12 +119,12 @@ const en = {
113
119
 
114
120
  const zh = {
115
121
  title: 'ClineBot',
116
- subtitle: 'ClinePass 订阅服务 ($9.99/月),包含精选与自定义模型。',
122
+ subtitle: 'ClinePass 订阅服务,包含动态套餐模型。',
117
123
  'settings.loading': '正在加载 ClineBot 设置…',
118
124
  'settings.retry': '重试',
119
125
  'settings.unavailable': 'ClineBot 设置不可用(宿主命名空间尚未就绪)。',
120
126
  'header.title': 'ClineBot (ClinePass) 模型服务商',
121
- 'header.sub': '通过 ClinePass 订阅 ($9.99/月) 连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
127
+ 'header.sub': '通过 ClinePass 订阅连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
122
128
  'badge.online': '服务在线 ({latency} ms)',
123
129
  'badge.offline': '主机无法连接',
124
130
  'badge.key_ok': '密钥正常 ({source})',
@@ -133,8 +139,11 @@ const en = {
133
139
  'key.hide': '隐藏',
134
140
  'key.save': '保存密钥',
135
141
  'key.saving': '正在保存…',
136
- 'key.login_fast': '🚀 浏览器快速登录',
137
- 'key.login_waiting': '等待在浏览器中完成授权…',
142
+ 'key.login_fast': '🔑 打开 API 密钥页 ↗',
143
+ 'key.login_waiting': '请在下方粘贴密钥,系统将自动进行有效性校验。',
144
+ 'key.verifying': '正在连接 Cline 验证密钥…',
145
+ 'key.verified': '✓ 密钥有效:{email} ({plan})',
146
+ 'key.invalid': '✗ 密钥验证未通过:{error}',
138
147
  'key.env_label': '凭据环境变量名称:',
139
148
  'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
140
149
  'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
@@ -164,6 +173,9 @@ const en = {
164
173
  'models.sync': '🔄 同步订阅模型',
165
174
  'models.syncing': '正在同步…',
166
175
  'models.synced_msg': '已成功同步订阅模型:共 {total} 个(发现 {discovered} 个新模型)',
176
+ 'models.verified': '已与套餐同步',
177
+ 'models.unverified': '未同步(备用目录)',
178
+ 'models.synced_at': '同步时间:{date}',
167
179
  'models.all': '全部模型',
168
180
  'models.vision': '仅视觉 (Vision)',
169
181
  'models.coding': '仅编程 (Coding)',
@@ -275,11 +287,14 @@ function ensureCss() {
275
287
  .cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent) !important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}
276
288
  .cb-btn-disabled{opacity:0.5;cursor:not-allowed}
277
289
 
278
- .cb-bar-container{display:flex;flex-direction:column;gap:6px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
279
- .cb-bar-head{display:flex;justify-content:space-between;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
280
- .cb-bar-track{width:100%;height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
281
- .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s}
282
- .cb-bar-meta{display:flex;justify-content:space-between;font-size:12px;color:var(--dsw-alias-label-secondary)}
290
+ .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
291
+ .cb-progress-head, .cb-bar-head{display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary);width:100%}
292
+ .cb-progress-label{font-weight:500;white-space:nowrap}
293
+ .cb-progress-meta, .cb-bar-meta{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;margin-left:auto}
294
+ .cb-progress-track, .cb-bar-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
295
+ .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-brand-primary, var(--dsw-alias-state-success-primary))}
296
+ .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warning-primary)}
297
+ .cb-progress-bar-bad{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-error-primary)}
283
298
 
284
299
  .cb-table{width:100%;border-collapse:collapse;margin-top:8px}
285
300
  .cb-table th{text-align:left;font-size:12px;color:var(--dsw-alias-label-secondary);padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);font-weight:600}
@@ -365,24 +380,62 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
365
380
 
366
381
  return React.createElement(
367
382
  'div',
368
- { className: 'cb-progress-card' },
383
+ {
384
+ className: 'cb-progress-card cb-bar-container',
385
+ style: {
386
+ display: 'flex',
387
+ flexDirection: 'column',
388
+ gap: '8px',
389
+ padding: '12px 14px',
390
+ borderRadius: '8px',
391
+ border: '1px solid var(--dsw-alias-border-l2)',
392
+ background: 'var(--dsw-alias-bg-layer-2)',
393
+ },
394
+ },
369
395
  React.createElement(
370
396
  'div',
371
- { className: 'cb-progress-head' },
372
- React.createElement('span', { className: 'cb-progress-label' }, label),
397
+ {
398
+ className: 'cb-progress-head cb-bar-head',
399
+ style: {
400
+ display: 'flex',
401
+ justifyContent: 'space-between',
402
+ alignItems: 'baseline',
403
+ gap: '16px',
404
+ width: '100%',
405
+ },
406
+ },
407
+ React.createElement('span', { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } }, label),
373
408
  React.createElement(
374
409
  'span',
375
- { className: 'cb-progress-meta' },
410
+ {
411
+ className: 'cb-progress-meta cb-bar-meta',
412
+ style: {
413
+ fontSize: '12px',
414
+ color: 'var(--dsw-alias-label-secondary)',
415
+ whiteSpace: 'nowrap',
416
+ marginLeft: 'auto',
417
+ },
418
+ },
376
419
  t('quota.used', { pct }),
377
420
  resetLabel ? ` · ${t('quota.reset_at', { time: resetLabel })}` : ''
378
421
  )
379
422
  ),
380
423
  React.createElement(
381
424
  'div',
382
- { className: 'cb-progress-track' },
425
+ {
426
+ className: 'cb-progress-track cb-bar-track',
427
+ style: {
428
+ width: '100%',
429
+ height: '8px',
430
+ borderRadius: '999px',
431
+ background: 'var(--dsw-alias-bg-layer-1)',
432
+ overflow: 'hidden',
433
+ border: '1px solid var(--dsw-alias-border-l2)',
434
+ },
435
+ },
383
436
  React.createElement('div', {
384
437
  className: barClass,
385
- style: { width: `${pct}%` },
438
+ style: { width: `${pct}%`, height: '100%', borderRadius: '999px' },
386
439
  })
387
440
  )
388
441
  )
@@ -445,7 +498,7 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
445
498
  }
446
499
 
447
500
  // --- src/client/components/key-section.js ---
448
- function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, t }) {
501
+ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, verifyStatus, t }) {
449
502
  return React.createElement(
450
503
  'div',
451
504
  { className: 'cb-section-card' },
@@ -488,19 +541,28 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
488
541
  disabled: !!busy,
489
542
  onClick: handleFastLogin,
490
543
  },
491
- busy === 'fast-login' ? '…' : t('key.login_fast')
544
+ t('key.login_fast')
492
545
  )
493
546
  ),
547
+ verifyStatus && verifyStatus.state === 'verifying'
548
+ ? React.createElement('div', { style: { marginTop: '8px', fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('key.verifying'))
549
+ : null,
550
+ verifyStatus && verifyStatus.state === 'valid'
551
+ ? React.createElement('div', { className: 'cb-alert-ok', style: { marginTop: '8px', fontSize: '13px' } }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
552
+ : null,
553
+ verifyStatus && verifyStatus.state === 'invalid'
554
+ ? React.createElement('div', { className: 'cb-alert-bad', style: { marginTop: '8px', fontSize: '13px' } }, t('key.invalid', { error: verifyStatus.error }))
555
+ : null,
494
556
  React.createElement(
495
557
  'div',
496
- { className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
558
+ { className: 'cb-row', style: { fontSize: '13px', marginTop: '12px', color: 'var(--dsw-alias-label-secondary)' } },
497
559
  React.createElement('span', null, t('key.env_label')),
498
560
  React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
499
561
  React.createElement('span', null, ' · '),
500
562
  React.createElement(
501
563
  'a',
502
564
  {
503
- href: 'https://app.cline.bot',
565
+ href: 'https://app.cline.bot/settings/api-keys',
504
566
  target: '_blank',
505
567
  rel: 'noreferrer',
506
568
  style: { color: 'var(--dsw-alias-state-brand-primary)' },
@@ -576,7 +638,14 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
576
638
  )
577
639
  })
578
640
  )
579
- )
641
+ ),
642
+ status.lastRotation
643
+ ? React.createElement(
644
+ 'div',
645
+ { className: 'cb-rotation-info', style: { marginTop: '10px', fontSize: '12px', opacity: 0.85 } },
646
+ `Last failover: ${status.lastRotation.from} → ${status.lastRotation.to} (${status.lastRotation.reason})`
647
+ )
648
+ : null
580
649
  )
581
650
  }
582
651
 
@@ -627,7 +696,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
627
696
  'div',
628
697
  { className: 'cb-section-desc' },
629
698
  usage?.user?.email
630
- ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
699
+ ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass' })
631
700
  : t('quota.desc')
632
701
  ),
633
702
  React.createElement(
@@ -654,14 +723,40 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
654
723
  }
655
724
 
656
725
  // --- src/client/components/models-section.js ---
657
- function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy, handleSyncPlanModels, handleSetModelsFilter, handleToggleModel, t }) {
726
+ function ModelsSection({
727
+ modelsList,
728
+ disabledSet,
729
+ enabledCount,
730
+ keyPresent,
731
+ busy,
732
+ handleSyncPlanModels,
733
+ handleSetModelsFilter,
734
+ handleToggleModel,
735
+ planSynced,
736
+ planSyncedAt,
737
+ defaultModelWarning,
738
+ t,
739
+ }) {
740
+ const syncDateStr = planSyncedAt ? new Date(planSyncedAt).toLocaleDateString() : ''
741
+
658
742
  return React.createElement(
659
743
  'div',
660
744
  { className: 'cb-section-card' },
745
+ defaultModelWarning
746
+ ? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, `⚠️ ${defaultModelWarning}`)
747
+ : null,
661
748
  React.createElement(
662
749
  'div',
663
750
  { className: 'cb-section-title' },
664
- t('models.title', { enabled: enabledCount, total: modelsList.length }),
751
+ React.createElement(
752
+ 'span',
753
+ null,
754
+ t('models.title', { enabled: enabledCount, total: modelsList.length }),
755
+ planSynced
756
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '8px', fontSize: '11px' } }, t('models.verified'))
757
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn', style: { marginLeft: '8px', fontSize: '11px' } }, t('models.unverified')),
758
+ syncDateStr ? React.createElement('span', { style: { marginLeft: '8px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('models.synced_at', { date: syncDateStr })) : null
759
+ ),
665
760
  React.createElement(
666
761
  'div',
667
762
  { className: 'cb-row' },
@@ -727,17 +822,31 @@ function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy
727
822
  : null
728
823
  ),
729
824
  React.createElement('td', null, React.createElement('code', null, m.id)),
730
- React.createElement('td', null, `${Math.round((m.contextLength || 200000) / 1000)}k`),
825
+ React.createElement('td', null, `${Math.round((m.contextLength || 128000) / 1000)}k`),
731
826
  React.createElement(
732
827
  'td',
733
828
  null,
734
- React.createElement('span', { className: 'cb-badge' }, m.category || 'general'),
735
- m.input?.includes('image') || m.input?.includes('vision')
736
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, 'Vision')
737
- : null,
738
- (Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts.length > 0 : (m.reasoningEfforts && typeof m.reasoningEfforts === 'object' ? Object.keys(m.reasoningEfforts).length > 0 : Boolean(m.reasoning)))
739
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '4px' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning')
740
- : null
829
+ (() => {
830
+ const hasReasoning = Boolean(
831
+ (Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0) ||
832
+ (m.reasoningEfforts && typeof m.reasoningEfforts === 'object' && Object.keys(m.reasoningEfforts).length > 0) ||
833
+ m.reasoning ||
834
+ m.category === 'reasoning'
835
+ )
836
+ const category = m.category && m.category !== 'reasoning' ? m.category : (hasReasoning ? null : 'general')
837
+ const hasVision = Boolean(m.input?.includes('image') || m.input?.includes('vision'))
838
+ return React.createElement(
839
+ React.Fragment,
840
+ null,
841
+ category ? React.createElement('span', { className: 'cb-badge' }, category) : null,
842
+ hasVision
843
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, 'Vision')
844
+ : null,
845
+ hasReasoning
846
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning')
847
+ : null
848
+ )
849
+ })()
741
850
  )
742
851
  )
743
852
  })
@@ -916,21 +1025,6 @@ function SettingsPage(props) {
916
1025
  const [apiKeyInput, setApiKeyInput] = React.useState('')
917
1026
  const [showKey, setShowKey] = React.useState(false)
918
1027
 
919
- // Accounts state
920
- const [newAccountLabel, setNewAccountLabel] = React.useState('')
921
- const [newAccountEnv, setNewAccountEnv] = React.useState('')
922
- const [showAddAccount, setShowAddAccount] = React.useState(false)
923
-
924
- // Filter & Search for Models
925
- const [modelsFilter, setModelsFilter] = React.useState('all')
926
- const [modelsSearch, setModelsSearch] = React.useState('')
927
- const [disabledModels, setDisabledModels] = React.useState([])
928
- const [advancedOpen, setAdvancedOpen] = React.useState(false)
929
-
930
- // Loopback fast login state
931
- const [fastLoginActive, setFastLoginActive] = React.useState(false)
932
- const [fastLoginNotice, setFastLoginNotice] = React.useState('')
933
-
934
1028
  React.useEffect(() => {
935
1029
  ensureCss()
936
1030
  }, [])
@@ -1117,21 +1211,41 @@ function SettingsPage(props) {
1117
1211
  }
1118
1212
  }
1119
1213
 
1120
- async function handleFastLogin() {
1121
- setBusy('fast-login')
1122
- setErr('')
1123
- try {
1124
- const res = await fetch(`${ROUTE_PREFIX}/auth/begin`, { method: 'POST' })
1125
- const data = await res.json().catch(() => ({}))
1126
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1127
- if (data.authUrl) {
1128
- window.open(data.authUrl, '_blank')
1214
+ const [verifyStatus, setVerifyStatus] = React.useState({ state: 'idle', email: '', plan: '', error: '' })
1215
+ const verifyTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
1216
+
1217
+ React.useEffect(() => {
1218
+ const raw = String(apiKeyInput || '').trim()
1219
+ if (!raw || raw.length < 10) {
1220
+ setVerifyStatus({ state: 'idle', email: '', plan: '', error: '' })
1221
+ return
1222
+ }
1223
+ if (verifyTimerRef.current) clearTimeout(verifyTimerRef.current)
1224
+ verifyTimerRef.current = setTimeout(async () => {
1225
+ setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
1226
+ try {
1227
+ const res = await fetch(`${ROUTE_PREFIX}/key/verify`, {
1228
+ method: 'POST',
1229
+ headers: { 'Content-Type': 'application/json' },
1230
+ body: JSON.stringify({ key: raw }),
1231
+ })
1232
+ const data = await res.json().catch(() => ({}))
1233
+ if (data.ok && data.valid) {
1234
+ setVerifyStatus({ state: 'valid', email: data.email, plan: data.plan, error: '' })
1235
+ } else {
1236
+ setVerifyStatus({ state: 'invalid', email: '', plan: '', error: data.error || 'Verification failed' })
1237
+ }
1238
+ } catch (err) {
1239
+ setVerifyStatus({ state: 'invalid', email: '', plan: '', error: String(err?.message || err) })
1129
1240
  }
1130
- } catch (e) {
1131
- setErr(String(e.message || e))
1132
- } finally {
1133
- setBusy('')
1241
+ }, 500)
1242
+ return () => {
1243
+ if (verifyTimerRef.current) clearTimeout(verifyTimerRef.current)
1134
1244
  }
1245
+ }, [apiKeyInput])
1246
+
1247
+ function handleFastLogin() {
1248
+ window.open('https://app.cline.bot/settings/api-keys', '_blank')
1135
1249
  }
1136
1250
 
1137
1251
  async function handlePinAccount(accountEnv) {
@@ -1199,13 +1313,6 @@ function SettingsPage(props) {
1199
1313
  }
1200
1314
 
1201
1315
  if (!status || !draft) {
1202
- if (snapshotStatus === 'unavailable') {
1203
- return React.createElement(
1204
- 'div',
1205
- { className: 'cb-page' },
1206
- React.createElement('div', { className: 'cb-banner-warning' }, t('settings.unavailable'))
1207
- )
1208
- }
1209
1316
  if (err) {
1210
1317
  return React.createElement(
1211
1318
  'div',
@@ -1286,6 +1393,7 @@ function SettingsPage(props) {
1286
1393
  draft,
1287
1394
  handleSaveKey,
1288
1395
  handleFastLogin,
1396
+ verifyStatus,
1289
1397
  t,
1290
1398
  }),
1291
1399
 
@@ -1305,6 +1413,9 @@ function SettingsPage(props) {
1305
1413
  handleSyncPlanModels,
1306
1414
  handleSetModelsFilter,
1307
1415
  handleToggleModel,
1416
+ planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
1417
+ planSyncedAt: status?.config?.planSyncedAt || 0,
1418
+ defaultModelWarning: status?.config?.defaultModelWarning || '',
1308
1419
  t,
1309
1420
  }),
1310
1421
 
@@ -1377,7 +1488,7 @@ function PluginCard(props) {
1377
1488
  React.createElement(
1378
1489
  ErrorBoundary,
1379
1490
  null,
1380
- React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx })
1491
+ React.createElement(SettingsPage, { ...props, ctx: props && props.ctx })
1381
1492
  )
1382
1493
  )
1383
1494
  : null
@@ -51,46 +51,12 @@ export function normalizeBaseUrl(raw) {
51
51
  return s
52
52
  }
53
53
 
54
- // In-memory runtime session metrics for ClinePass requests
55
- export const sessionStats = {
56
- totalRequests: 0,
57
- successfulRequests: 0,
58
- failedRequests: 0,
59
- promptTokensEst: 0,
60
- completionTokensEst: 0,
61
- totalTokensEst: 0,
62
- lastLatencyMs: null,
63
- lastRequestAt: null,
64
- lastError: null,
65
- }
66
-
67
- export function recordSessionRequest({ latencyMs, ok, error, promptTokens = 0, completionTokens = 0 }) {
68
- sessionStats.totalRequests += 1
69
- if (ok) {
70
- sessionStats.successfulRequests += 1
71
- sessionStats.lastLatencyMs = typeof latencyMs === 'number' ? latencyMs : null
72
- sessionStats.lastError = null
73
- } else {
74
- sessionStats.failedRequests += 1
75
- sessionStats.lastError = error || 'Request failed'
76
- }
77
- sessionStats.lastRequestAt = Date.now()
78
- sessionStats.promptTokensEst += Number(promptTokens) || 0
79
- sessionStats.completionTokensEst += Number(completionTokens) || 0
80
- sessionStats.totalTokensEst += (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
81
- }
82
-
83
- export function resetSessionStats() {
84
- sessionStats.totalRequests = 0
85
- sessionStats.successfulRequests = 0
86
- sessionStats.failedRequests = 0
87
- sessionStats.promptTokensEst = 0
88
- sessionStats.completionTokensEst = 0
89
- sessionStats.totalTokensEst = 0
90
- sessionStats.lastLatencyMs = null
91
- sessionStats.lastRequestAt = null
92
- sessionStats.lastError = null
93
- }
54
+ export {
55
+ sessionStats,
56
+ recordSessionRequest,
57
+ recordSmokeTest,
58
+ resetSessionStats,
59
+ } from './telemetry.js'
94
60
 
95
61
  /**
96
62
  * Resolve API key from environment variables.
@@ -279,7 +245,7 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
279
245
 
280
246
  let userEmail = null
281
247
  let createdAt = null
282
- let planDisplayName = 'ClinePass ($9.99/mo)'
248
+ let planDisplayName = 'ClinePass'
283
249
  let dynamicModels = []
284
250
 
285
251
  try {
@@ -297,8 +263,14 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
297
263
  if (planRes?.ok) {
298
264
  const planData = await planRes.json().catch(() => ({}))
299
265
  const plan = planData?.data?.plan || planData?.data || planData?.plan || planData
300
- if (plan?.displayName || plan?.title || plan?.name) {
301
- planDisplayName = `${plan.displayName || plan.title || plan.name} ($${((plan.pricePerSeatCents || plan.priceInCents || 999) / 100).toFixed(2)}/mo)`
266
+ const name = plan?.displayName || plan?.title || plan?.name || 'ClinePass'
267
+ const priceCents = typeof plan?.pricePerSeatCents === 'number'
268
+ ? plan.pricePerSeatCents
269
+ : (typeof plan?.priceInCents === 'number' ? plan.priceInCents : null)
270
+ if (priceCents !== null) {
271
+ planDisplayName = `${name} ($${(priceCents / 100).toFixed(2)}/mo)`
272
+ } else {
273
+ planDisplayName = name
302
274
  }
303
275
  const featuresIncluded = plan?.features?.included || plan?.includedModels
304
276
  if (featuresIncluded) {
@@ -434,6 +406,15 @@ export async function smokeChat(baseUrl, apiKey, {
434
406
  fetchImpl = fetch,
435
407
  } = {}) {
436
408
  const base = normalizeBaseUrl(baseUrl)
409
+ try {
410
+ const parsed = new URL(base)
411
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
412
+ return { ok: false, error: 'Insecure protocol: smokeChat requires https: for remote endpoints' }
413
+ }
414
+ } catch (err) {
415
+ return { ok: false, error: `Invalid baseUrl: ${err?.message || err}` }
416
+ }
417
+
437
418
  if (!apiKey) {
438
419
  return { ok: false, error: 'Missing API key. Set credential or environment variable.' }
439
420
  }
@@ -587,4 +568,4 @@ export async function resolveKeyValue(ctx, apiKeyEnv) {
587
568
  }
588
569
 
589
570
 
590
- export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount } from './account-pool.js'
571
+ export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount, getLastRotation, setLastRotation, isQuotaExceededError } from './account-pool.js'
package/lib/config.js CHANGED
@@ -38,7 +38,7 @@ export const Config = z.object({
38
38
  disabledModels: z.array(z.string()).default([])
39
39
  .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).').volatile(),
40
40
  enabledModels: z.array(z.string()).default([])
41
- .description('Deprecated: preserved for backwards compatibility with earlier versions.').volatile(),
41
+ .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
42
42
  dynamicModels: z.array(z.object({
43
43
  id: z.string(),
44
44
  name: z.string(),
@@ -50,7 +50,9 @@ export const Config = z.object({
50
50
  isCustom: z.boolean().default(false),
51
51
  reasoningEfforts: z.any().default(undefined),
52
52
  })).default([])
53
- .description('Models automatically discovered from the official ClinePass subscription plan.').volatile(),
53
+ .description('Models automatically discovered from the official ClinePass subscription plan.'),
54
+ planSyncedAt: z.number().default(0)
55
+ .description('Timestamp when models were last synchronized with active subscription plan.').volatile(),
54
56
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
55
57
  .description('HTTP probe timeout in milliseconds.').volatile(),
56
58
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
@@ -84,25 +86,56 @@ export function plainConfig(cfg) {
84
86
  return out
85
87
  }
86
88
 
89
+ export function volatileConfig(cfg) {
90
+ const plain = plainConfig(cfg)
91
+ if (!plain || typeof plain !== 'object') return plain
92
+ const out = {}
93
+ for (const [key, field] of Object.entries(Config.dict || {})) {
94
+ if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
95
+ out[key] = plain[key]
96
+ }
97
+ }
98
+ return out
99
+ }
100
+
87
101
  export function publicConfig(cfg) {
88
102
  cfg = plainConfig(cfg)
89
103
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
90
- const allDefaultIds = getDefaultModelIds(dynamic)
104
+ const isSynced = dynamic.length > 0
105
+ const allModels = getAllModels(dynamic)
106
+ const allDefaultIds = allModels.map((m) => m.id)
91
107
 
92
108
  let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
93
- if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
109
+ if (cfg?.disabledModels === undefined && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0) {
94
110
  const enabledSet = new Set(cfg.enabledModels)
95
111
  disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
96
112
  }
97
113
 
98
- const activeIds = getActiveModelIds(allDefaultIds, disabledList)
114
+ let activeIds = getActiveModelIds(allDefaultIds, disabledList)
115
+ if (!activeIds.length && allDefaultIds.length) {
116
+ activeIds = [allDefaultIds[0]]
117
+ }
118
+
119
+ let defaultModel = cfg?.defaultModel || DEFAULT_MODEL_ID
120
+ let defaultModelWarning = null
121
+
122
+ if (allDefaultIds.length > 0 && !allDefaultIds.includes(defaultModel)) {
123
+ const fallbackModel = activeIds[0] || allDefaultIds[0]
124
+ defaultModelWarning = `Configured default model "${defaultModel}" is not available in your active plan. Using "${fallbackModel}" instead.`
125
+ defaultModel = fallbackModel
126
+ }
127
+
128
+ const planSyncedAt = Number(cfg?.planSyncedAt) || (isSynced ? (dynamic.planSyncedAt || 0) : 0)
99
129
 
100
130
  return {
101
131
  enabled: !!cfg?.enabled,
102
132
  baseUrl: normalizeBaseUrl(cfg?.baseUrl),
103
133
  apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
104
- defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
134
+ defaultModel,
135
+ defaultModelWarning,
105
136
  dynamicModels: dynamic,
137
+ planSynced: isSynced,
138
+ planSyncedAt,
106
139
  disabledModels: disabledList,
107
140
  enabledModels: activeIds,
108
141
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
package/lib/http.js CHANGED
@@ -10,7 +10,20 @@ export function writeJson(res, code, body) {
10
10
  }
11
11
  }
12
12
 
13
- export function readBody(req, maxBytes = 256 * 1024) {
13
+ export async function readBody(req, maxBytes = 256 * 1024) {
14
+ if (typeof req?.[Symbol.asyncIterator] === 'function') {
15
+ const chunks = []
16
+ let size = 0
17
+ for await (const chunk of req) {
18
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
19
+ size += buf.length
20
+ if (size > maxBytes) {
21
+ throw new Error('body too large')
22
+ }
23
+ chunks.push(buf)
24
+ }
25
+ return Buffer.concat(chunks)
26
+ }
14
27
  return new Promise((resolve, reject) => {
15
28
  const chunks = []
16
29
  let size = 0
@@ -18,7 +31,7 @@ export function readBody(req, maxBytes = 256 * 1024) {
18
31
  size += c.length
19
32
  if (size > maxBytes) {
20
33
  reject(new Error('body too large'))
21
- req.destroy()
34
+ req.destroy?.()
22
35
  return
23
36
  }
24
37
  chunks.push(c)