dsh-router-core 0.3.8 → 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.
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * PATCH /router/api/suppliers/:id/models/:mid {enabled}
28
28
  *
29
29
  * `/v1/*` auth: gated by KeysStore.requireApiKey — when on, Bearer must be an
30
- * active library key or TW2A_API_KEY env. `/router/api/*` is same-origin, no auth.
30
+ * active library key. `/router/api/*` is same-origin, no auth.
31
31
  */
32
32
  import type { IncomingMessage, ServerResponse } from 'node:http'
33
33
  import { join, dirname } from 'node:path'
@@ -132,9 +132,9 @@ export function apply(rawContext: unknown): void {
132
132
  }
133
133
  const log = (msg: string): void => ctx.logger.info(`[dsh-router] ${msg}`)
134
134
 
135
- const stateFile = process.env.TW2A_STATE_FILE ?? 'data/state.json'
135
+ const stateFile = 'data/state.json'
136
136
  const store = new SupplierConfigStore(stateFile)
137
- const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), 'auths'))
137
+ const credentials = new CredentialStore(join(dirname(stateFile), 'auths'))
138
138
  const router = new Router(stateFile, store, log)
139
139
 
140
140
  const keys = new KeysStore(stateFile)
@@ -222,7 +222,7 @@ export function apply(rawContext: unknown): void {
222
222
  })
223
223
  void (async () => {
224
224
  try {
225
- const userDir = join(process.env.DSH_PROFILE_DIR ?? join(process.env.HOME ?? '', '.dsh', 'profiles', 'web'), 'suppliers')
225
+ const userDir = join(join(process.env.HOME ?? '', '.dsh', 'profiles', 'web'), 'suppliers')
226
226
  const { suppliers, errors } = await loadSuppliers({
227
227
  builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
228
228
  userDir,
@@ -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()
package/src/keys.ts CHANGED
@@ -4,12 +4,11 @@
4
4
  *
5
5
  * /v1/* 鉴权规则:
6
6
  * - requireApiKey=false(默认)→ 不鉴权
7
- * - requireApiKey=true → Bearer 必须是「启用的库内 key」或 TW2A_API_KEY env
7
+ * - requireApiKey=true → Bearer 必须是「启用的库内 key」
8
8
  */
9
9
  import { randomBytes } from 'node:crypto'
10
10
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
11
11
  import { dirname, join } from 'node:path'
12
- import { API_KEY_REF } from './shared.ts'
13
12
 
14
13
  /** 库内一条 key。 */
15
14
  export interface ApiKeyEntry {
@@ -132,12 +131,10 @@ export class KeysStore {
132
131
  this.save()
133
132
  }
134
133
 
135
- /** 校验 Bearer key:requireApiKey 关 → 放行;开 → 库内启用 key 或 TW2A_API_KEY env。 */
134
+ /** 校验 Bearer key:requireApiKey 关 → 放行;开 → 必须是库内启用的 key。 */
136
135
  verify(bearer: string | undefined): boolean {
137
136
  if (!this.require) return true
138
137
  if (bearer === undefined || bearer === '') return false
139
- if (this.keys.some((k) => k.isActive && k.key === bearer)) return true
140
- const envKey = process.env[API_KEY_REF]
141
- return envKey !== undefined && envKey !== '' && envKey === bearer
138
+ return this.keys.some((k) => k.isActive && k.key === bearer)
142
139
  }
143
140
  }
@@ -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
+ })
@@ -7,13 +7,16 @@
7
7
  * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
8
8
  */
9
9
  import {
10
- CallId,
11
10
  EMPTY_RESPONSE_CODE,
12
11
  LlmAdapter,
13
12
  LlmError,
13
+ ReasoningEffortId,
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'
@@ -183,7 +186,7 @@ export async function* translateSse(payloads: AsyncIterable<string> | Iterable<s
183
186
  yield {
184
187
  type: 'block-end',
185
188
  index: block.index,
186
- block: { type: 'tool-call', id: CallId(block.callId ?? ''), name: '', arguments: '{}' },
189
+ block: { type: 'tool-call', id: ToolCallId(block.callId ?? ''), name: '', arguments: '{}' },
187
190
  }
188
191
  }
189
192
  if (dropped > 0) {
@@ -270,7 +273,7 @@ export async function* translateSse(payloads: AsyncIterable<string> | Iterable<s
270
273
  yield {
271
274
  type: 'tool-call-delta',
272
275
  index: block.index,
273
- id: CallId(block.callId ?? ''),
276
+ id: ToolCallId(block.callId ?? ''),
274
277
  ...(block.name !== undefined ? { name: block.name } : {}),
275
278
  argumentsDelta: fragment,
276
279
  }
@@ -323,7 +326,7 @@ function closeBlock(block: OpenBlock): never {
323
326
  case 'reasoning': return { type: 'reasoning', text: block.text } as never
324
327
  case 'tool-call': return {
325
328
  type: 'tool-call',
326
- id: CallId(block.callId ?? ''),
329
+ id: ToolCallId(block.callId ?? ''),
327
330
  name: block.name ?? '',
328
331
  // 空串 = 无参工具,合法(上游很多工具不吃参数);补成 `{}` 让下游
329
332
  // 不必分支。能走到这里的必然是完整 JSON(残缺的已在 [DONE] 处拦掉)。
@@ -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 {
package/src/shared.ts CHANGED
@@ -20,9 +20,6 @@ export const ROUTER_TITLE = '路由'
20
20
  /** Same-origin API base the client panel fetches (host half serves it). */
21
21
  export const ROUTER_API_BASE = '/router/api'
22
22
 
23
- /** Bearer key env / credential reference protecting /v1/*. */
24
- export const API_KEY_REF = 'TW2A_API_KEY'
25
-
26
23
  /** One account row from `/router/api/status`. */
27
24
  export interface RouterAccount {
28
25
  uid: string
@@ -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
  // 保持原样
@@ -82,7 +82,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
82
82
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
83
83
  // testModel 由 dsh-router 核心统一走 chatOnce 路径(无账号,无需回退)
84
84
  /** 无账号供应商:uid 恒为空,只认 free 模型。 */
85
- async chatOnce(_uid: string, req: ChatRequest): Promise<ChatOnceResult> {
85
+ async chatOnce(_uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult> {
86
86
  // 只认 free 模型,其他模型交给别的供应商
87
87
  if (!isFreeModel(req.model)) {
88
88
  const msg = `unknown free model ${JSON.stringify(req.model)}`
@@ -93,6 +93,11 @@ export default function factory(env: SupplierEnv): SupplierModule {
93
93
  try {
94
94
  const obj = JSON.parse(body) as Record<string, unknown>
95
95
  obj.model = stripAlias(String(obj.model ?? req.model), currentAlias())
96
+ if (lv !== 'auto' && lv !== '' && lv !== 'none' && lv !== 'off') obj.reasoning_effort = lv
97
+ else if (lv === 'none' || lv === 'off') {
98
+ delete obj.reasoning_effort
99
+ delete obj.reasoning_summary
100
+ }
96
101
  body = JSON.stringify(obj)
97
102
  } catch {
98
103
  // 保持原样
@@ -150,7 +150,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
150
150
  return true
151
151
  },
152
152
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
153
- async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
153
+ async chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult> {
154
154
  const base = stripAlias(req.model, currentAlias())
155
155
  if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
156
156
  const msg = `unknown free model ${JSON.stringify(req.model)}`
@@ -166,6 +166,11 @@ export default function factory(env: SupplierEnv): SupplierModule {
166
166
  try {
167
167
  const obj = JSON.parse(body) as Record<string, unknown>
168
168
  obj.model = base
169
+ if (lv !== 'auto' && lv !== '' && lv !== 'none' && lv !== 'off') obj.reasoning_effort = lv
170
+ else if (lv === 'none' || lv === 'off') {
171
+ delete obj.reasoning_effort
172
+ delete obj.reasoning_summary
173
+ }
169
174
  body = JSON.stringify(obj)
170
175
  } catch {
171
176
  // 保持原样