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
@@ -29,12 +29,28 @@ interface ConfigFile {
29
29
  export class SupplierConfigStore {
30
30
  private fp = ''
31
31
  private bySupplier = new Map<string, SupplierConfig>()
32
+ /** 全部供应商 id(加载时同步),用于别名唯一性校验。 */
33
+ private knownIds = new Set<string>()
32
34
 
33
35
  constructor(stateFile: string) {
34
36
  this.fp = stateFile ? join(dirname(stateFile), 'supplier-config.json') : ''
35
37
  this.load()
36
38
  }
37
39
 
40
+ /** 同步已知供应商 id 集合(加载供应商后调用)。 */
41
+ sync(supplierIds: string[]): void {
42
+ this.knownIds = new Set(supplierIds)
43
+ }
44
+
45
+ /** 各供应商当前生效的别名(未设置别名的用供应商 id)。 */
46
+ effectiveAliases(): Map<string, string> {
47
+ const out = new Map<string, string>()
48
+ for (const id of this.knownIds) {
49
+ out.set(id, this.bySupplier.get(id)?.alias || id)
50
+ }
51
+ return out
52
+ }
53
+
38
54
  /** 读取某供应商配置(不存在则返回默认并注册)。 */
39
55
  get(supplierId: string): SupplierConfig {
40
56
  let cfg = this.bySupplier.get(supplierId)
@@ -45,9 +61,21 @@ export class SupplierConfigStore {
45
61
  return cfg
46
62
  }
47
63
 
48
- setAlias(supplierId: string, alias: string): void {
49
- this.get(supplierId).alias = alias
64
+ /**
65
+ * 设置别名。别名必须唯一——它是对外模型全名的前缀(alias/model),
66
+ * 请求时用别名反查供应商,重复就会指错人。空别名表示用供应商 id(默认值)。
67
+ * @returns 冲突时返回占用的供应商 id
68
+ */
69
+ setAlias(supplierId: string, alias: string): { ok: boolean; conflictWith?: string } {
70
+ const clean = alias.trim()
71
+ // 空 = 用默认(供应商 id);此时要确认没有别人显式占用了本供应商的 id 作别名
72
+ const effective = clean === '' ? supplierId : clean
73
+ for (const [id, cur] of this.effectiveAliases()) {
74
+ if (id !== supplierId && cur === effective) return { ok: false, conflictWith: id }
75
+ }
76
+ this.get(supplierId).alias = clean
50
77
  this.saveLocked()
78
+ return { ok: true }
51
79
  }
52
80
 
53
81
  setModelEnabled(supplierId: string, modelId: string, enabled: boolean): void {
@@ -123,7 +123,8 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
123
123
  })),
124
124
  )
125
125
  },
126
- getAlias: (): string => env.store.get(instance.id).alias || instance.getAlias(),
126
+ // 别名留空即用供应商 id(默认值)——别名是用户可改的展示前缀,唯一性由核心校验
127
+ getAlias: (): string => env.store.get(instance.id).alias || instance.id,
127
128
  customModelIds: (): string[] => [...env.store.get(instance.id).custom],
128
129
  accounts: (): SupplierAccountNow[] => instance.status().accounts,
129
130
  chatOnce: (uid: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, req),
@@ -29,7 +29,6 @@ const BASE = 'https://integrate.api.nvidia.com/v1'
29
29
  const MODELS_URL = `${BASE}/models`
30
30
  const CHAT_URL = `${BASE}/chat/completions`
31
31
  /** 默认前缀(用户可在面板改;loader 包装会优先用 store 里的值)。 */
32
- const DEFAULT_ALIAS = 'nv'
33
32
  /** 非聊天用途的模型(护栏/翻译/解析/检测/图像生成等),聊天路由用不上,拉列表时过滤掉。 */
34
33
  const NON_CHAT_MODEL = /(guard|safety|moderation|translate|transcription|parse|detector|embed|rerank|diffusion|tts|asr|calibration)/i
35
34
 
@@ -68,7 +67,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
68
67
 
69
68
  /** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
70
69
  function currentAlias(): string {
71
- return env.store.get(id).alias || DEFAULT_ALIAS
70
+ return env.store.get(id).alias || id
72
71
  }
73
72
 
74
73
  // ---- 模型可用性探测(NIM 的 /v1/models 无状态字段,大量模型已下线/未授权) ----
@@ -218,7 +217,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
218
217
  return { id, name, accounts }
219
218
  },
220
219
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
221
- getAlias: (): string => 'nv',
220
+ getAlias: (): string => id,
222
221
  async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
223
222
  const key = input.apiKey.trim()
224
223
  if (key === '') return { ok: false, error: 'API key 不能为空' }
@@ -11,7 +11,6 @@
11
11
  import type { ChatRequest, ModelInfo } from '../../router/types.ts'
12
12
  import type { AccountState, ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
13
13
 
14
- const DEFAULT_ALIAS = 'oc'
15
14
 
16
15
  export const id = 'opencode'
17
16
  export const name = 'OpenCode Free'
@@ -61,7 +60,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
61
60
 
62
61
  /** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
63
62
  function currentAlias(): string {
64
- return env.store.get(id).alias || DEFAULT_ALIAS
63
+ return env.store.get(id).alias || id
65
64
  }
66
65
 
67
66
  /** 剥本供应商 alias 前缀(只剥自己的,模型 id 自带的斜杠保留)。 */
@@ -82,7 +81,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
82
81
  icon,
83
82
  status: (): SupplierStatusNow => ({ id, name, accounts: [] }),
84
83
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
85
- getAlias: (): string => 'oc',
84
+ getAlias: (): string => id,
86
85
  // testModel 由 dsh-router 核心统一走 chatOnce 路径(无账号,无需回退)
87
86
  lastError: (): string | undefined => lastErr,
88
87
  /** 无账号供应商:uid 恒为空,只认 free 模型。 */
@@ -23,7 +23,6 @@ const BASE = 'https://openrouter.ai/api/v1'
23
23
  const MODELS_URL = `${BASE}/models`
24
24
  const CHAT_URL = `${BASE}/chat/completions`
25
25
  /** 默认前缀(用户可在面板改;loader 包装会优先用 store 里的值)。 */
26
- const DEFAULT_ALIAS = 'or'
27
26
 
28
27
  /** 免费模型过滤(同 9Router openrouter-free):pricing 全 0 且 context ≥ 200k。 */
29
28
  function isFreeModelMeta(m: { pricing?: { prompt?: string; completion?: string }; context_length?: number }): boolean {
@@ -70,7 +69,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
70
69
 
71
70
  /** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
72
71
  function currentAlias(): string {
73
- return env.store.get(id).alias || DEFAULT_ALIAS
72
+ return env.store.get(id).alias || id
74
73
  }
75
74
 
76
75
  async function refreshModels(): Promise<ModelInfo[]> {
@@ -122,7 +121,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
122
121
  return { id, name, accounts }
123
122
  },
124
123
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
125
- getAlias: (): string => 'or',
124
+ getAlias: (): string => id,
126
125
  async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
127
126
  const key = input.apiKey.trim()
128
127
  if (key === '') return { ok: false, error: 'API key 不能为空' }
@@ -121,11 +121,16 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
121
121
  const body = JSON.parse(await readBody(req)) as { alias?: string }
122
122
  const alias = body.alias ?? ''
123
123
  const clean = alias.trim()
124
- if (clean === '' || !/^[A-Za-z0-9_-]+$/.test(clean)) {
124
+ // 空 = 用供应商 id(默认值),合法;非空则校验字符集
125
+ if (clean !== '' && !/^[A-Za-z0-9_-]+$/.test(clean)) {
125
126
  writeJson(res, 400, { ok: false, error: '前缀只能包含字母、数字、- 和 _' })
126
127
  return
127
128
  }
128
- store.setAlias(s.id, clean)
129
+ const r = store.setAlias(s.id, clean)
130
+ if (!r.ok) {
131
+ writeJson(res, 400, { ok: false, error: `前缀已被供应商 ${JSON.stringify(r.conflictWith ?? '?')} 占用` })
132
+ return
133
+ }
129
134
  writeJson(res, 200, { ok: true })
130
135
  },
131
136
  })