dsh-router-core 0.3.1 → 0.3.3
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/LICENSE +21 -0
- package/README.md +77 -47
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +19 -3
- package/lib/client-registry.js +21 -10
- package/lib/client.js +21 -10
- package/lib/index.js +611 -215
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/router/account-pool.d.ts +54 -19
- package/lib/types/router/index.d.ts +5 -0
- package/lib/types/router/usage-store.d.ts +20 -3
- package/lib/types/shared.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/RouterView.tsx +10 -2
- package/src/client/SupplierDetail.tsx +17 -9
- package/src/llm/adapter.test.ts +404 -1
- package/src/llm/adapter.ts +242 -34
- package/src/router/account-pool.test.ts +163 -61
- package/src/router/account-pool.ts +200 -68
- package/src/router/index.ts +137 -38
- package/src/router/models-cache.test.ts +87 -4
- package/src/router/precise-dispatch.test.ts +110 -7
- package/src/router/usage-store.ts +174 -74
- package/src/router/usage.test.ts +115 -0
- package/src/shared.ts +2 -0
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/nvidia/plugin.ts +8 -2
- package/src/suppliers/registry.test.ts +23 -3
- package/src/suppliers/registry.ts +7 -2
|
@@ -15,7 +15,13 @@ export interface RouterAdapterSource {
|
|
|
15
15
|
}>>;
|
|
16
16
|
}
|
|
17
17
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
20
|
+
*
|
|
21
|
+
* 吃 `AsyncIterable`(真流式:边收边吐)或 `Iterable`(测试用数组)。
|
|
22
|
+
* 用 `for await` 是因为它天然兼容两者 —— 不必为流式再写一份。
|
|
23
|
+
*/
|
|
24
|
+
export declare function translateSse(payloads: AsyncIterable<string> | Iterable<string>): AsyncGenerator<StreamChunk>;
|
|
19
25
|
/**
|
|
20
26
|
* DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
|
|
21
27
|
* /v1/chat/completions(组合路由在 /v1 内完成)。
|
|
@@ -1,37 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 账号池 —— dsh-router
|
|
2
|
+
* 账号池 —— dsh-router 核心策略:选号、冷却、禁用、遍历回退。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - 按 poolOrder 排序(未配置的按插件报告顺序追加)
|
|
6
|
-
* - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
|
|
7
|
-
* - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
|
|
4
|
+
* 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
|
|
8
5
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* ## 冷却颗粒度 = (供应商, 模型, 连接) 三元组
|
|
7
|
+
*
|
|
8
|
+
* 冷却**不按连接整体记**,而是按「哪家供应商 × 哪个模型 × 哪个连接」单独记。
|
|
9
|
+
* 理由:一个连接可以服务多个模型。若某连接调模型 A 时被限流/瞬时抖动而把
|
|
10
|
+
* **整个连接**冷掉,那它调模型 B 也被殃及——一个付费连接会因一个模型的瞬时
|
|
11
|
+
* 失败而对其它模型也不可用,这是错的。
|
|
12
|
+
*
|
|
13
|
+
* - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
|
|
14
|
+
* - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
|
|
15
|
+
* - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
|
|
16
|
+
*
|
|
17
|
+
* 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
|
|
18
|
+
* codebuddy 都有 `deepseek-v4-flash`),它们是不同的实体,单靠 (modelId, uid)
|
|
19
|
+
* 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
|
|
20
|
+
* 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
|
|
21
|
+
*
|
|
22
|
+
* 天花板:冷却/禁用都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
|
|
23
|
+
* 得落盘,届时加 stateFile 即可,接口不用变。
|
|
12
24
|
*/
|
|
13
25
|
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts';
|
|
14
26
|
export declare class AccountPool {
|
|
15
|
-
|
|
27
|
+
/** 本池所属供应商(loader 每供应商一个实例;键里带上,跨供应商同名模型不串)。 */
|
|
28
|
+
private supplierId;
|
|
29
|
+
/** (supplier, model, uid) → 冷却/退避。 */
|
|
30
|
+
private cooldowns;
|
|
31
|
+
/** uid → 连接级禁用 / 手动暂停。 */
|
|
32
|
+
private byUid;
|
|
16
33
|
private rrCursor;
|
|
34
|
+
constructor(supplierId?: string);
|
|
35
|
+
private key;
|
|
17
36
|
private entry;
|
|
18
|
-
/**
|
|
37
|
+
/** 某连接整体状态(禁用/手动暂停)。 */
|
|
38
|
+
private uidState;
|
|
39
|
+
/** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
|
|
40
|
+
private uidBlocked;
|
|
41
|
+
/**
|
|
42
|
+
* 给定要试的模型,该连接是否可服务:
|
|
43
|
+
* - 未被连接级禁用/手动暂停(跨模型);
|
|
44
|
+
* - 且此 (supplier, model, uid) 冷却单元不在冷却中。
|
|
45
|
+
*/
|
|
19
46
|
private healthy;
|
|
20
47
|
/**
|
|
21
|
-
*
|
|
48
|
+
* 按策略为「某个模型」选一个健康账号。
|
|
22
49
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
23
50
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
51
|
+
* @param strategy fallback 取第一个健康 / round-robin 轮转健康号
|
|
52
|
+
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
24
53
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
25
54
|
*/
|
|
26
|
-
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined;
|
|
27
|
-
/**
|
|
28
|
-
noteFailure(uid: string, state: AccountState, message: string): void;
|
|
29
|
-
/**
|
|
30
|
-
noteSuccess(uid: string): void;
|
|
31
|
-
/**
|
|
55
|
+
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
|
|
56
|
+
/** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
57
|
+
noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
|
|
58
|
+
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
|
|
59
|
+
noteSuccess(uid: string, modelId: string): void;
|
|
60
|
+
/** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
|
|
32
61
|
cooldown(uid: string, ms: number, reason: string): void;
|
|
33
|
-
/**
|
|
34
|
-
|
|
62
|
+
/** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
|
|
63
|
+
*
|
|
64
|
+
* 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
|
|
65
|
+
* 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
|
|
66
|
+
* (reason 带上具体模型),并在 `until` 给最长者。真实路由用 `pick` 的
|
|
67
|
+
* 模型级判定,不受此展示聚合影响。传 modelId 可只按该模型判定。
|
|
68
|
+
*/
|
|
69
|
+
decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
|
|
35
70
|
cooling: boolean;
|
|
36
71
|
disabled: boolean;
|
|
37
72
|
err_count: number;
|
|
@@ -117,6 +117,11 @@ export declare class Router {
|
|
|
117
117
|
* 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
|
|
118
118
|
* 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
|
|
119
119
|
*/
|
|
120
|
+
/**
|
|
121
|
+
* @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
|
|
122
|
+
* 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
|
|
123
|
+
* 失败状态没有单一归属)。
|
|
124
|
+
*/
|
|
120
125
|
private chatWithModel;
|
|
121
126
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
122
127
|
supplierByAlias(alias: string): Supplier | undefined;
|
|
@@ -41,11 +41,28 @@ export declare class UsageStore {
|
|
|
41
41
|
ts?: number;
|
|
42
42
|
}, usage: Usage): void;
|
|
43
43
|
private bump;
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 从 `first` 起的 24 个小时格:每格带整点时间戳、所属自然日、那天的数据。
|
|
46
|
+
*
|
|
47
|
+
* `today` 传今天 00:00,`24h` 传「当前整点往前 23 格」。
|
|
48
|
+
* 跨天一律靠 `localDateKey(start)` 现算,不用「减 N 小时」去猜天数:
|
|
49
|
+
* 本地时区(尤其 DST)下小时偏移和自然日不是一回事,算错就漏一整天。
|
|
50
|
+
*/
|
|
51
|
+
private hourSlots;
|
|
52
|
+
/** 今天的小时格起点(自然日零点)。 */
|
|
53
|
+
private static dayStartOf;
|
|
54
|
+
/** 24h 滚动窗口的小时格起点(当前整点往前 23 格)。 */
|
|
55
|
+
private static rollingStartOf;
|
|
56
|
+
/**
|
|
57
|
+
* 一整天的小时桶是否齐(各格之和 >= 天桶计数)。
|
|
58
|
+
*
|
|
59
|
+
* 不齐 = 这天是升级前落的盘(没有小时粒度)→ 24h 窗口踩到它就只能整桶计入。
|
|
60
|
+
* 宁可多算窗口外那截,也不能报一个比「今日」还小的数。
|
|
61
|
+
*/
|
|
62
|
+
private static hoursComplete;
|
|
46
63
|
/** 汇总 + Top 榜。 */
|
|
47
64
|
stats(period: Period, now?: number): StatsResult;
|
|
48
|
-
/** 柱状图数据:today/24h = 24
|
|
65
|
+
/** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
|
|
49
66
|
chart(period: Period, now?: number): ChartBucket[];
|
|
50
67
|
/** 最近请求明细。 */
|
|
51
68
|
recentList(limit?: number): UsageRecord[];
|
package/lib/types/shared.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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": {
|
|
@@ -103,13 +103,21 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
|
|
|
103
103
|
<section key={supplier.id} className="dshr-supplierCard" onClick={() => setDetailSupplier(supplier)}>
|
|
104
104
|
<div className="dshr-supplierRow">
|
|
105
105
|
{supplier.icon !== undefined && (
|
|
106
|
-
<img
|
|
106
|
+
<img
|
|
107
|
+
className="dshr-supplierIcon"
|
|
108
|
+
src={supplier.icon}
|
|
109
|
+
alt=""
|
|
110
|
+
// 加载失败就藏起来(别留碎图)。图标不是关键信息,
|
|
111
|
+
// 旁边有供应商名字。历史上的坑:icon 曾是
|
|
112
|
+
// http://localhost:20128/... 这种外链,9router 没跑就全是坏图。
|
|
113
|
+
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
|
114
|
+
/>
|
|
107
115
|
)}
|
|
108
116
|
<div className="dshr-supplierName">{supplier.name}</div>
|
|
109
117
|
</div>
|
|
110
118
|
<div className="dshr-supplierMeta">
|
|
111
119
|
{supplierAccounts.length > 0 && (
|
|
112
|
-
<span className="dshr-muted">{supplierAccounts.length}
|
|
120
|
+
<span className="dshr-muted">{healthy}/{supplierAccounts.length} 健康</span>
|
|
113
121
|
)}
|
|
114
122
|
<span className="dshr-chevron">›</span>
|
|
115
123
|
</div>
|
|
@@ -184,20 +184,20 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
184
184
|
setCheckingIn(true)
|
|
185
185
|
try {
|
|
186
186
|
const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin`, { method: 'POST', cache: 'no-store' })
|
|
187
|
-
const data = await response.json() as { ok: boolean; total: number; succeeded: number; already?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }
|
|
187
|
+
const data = await response.json() as { ok: boolean; total: number; succeeded: number; already?: number; failed?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }
|
|
188
|
+
// 核心口径(2026-09-02 修正):有任一链接失败 → ok:false + HTTP 400。
|
|
189
|
+
// 别再用「签到完成」打头——部分失败会被 UI 吞成成功。
|
|
188
190
|
if (data.ok && data.total > 0) {
|
|
189
|
-
// 真实语义:succeeded = 真正 claim 成功;already = 今日已签;其余 = 失败/未开放
|
|
190
|
-
const failed = (data.results ?? []).filter(r => r.status !== 'ok' && r.status !== 'already')
|
|
191
191
|
let msg = `签到完成:${data.succeeded}/${data.total} 成功`
|
|
192
192
|
const already = data.already ?? 0
|
|
193
193
|
if (already > 0) msg += ` · ${already} 今日已签`
|
|
194
|
-
if (failed.length > 0) {
|
|
195
|
-
const first = failed[0]
|
|
196
|
-
msg += ` · ${first?.message ?? '有失败'}`
|
|
197
|
-
}
|
|
198
194
|
showToast(msg)
|
|
199
195
|
} else {
|
|
200
|
-
|
|
196
|
+
const firstFail = (data.results ?? []).find(r => r.status !== 'ok' && r.status !== 'already')
|
|
197
|
+
const reason = firstFail ? `${firstFail.message ?? '有链接签到失败'}` : ''
|
|
198
|
+
const already = data.already ?? 0
|
|
199
|
+
const head = data.total > 0 ? `签到未完成:${data.succeeded}/${data.total} 成功` : '没有可签到的链接'
|
|
200
|
+
showToast(data.error ?? [head, already > 0 ? `${already} 今日已签` : '', reason].filter(Boolean).join(' · '))
|
|
201
201
|
}
|
|
202
202
|
onRefresh()
|
|
203
203
|
} catch (err) {
|
|
@@ -560,7 +560,15 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
560
560
|
<div className="dshr-providerTitleRow">
|
|
561
561
|
<div className="dshr-providerIcon">
|
|
562
562
|
{supplier.icon !== undefined
|
|
563
|
-
?
|
|
563
|
+
? (
|
|
564
|
+
<img
|
|
565
|
+
className="dshr-providerImg"
|
|
566
|
+
src={supplier.icon}
|
|
567
|
+
alt=""
|
|
568
|
+
// 同上:失败就藏,露出备用图标位置(不显示碎图)
|
|
569
|
+
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
|
570
|
+
/>
|
|
571
|
+
)
|
|
564
572
|
: <Icon d={I.link} size={22} />}
|
|
565
573
|
</div>
|
|
566
574
|
<div className="dshr-providerMeta">
|