dsh-router-core 0.3.9 → 0.3.10

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.
@@ -260,7 +260,7 @@ function factory(env) {
260
260
  return true;
261
261
  },
262
262
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
263
- async chatOnce(uid, req) {
263
+ async chatOnce(uid, lv, req) {
264
264
  const base = stripAlias(req.model, currentAlias());
265
265
  if (!(await allModels(false)).some((m) => m.id === base)) return {
266
266
  ok: false,
@@ -277,6 +277,11 @@ function factory(env) {
277
277
  try {
278
278
  const obj = JSON.parse(body);
279
279
  obj.model = base;
280
+ if (lv !== "auto" && lv !== "" && lv !== "none" && lv !== "off") obj.reasoning_effort = lv;
281
+ else if (lv === "none" || lv === "off") {
282
+ delete obj.reasoning_effort;
283
+ delete obj.reasoning_summary;
284
+ }
280
285
  body = JSON.stringify(obj);
281
286
  } catch {}
282
287
  let upstream;
@@ -64,7 +64,7 @@ function factory(env) {
64
64
  }),
65
65
  listModels: (force) => allModels(!!force),
66
66
  /** 无账号供应商:uid 恒为空,只认 free 模型。 */
67
- async chatOnce(_uid, req) {
67
+ async chatOnce(_uid, lv, req) {
68
68
  if (!isFreeModel(req.model)) return {
69
69
  ok: false,
70
70
  state: "no_such_model",
@@ -74,6 +74,11 @@ function factory(env) {
74
74
  try {
75
75
  const obj = JSON.parse(body);
76
76
  obj.model = stripAlias(String(obj.model ?? req.model), currentAlias());
77
+ if (lv !== "auto" && lv !== "" && lv !== "none" && lv !== "off") obj.reasoning_effort = lv;
78
+ else if (lv === "none" || lv === "off") {
79
+ delete obj.reasoning_effort;
80
+ delete obj.reasoning_summary;
81
+ }
77
82
  body = JSON.stringify(obj);
78
83
  } catch {}
79
84
  let upstream;
@@ -142,7 +142,7 @@ function factory(env) {
142
142
  return true;
143
143
  },
144
144
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
145
- async chatOnce(uid, req) {
145
+ async chatOnce(uid, lv, req) {
146
146
  const base = stripAlias(req.model, currentAlias());
147
147
  if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) return {
148
148
  ok: false,
@@ -159,6 +159,11 @@ function factory(env) {
159
159
  try {
160
160
  const obj = JSON.parse(body);
161
161
  obj.model = base;
162
+ if (lv !== "auto" && lv !== "" && lv !== "none" && lv !== "off") obj.reasoning_effort = lv;
163
+ else if (lv === "none" || lv === "off") {
164
+ delete obj.reasoning_effort;
165
+ delete obj.reasoning_summary;
166
+ }
162
167
  body = JSON.stringify(obj);
163
168
  } catch {}
164
169
  let upstream;
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
8
8
  */
9
- import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
9
+ import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
10
10
  /** 模型目录来源:组合。 */
11
11
  export interface RouterAdapterSource {
12
12
  comboModels: () => Promise<Array<{
@@ -22,10 +22,6 @@ export interface RouterAdapterSource {
22
22
  * 用 `for await` 是因为它天然兼容两者 —— 不必为流式再写一份。
23
23
  */
24
24
  export declare function translateSse(payloads: AsyncIterable<string> | Iterable<string>): AsyncGenerator<StreamChunk>;
25
- /**
26
- * DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
27
- * /v1/chat/completions(组合路由在 /v1 内完成)。
28
- */
29
25
  export declare class RouterAdapter extends LlmAdapter {
30
26
  private readonly baseURL;
31
27
  private readonly source;
@@ -35,5 +31,6 @@ export declare class RouterAdapter extends LlmAdapter {
35
31
  name: string;
36
32
  };
37
33
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
34
+ resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
38
35
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
39
36
  }
@@ -48,6 +48,8 @@ export interface ChatRequest {
48
48
  rawBody: string;
49
49
  stream: boolean;
50
50
  model: string;
51
+ /** 推理等级(reasoning effort),供应商各自决定怎么映射到上游。缺省 = 'auto'。 */
52
+ lv?: string;
51
53
  }
52
54
  /**
53
55
  * 供应商接口。核心负责账号池(选号/冷却/禁用/错误累计/遍历回退),
@@ -71,7 +73,7 @@ export interface Supplier {
71
73
  /** 账号「现在状态」(从 status() 派生,插件只报它观察到的)。空 = 无账号供应商。 */
72
74
  accounts(): SupplierAccountNow[];
73
75
  /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
74
- chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
76
+ chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>;
75
77
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
76
78
  removeLink?(uid: string): Promise<boolean>;
77
79
  dispose(): void;
@@ -16,6 +16,8 @@ export interface SupplierConfig {
16
16
  poolStrategy: PoolStrategy;
17
17
  /** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
18
18
  credits: Record<string, number>;
19
+ /** 每连接显示别名:uid → 显示名(空/缺省用供应商原始昵称,纯展示不改路由键)。 */
20
+ accountNames: Record<string, string>;
19
21
  }
20
22
  /** 通用供应商配置存储。 */
21
23
  export declare class SupplierConfigStore {
@@ -45,6 +47,10 @@ export declare class SupplierConfigStore {
45
47
  removeCustomModel(supplierId: string, modelId: string): void;
46
48
  setPoolOrder(supplierId: string, uids: string[]): void;
47
49
  setPoolStrategy(supplierId: string, strategy: string): void;
50
+ /** 读某连接的显示别名;没设或为空返回 `undefined`(用供应商原始昵称)。 */
51
+ getAccountName(supplierId: string, uid: string): string | undefined;
52
+ /** 设置连接显示名。空名 = 删除别名(回到供应商原始昵称)。 */
53
+ setAccountName(supplierId: string, uid: string, name: string): void;
48
54
  /**
49
55
  * 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
50
56
  * 显示什么——不要把 -1 当 0 展示。
@@ -102,10 +102,14 @@ export interface SupplierModule {
102
102
  * 选号/回退/健康判定全是核心的活。
103
103
  * 无账号供应商(如 opencode)传空 uid,忽略即可。
104
104
  *
105
+ * `lv` 是推理等级(reasoning effort),`'auto'` 表示不显式下发;
106
+ * 供应商各自决定怎么把 `lv` 映射到上游(OpenAI 兼容一般是
107
+ * `reasoning_effort` 字段)。
108
+ *
105
109
  * 失败原因通过返回值的 `message` 报(核心在测试模型时直接用它做诊断提示),
106
110
  * 插件不必自己存一份「上次失败」的状态。
107
111
  */
108
- chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
112
+ chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>;
109
113
  dispose(): void;
110
114
  /** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
111
115
  generateLoginUrl?(): string | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -112,6 +112,8 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
112
112
  const [checkingIn, setCheckingIn] = useState(false)
113
113
  const [refreshing, setRefreshing] = useState(false)
114
114
  const [dragIndex, setDragIndex] = useState<number | null>(null)
115
+ const [renameUid, setRenameUid] = useState<string | null>(null)
116
+ const [renameDraft, setRenameDraft] = useState('')
115
117
  const mounted = useRef(true)
116
118
 
117
119
  useEffect(() => {
@@ -343,6 +345,33 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
343
345
  setRemoveTarget(null)
344
346
  }
345
347
 
348
+ const startRename = (account: RouterAccount): void => {
349
+ setRenameDraft(account.nickname ?? '')
350
+ setRenameUid(account.uid)
351
+ }
352
+
353
+ const saveRename = async (account: RouterAccount): Promise<void> => {
354
+ try {
355
+ const response = await fetch(`${ROUTER_API_BASE}/status/rename`, {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({ supplier: supplier.id, uid: account.uid, name: renameDraft.trim() }),
359
+ cache: 'no-store',
360
+ })
361
+ if (!response.ok) {
362
+ showToast(`保存失败:HTTP ${response.status}`)
363
+ setRenameUid(null)
364
+ return
365
+ }
366
+ onRefresh()
367
+ showToast('连接名已保存')
368
+ } catch (err) {
369
+ showToast(`保存失败:${(err as Error).message}`)
370
+ } finally {
371
+ setRenameUid(null)
372
+ }
373
+ }
374
+
346
375
  const toggleModel = async (id: string, enabled: boolean): Promise<void> => {
347
376
  setToggling(true)
348
377
  try {
@@ -644,7 +673,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
644
673
  <div
645
674
  key={account.uid}
646
675
  className={`dshr-linkRow${dragIndex === index ? ' dshr-linkRowDragging' : ''}`}
647
- draggable
676
+ draggable={renameUid !== account.uid}
648
677
  onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
649
678
  onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
650
679
  onDrop={(e) => {
@@ -667,7 +696,20 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
667
696
  </div>
668
697
  <div className="dshr-linkMain">
669
698
  <div className="dshr-linkTitleLine">
670
- <span className="dshr-linkName">{account.nickname || account.uid}</span>
699
+ {renameUid === account.uid
700
+ ? (
701
+ <input
702
+ className="dshr-input dshr-mono"
703
+ value={renameDraft}
704
+ autoFocus
705
+ onChange={(e) => setRenameDraft(e.target.value)}
706
+ onKeyDown={(e) => {
707
+ if (e.key === 'Enter') void saveRename(account)
708
+ if (e.key === 'Escape') setRenameUid(null)
709
+ }}
710
+ />
711
+ )
712
+ : <span className="dshr-linkName">{account.nickname || account.uid}</span>}
671
713
  <span className={`dshr-badge ${badge.cls}`}>{badge.text}</span>
672
714
  </div>
673
715
  <div className="dshr-linkSub">
@@ -689,6 +731,17 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
689
731
  </div>
690
732
  </div>
691
733
  <div className="dshr-linkOps">
734
+ <button
735
+ type="button"
736
+ className={`dshr-iconBtn dshr-iconBtn-sm dshr-linkEdit${renameUid === account.uid ? ' dshr-linkEdit-active' : ''}`}
737
+ title={renameUid === account.uid ? '保存名字' : '改名'}
738
+ onClick={() => {
739
+ if (renameUid === account.uid) void saveRename(account)
740
+ else startRename(account)
741
+ }}
742
+ >
743
+ <Icon d={renameUid === account.uid ? I.check : I.edit} size={15} />
744
+ </button>
692
745
  <button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-linkDelete" title="删除链接" onClick={() => setRemoveTarget(account)}>
693
746
  <Icon d={I.delete} size={15} />
694
747
  </button>
@@ -1634,6 +1634,20 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
1634
1634
  background: color-mix(in srgb, var(--rs-danger) 14%, transparent);
1635
1635
  }
1636
1636
 
1637
+ /* 改名:与删除一致,hover 行才显示;编辑中保持可见(否则保存按钮被藏住) */
1638
+ .dshr-linkEdit {
1639
+ color: var(--rs-muted);
1640
+ opacity: 0;
1641
+ transition: opacity .12s ease, color .12s ease, background .12s ease;
1642
+ }
1643
+ .dshr-linkRow:hover .dshr-linkEdit { opacity: 1; }
1644
+ .dshr-linkEdit:focus-visible { opacity: 1; }
1645
+ .dshr-linkEdit:hover {
1646
+ color: var(--rs-accent);
1647
+ background: color-mix(in srgb, var(--rs-accent) 14%, transparent);
1648
+ }
1649
+ .dshr-linkEdit.dshr-linkEdit-active { opacity: 1; }
1650
+
1637
1651
  /* 模型 chips(9router ModelRow 风格) */
1638
1652
  .dshr-modelChips {
1639
1653
  display: flex;
package/src/index.ts CHANGED
@@ -269,15 +269,18 @@ export function apply(rawContext: unknown): void {
269
269
  writeJson(res, 413, { error: { message: 'request body too large', type: 'api_error', code: 'request_too_large' } })
270
270
  return
271
271
  }
272
- let peek: { stream?: boolean; model?: string }
272
+ let peek: { stream?: boolean; model?: string; reasoning_effort?: unknown }
273
273
  try {
274
- peek = JSON.parse(body) as { stream?: boolean; model?: string }
274
+ peek = JSON.parse(body) as { stream?: boolean; model?: string; reasoning_effort?: unknown }
275
275
  } catch {
276
276
  writeJson(res, 400, { error: { message: 'invalid JSON body', type: 'api_error', code: 'invalid_request' } })
277
277
  return
278
278
  }
279
+ const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
280
+ ? peek.reasoning_effort
281
+ : 'auto'
279
282
  await router.chatCompletions(
280
- { rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '' },
283
+ { rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '', lv },
281
284
  res,
282
285
  )
283
286
  }))
@@ -309,6 +312,23 @@ export function apply(rawContext: unknown): void {
309
312
  writeJson(res, 200, { ok: true, accounts })
310
313
  })
311
314
 
315
+ // 连接池连接改名:只改面板显示名(存核心 store 的 accountNames,uid 不变)。
316
+ route(`${ROUTER_API_BASE}/status/rename`, async (req, res) => {
317
+ let body: { supplier?: string; uid?: string; name?: string }
318
+ try {
319
+ body = JSON.parse(await readBody(req, 64 << 10)) as { supplier?: string; uid?: string; name?: string }
320
+ } catch {
321
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
322
+ return
323
+ }
324
+ if (typeof body.supplier !== 'string' || body.supplier === '' || typeof body.uid !== 'string' || body.uid === '') {
325
+ writeJson(res, 400, { ok: false, error: 'supplier and uid are required' })
326
+ return
327
+ }
328
+ store.setAccountName(body.supplier, body.uid, typeof body.name === 'string' ? body.name : '')
329
+ writeJson(res, 200, { ok: true })
330
+ })
331
+
312
332
  route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
313
333
  try {
314
334
  const models = await router.listModels()
@@ -509,3 +509,20 @@ test('流式:上游不发空行分隔时也要能分帧(前瞻完整 JSON)
509
509
  }
510
510
  assert.equal(texts.join(''), 'AB', '两帧都要收到')
511
511
  })
512
+
513
+ test('模型目录:resolveModel 对每个组合声明推理等级(off/low/high/max,默认 high)', async () => {
514
+ const adapter = new RouterAdapter('http://x', { comboModels: async () => [
515
+ { id: 'c-a', name: '组合A' },
516
+ { id: 'c-b' },
517
+ ] })
518
+ const a = await adapter.resolveModel('router', 'c-a')
519
+ assert.equal(a.provider, 'router')
520
+ assert.equal(a.id, 'c-a')
521
+ assert.equal(a.name, '组合A')
522
+ assert.ok(a.reasoning, '组合应声明推理等级,DSH 才允许显式 effort')
523
+ assert.deepEqual(a.reasoning!.efforts.map((e) => e.id), ['off', 'low', 'high', 'max'])
524
+ assert.equal(a.reasoning!.defaultEffort, 'high', '默认 High:调用方不指定时物化为 high')
525
+ // 无显示名时用模型 id 兜底,保证 name 非空(dsh-llm normalizeModelInfo 要求)
526
+ const b = await adapter.resolveModel('router', 'c-b')
527
+ assert.equal(b.name, 'c-b')
528
+ })
@@ -10,10 +10,13 @@ import {
10
10
  EMPTY_RESPONSE_CODE,
11
11
  LlmAdapter,
12
12
  LlmError,
13
+ ReasoningEffortId,
13
14
  ToolCallId,
14
15
  attributionHeaders,
15
16
  type GenerateOptions,
16
17
  type LlmModelInfo,
18
+ type LlmReasoningEffortInfo,
19
+ type LlmResolvedModelInfo,
17
20
  type StreamChunk,
18
21
  } from '@deepseek-ai/dsh-llm'
19
22
  import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
@@ -353,6 +356,14 @@ function completeJson(text: string): boolean {
353
356
  * DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
354
357
  * /v1/chat/completions(组合路由在 /v1 内完成)。
355
358
  */
359
+ /** Router provider 对外暴露的推理等级(对齐 dsh/DeepSeek:off/low/high/max)。 */
360
+ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
361
+ { id: ReasoningEffortId('off'), name: 'Off' },
362
+ { id: ReasoningEffortId('low'), name: 'Low' },
363
+ { id: ReasoningEffortId('high'), name: 'High' },
364
+ { id: ReasoningEffortId('max'), name: 'Max' },
365
+ ]
366
+
356
367
  export class RouterAdapter extends LlmAdapter {
357
368
  private readonly baseURL: string
358
369
  private readonly source: RouterAdapterSource
@@ -379,6 +390,21 @@ export class RouterAdapter extends LlmAdapter {
379
390
  return out
380
391
  }
381
392
 
393
+ async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
394
+ const combos = await this.source.comboModels()
395
+ const found = combos.find((m) => m.id === model || m.name === model)
396
+ const name = found?.name ?? found?.id ?? model
397
+ // 组合背后是异构供应商,统一声明 dsh 推理等级;实际能否生效取决于
398
+ // 命中的上游(供应商各有各自的映射/忽略规则,见 applyReasoning + chatOnce(lv))。
399
+ // 默认 High:调用方不指定 effort 时,runtime 会物化为 'high' 下发。
400
+ return {
401
+ provider: 'router',
402
+ id: model,
403
+ name,
404
+ reasoning: { efforts: ROUTER_REASONING_EFFORTS, defaultEffort: ReasoningEffortId('high') },
405
+ }
406
+ }
407
+
382
408
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
383
409
  const body = wireRequest(options)
384
410
  const controller = new AbortController()
@@ -950,7 +950,7 @@ export class Router {
950
950
  const cfg = this.store.get(s.id)
951
951
  // 无账号供应商:直接调一次(uid 传空,插件忽略)
952
952
  if (s.accounts !== undefined && s.accounts().length === 0) {
953
- const r = await s.chatOnce('', req)
953
+ const r = await s.chatOnce('', req.lv ?? 'auto', req)
954
954
  if (!r.ok) {
955
955
  if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
956
956
  if (trace !== undefined) trace.lastState = r.state
@@ -977,7 +977,7 @@ export class Router {
977
977
  const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model)
978
978
  if (uid === undefined) return false
979
979
  tried.add(uid)
980
- const r = await s.chatOnce(uid, req)
980
+ const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
981
981
  if (!r.ok) {
982
982
  // 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
983
983
  // 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
@@ -35,7 +35,7 @@ function supplier(id: string, models: ModelInfo[], delayMs = 0) {
35
35
  models.map((m) => ({ ...m, enabled: true })),
36
36
  getAlias: (): string => id,
37
37
  accounts: (): SupplierAccountNow[] => accts,
38
- chatOnce: async (uid: string, req: { rawBody: string }): Promise<ChatOnceResult> => {
38
+ chatOnce: async (uid: string, _lv: string, req: { rawBody: string }): Promise<ChatOnceResult> => {
39
39
  const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
40
40
  if (!models.some((mm) => mm.id === m)) {
41
41
  return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
@@ -27,7 +27,7 @@ interface Spy {
27
27
  modelsWithEnabled: () => Promise<Array<ModelInfo & { enabled: boolean }>>
28
28
  getAlias: () => string
29
29
  accounts: () => SupplierAccountNow[]
30
- chatOnce: (uid: string, req: { rawBody: string }) => Promise<ChatOnceResult>
30
+ chatOnce: (uid: string, lv: string, req: { rawBody: string }) => Promise<ChatOnceResult>
31
31
  dispose: () => void
32
32
  }
33
33
  }
@@ -46,7 +46,7 @@ function spy(id: string, modelId: string, alias = id): Spy {
46
46
  modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
47
47
  getAlias: () => alias,
48
48
  accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
49
- chatOnce: async (_uid, req) => {
49
+ chatOnce: async (_uid, _lv, req) => {
50
50
  calls.n += 1
51
51
  const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
52
52
  seen.push(m) // 记核心传来的**原样**全名,剥前缀是插件内部的事
@@ -55,6 +55,8 @@ export interface ChatRequest {
55
55
  rawBody: string
56
56
  stream: boolean
57
57
  model: string
58
+ /** 推理等级(reasoning effort),供应商各自决定怎么映射到上游。缺省 = 'auto'。 */
59
+ lv?: string
58
60
  }
59
61
 
60
62
  /**
@@ -79,7 +81,7 @@ export interface Supplier {
79
81
  /** 账号「现在状态」(从 status() 派生,插件只报它观察到的)。空 = 无账号供应商。 */
80
82
  accounts(): SupplierAccountNow[]
81
83
  /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
82
- chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
84
+ chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>
83
85
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
84
86
  removeLink?(uid: string): Promise<boolean>
85
87
  dispose(): void
@@ -489,7 +489,7 @@ interface Spy {
489
489
  modelsWithEnabled: () => Promise<Array<ModelInfo & { enabled: boolean }>>
490
490
  getAlias: () => string
491
491
  accounts: () => SupplierAccountNow[]
492
- chatOnce: (uid: string, req: { rawBody: string }) => Promise<ChatOnceResult>
492
+ chatOnce: (uid: string, lv: string, req: { rawBody: string }) => Promise<ChatOnceResult>
493
493
  dispose: () => void
494
494
  }
495
495
  }
@@ -506,7 +506,7 @@ function spy(id: string, modelId: string, reply: ChatOnceResult): Spy {
506
506
  modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
507
507
  getAlias: () => id,
508
508
  accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
509
- chatOnce: async (_uid, req) => {
509
+ chatOnce: async (_uid, _lv, req) => {
510
510
  calls.n += 1
511
511
  let m = ''
512
512
  try {
@@ -96,3 +96,18 @@ test('按供应商隔离:同 uid 不同供应商互不干扰', () => {
96
96
  assert.equal(store.getCredits('codebuddy', 'key-1'), 100)
97
97
  assert.equal(store.getCredits('traework', 'key-1'), 200)
98
98
  })
99
+
100
+ test('连接显示别名:set 落盘重开还在;空名删除别名回原始', () => {
101
+ const { store, file } = tempStore()
102
+ assert.equal(store.getAccountName('traework', 't1'), undefined, '没设过就是 undefined')
103
+ store.setAccountName('traework', 't1', '主号')
104
+ assert.equal(store.getAccountName('traework', 't1'), '主号')
105
+ store.setAccountName('traework', 't2', '') // 空名不建别名
106
+ assert.equal(store.getAccountName('traework', 't2'), undefined)
107
+ // 落盘:重开还在
108
+ const reopened = new SupplierConfigStore(join(file, '..', 'state.json'))
109
+ assert.equal(reopened.getAccountName('traework', 't1'), '主号')
110
+ // 空串清空:回到供应商原始昵称
111
+ store.setAccountName('traework', 't1', ' ')
112
+ assert.equal(new SupplierConfigStore(join(file, '..', 'state.json')).getAccountName('traework', 't1'), undefined)
113
+ })
@@ -32,6 +32,8 @@ export interface SupplierConfig {
32
32
  poolStrategy: PoolStrategy
33
33
  /** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
34
34
  credits: Record<string, number>
35
+ /** 每连接显示别名:uid → 显示名(空/缺省用供应商原始昵称,纯展示不改路由键)。 */
36
+ accountNames: Record<string, string>
35
37
  }
36
38
 
37
39
  interface ConfigFile {
@@ -48,6 +50,16 @@ function readCredits(raw: unknown): Record<string, number> {
48
50
  return out
49
51
  }
50
52
 
53
+ /** 读盘时的连接显示别名校验:只留非空字符串。 */
54
+ function readNames(raw: unknown): Record<string, string> {
55
+ const out: Record<string, string> = {}
56
+ if (typeof raw !== 'object' || raw === null) return out
57
+ for (const [uid, v] of Object.entries(raw as Record<string, unknown>)) {
58
+ if (typeof v === 'string' && v.trim() !== '') out[uid] = v.trim()
59
+ }
60
+ return out
61
+ }
62
+
51
63
  /** 通用供应商配置存储。 */
52
64
  export class SupplierConfigStore {
53
65
  private fp = ''
@@ -78,7 +90,7 @@ export class SupplierConfigStore {
78
90
  get(supplierId: string): SupplierConfig {
79
91
  let cfg = this.bySupplier.get(supplierId)
80
92
  if (!cfg) {
81
- cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback', credits: {} }
93
+ cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback', credits: {}, accountNames: {} }
82
94
  this.bySupplier.set(supplierId, cfg)
83
95
  }
84
96
  return cfg
@@ -139,6 +151,28 @@ export class SupplierConfigStore {
139
151
  this.saveLocked()
140
152
  }
141
153
 
154
+ // ---- 每连接显示别名(连接池改名,纯展示;uid 仍是路由/匹配键) ----
155
+
156
+ /** 读某连接的显示别名;没设或为空返回 `undefined`(用供应商原始昵称)。 */
157
+ getAccountName(supplierId: string, uid: string): string | undefined {
158
+ const n = this.get(supplierId).accountNames[uid]
159
+ return typeof n === 'string' && n !== '' ? n : undefined
160
+ }
161
+
162
+ /** 设置连接显示名。空名 = 删除别名(回到供应商原始昵称)。 */
163
+ setAccountName(supplierId: string, uid: string, name: string): void {
164
+ const cfg = this.get(supplierId)
165
+ const clean = (name ?? '').trim()
166
+ if (clean === '') {
167
+ if (cfg.accountNames[uid] === undefined) return
168
+ delete cfg.accountNames[uid]
169
+ this.saveLocked()
170
+ return
171
+ }
172
+ cfg.accountNames[uid] = clean
173
+ this.saveLocked()
174
+ }
175
+
142
176
  // ---- 积分缓存(核心统一持久化,插件只管报) ----
143
177
 
144
178
  /**
@@ -189,6 +223,7 @@ export class SupplierConfigStore {
189
223
  poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === 'string') : [],
190
224
  poolStrategy: c.poolStrategy === 'round-robin' ? 'round-robin' : 'fallback',
191
225
  credits: readCredits(c.credits),
226
+ accountNames: readNames(c.accountNames),
192
227
  })
193
228
  }
194
229
  }
@@ -204,6 +239,7 @@ export class SupplierConfigStore {
204
239
  poolOrder: [...cfg.poolOrder],
205
240
  poolStrategy: cfg.poolStrategy,
206
241
  credits: { ...cfg.credits },
242
+ accountNames: { ...cfg.accountNames },
207
243
  }
208
244
  }
209
245
  try {
@@ -107,10 +107,14 @@ export interface SupplierModule {
107
107
  * 选号/回退/健康判定全是核心的活。
108
108
  * 无账号供应商(如 opencode)传空 uid,忽略即可。
109
109
  *
110
+ * `lv` 是推理等级(reasoning effort),`'auto'` 表示不显式下发;
111
+ * 供应商各自决定怎么把 `lv` 映射到上游(OpenAI 兼容一般是
112
+ * `reasoning_effort` 字段)。
113
+ *
110
114
  * 失败原因通过返回值的 `message` 报(核心在测试模型时直接用它做诊断提示),
111
115
  * 插件不必自己存一份「上次失败」的状态。
112
116
  */
113
- chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
117
+ chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>
114
118
  dispose(): void
115
119
 
116
120
  // ---- 差异化能力(可选,按存在性暴露端点/UI) ----
@@ -126,7 +126,12 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
126
126
  icon: (instance as { icon?: string }).icon,
127
127
  status: (): SupplierStatus => {
128
128
  const now = instance.status()
129
- return { id: now.id, name: now.name, accounts: pool.decorate(hydrateCredits(instance.id, env.store, now.accounts)) }
129
+ // 连接池显示别名覆盖:uid 不变,只改面板展示名
130
+ const accounts = now.accounts.map((a) => {
131
+ const alias = env.store.getAccountName(instance.id, a.uid)
132
+ return alias === undefined || alias === a.nickname ? a : { ...a, nickname: alias }
133
+ })
134
+ return { id: now.id, name: now.name, accounts: pool.decorate(hydrateCredits(instance.id, env.store, accounts)) }
130
135
  },
131
136
  listModels: (force?: boolean): Promise<ModelInfo[]> | ModelInfo[] => instance.listModels(force),
132
137
  // 通用能力:模型启用状态/自定义由核心 SupplierConfigStore 合并
@@ -146,7 +151,7 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
146
151
  getAlias: (): string => env.store.get(instance.id).alias || instance.id,
147
152
  customModelIds: (): string[] => [...env.store.get(instance.id).custom],
148
153
  accounts: (): SupplierAccountNow[] => instance.status().accounts,
149
- chatOnce: (uid: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, req),
154
+ chatOnce: (uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, lv, req),
150
155
  // 删除链接:数据删除是通用能力,凭证清理由供应商内部实现(js 契约不要求)
151
156
  removeLink: isFn((instance as unknown as Record<string, unknown>).removeLink)
152
157
  ? (uid: string): Promise<boolean> => (instance as unknown as { removeLink(uid: string): Promise<boolean> }).removeLink(uid)
@@ -245,7 +245,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
245
245
  return true
246
246
  },
247
247
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
248
- async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
248
+ async chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult> {
249
249
  const base = stripAlias(req.model, currentAlias())
250
250
  if (!(await allModels(false)).some((m) => m.id === base)) {
251
251
  const msg = `unknown model ${JSON.stringify(req.model)}`
@@ -261,6 +261,11 @@ export default function factory(env: SupplierEnv): SupplierModule {
261
261
  try {
262
262
  const obj = JSON.parse(body) as Record<string, unknown>
263
263
  obj.model = base
264
+ if (lv !== 'auto' && lv !== '' && lv !== 'none' && lv !== 'off') obj.reasoning_effort = lv
265
+ else if (lv === 'none' || lv === 'off') {
266
+ delete obj.reasoning_effort
267
+ delete obj.reasoning_summary
268
+ }
264
269
  body = JSON.stringify(obj)
265
270
  } catch {
266
271
  // 保持原样