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.
- package/README.md +1 -1
- package/lib/client-registry.js +58 -6
- package/lib/client.js +58 -6
- package/lib/index.js +98 -9
- package/lib/suppliers/nvidia.js +6 -1
- package/lib/suppliers/opencode.js +6 -1
- package/lib/suppliers/openrouter.js +6 -1
- package/lib/types/llm/adapter.d.ts +2 -5
- package/lib/types/router/types.d.ts +3 -1
- package/lib/types/supplier-config.d.ts +6 -0
- package/lib/types/suppliers/contract.d.ts +5 -1
- package/package.json +1 -1
- package/src/client/SupplierDetail.tsx +55 -2
- package/src/client/router.css +14 -0
- package/src/index.ts +23 -3
- package/src/llm/adapter.test.ts +17 -0
- package/src/llm/adapter.ts +26 -0
- 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/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/lib/suppliers/nvidia.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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>
|
package/src/client/router.css
CHANGED
|
@@ -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()
|
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
|
@@ -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()
|
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 {
|
|
@@ -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
|
// 保持原样
|