dsh-router-core 0.2.0 → 0.3.1

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.
Files changed (38) hide show
  1. package/README.md +40 -11
  2. package/docs/screenshot.png +0 -0
  3. package/docs/suppliers.md +302 -0
  4. package/lib/client-registry.js +1119 -213
  5. package/lib/client.js +1119 -213
  6. package/lib/index.js +1066 -50
  7. package/lib/types/llm/adapter.d.ts +2 -0
  8. package/lib/types/router/index.d.ts +34 -5
  9. package/lib/types/router/usage-store.d.ts +93 -0
  10. package/lib/types/router/usage-tokens.d.ts +118 -0
  11. package/lib/types/shared.d.ts +86 -1
  12. package/lib/types/supplier-config.d.ts +15 -1
  13. package/package.json +21 -4
  14. package/src/client/CheckinCard.tsx +180 -0
  15. package/src/client/CombosTab.tsx +28 -18
  16. package/src/client/RouterView.tsx +44 -27
  17. package/src/client/StatsTab.tsx +531 -0
  18. package/src/client/index.tsx +61 -11
  19. package/src/client/router.css +688 -35
  20. package/src/client/settings-nav-icon.ts +55 -0
  21. package/src/client/settings-section.tsx +21 -0
  22. package/src/client/workspace-mount.tsx +2 -9
  23. package/src/index.ts +51 -3
  24. package/src/llm/adapter.test.ts +108 -0
  25. package/src/llm/adapter.ts +18 -6
  26. package/src/router/index.ts +461 -49
  27. package/src/router/models-cache.test.ts +269 -0
  28. package/src/router/precise-dispatch.test.ts +198 -0
  29. package/src/router/usage-store.ts +499 -0
  30. package/src/router/usage-tokens.ts +327 -0
  31. package/src/router/usage.test.ts +558 -0
  32. package/src/shared.ts +90 -1
  33. package/src/supplier-config.ts +30 -2
  34. package/src/suppliers/loader.ts +2 -1
  35. package/src/suppliers/nvidia/plugin.ts +2 -3
  36. package/src/suppliers/opencode/plugin.ts +2 -3
  37. package/src/suppliers/openrouter/plugin.ts +2 -3
  38. package/src/suppliers/registry.ts +7 -2
@@ -49,17 +49,25 @@ interface ComboForm {
49
49
  }
50
50
 
51
51
  export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.Element {
52
- /** 模型 → 所属供应商前缀(按 groups 分组匹配)。
53
- * 组合只存裸 id,同名模型可能属于多个供应商,故优先取「已启用」的那个;
54
- * 谁都没有的模型显示裸名——不能兜底套第一个供应商的前缀,那样前缀是假的。 */
55
- const aliasOf = (model: string): string => {
56
- for (const g of groups) {
57
- if (g.models.some(m => m.id === model && m.enabled)) return g.supplier.alias
52
+ /**
53
+ * 存储值 → 展示全名 alias/model。
54
+ * 存储格式 = `supplierId,modelId`:精准,同名模型不会串台。
55
+ * 兼容旧数据的裸 modelId:查不到归属就显示裸名——不能兜底套第一个供应商的
56
+ * 前缀,那样前缀是假的。
57
+ */
58
+ const displayName = (stored: string): string => {
59
+ const comma = stored.indexOf(',')
60
+ if (comma > 0) {
61
+ const sid = stored.slice(0, comma)
62
+ const mid = stored.slice(comma + 1)
63
+ const g = groups.find(x => x.supplier.id === sid)
64
+ const alias = g?.supplier.alias ?? aliases.find(a => a.id === sid)?.alias ?? ''
65
+ return alias === '' ? mid : `${alias}/${mid}`
58
66
  }
59
- for (const g of groups) {
60
- if (g.models.some(m => m.id === model)) return g.supplier.alias
61
- }
62
- return ''
67
+ // 旧数据裸 id:按分组找归属(优先已启用的),找不到显示裸名
68
+ const hit = groups.find(g => g.models.some(m => m.id === stored && m.enabled))
69
+ ?? groups.find(g => g.models.some(m => m.id === stored))
70
+ return hit === undefined ? stored : `${hit.supplier.alias}/${stored}`
63
71
  }
64
72
  const [showForm, setShowForm] = useState(false)
65
73
  const [editing, setEditing] = useState<RouterCombo | null>(null)
@@ -134,11 +142,13 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
134
142
  setForm(prev => ({ ...prev, ...patch }))
135
143
  }
136
144
 
137
- const toggleModel = (id: string): void => {
145
+ /** 勾选:存 `supplierId,modelId`(精准归属,展示时才拼 alias)。 */
146
+ const toggleModel = (supplierId: string, id: string): void => {
147
+ const stored = `${supplierId},${id}`
138
148
  patchForm({
139
- models: form.models.includes(id)
140
- ? form.models.filter(m => m !== id)
141
- : [...form.models, id],
149
+ models: form.models.includes(stored)
150
+ ? form.models.filter(m => m !== stored)
151
+ : [...form.models, stored],
142
152
  })
143
153
  }
144
154
 
@@ -313,7 +323,7 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
313
323
  {combo.models.slice(0, 4).map((model, i) => (
314
324
  <span key={`${model}-${i}`} className="dshr-comboChip">
315
325
  <span className="dshr-comboChipNum">{i + 1}</span>
316
- <span className="dshr-comboChipSupplier">{aliasOf(model) ? `${aliasOf(model)}/${model}` : model}</span>
326
+ <span className="dshr-comboChipSupplier">{displayName(model)}</span>
317
327
  </span>
318
328
  ))}
319
329
  {combo.models.length > 4 && (
@@ -384,7 +394,7 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
384
394
  >
385
395
  <span className="dshr-comboGrip" title="拖拽排序"><Icon d={I.grip} size={14} /></span>
386
396
  <span className="dshr-comboStepNum">{index + 1}</span>
387
- <code className="dshr-comboSelectedModel">{aliasOf(model) ? `${aliasOf(model)}/${model}` : model}</code>
397
+ <code className="dshr-comboSelectedModel">{displayName(model)}</code>
388
398
  <button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-comboStepRemove" title="移除" onClick={() => removeModel(index)}>
389
399
  <Icon d={I.delete} size={14} />
390
400
  </button>
@@ -413,14 +423,14 @@ export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.E
413
423
  ? <p className="dshr-muted dshr-comboPickEmpty">没有可添加的模型</p>
414
424
  : groups.map(g => {
415
425
  const items = g.models.filter(m =>
416
- !form.models.includes(m.id) &&
426
+ !form.models.includes(`${g.supplier.id},${m.id}`) &&
417
427
  (modelQuery === '' || m.id.toLowerCase().includes(modelQuery.toLowerCase())))
418
428
  if (items.length === 0) return null
419
429
  return (
420
430
  <div key={g.supplier.id} className="dshr-comboPickGroup">
421
431
  <p className="dshr-comboPickGroupTitle">{g.supplier.name} · {g.supplier.alias}</p>
422
432
  {items.map(m => (
423
- <button key={m.id} type="button" className="dshr-comboPickItem" onClick={() => toggleModel(m.id)}>
433
+ <button key={m.id} type="button" className="dshr-comboPickItem" onClick={() => toggleModel(g.supplier.id, m.id)}>
424
434
  <span className="dshr-comboPickName">{m.id}</span>
425
435
  </button>
426
436
  ))}
@@ -1,17 +1,24 @@
1
1
  /**
2
2
  * 路由系统 (Routing System) panel — a simplified 9router.
3
3
  *
4
- * Three tabs fed by the host half (/router/api/*), mirroring 9router's
5
- * dashboard: 供应商 (supplier cards → detail: accounts + add-account + models),
6
- * 组合 (combo fallback chains), 端点与密钥 (endpoint URL + API keys + auth).
4
+ * Four tabs fed by the host half (/router/api/*), mirroring 9router's
5
+ * dashboard: 概览 (usage), 供应商 (supplier cards → detail: accounts +
6
+ * add-account + models), 组合 (combo fallback chains),
7
+ * 端点与密钥 (endpoint URL + API keys + auth).
7
8
  *
8
- * Masthead: 返回会话 button on the LEFT, refresh on the right.
9
- * No online/offline concept — suppliers render whatever state they have.
9
+ * **挂载在「设置」里**(`settings.section` 座位),所以布局规则跟中心栏劫持时期不同:
10
+ * - 没有 masthead / 返回会话按钮 —— 设置面板自带标题栏和关闭
11
+ * - **tab 条固定、只有内容区滚**:根节点限高 `height:100%`,内容区
12
+ * `flex:1 + min-height:0 + overflow-y:auto` 自己滚。
13
+ * 根节点**必须限高** —— 不限高内容会把外壳撑开,退化成整页滚、
14
+ * tab 条跟着滚上去
15
+ * - 根节点外面套了一层 `display: contents`(宿主 slot renderer 给的),
16
+ * 不过实测 `height:100%` 仍能穿过它解析到外壳高度
17
+ * - 内容宽度实测 564px(面板 612 − 左右 padding 24),卡片 grid 按这个宽度排
10
18
  */
11
19
  import { useEffect, useRef, useState, type ReactNode } from 'react'
12
20
  import {
13
21
  ROUTER_API_BASE,
14
- ROUTER_TITLE,
15
22
  type RouterCombosResponse,
16
23
  type RouterHealthResponse,
17
24
  type RouterStatusResponse,
@@ -19,8 +26,9 @@ import {
19
26
  import { SupplierDetail } from './SupplierDetail.tsx'
20
27
  import { EndpointTab } from './EndpointTab.tsx'
21
28
  import { CombosTab } from './CombosTab.tsx'
29
+ import { StatsTab } from './StatsTab.tsx'
22
30
 
23
- type TabId = 'suppliers' | 'combos' | 'endpoint'
31
+ type TabId = 'overview' | 'suppliers' | 'combos' | 'endpoint'
24
32
 
25
33
  interface Snapshot {
26
34
  health: RouterHealthResponse | null
@@ -37,12 +45,15 @@ async function getJson<T>(path: string): Promise<T> {
37
45
  }
38
46
 
39
47
  interface RouterViewProps {
40
- /** 关闭面板、返回会话。 */
41
- onBack: () => void
48
+ /**
49
+ * 设置页不提供「返回会话」(关闭由设置面板自己负责),所以这里是可选的:
50
+ * 中心栏劫持那套老挂载方式还留着时会传。
51
+ */
52
+ onBack?: () => void
42
53
  }
43
54
 
44
55
  export function RouterView({ onBack }: RouterViewProps): JSX.Element {
45
- const [tab, setTab] = useState<TabId>('suppliers')
56
+ const [tab, setTab] = useState<TabId>('overview')
46
57
  const [detailSupplier, setDetailSupplier] = useState<{ id: string; name: string; icon?: string } | null>(null)
47
58
  const [snapshot, setSnapshot] = useState<Snapshot>({ health: null, status: null, combos: null })
48
59
  const [refreshing, setRefreshing] = useState(false)
@@ -111,32 +122,38 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
111
122
  }
112
123
 
113
124
  return (
114
- <div className="dshr-shell">
115
- <header className="dshr-masthead">
116
- <button type="button" className="dshr-backButton" onClick={onBack} title="返回会话">
117
- <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
118
- <path d="M10 3 5 8l5 5" />
119
- </svg>
120
- <span>返回会话</span>
125
+ <div className="dshr-settings">
126
+ {/* tab 切换:设置页已有页面标题,这里只做二级切换 */}
127
+ <nav className="dshr-tabs" role="tablist" aria-label="路由分区">
128
+ <button type="button" role="tab" aria-selected={tab === 'overview'} className={`dshr-tab${tab === 'overview' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('overview')}>
129
+ 概览
121
130
  </button>
122
- <div className="dshr-brand">
123
- <div className="dshr-brandTitle">{ROUTER_TITLE}</div>
124
- </div>
125
- </header>
126
-
127
- <nav className="dshr-nav">
128
- <button type="button" aria-current={tab === 'suppliers' ? 'page' : undefined} onClick={() => setTab('suppliers')}>
131
+ <button type="button" role="tab" aria-selected={tab === 'suppliers'} className={`dshr-tab${tab === 'suppliers' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('suppliers')}>
129
132
  供应商 <span className="dshr-navCount">{suppliers.length}</span>
130
133
  </button>
131
- <button type="button" aria-current={tab === 'combos' ? 'page' : undefined} onClick={() => setTab('combos')}>
134
+ <button type="button" role="tab" aria-selected={tab === 'combos'} className={`dshr-tab${tab === 'combos' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('combos')}>
132
135
  组合 <span className="dshr-navCount">{combos.length}</span>
133
136
  </button>
134
- <button type="button" aria-current={tab === 'endpoint' ? 'page' : undefined} onClick={() => setTab('endpoint')}>
137
+ <button type="button" role="tab" aria-selected={tab === 'endpoint'} className={`dshr-tab${tab === 'endpoint' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('endpoint')}>
135
138
  端点与密钥
136
139
  </button>
140
+ {/* 返回会话只在老的中心栏挂载方式下出现(设置页有关闭按钮) */}
141
+ {onBack !== undefined && (
142
+ <button type="button" className="dshr-tabBack" onClick={onBack}>返回会话</button>
143
+ )}
137
144
  </nav>
138
145
 
139
- <div className="dshr-content">
146
+ <div className="dshr-settingsBody">
147
+ {/* ---------------- 概览(用量看板) ---------------- */}
148
+ {tab === 'overview' && (
149
+ <StatsTab
150
+ active
151
+ refreshing={refreshing}
152
+ health={snapshot.health}
153
+ onRefresh={() => void refresh()}
154
+ />
155
+ )}
156
+
140
157
  {/* ---------------- 供应商 ---------------- */}
141
158
  {tab === 'suppliers' && (
142
159
  detailSupplier !== null