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/README.md +8 -11
- package/lib/client-registry.js +71 -30
- package/lib/client.js +71 -30
- package/lib/index.js +107 -22
- package/lib/suppliers/nvidia.js +6 -1
- package/lib/suppliers/opencode.js +6 -1
- package/lib/suppliers/openrouter.js +6 -1
- package/lib/types/keys.d.ts +1 -1
- package/lib/types/llm/adapter.d.ts +2 -5
- package/lib/types/router/types.d.ts +3 -1
- package/lib/types/shared.d.ts +0 -2
- package/lib/types/supplier-config.d.ts +6 -0
- package/lib/types/suppliers/contract.d.ts +5 -1
- package/package.json +15 -19
- package/src/client/SupplierDetail.tsx +55 -2
- package/src/client/env.d.ts +7 -0
- package/src/client/router.css +14 -0
- package/src/index.ts +27 -7
- package/src/keys.ts +3 -6
- package/src/llm/adapter.test.ts +17 -0
- package/src/llm/adapter.ts +30 -4
- package/src/router/index.ts +2 -2
- package/src/router/models-cache.test.ts +1 -1
- package/src/router/precise-dispatch.test.ts +2 -2
- package/src/router/types.ts +3 -1
- package/src/router/usage.test.ts +2 -2
- package/src/shared.ts +0 -3
- package/src/supplier-config.test.ts +15 -0
- package/src/supplier-config.ts +37 -1
- package/src/suppliers/contract.ts +5 -1
- package/src/suppliers/loader.ts +7 -2
- package/src/suppliers/nvidia/plugin.ts +6 -1
- package/src/suppliers/opencode/plugin.ts +6 -1
- package/src/suppliers/openrouter/plugin.ts +6 -1
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
|
|
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 =
|
|
135
|
+
const stateFile = 'data/state.json'
|
|
136
136
|
const store = new SupplierConfigStore(stateFile)
|
|
137
|
-
const credentials = new CredentialStore(
|
|
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(
|
|
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
|
|
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 关 → 放行;开 →
|
|
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
|
-
|
|
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
|
}
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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()
|
package/src/router/index.ts
CHANGED
|
@@ -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) // 记核心传来的**原样**全名,剥前缀是插件内部的事
|
package/src/router/types.ts
CHANGED
|
@@ -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
|
package/src/router/usage.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/supplier-config.ts
CHANGED
|
@@ -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) ----
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
// 保持原样
|