@goodandready/dsh-model-sync 0.5.15 → 0.5.17

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.md CHANGED
@@ -78,8 +78,13 @@ graph LR
78
78
 
79
79
  ## ✨ Key Features
80
80
 
81
+ * 🏷️ **Smart Collections & Catalog Auto-Tagging**: Automatically categorizes models into standardized tags (`coding`, `reasoning`, `fast`, `vision`, `long-context`) during discovery, provides fast pill-button filtering in the Web UI, and introduces a one-click "⭐ Curated Best" selection preset for top flagship models.
82
+ * ⚡ **Latency TTFT Radar & Sparklines**: Probes response latency and Time To First Token (TTFT) across models, renders ASCII sparklines (` ▂▃▂ `) and 4-tier speed badges (⚡ `< 300ms`, 🟢 `< 1.2s`, 🟡 `1.5 - 3.0s`, 🔴 `> 3.0s`), and exposes `GET /dsh-model-sync/health/latency` for intelligent downstream routing.
83
+ * 💸 **Dynamic Live Pricing Sync & Prompt Caching**: Periodic background updates of live model pricing from OpenRouter and LiteLLM snapshots with persistent disk fallback cache, tracking prompt cache read/write costs (`cacheReadPerToken`, `cacheWritePerToken`), with `GET /dsh-model-sync/pricing` and `POST /dsh-model-sync/pricing/sync` endpoints.
81
84
  * 💰 **Built-in Reference Pricing Database**: Canonical token pricing database automatically enriches models for OpenAI, Anthropic, Groq, Mistral, xAI, and DeepSeek, enabling cost-limit filtering and `@cheap` auto-resolution even when upstream APIs omit pricing metadata.
82
85
  * 🎯 **Dynamic Smart Virtual Aliases & Auto-Fallback**: Route requests to virtual aliases like `@fast`, `@cheap`, and `@best-code`, with automatic fallback chains when circuit breakers open or health probes fail.
86
+ * 📏 **Real Context Limits & Boundary Safety (`contextWindow` / `maxOutputTokens`)**: precise separation of input and generation limits, prevention of 400 `max_tokens` errors via auto-clamping, and clear `In: 128k | Out: 8k` badges in Web UI.
87
+ * 🧠 **Unified Reasoning & Thinking Configuration**: automatic format detection (`effort`, `budget_tokens`, `boolean`) and parameter translation with suppression of incompatible options (such as `temperature` on o1/o3).
83
88
  * 🔔 **External Diff Webhooks & Event Dispatching**: Real-time webhook notifications with HMAC-SHA256 signature verification and Cordis `model-sync/diff` events when models are added, deprecated, or prices change.
84
89
  * ⚡ **High-Performance Policy Filtering**: Fast-path evaluation in `filterModels` bypasses regular expressions and array filtering when no filtering policy is configured, guaranteeing zero-overhead discovery runs.
85
90
  * 🔄 **One-Click In-App Updates**: Built-in update checker in Settings with automatic npm cache bypass on demand (`?fresh=1` / `Cache-Control: no-cache`), ensuring instantaneous discovery of new package releases.
package/README.ru.md CHANGED
@@ -78,8 +78,13 @@ graph LR
78
78
 
79
79
  ## ✨ Ключевые возможности
80
80
 
81
+ * 🏷️ **Смарт-коллекции и авто-тегирование каталога**: Автоматическое присвоение тегов моделям (`coding`, `reasoning`, `fast`, `vision`, `long-context`) при синхронизации, фильтрация по тегам pill-кнопками в Web UI и пресет «⭐ Curated Best» для выбора лучших флагманских моделей в один клик.
82
+ * ⚡ **Радар задержек (Latency & TTFT) и спарклайны**: Измерение времени ответа и Time To First Token (TTFT), визуализация спарклайнами (` ▂▃▂ `) с 4 градациями скорости (⚡ `< 300мс`, 🟢 `< 1.2с`, 🟡 `1.5 - 3.0с`, 🔴 `> 3.0с`) и эндпоинт `GET /dsh-model-sync/health/latency` для агентов-маршрутизаторов.
83
+ * 💸 **Динамическая синхронизация цен и учёт Prompt Caching**: Фоновое обновление актуальных тарифов из OpenRouter и LiteLLM с дисковым fallback-кэшем, поддержка скидок кэширования промптов (`cacheReadPerToken`, `cacheWritePerToken`) и эндпоинты `GET /dsh-model-sync/pricing`, `POST /dsh-model-sync/pricing/sync`.
81
84
  * 💰 **Встроенная база эталонных цен токенов**: Автоматическое обогащение моделей тарифами для OpenAI, Anthropic, Groq, Mistral, xAI и DeepSeek, обеспечивающее работу лимитов цен и псевдонима `@cheap` даже при отсутствии метаданных в API провайдера.
82
85
  * 🎯 **Динамические «умные» псевдонимы и авто-фоллбэк**: Поддержка виртуальных алиасов (`@fast`, `@cheap`, `@best-code`) и цепочек резервирования с автопереключением при размыкании Circuit Breaker.
86
+ * 📏 **Реальные лимиты контекста и авто-клампинг (`contextWindow` / `maxOutputTokens`)**: точное разделение входного и выходного лимита токенов, защита от ошибок 400 `max_tokens` и индикация `Вход: 128k | Выход: 8k` в Web UI.
87
+ * 🧠 **Унификация параметров Reasoning & Thinking**: автоматическое определение формата (`effort`, `budget_tokens`, `boolean`) и параметров бюджета рассуждений с подавлением несовместимых параметров (например, `temperature` для o1/o3).
83
88
  * 🔔 **Внешние Diff Webhooks и диспетчеризация событий**: Уведомления в реальном времени с проверкой подписи HMAC-SHA256 и событиями Cordis `model-sync/diff` при добавлении моделей, их устаревании или изменении цен.
84
89
  * ⚡ **Высокопроизводительная фильтрация моделей**: Быстрый путь (`fast-path`) в `filterModels` мгновенно возвращает каталог при отсутствии активных правил политики, исключая лишнюю компиляцию regex и прогоны массивов.
85
90
  * 🔄 **Обновление плагина в один клик**: Встроенный механизм проверки обновлений в Настройках со сбросом кэша npm при ручном запросе (`?fresh=1` / `Cache-Control: no-cache`), гарантирующий получение актуальной версии.
package/README.zh.md CHANGED
@@ -78,8 +78,13 @@ graph LR
78
78
 
79
79
  ## ✨ 核心特性
80
80
 
81
+ * 🏷️ **智能集合与模型自动标签**:同步时自动识别并标记模型分类标签(`coding` 编程、`reasoning` 思考推理、`fast` 极速轻量、`vision` 多模态、`long-context` 128k+ 长上下文),支持 UI 药丸胶囊快速筛选与一键精选顶级旗舰预设("⭐ 精选最佳")。
82
+ * ⚡ **延迟雷达 (TTFT) 与火花线可视化**:实时测量模型响应时间与首字延迟 (TTFT),提供火花线走势(` ▂▃▂ `)及 4 档速度徽章(⚡ `< 300ms`、🟢 `< 1.2s`、🟡 `1.5 - 3.0s`、🔴 `> 3.0s`),并开放 `GET /dsh-model-sync/health/latency` 路由接口。
83
+ * 💸 **实时价格动态后台同步与提示词缓存**:定期从 OpenRouter 和 LiteLLM 获取最新模型单价,支持本地磁盘降级缓存,支持提示词缓存读写定价(`cacheReadPerToken`、`cacheWritePerToken`),并提供 `GET /dsh-model-sync/pricing` 和 `POST /dsh-model-sync/pricing/sync` 端点。
81
84
  * 💰 **内置基准价格数据库**:自动为 OpenAI、Anthropic、Groq、Mistral、xAI 和 DeepSeek 模型补充 Token 价格数据,即使服务商接口未返回价格,也可无缝支持成本上限过滤与 `@cheap` 别名解析。
82
85
  * 🎯 **动态智能虚拟别名与故障转移**:支持 `@fast`、`@cheap`、`@best-code` 等虚拟别名及故障转移链,在熔断器打开或健康检查失败时自动切换至备用模型。
86
+ * 📏 **真实上下文限制与边界安全 (`contextWindow` / `maxOutputTokens`)**:精确区分输入与输出令牌限制,通过自动限制防止 400 `max_tokens` 错误,并在 Web UI 中显示 `输入: 128k | 输出: 8k` 徽章。
87
+ * 🧠 **统一的深度思考与推理配置 (Reasoning & Thinking)**:自动检测参数格式 (`effort`, `budget_tokens`, `boolean`) 并转换推理预算,自动屏蔽不兼容参数(如 o1/o3 的 `temperature`)。
83
88
  * 🔔 **外部差异通知 Webhook 与事件分发**:支持 HMAC-SHA256 签名验证的实时 Webhook 通知与 Cordis `model-sync/diff` 事件,可在新模型上线、模型废弃或价格变更时即时推送。
84
89
  * ⚡ **高性能模型策略过滤**:在未配置过滤策略时,`filterModels` 采用快速路径直接返回模型目录,避免不必要的正则表达式编译和数组遍历。
85
90
  * 🔄 **一键检查与更新**:内置设置面板更新检查,在手动刷新时通过 `?fresh=1` 或 `Cache-Control: no-cache` 绕过 npm 缓存,即时获取最新版本。
@@ -32,6 +32,7 @@ const PROVIDER_ADAPTERS = Object.freeze({
32
32
  id: 'id',
33
33
  name: 'name',
34
34
  contextWindow: 'context_length',
35
+ maxOutputTokens: 'max_tokens',
35
36
  },
36
37
  defaultModels: [
37
38
  'deepseek/deepseek-v4.1-flash',
@@ -83,7 +84,7 @@ function mappedRows(provider, rows, descriptor) {
83
84
  return rows.map((row) => {
84
85
  const mapped = { ...(row && typeof row === 'object' ? row : {}) }
85
86
  for (const [target, source] of Object.entries(fields)) {
86
- if (!['id', 'name', 'description', 'contextWindow', 'maxTokens', 'pricing', 'tags'].includes(target)) continue
87
+ if (!['id', 'name', 'description', 'contextWindow', 'maxTokens', 'maxOutputTokens', 'pricing', 'tags'].includes(target)) continue
87
88
  const value = pathValue(row, source)
88
89
  if (value !== undefined) mapped[target] = value
89
90
  }
@@ -128,6 +129,7 @@ function parseRows(provider, payload, descriptor) {
128
129
  id: String(row.name ?? '').replace(/^models\//, ''),
129
130
  name: row.displayName ?? row.display_name ?? row.name,
130
131
  contextWindow: row.inputTokenLimit,
132
+ maxOutputTokens: row.outputTokenLimit,
131
133
  maxTokens: row.outputTokenLimit,
132
134
  })))
133
135
  }
@@ -194,7 +196,7 @@ export function normalizeAdapterConfig(config, profile = {}) {
194
196
  const capabilityMap = source.capabilityMap && typeof source.capabilityMap === 'object' && !Array.isArray(source.capabilityMap) ? structuredClone(source.capabilityMap) : {}
195
197
  for (const key of Object.keys(capabilityMap)) if (!CAPABILITIES.has(key)) delete capabilityMap[key]
196
198
  const fields = source.fields && typeof source.fields === 'object' && !Array.isArray(source.fields) ? structuredClone(source.fields) : {}
197
- for (const key of Object.keys(fields)) if (!['id', 'name', 'description', 'contextWindow', 'maxTokens', 'pricing', 'tags'].includes(key) || typeof fields[key] !== 'string' || fields[key].length > 128) delete fields[key]
199
+ for (const key of Object.keys(fields)) if (!['id', 'name', 'description', 'contextWindow', 'maxTokens', 'maxOutputTokens', 'pricing', 'tags'].includes(key) || typeof fields[key] !== 'string' || fields[key].length > 128) delete fields[key]
198
200
  for (const key of Object.keys(capabilityMap)) {
199
201
  const values = Array.isArray(capabilityMap[key]) ? capabilityMap[key] : [capabilityMap[key]]
200
202
  capabilityMap[key] = values.filter((value) => typeof value === 'string' && value.length <= 128).slice(0, 8)
package/lib/client.js CHANGED
@@ -65,6 +65,14 @@ window.__ModuleLoader__.load({
65
65
  '.dms-alert-ok{background:var(--dsw-alias-state-success-secondary,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-success-primary)}' +
66
66
  '.dms-alert-bad{background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-error-primary)}' +
67
67
  '.dms-probe-result{padding:2px 6px;border-radius:4px;font-family:monospace;font-size:11px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px}' +
68
+ '.dms-pill{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}' +
69
+ '.dms-pill:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}' +
70
+ '.dms-pill-active{background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;border-color:transparent!important;font-weight:600}' +
71
+ '.dms-tier-lightning{border-color:#a855f7;color:#a855f7;background:rgba(168,85,247,0.1)}' +
72
+ '.dms-tier-fast{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary,rgba(34,197,94,0.1))}' +
73
+ '.dms-tier-moderate{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-secondary,rgba(234,179,8,0.1))}' +
74
+ '.dms-tier-slow{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-secondary,rgba(239,68,68,0.1))}' +
75
+ '.dms-sparkline{font-family:monospace;letter-spacing:-1px;margin-left:3px;font-weight:bold}' +
68
76
  '@media (max-width:540px){.dms-provider{grid-template-columns:1fr!important}.dms-provider .dms-btn{justify-self:start}.dms-toolbar>div:first-child{align-items:stretch;flex-direction:column}.dms-toolbar>div:first-child select{width:100%}.dms-toolbar,.dms-section,.dms-section-card,.dms-editor{padding:11px 12px}}'
69
77
 
70
78
  function createErrorBoundary() {
@@ -228,6 +236,18 @@ window.__ModuleLoader__.load({
228
236
  notifyOnPriceChanged: 'Notify on price changes',
229
237
  saveWebhook: 'Save Webhook Settings',
230
238
  testWebhook: 'Test Webhook',
239
+ selectCurated: '⭐ Curated Best',
240
+ tagAll: 'All',
241
+ tagCurated: '⭐ Curated',
242
+ tagReasoning: '🧠 Reasoning',
243
+ tagCoding: '💻 Coding',
244
+ tagFast: '⚡ Fast',
245
+ tagVision: '👁️ Vision',
246
+ tagLongContext: '📜 128k+',
247
+ speedRadar: 'Latency Radar',
248
+ ttft: 'TTFT',
249
+ pricingSync: 'Sync Prices',
250
+ pricingSyncing: 'Syncing prices…',
231
251
  webhookTesting: 'Testing...',
232
252
  webhookSuccess: 'Webhook test succeeded',
233
253
  webhookFailed: 'Webhook test failed: %s',
@@ -295,6 +315,9 @@ window.__ModuleLoader__.load({
295
315
  saveFailed: 'Save rejected by settings service',
296
316
  scopeReadOnly: 'Settings are read-only in this environment',
297
317
  firstSeen: 'First seen',
318
+ contextIn: 'In',
319
+ contextOut: 'Out',
320
+ reasoningFormat: 'Reasoning',
298
321
  }
299
322
 
300
323
  const zh = {
@@ -397,6 +420,18 @@ window.__ModuleLoader__.load({
397
420
  notifyOnPriceChanged: '模型价格变更时通知',
398
421
  saveWebhook: '保存 Webhook 设置',
399
422
  testWebhook: '测试 Webhook',
423
+ selectCurated: '⭐ 精选最佳',
424
+ tagAll: '全部',
425
+ tagCurated: '⭐ 精选',
426
+ tagReasoning: '🧠 深度思考',
427
+ tagCoding: '💻 编程优化',
428
+ tagFast: '⚡ 极速轻量',
429
+ tagVision: '👁️ 多模态视觉',
430
+ tagLongContext: '📜 长上下文',
431
+ speedRadar: '延迟雷达',
432
+ ttft: '首字延迟',
433
+ pricingSync: '同步价格',
434
+ pricingSyncing: '正在同步价格…',
400
435
  webhookTesting: '正在测试...',
401
436
  webhookSuccess: 'Webhook 测试成功',
402
437
  webhookFailed: 'Webhook 测试失败:%s',
@@ -464,6 +499,98 @@ window.__ModuleLoader__.load({
464
499
  saveFailed: '设置服务拒绝保存',
465
500
  scopeReadOnly: '当前环境为只读设置',
466
501
  firstSeen: '首次发现',
502
+ contextIn: '输入',
503
+ contextOut: '输出',
504
+ reasoningFormat: '推理',
505
+ }
506
+
507
+
508
+ const SPARKLINE_BARS = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█']
509
+ function generateSparkline(values) {
510
+ if (!Array.isArray(values) || values.length === 0) return ''
511
+ const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v))
512
+ if (nums.length === 0) return ''
513
+ if (nums.length === 1) return '▄'
514
+ const min = Math.min(...nums)
515
+ const max = Math.max(...nums)
516
+ if (min === max) return '▄'.repeat(nums.length)
517
+ const range = max - min
518
+ return nums.map((v) => SPARKLINE_BARS[Math.min(7, Math.max(0, Math.floor(((v - min) / range) * 7)))]).join('')
519
+ }
520
+
521
+ function classifySpeedTier(latencyMs, isOk = true) {
522
+ if (!isOk) return 'degraded'
523
+ if (typeof latencyMs !== 'number' || !Number.isFinite(latencyMs) || latencyMs <= 0) return 'unknown'
524
+ if (latencyMs < 300) return 'lightning'
525
+ if (latencyMs < 1200) return 'fast'
526
+ if (latencyMs <= 3000) return 'moderate'
527
+ return 'slow'
528
+ }
529
+
530
+ function deriveModelTags(model) {
531
+ if (!model || typeof model !== 'object') return []
532
+ const tags = new Set(Array.isArray(model.tags) ? model.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim().toLowerCase()) : [])
533
+ const idLower = String(model.id || '').toLowerCase()
534
+ const nameLower = String(model.name || '').toLowerCase()
535
+ const provLower = String(model.provider || '').toLowerCase()
536
+ const capabilities = model.capabilities || {}
537
+
538
+ if (capabilities.code === true || idLower.includes('coder') || idLower.includes('codestral') || idLower.includes('starcoder') || idLower.includes('deepseek-coder') || idLower.includes('claude-3-7-sonnet') || idLower.includes('claude-3-5-sonnet') || idLower.includes('kimi-k2.7-code') || idLower.includes('qwen-2.5-coder') || idLower.includes('qwen2.5-coder') || (/(^|[_-])(code|dev|prog)($|[_-])/i.test(idLower)) || (/(^|[_-])(code|coder)($|[_-])/i.test(nameLower))) {
539
+ tags.add('coding')
540
+ }
541
+ if (model.reasoningConfig?.supportsReasoning === true || capabilities.reasoning === true || /(^|[_-])(o1|o3|r1|qwq)($|[_-])/i.test(idLower) || idLower.includes('reasoner') || idLower.includes('deepseek-r1') || idLower.includes('thinking') || idLower.includes('-r1')) {
542
+ tags.add('reasoning')
543
+ }
544
+ if (provLower === 'groq' || provLower === 'cerebras' || idLower.includes('flash') || idLower.includes('mini') || idLower.includes('instant') || idLower.includes('haiku') || idLower.includes('small') || idLower.includes('8b') || idLower.includes('3b') || idLower.includes('1b') || idLower.includes('turbo') || idLower.includes('lite') || idLower.includes('nano')) {
545
+ tags.add('fast')
546
+ }
547
+ if (capabilities.vision === true || (Array.isArray(model.input) && model.input.includes('image')) || idLower.includes('vision') || idLower.includes('vl') || idLower.includes('pixtral') || idLower.includes('gemini') || idLower.includes('claude-3') || idLower.includes('4o')) {
548
+ tags.add('vision')
549
+ }
550
+ if (typeof model.contextWindow === 'number' && model.contextWindow >= 128000) {
551
+ tags.add('long-context')
552
+ }
553
+ return [...tags].sort()
554
+ }
555
+
556
+ function isCuratedModel(model) {
557
+ if (!model || typeof model !== 'object') return false
558
+ if (model.lifecycle?.deprecated) return false
559
+ const id = String(model.id || '').toLowerCase()
560
+ if (/-(202\d{5}|202\d-\d{2}-\d{2}|\d{4})$/.test(id) && !id.includes('claude-3-7') && !id.includes('claude-3-5')) {
561
+ return false
562
+ }
563
+ if (id.includes('-0314') || id.includes('-0613') || id.includes('-1106') || id.includes('-0125')) {
564
+ return false
565
+ }
566
+ const curatedIds = [
567
+ 'claude-3-7-sonnet', 'claude-3-5-sonnet', 'claude-3-5-haiku',
568
+ 'gpt-4o', 'gpt-4o-mini', 'o1', 'o3-mini',
569
+ 'deepseek-chat', 'deepseek-reasoner', 'deepseek-v3', 'deepseek-r1',
570
+ 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash',
571
+ 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'llama-3.1-70b-versatile',
572
+ 'qwen-2.5-coder-32b', 'qwen-2.5-72b', 'qwq-32b',
573
+ 'mistral-large', 'codestral', 'pixtral-large',
574
+ 'kimi-k2.7-code'
575
+ ]
576
+ for (const c of curatedIds) {
577
+ if (id === c || id.endsWith('/' + c) || id.includes(c)) return true
578
+ }
579
+ const tags = model.tags || deriveModelTags(model)
580
+ return tags.includes('coding') && tags.includes('reasoning') && tags.includes('long-context')
581
+ }
582
+
583
+ function formatTokens(num) {
584
+ if (typeof num !== "number" || !Number.isFinite(num) || num <= 0) return "?";
585
+ if (num >= 1000000) {
586
+ const m = num / 1000000;
587
+ return (m % 1 === 0 ? m : m.toFixed(1)) + "M";
588
+ }
589
+ if (num >= 1000) {
590
+ const k = num / 1000;
591
+ return (k % 1 === 0 ? k : k.toFixed(0)) + "k";
592
+ }
593
+ return String(num);
467
594
  }
468
595
 
469
596
  const API = '/dsh-model-sync'
@@ -660,6 +787,7 @@ window.__ModuleLoader__.load({
660
787
  })
661
788
  const [pageSize, setPageSize] = React.useState(50)
662
789
  const [quickFilter, setQuickFilter] = React.useState({ context100k: false, cheap: false })
790
+ const [selectedTag, setSelectedTag] = React.useState('all')
663
791
  const [tryState, setTryState] = React.useState({})
664
792
  const [batchTrying, setBatchTrying] = React.useState(false)
665
793
  const [batchSummary, setBatchSummary] = React.useState(null)
@@ -1253,7 +1381,15 @@ window.__ModuleLoader__.load({
1253
1381
  const haystack = (m.id + ' ' + (m.name || '') + ' ' + (m.aliases || []).join(' ') + ' ' + (m.tags || []).join(' ') + ' ' + (m.description || '')).toLowerCase()
1254
1382
  return tokens.every((tok) => haystack.includes(tok))
1255
1383
  }) : filteredChoicesBase
1256
- const quickFilteredChoices = searchedChoices.filter((m) => {
1384
+ const taggedChoices = selectedTag === 'all'
1385
+ ? searchedChoices
1386
+ : selectedTag === 'curated'
1387
+ ? searchedChoices.filter((m) => isCuratedModel(m))
1388
+ : searchedChoices.filter((m) => {
1389
+ const tags = (m.tags && m.tags.length > 0) ? m.tags : deriveModelTags(m)
1390
+ return tags.includes(selectedTag)
1391
+ })
1392
+ const quickFilteredChoices = taggedChoices.filter((m) => {
1257
1393
  if (quickFilter.context100k && (!m.contextWindow || m.contextWindow < 100000)) return false
1258
1394
  if (quickFilter.cheap) {
1259
1395
  const price = m.pricing?.inputPerToken
@@ -1276,7 +1412,7 @@ window.__ModuleLoader__.load({
1276
1412
  }
1277
1413
  return (a.name || a.id || '').localeCompare(b.name || b.id || '')
1278
1414
  })
1279
- }, [choices, capabilityFilter, debouncedSearchQuery, quickFilter, sortBy])
1415
+ }, [choices, capabilityFilter, debouncedSearchQuery, selectedTag, quickFilter, sortBy])
1280
1416
 
1281
1417
  const maxContext = React.useMemo(() => filteredChoices.reduce((max, m) => Math.max(max, m.contextWindow || 0), 0), [filteredChoices])
1282
1418
  const minPrice = React.useMemo(() => filteredChoices.reduce((min, m) => m.pricing?.inputPerToken !== undefined ? Math.min(min, m.pricing.inputPerToken) : min, Infinity), [filteredChoices])
@@ -1680,6 +1816,13 @@ window.__ModuleLoader__.load({
1680
1816
  ))
1681
1817
  ) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities),
1682
1818
  React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } },
1819
+ button(t.selectCurated || '⭐ Curated Best', () => {
1820
+ const ids = new Set(selectionDraft)
1821
+ for (const m of choices) {
1822
+ if (isCuratedModel(m)) ids.add(m.id)
1823
+ }
1824
+ setSelectionDraft([...ids])
1825
+ }, 'primary'),
1683
1826
  button(t.selectAllFiltered, () => { const ids = new Set(selectionDraft); for (const m of filteredChoices) ids.add(m.id); setSelectionDraft([...ids]) }),
1684
1827
  button(t.deselectAllFiltered, () => { const filterSet = new Set(filteredChoices.map((m) => m.id)); setSelectionDraft(selectionDraft.filter((id) => !filterSet.has(id))) }),
1685
1828
  button(t.invertFiltered, () => { const current = new Set(selectionDraft); for (const m of filteredChoices) { if (current.has(m.id)) current.delete(m.id); else current.add(m.id); } setSelectionDraft([...current]) })
@@ -1690,6 +1833,22 @@ window.__ModuleLoader__.load({
1690
1833
  batchSummary ? React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.batchTryResults.replace('%d', batchSummary.reachable).replace('%d', batchSummary.total)) : null
1691
1834
  ),
1692
1835
  React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, `${filteredChoices.length}/${choices.length} ${t.capabilityCount}`),
1836
+ React.createElement('div', { style: { display: 'flex', gap: 6, margin: '8px 0', flexWrap: 'wrap', alignItems: 'center' } },
1837
+ [
1838
+ { id: 'all', label: t.tagAll || 'All' },
1839
+ { id: 'curated', label: t.tagCurated || '⭐ Curated' },
1840
+ { id: 'reasoning', label: t.tagReasoning || '🧠 Reasoning' },
1841
+ { id: 'coding', label: t.tagCoding || '💻 Coding' },
1842
+ { id: 'fast', label: t.tagFast || '⚡ Fast' },
1843
+ { id: 'vision', label: t.tagVision || '👁️ Vision' },
1844
+ { id: 'long-context', label: t.tagLongContext || '📜 128k+' },
1845
+ ].map((pill) => React.createElement('button', {
1846
+ key: pill.id,
1847
+ type: 'button',
1848
+ className: 'dms-pill' + (selectedTag === pill.id ? ' dms-pill-active' : ''),
1849
+ onClick: () => setSelectedTag(pill.id),
1850
+ }, pill.label))
1851
+ ),
1693
1852
  visibleChoices.map((model) => {
1694
1853
  const deprecationDays = (() => {
1695
1854
  const d = model.lifecycle?.deprecationDate
@@ -1697,20 +1856,69 @@ window.__ModuleLoader__.load({
1697
1856
  const diff = new Date(d).getTime() - Date.now()
1698
1857
  return diff > 0 ? Math.ceil(diff / (1000 * 60 * 60 * 24)) : 0
1699
1858
  })()
1700
- const probe = tryState[`${row.provider}:${model.id}`]
1859
+ const radar = status?.latencyRadar?.radar?.find((r) => r.provider === row.provider && r.model === model.id)
1860
+ const probe = tryState[`${row.provider}:${model.id}`] || (radar ? { ok: radar.ok, latency: radar.latencyMs, ttftMs: radar.ttftMs, sparkline: radar.sparkline, status: radar.status } : null)
1701
1861
  return React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0', padding: '4px 6px', borderRadius: 6, background: isNewModel(model, row) ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' } },
1702
1862
  React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }),
1703
1863
  ` ${model.name ?? model.id} (${model.id})`,
1704
1864
  isNewModel(model, row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null,
1705
1865
  deprecationDays !== null ? React.createElement('span', { style: { marginLeft: 6, fontSize: 10, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-warn-primary)', color: 'white' } }, deprecationDays > 0 ? t.deprecatesIn.replace('%d', deprecationDays) : t.deprecated) : null,
1866
+ (model.contextWindow || model.maxOutputTokens || model.maxTokens) ? React.createElement('span', {
1867
+ style: {
1868
+ marginLeft: 6,
1869
+ fontSize: 11,
1870
+ padding: '1px 5px',
1871
+ borderRadius: 4,
1872
+ background: 'var(--dsw-alias-bg-layer-2)',
1873
+ border: '1px solid var(--dsw-alias-border-l1)',
1874
+ color: 'var(--dsw-alias-label-secondary)',
1875
+ },
1876
+ title: `${t.contextIn || 'In'}: ${model.contextWindow || '?'} | ${t.contextOut || 'Out'}: ${model.maxOutputTokens || model.maxTokens || '?'}`
1877
+ }, `${t.contextIn || 'In'}: ${formatTokens(model.contextWindow)} | ${t.contextOut || 'Out'}: ${formatTokens(model.maxOutputTokens || model.maxTokens)}`) : null,
1878
+ (model.reasoningConfig?.supportsReasoning || model.capabilities?.reasoning) ? React.createElement('span', {
1879
+ style: {
1880
+ marginLeft: 6,
1881
+ fontSize: 11,
1882
+ padding: '1px 5px',
1883
+ borderRadius: 4,
1884
+ background: 'var(--dsw-alias-state-business-secondary, var(--dsw-alias-bg-layer-2))',
1885
+ color: 'var(--dsw-alias-state-business-primary)',
1886
+ border: '1px solid var(--dsw-alias-border-l1)',
1887
+ },
1888
+ title: model.reasoningConfig?.paramFormat ? `Reasoning format: ${model.reasoningConfig.paramFormat}` : 'Reasoning supported'
1889
+ }, `🧠 ${model.reasoningConfig?.paramFormat || 'reasoning'}`) : null,
1706
1890
  (model.contextWindow && maxContext > 0 && model.contextWindow >= maxContext) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warn-primary)', color: 'white' } }, '★') : null,
1707
1891
  (model.pricing && minPrice !== Infinity && model.pricing.inputPerToken === minPrice) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-success-primary)', color: 'white' } }, '$') : null,
1708
- model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null,
1892
+ (() => {
1893
+ const modelTags = (model.tags && model.tags.length > 0) ? model.tags : deriveModelTags(model)
1894
+ if (!modelTags || modelTags.length === 0) return null
1895
+ return React.createElement('span', { style: { marginLeft: 6, display: 'inline-flex', gap: 3, verticalAlign: 'middle' } },
1896
+ modelTags.map((tag) => React.createElement('span', {
1897
+ key: tag,
1898
+ style: {
1899
+ fontSize: 10,
1900
+ padding: '0 4px',
1901
+ borderRadius: 3,
1902
+ background: 'var(--dsw-alias-bg-layer-2)',
1903
+ border: '1px solid var(--dsw-alias-border-l1)',
1904
+ color: 'var(--dsw-alias-label-secondary)',
1905
+ }
1906
+ }, tag === 'coding' ? '💻 coding' : tag === 'reasoning' ? '🧠 reasoning' : tag === 'fast' ? '⚡ fast' : tag === 'vision' ? '👁️ vision' : tag === 'long-context' ? '📜 128k+' : tag))
1907
+ )
1908
+ })(),
1709
1909
  model.pricing ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, `in $${(model.pricing.inputPerToken * 1000000).toFixed(2)}/1M` + (model.pricing.cacheReadPerToken !== undefined ? ` / cache $${(model.pricing.cacheReadPerToken * 1000000).toFixed(2)}` : '') + (model.pricing.outputPerToken !== undefined ? ` / out $${(model.pricing.outputPerToken * 1000000).toFixed(2)}` : '')) : null,
1710
1910
  model.description ? React.createElement('small', { title: model.description, style: { display: 'block', marginLeft: 22, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, model.description.slice(0, 120)) : null,
1711
1911
  row.baseURL || row.api ? React.createElement('a', { href: (row.baseURL || '') + '/models/' + encodeURIComponent(model.id), target: '_blank', rel: 'noopener', style: { marginLeft: 8, fontSize: 11 } }, 'docs') : null,
1712
1912
  React.createElement('button', { type: 'button', onClick: (e) => { e.preventDefault(); doTry(row.provider, model.id) }, style: { marginLeft: 8, fontSize: 11, padding: '1px 6px', borderRadius: 4 } }, (probe && probe.loading) ? '…' : '▶'),
1713
- probe && !probe.loading ? React.createElement('span', { className: 'dms-badge ' + (probe.ok ? 'dms-badge-ok' : 'dms-badge-bad'), style: { marginLeft: 6 } }, probe.ok ? `${probe.latency}ms` : (probe.error || probe.status || 'offline')) : null
1913
+ probe && !probe.loading ? React.createElement('span', {
1914
+ className: 'dms-badge ' + (probe.ok ? (classifySpeedTier(probe.latency, probe.ok) === 'lightning' ? 'dms-tier-lightning' : classifySpeedTier(probe.latency, probe.ok) === 'fast' ? 'dms-tier-fast' : 'dms-tier-moderate') : 'dms-badge-bad'),
1915
+ style: { marginLeft: 6 },
1916
+ title: probe.ttftMs ? `TTFT: ${probe.ttftMs}ms | Latency: ${probe.latency}ms` : (probe.latency ? `${probe.latency}ms` : undefined)
1917
+ },
1918
+ (classifySpeedTier(probe.latency, probe.ok) === 'lightning' ? '⚡ ' : classifySpeedTier(probe.latency, probe.ok) === 'fast' ? '🟢 ' : classifySpeedTier(probe.latency, probe.ok) === 'moderate' ? '🟡 ' : '🔴 ') +
1919
+ (probe.ok ? `${probe.latency}ms` : (probe.error || probe.status || 'offline')) +
1920
+ (probe.sparkline ? ` ${probe.sparkline}` : '')
1921
+ ) : null
1714
1922
  )
1715
1923
  }),
1716
1924
  filteredChoices.length > pageSize ? React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' } },
@@ -1872,7 +2080,7 @@ window.__ModuleLoader__.load({
1872
2080
  trySlot('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
1873
2081
  }
1874
2082
 
1875
- module.exports = { apply, inject: ['slots', 'locale', 'configForms'], filterModelsByCapabilities }
2083
+ module.exports = { apply, inject: ['slots', 'locale', 'configForms'], filterModelsByCapabilities, deriveModelTags, isCuratedModel, generateSparkline, classifySpeedTier }
1876
2084
  return module.exports
1877
2085
  },
1878
2086
  })
package/lib/http.js CHANGED
@@ -339,7 +339,46 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
339
339
  }
340
340
  }
341
341
  if (route === 'import') {
342
- if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
342
+ if (route === 'health-latency') {
343
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
344
+ try {
345
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
346
+ const provider = urlObj.searchParams.get('provider') || undefined
347
+ const report = typeof synchronizer.getLatencyRadar === 'function'
348
+ ? synchronizer.getLatencyRadar({ provider })
349
+ : { updatedAt: Date.now(), radar: [] }
350
+ return json(res, 200, report)
351
+ } catch (error) {
352
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
353
+ }
354
+ }
355
+ if (route === 'pricing') {
356
+ if (req.method === 'GET') {
357
+ try {
358
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
359
+ const provider = urlObj.searchParams.get('provider') || undefined
360
+ const model = urlObj.searchParams.get('model') || undefined
361
+ const report = typeof synchronizer.getPricingInfo === 'function'
362
+ ? synchronizer.getPricingInfo({ provider, model })
363
+ : { pricing: {} }
364
+ return json(res, 200, report)
365
+ } catch (error) {
366
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
367
+ }
368
+ }
369
+ if (req.method === 'POST') {
370
+ try {
371
+ const result = typeof synchronizer.syncPricing === 'function'
372
+ ? await synchronizer.syncPricing()
373
+ : { updated: 0, source: 'reference' }
374
+ return json(res, 200, result)
375
+ } catch (error) {
376
+ return json(res, 500, { error: { code: 'sync-failed', message: error instanceof Error ? error.message : String(error) } })
377
+ }
378
+ }
379
+ return json(res, 405, { error: { code: 'method', message: 'GET or POST only' } })
380
+ }
381
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
343
382
  try {
344
383
  const body = await readJson(req)
345
384
  const result = await synchronizer.importConfig(body)
@@ -357,6 +396,45 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
357
396
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
358
397
  return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
359
398
  }
399
+ if (route === 'health-latency') {
400
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
401
+ try {
402
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
403
+ const provider = urlObj.searchParams.get('provider') || undefined
404
+ const report = typeof synchronizer.getLatencyRadar === 'function'
405
+ ? synchronizer.getLatencyRadar({ provider })
406
+ : { updatedAt: Date.now(), radar: [] }
407
+ return json(res, 200, report)
408
+ } catch (error) {
409
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
410
+ }
411
+ }
412
+ if (route === 'pricing') {
413
+ if (req.method === 'GET') {
414
+ try {
415
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
416
+ const provider = urlObj.searchParams.get('provider') || undefined
417
+ const model = urlObj.searchParams.get('model') || undefined
418
+ const report = typeof synchronizer.getPricingInfo === 'function'
419
+ ? synchronizer.getPricingInfo({ provider, model })
420
+ : { pricing: {} }
421
+ return json(res, 200, report)
422
+ } catch (error) {
423
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
424
+ }
425
+ }
426
+ if (req.method === 'POST') {
427
+ try {
428
+ const result = typeof synchronizer.syncPricing === 'function'
429
+ ? await synchronizer.syncPricing()
430
+ : { updated: 0, source: 'reference' }
431
+ return json(res, 200, result)
432
+ } catch (error) {
433
+ return json(res, 500, { error: { code: 'sync-failed', message: error instanceof Error ? error.message : String(error) } })
434
+ }
435
+ }
436
+ return json(res, 405, { error: { code: 'method', message: 'GET or POST only' } })
437
+ }
360
438
  if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
361
439
  let options
362
440
  try {
@@ -423,5 +501,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
423
501
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
424
502
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
425
503
  ctx.webServer.register({ kind: 'exact', path: BASE + '/webhook/test', handler: makeHandler('webhook-test') }),
504
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/health/latency', handler: makeHandler('health-latency') }),
505
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/pricing', handler: makeHandler('pricing') }),
426
506
  ]
427
507
  }