dsh-router-core 0.1.0 → 0.3.0
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 +58 -13
- package/lib/client-registry.js +824 -153
- package/lib/client.js +824 -153
- package/lib/index.js +1606 -227
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +72 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +69 -0
- package/lib/types/supplier-config.d.ts +15 -4
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +28 -16
- package/src/client/RouterView.tsx +15 -2
- package/src/client/StatsTab.tsx +521 -0
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +406 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +52 -4
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +652 -26
- package/src/router/models-cache.test.ts +455 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/types.ts +12 -7
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +77 -0
- package/src/supplier-config.ts +31 -14
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +21 -13
- package/src/suppliers/nvidia/plugin.ts +161 -119
- package/src/suppliers/opencode/plugin.ts +47 -95
- package/src/suppliers/openrouter/plugin.ts +67 -122
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +79 -59
|
@@ -14,6 +14,8 @@ export interface RouterAdapterSource {
|
|
|
14
14
|
name?: string;
|
|
15
15
|
}>>;
|
|
16
16
|
}
|
|
17
|
+
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
18
|
+
export declare function translateSse(payloads: Iterable<string>): AsyncGenerator<StreamChunk>;
|
|
17
19
|
/**
|
|
18
20
|
* DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
|
|
19
21
|
* /v1/chat/completions(组合路由在 /v1 内完成)。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
|
|
3
|
+
*
|
|
4
|
+
* 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
|
|
5
|
+
* - 按 poolOrder 排序(未配置的按插件报告顺序追加)
|
|
6
|
+
* - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
|
|
7
|
+
* - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
|
|
8
|
+
*
|
|
9
|
+
* 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
|
|
10
|
+
* 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
|
|
11
|
+
* stateFile 即可,接口不用变。
|
|
12
|
+
*/
|
|
13
|
+
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts';
|
|
14
|
+
export declare class AccountPool {
|
|
15
|
+
private entries;
|
|
16
|
+
private rrCursor;
|
|
17
|
+
private entry;
|
|
18
|
+
/** 是否可服务:未禁用且不在冷却中。 */
|
|
19
|
+
private healthy;
|
|
20
|
+
/**
|
|
21
|
+
* 按策略选一个健康账号。
|
|
22
|
+
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
23
|
+
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
24
|
+
* @returns 选中的 uid;无健康账号返回 undefined
|
|
25
|
+
*/
|
|
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
|
+
/** 手动冷却(面板/管理用途)。 */
|
|
32
|
+
cooldown(uid: string, ms: number, reason: string): void;
|
|
33
|
+
/** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
|
|
34
|
+
decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
|
|
35
|
+
cooling: boolean;
|
|
36
|
+
disabled: boolean;
|
|
37
|
+
err_count: number;
|
|
38
|
+
until?: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ServerResponse } from 'node:http';
|
|
2
2
|
import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts';
|
|
3
|
+
import { SupplierConfigStore } from '../supplier-config.ts';
|
|
4
|
+
import { UsageStore } from './usage-store.ts';
|
|
3
5
|
/** 路由器。 */
|
|
4
6
|
export declare class Router {
|
|
5
7
|
private suppliers;
|
|
@@ -7,7 +9,21 @@ export declare class Router {
|
|
|
7
9
|
private customCombos;
|
|
8
10
|
/** round-robin 轮转游标(按组合 id 记忆)。 */
|
|
9
11
|
private rrCursors;
|
|
10
|
-
|
|
12
|
+
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
13
|
+
private store;
|
|
14
|
+
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
15
|
+
private modelsCache;
|
|
16
|
+
/** 请求日志出口(面板/宿主 logger)。 */
|
|
17
|
+
private log;
|
|
18
|
+
/** 用量统计(概览看板)。 */
|
|
19
|
+
usage: UsageStore;
|
|
20
|
+
constructor(stateFile?: string, store?: SupplierConfigStore, log?: (msg: string) => void);
|
|
21
|
+
/**
|
|
22
|
+
* 请求追踪:每次 chat 记一行,回答「这个组合到底用的哪个模型和账号」。
|
|
23
|
+
* 排查「组合里某个模型不稳定」全靠它——没这个就只能猜。
|
|
24
|
+
* 格式刻意做成一行 grep 友好:`chat 请求model → supplier/model (uid) 结果 耗时`。
|
|
25
|
+
*/
|
|
26
|
+
private logChat;
|
|
11
27
|
private loadCombos;
|
|
12
28
|
private saveCombos;
|
|
13
29
|
add(supplier: Supplier): void;
|
|
@@ -30,7 +46,22 @@ export declare class Router {
|
|
|
30
46
|
listModels(): Promise<ModelInfo[]>;
|
|
31
47
|
/** 组合列表(面板用):用户自定义组合。 */
|
|
32
48
|
combos(): Promise<Combo[]>;
|
|
33
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* 取某供应商的模型(核心统一缓存)。
|
|
51
|
+
*
|
|
52
|
+
* 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
|
|
53
|
+
* `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
|
|
54
|
+
* 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
|
|
55
|
+
* 按钮(force)触发。
|
|
56
|
+
*
|
|
57
|
+
* 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
|
|
58
|
+
* 就得让供应商暴露 etag/版本号,目前没有这个需求。
|
|
59
|
+
*/
|
|
60
|
+
modelsOf(supplierId: string, force?: boolean): Promise<ModelWithEnabled[]>;
|
|
61
|
+
/** 失效某供应商的模型缓存(增删改模型后调用)。 */
|
|
62
|
+
invalidateModels(supplierId: string): void;
|
|
63
|
+
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
64
|
+
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
34
65
|
supplierModels(): Promise<Array<{
|
|
35
66
|
supplier: {
|
|
36
67
|
id: string;
|
|
@@ -41,7 +72,15 @@ export declare class Router {
|
|
|
41
72
|
}>>;
|
|
42
73
|
private validModels;
|
|
43
74
|
private validStrategy;
|
|
44
|
-
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
75
|
+
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
76
|
+
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
77
|
+
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
78
|
+
* 命名空间一起吃掉,后面请求必然 404。 */
|
|
79
|
+
/**
|
|
80
|
+
* 归一组合模型名为存储格式 `supplierId,modelId`。
|
|
81
|
+
* 面板提交的是「供应商 id + 模型 id」两两组合(或旧数据的裸 id),
|
|
82
|
+
* 这里只做清理与校验,不再剥掉前缀——精准调用靠的就是供应商 id。
|
|
83
|
+
*/
|
|
45
84
|
private normalizeModelIds;
|
|
46
85
|
/** 创建组合(name 唯一,非 default)。 */
|
|
47
86
|
createCombo(name: string, strategy: string, models: string[]): {
|
|
@@ -67,7 +106,36 @@ export declare class Router {
|
|
|
67
106
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
68
107
|
*/
|
|
69
108
|
chatCompletions(req: ChatRequest, res: ServerResponse): Promise<void>;
|
|
70
|
-
/**
|
|
109
|
+
/** 调指定供应商。
|
|
110
|
+
* 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
|
|
111
|
+
* (如 traework 会剥第一段)。核心在这剥就会剥两次,把模型 id 里的
|
|
112
|
+
* 命名空间(如 org/name)吃掉。 */
|
|
113
|
+
private chatWithTarget;
|
|
114
|
+
/** 把 model 改写为组合选中的模型名后,找对应的供应商 + 账号(策略全在核心)。
|
|
115
|
+
*
|
|
116
|
+
* `model` 形如 `supplierId,modelId`(组合的存储格式):直接用 supplierId 定位,
|
|
117
|
+
* 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
|
|
118
|
+
* 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
|
|
119
|
+
*/
|
|
71
120
|
private chatWithModel;
|
|
121
|
+
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
122
|
+
supplierByAlias(alias: string): Supplier | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
125
|
+
* AccountState 处置 → 失败换号;写响应只在成功后发生。
|
|
126
|
+
*
|
|
127
|
+
* 流式**推迟到第一个字节才写响应头**,所以「上游刚连上就断」这种情况
|
|
128
|
+
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
129
|
+
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
130
|
+
* 延迟,目前未做。
|
|
131
|
+
*/
|
|
132
|
+
private chatWithSupplier;
|
|
133
|
+
/** 测试某供应商的某模型是否可用。
|
|
134
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
135
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
|
|
136
|
+
testModel(supplierId: string, model: string): Promise<{
|
|
137
|
+
ok: boolean;
|
|
138
|
+
error?: string;
|
|
139
|
+
}>;
|
|
72
140
|
dispose(): void;
|
|
73
141
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
|
|
4
4
|
* 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts';
|
|
7
|
+
import type { AccountPool } from './account-pool.ts';
|
|
7
8
|
/** 面板展示的账号状态(脱敏)。 */
|
|
8
9
|
export interface SupplierAccount {
|
|
9
10
|
uid: string;
|
|
@@ -49,9 +50,8 @@ export interface ChatRequest {
|
|
|
49
50
|
model: string;
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* 轮换到下一个供应商)。写响应后不得再改 res。
|
|
53
|
+
* 供应商接口。核心负责账号池(选号/冷却/禁用/错误累计/遍历回退),
|
|
54
|
+
* 供应商只对**单个账号**调一次上游并报告结果,不碰 res。
|
|
55
55
|
*/
|
|
56
56
|
export interface Supplier {
|
|
57
57
|
readonly id: string;
|
|
@@ -68,13 +68,15 @@ export interface Supplier {
|
|
|
68
68
|
getAlias(): string;
|
|
69
69
|
/** 用户手动添加的模型 id(可选,/v1/models 用它聚合自定义模型)。 */
|
|
70
70
|
customModelIds?(): string[];
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
/** 账号「现在状态」(从 status() 派生,插件只报它观察到的)。空 = 无账号供应商。 */
|
|
72
|
+
accounts(): SupplierAccountNow[];
|
|
73
|
+
/** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
|
|
74
|
+
chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
|
|
75
|
+
/** 上次 chatOnce 失败原因(诊断用,可选)。 */
|
|
76
|
+
lastError?(): string | undefined;
|
|
77
77
|
/** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
|
|
78
78
|
removeLink?(uid: string): Promise<boolean>;
|
|
79
79
|
dispose(): void;
|
|
80
|
+
/** 该供应商的账号池(核心持有:选号/冷却/禁用/错误累计)。 */
|
|
81
|
+
readonly pool: AccountPool;
|
|
80
82
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Usage } from './usage-tokens.ts';
|
|
2
|
+
/** 一条明细(最近请求的环)。 */
|
|
3
|
+
export interface UsageRecord {
|
|
4
|
+
ts: number;
|
|
5
|
+
supplier: string;
|
|
6
|
+
model: string;
|
|
7
|
+
/** 客户端原始请求的 model(组合场景下就是组合名)。 */
|
|
8
|
+
requested: string;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
promptTokens: number;
|
|
11
|
+
completionTokens: number;
|
|
12
|
+
cachedTokens: number;
|
|
13
|
+
inputEstimated: boolean;
|
|
14
|
+
outputEstimated: boolean;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
/** 首字节延迟(ms);非流式为 0。 */
|
|
17
|
+
ttfbMs: number;
|
|
18
|
+
/** 失败原因(ok=false 时)。 */
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
export type Period = 'today' | '24h' | '7d' | '30d';
|
|
22
|
+
/** 本地日期键 `YYYY-MM-DD`(照 9router getLocalDateKey,不按 UTC 切天)。 */
|
|
23
|
+
export declare function localDateKey(ts: number): string;
|
|
24
|
+
/** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
|
|
25
|
+
export declare class UsageStore {
|
|
26
|
+
private fp;
|
|
27
|
+
private days;
|
|
28
|
+
private recent;
|
|
29
|
+
private lifetime;
|
|
30
|
+
private timer;
|
|
31
|
+
constructor(stateFile: string);
|
|
32
|
+
/**
|
|
33
|
+
* 记一次请求。
|
|
34
|
+
*
|
|
35
|
+
* **成功的零 token 请求不记**(照 9router `saveUsageStats` 的闸门:
|
|
36
|
+
* `in === 0 && out === 0` 直接 return,空请求混进来只会稀释统计)。
|
|
37
|
+
* **失败请求一定记** —— 不管有没有 token。成功率全靠它,挡掉的话
|
|
38
|
+
* 失败永远是 0 条,面板会显示 100% 成功,这个数字是骗人的。
|
|
39
|
+
*/
|
|
40
|
+
record(r: Omit<UsageRecord, 'ts' | 'promptTokens' | 'completionTokens' | 'cachedTokens' | 'inputEstimated' | 'outputEstimated'> & {
|
|
41
|
+
ts?: number;
|
|
42
|
+
}, usage: Usage): void;
|
|
43
|
+
private bump;
|
|
44
|
+
/** 窗口内的明细(today/24h 路径)。 */
|
|
45
|
+
private inWindow;
|
|
46
|
+
/** 汇总 + Top 榜。 */
|
|
47
|
+
stats(period: Period, now?: number): StatsResult;
|
|
48
|
+
/** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
|
|
49
|
+
chart(period: Period, now?: number): ChartBucket[];
|
|
50
|
+
/** 最近请求明细。 */
|
|
51
|
+
recentList(limit?: number): UsageRecord[];
|
|
52
|
+
clear(): void;
|
|
53
|
+
/** 进程退出前把防抖中的写入落盘。 */
|
|
54
|
+
flush(): void;
|
|
55
|
+
private scheduleSave;
|
|
56
|
+
private load;
|
|
57
|
+
private save;
|
|
58
|
+
}
|
|
59
|
+
/** 柱状图一桶。 */
|
|
60
|
+
export interface ChartBucket {
|
|
61
|
+
label: string;
|
|
62
|
+
requests: number;
|
|
63
|
+
tokens: number;
|
|
64
|
+
}
|
|
65
|
+
export interface StatsResult {
|
|
66
|
+
requests: number;
|
|
67
|
+
ok: number;
|
|
68
|
+
failed: number;
|
|
69
|
+
promptTokens: number;
|
|
70
|
+
completionTokens: number;
|
|
71
|
+
cachedTokens: number;
|
|
72
|
+
/** 平均耗时(ms)。 */
|
|
73
|
+
avgDurationMs: number;
|
|
74
|
+
/** 平均首字节延迟(ms);没有 TTFB 记录时为 0。 */
|
|
75
|
+
avgTtfbMs: number;
|
|
76
|
+
/** 输入/输出为估算的请求数(面板提示用)。 */
|
|
77
|
+
estimatedInputs: number;
|
|
78
|
+
estimatedOutputs: number;
|
|
79
|
+
/** 累计请求数(不受周期影响)。 */
|
|
80
|
+
lifetime: number;
|
|
81
|
+
bySupplier: RankRow[];
|
|
82
|
+
byModel: RankRow[];
|
|
83
|
+
byRequested: RankRow[];
|
|
84
|
+
}
|
|
85
|
+
export interface RankRow {
|
|
86
|
+
name: string;
|
|
87
|
+
requests: number;
|
|
88
|
+
ok: number;
|
|
89
|
+
failed: number;
|
|
90
|
+
promptTokens: number;
|
|
91
|
+
completionTokens: number;
|
|
92
|
+
lastTs: number;
|
|
93
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token 用量提取 —— 口径照 9router 的 `open-sse/utils/usageTracking.js`。
|
|
3
|
+
*
|
|
4
|
+
* 三件事:
|
|
5
|
+
* 1. **归一化**:上游字段名各不相同(prompt_tokens / input_tokens /
|
|
6
|
+
* promptTokenCount …),统一到 OpenAI 口径。
|
|
7
|
+
* 2. **合并**:流式里 usage 常被拆在多个事件里(Claude 系:
|
|
8
|
+
* message_start 给输入+缓存,message_delta 给输出)。所以是
|
|
9
|
+
* **字段级 max 合并**,不是覆盖——照 9router 的 `mergeUsage`。
|
|
10
|
+
* 3. **回退估算**:上游压根不发 usage 时,按字符数/4 估算(照 9router 的
|
|
11
|
+
* `estimateInputTokens`/`estimateOutputTokens`),并**分别**标记输入/输出
|
|
12
|
+
* 哪个是估算的——9router 只有一个 `estimated` 布尔,这里拆开更准,
|
|
13
|
+
* 面板才知道该给哪个数打「~」。
|
|
14
|
+
*
|
|
15
|
+
* 天花板(刻意不做):
|
|
16
|
+
* - 估算就是估算,~4 字符/token 是跨 tokenizer 的粗略平均。
|
|
17
|
+
* 要准得上真 tokenizer,代价是引入依赖,不值。
|
|
18
|
+
* - 不注入 `stream_options.include_usage`:那要改写客户端的请求体,
|
|
19
|
+
* 个别上游不认这个字段。上游不发我们就估算,不动请求语义。
|
|
20
|
+
*/
|
|
21
|
+
import type { TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
22
|
+
/** 归一化后的用量(不含估算标记)。 */
|
|
23
|
+
export interface UsageTokens {
|
|
24
|
+
promptTokens: number;
|
|
25
|
+
completionTokens: number;
|
|
26
|
+
cachedTokens: number;
|
|
27
|
+
}
|
|
28
|
+
/** 最终用量:token 数 + 哪些字段是估算的。 */
|
|
29
|
+
export interface Usage extends UsageTokens {
|
|
30
|
+
/** 输入是估算的(上游没给)。 */
|
|
31
|
+
inputEstimated: boolean;
|
|
32
|
+
/** 输出是估算的(上游没给)。 */
|
|
33
|
+
outputEstimated: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 从上游任意形态的 usage 对象提取归一用量。
|
|
37
|
+
*
|
|
38
|
+
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
39
|
+
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
40
|
+
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
41
|
+
* 判别式就是「有没有 cache_read_input_tokens」。
|
|
42
|
+
*
|
|
43
|
+
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
44
|
+
*/
|
|
45
|
+
export declare function normalizeUsage(raw: unknown): UsageTokens | null;
|
|
46
|
+
/** 字段级 max 合并:后来的同名字段取大者(Claude 把 usage 拆在多个事件里)。 */
|
|
47
|
+
export declare function mergeUsage(prev: UsageTokens | null, next: UsageTokens | null): UsageTokens | null;
|
|
48
|
+
/**
|
|
49
|
+
* 从流式上游的一帧 JSON 里取 usage。
|
|
50
|
+
* 覆盖 OpenAI(Chat/Responses)、Claude、Gemini 三种事件形状,照 9router
|
|
51
|
+
* `extractUsage` 的形态判断(dsh-router 的上游多是 OpenAI SSE,但供应商
|
|
52
|
+
* 插件可能有直通的原生流,多认几种不亏)。
|
|
53
|
+
*/
|
|
54
|
+
export declare function extractFrameUsage(chunk: unknown): UsageTokens | null;
|
|
55
|
+
/** 估算:~4 字符/token(照 9router 的 estimateInputTokens/estimateOutputTokens)。 */
|
|
56
|
+
export declare function estimateTokens(chars: number): number;
|
|
57
|
+
/**
|
|
58
|
+
* 非流式响应体取 usage。
|
|
59
|
+
*
|
|
60
|
+
* 正常是纯 JSON;但个别供应商客户端要 JSON 却回 SSE(核心会聚合成一次响应,
|
|
61
|
+
* 见 `aggregateSSE`),所以顺手扫尾部几帧——只扫尾部,不多做。
|
|
62
|
+
*/
|
|
63
|
+
export declare function usageFromJsonBody(body: string): UsageTokens | null;
|
|
64
|
+
/**
|
|
65
|
+
* 归一用量 → DSH 的 `TokenUsage`(**必须**走这一步再交给 dsh-llm)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么不能把上游 usage 原样透传:dsh-llm 的 `TokenUsage` 有两条硬性约定,
|
|
68
|
+
* 上游的 OpenAI 形态两条都踩:
|
|
69
|
+
* 1. 字段名是 `inputTokens` / `outputTokens`,不是 `prompt_tokens`;
|
|
70
|
+
* 名字对不上 → 下游读 `usage.inputTokens` 得 `undefined` → 累加出 `NaN`。
|
|
71
|
+
* 2. **DISJOINT 口径**:`inputTokens` 只算**未缓存**输入,缓存单列
|
|
72
|
+
* `cacheReadTokens`/`cacheWriteTokens`(计费输入 = 三者之和)。
|
|
73
|
+
* OpenAI 的 `prompt_tokens` 是**含**缓存的总量,直接当 inputTokens
|
|
74
|
+
* 就是重复计费。
|
|
75
|
+
*
|
|
76
|
+
* 后果不只是「数字不好看」:token-meter 的投影 schema 是
|
|
77
|
+
* `z.number().int().nonnegative()`,`NaN` 一进去校验就抛,整条
|
|
78
|
+
* `session.history` RPC 失败 —— 表现为「历史加载失败:history unavailable
|
|
79
|
+
* for session ... expected number, received NaN」。所以这里是**写入侧**
|
|
80
|
+
* 的防线:宁可丢掉这一帧 usage,也不能往会话日志里写 NaN。
|
|
81
|
+
*
|
|
82
|
+
* 天花板(刻意不做):不区分 cacheRead/cacheWrite。上游多数只报读不报写,
|
|
83
|
+
* 拆不开的字段硬拆只会更错;哪天上游稳定给 `cache_creation_input_tokens`
|
|
84
|
+
* 再补 `cacheWriteTokens`(升级路径就在这)。
|
|
85
|
+
*
|
|
86
|
+
* @returns DSH 契约用量;三个数全为 0 时返回 null(= 这一帧没真数据)
|
|
87
|
+
*/
|
|
88
|
+
export declare function toTokenUsage(tokens: UsageTokens | null): TokenUsage | null;
|
|
89
|
+
/** 流结束后汇总出的东西。 */
|
|
90
|
+
export interface StreamUsage {
|
|
91
|
+
usage: UsageTokens | null;
|
|
92
|
+
/** 输出内容字符数(上游没给 completion_tokens 时用来估算)。 */
|
|
93
|
+
outputChars: number;
|
|
94
|
+
/** 首字节延迟(ms);一个字节都没收到时为 0。 */
|
|
95
|
+
ttfbMs: number;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 边透传边统计:原流**原样**出去,统计在旁边做。
|
|
99
|
+
*
|
|
100
|
+
* 为什么不用 `source.tee()`:tee 的两条分支互相牵制,慢的一边会把 buffer 憋住;
|
|
101
|
+
* 而统计这条分支得读完整个流才出得了 usage,客户端那边却要尽快拿到首字节。
|
|
102
|
+
* 手动转发是把上游 chunk **即刻**交给下游,统计只攒几个数字,零缓冲、
|
|
103
|
+
* 首字节延迟不受影响。
|
|
104
|
+
*
|
|
105
|
+
* @param source 上游流(插件已转成 OpenAI SSE)
|
|
106
|
+
* @param startedAt 请求开始时间(`Date.now()`),TTFB 以此为基准
|
|
107
|
+
*/
|
|
108
|
+
export declare function tapStreamUsage(source: ReadableStream<Uint8Array>, startedAt: number): {
|
|
109
|
+
stream: ReadableStream<Uint8Array>;
|
|
110
|
+
done: () => Promise<StreamUsage>;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
114
|
+
*
|
|
115
|
+
* `requestChars` = 请求体字符数(估输入),`outputChars` = 输出字符数(估输出)。
|
|
116
|
+
* 上游给了的字段**不覆盖**——估算只填空,不抢真数据。
|
|
117
|
+
*/
|
|
118
|
+
export declare function withEstimates(usage: UsageTokens | null, requestChars: number, outputChars: number): Usage;
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -136,3 +136,72 @@ export interface RouterLoginResponse {
|
|
|
136
136
|
error?: string;
|
|
137
137
|
loginUrl?: string;
|
|
138
138
|
}
|
|
139
|
+
/** 面板周期。 */
|
|
140
|
+
export type RouterPeriod = 'today' | '24h' | '7d' | '30d';
|
|
141
|
+
/** 一条请求明细(面板「最近请求」)。 */
|
|
142
|
+
export interface RouterUsageRecord {
|
|
143
|
+
ts: number;
|
|
144
|
+
supplier: string;
|
|
145
|
+
model: string;
|
|
146
|
+
/** 客户端原始请求的 model(组合场景下就是组合名)。 */
|
|
147
|
+
requested: string;
|
|
148
|
+
ok: boolean;
|
|
149
|
+
promptTokens: number;
|
|
150
|
+
completionTokens: number;
|
|
151
|
+
cachedTokens: number;
|
|
152
|
+
/** 输入是估算的(上游没发 usage)。 */
|
|
153
|
+
inputEstimated: boolean;
|
|
154
|
+
/** 输出是估算的。 */
|
|
155
|
+
outputEstimated: boolean;
|
|
156
|
+
durationMs: number;
|
|
157
|
+
/** 首字节延迟(ms);非流式为 0。 */
|
|
158
|
+
ttfbMs: number;
|
|
159
|
+
error?: string;
|
|
160
|
+
}
|
|
161
|
+
/** Top 榜一行。 */
|
|
162
|
+
export interface RouterRankRow {
|
|
163
|
+
name: string;
|
|
164
|
+
requests: number;
|
|
165
|
+
ok: number;
|
|
166
|
+
failed: number;
|
|
167
|
+
promptTokens: number;
|
|
168
|
+
completionTokens: number;
|
|
169
|
+
lastTs: number;
|
|
170
|
+
}
|
|
171
|
+
/** `/router/api/stats` response. */
|
|
172
|
+
export interface RouterStatsResponse {
|
|
173
|
+
ok: boolean;
|
|
174
|
+
error?: string;
|
|
175
|
+
period?: RouterPeriod;
|
|
176
|
+
requests?: number;
|
|
177
|
+
okCount?: number;
|
|
178
|
+
failed?: number;
|
|
179
|
+
promptTokens?: number;
|
|
180
|
+
completionTokens?: number;
|
|
181
|
+
cachedTokens?: number;
|
|
182
|
+
/** 平均耗时(ms)。 */
|
|
183
|
+
avgDurationMs?: number;
|
|
184
|
+
/** 平均首字节延迟(ms)。 */
|
|
185
|
+
avgTtfbMs?: number;
|
|
186
|
+
/** 输入/输出为估算的请求数(面板据此提示)。 */
|
|
187
|
+
estimatedInputs?: number;
|
|
188
|
+
estimatedOutputs?: number;
|
|
189
|
+
/** 累计请求数(不受周期影响)。 */
|
|
190
|
+
lifetime?: number;
|
|
191
|
+
bySupplier?: RouterRankRow[];
|
|
192
|
+
byModel?: RouterRankRow[];
|
|
193
|
+
byRequested?: RouterRankRow[];
|
|
194
|
+
recent?: RouterUsageRecord[];
|
|
195
|
+
}
|
|
196
|
+
/** `/router/api/stats/chart` 一桶。 */
|
|
197
|
+
export interface RouterChartBucket {
|
|
198
|
+
label: string;
|
|
199
|
+
requests: number;
|
|
200
|
+
tokens: number;
|
|
201
|
+
}
|
|
202
|
+
/** `/router/api/stats/chart` response. */
|
|
203
|
+
export interface RouterChartResponse {
|
|
204
|
+
ok: boolean;
|
|
205
|
+
error?: string;
|
|
206
|
+
chart?: RouterChartBucket[];
|
|
207
|
+
}
|
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
export type PoolStrategy = 'fallback' | 'round-robin';
|
|
2
|
-
export type CheckinRule = 'all' | 'first';
|
|
3
2
|
export interface SupplierConfig {
|
|
4
3
|
alias: string;
|
|
5
4
|
disabled: string[];
|
|
6
5
|
custom: string[];
|
|
7
6
|
poolOrder: string[];
|
|
8
7
|
poolStrategy: PoolStrategy;
|
|
9
|
-
checkinRule: CheckinRule;
|
|
10
8
|
}
|
|
11
9
|
/** 通用供应商配置存储。 */
|
|
12
10
|
export declare class SupplierConfigStore {
|
|
13
11
|
private fp;
|
|
14
12
|
private bySupplier;
|
|
13
|
+
/** 全部供应商 id(加载时同步),用于别名唯一性校验。 */
|
|
14
|
+
private knownIds;
|
|
15
15
|
constructor(stateFile: string);
|
|
16
|
+
/** 同步已知供应商 id 集合(加载供应商后调用)。 */
|
|
17
|
+
sync(supplierIds: string[]): void;
|
|
18
|
+
/** 各供应商当前生效的别名(未设置别名的用供应商 id)。 */
|
|
19
|
+
effectiveAliases(): Map<string, string>;
|
|
16
20
|
/** 读取某供应商配置(不存在则返回默认并注册)。 */
|
|
17
21
|
get(supplierId: string): SupplierConfig;
|
|
18
|
-
|
|
22
|
+
/**
|
|
23
|
+
* 设置别名。别名必须唯一——它是对外模型全名的前缀(alias/model),
|
|
24
|
+
* 请求时用别名反查供应商,重复就会指错人。空别名表示用供应商 id(默认值)。
|
|
25
|
+
* @returns 冲突时返回占用的供应商 id
|
|
26
|
+
*/
|
|
27
|
+
setAlias(supplierId: string, alias: string): {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
conflictWith?: string;
|
|
30
|
+
};
|
|
19
31
|
setModelEnabled(supplierId: string, modelId: string, enabled: boolean): void;
|
|
20
32
|
setAllModelsEnabled(supplierId: string, enabled: boolean, allModelIds: string[]): void;
|
|
21
33
|
addCustomModel(supplierId: string, modelId: string): void;
|
|
22
34
|
removeCustomModel(supplierId: string, modelId: string): void;
|
|
23
35
|
setPoolOrder(supplierId: string, uids: string[]): void;
|
|
24
36
|
setPoolStrategy(supplierId: string, strategy: string): void;
|
|
25
|
-
setCheckinRule(supplierId: string, rule: string): void;
|
|
26
37
|
private load;
|
|
27
38
|
private saveLocked;
|
|
28
39
|
}
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
* 用户自定义供应商 js 放到 `~/.dsh/profiles/web/suppliers/*.js`,重启后自动加载。
|
|
10
10
|
* 内置供应商与用户 js 同构(差异化能力驱动)。
|
|
11
11
|
*/
|
|
12
|
-
import type {
|
|
13
|
-
import type { ChatRequest, ModelInfo, SupplierAccount, SupplierStatus } from '../router/types.ts';
|
|
12
|
+
import type { ChatRequest, ModelInfo, SupplierAccount } from '../router/types.ts';
|
|
14
13
|
import type { SupplierConfigStore } from '../supplier-config.ts';
|
|
15
14
|
import type { CredentialStore } from '../credential-store.ts';
|
|
16
15
|
/** 供应商配置:loader 注入的运行时环境。 */
|
|
@@ -26,6 +25,58 @@ export interface SupplierEnv {
|
|
|
26
25
|
}
|
|
27
26
|
/** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
|
|
28
27
|
export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
|
|
28
|
+
/**
|
|
29
|
+
* 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
|
|
30
|
+
*
|
|
31
|
+
* 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、是否禁用、要不要换号
|
|
32
|
+
* 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
|
|
33
|
+
*
|
|
34
|
+
* 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
|
|
35
|
+
* 字符串——核心的策略表要能穷举。
|
|
36
|
+
*/
|
|
37
|
+
export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'unavailable' | 'transport' | 'unknown'
|
|
38
|
+
/**
|
|
39
|
+
* 这个模型不属于本供应商(不是账号的失败,也不是上游故障)。
|
|
40
|
+
* 核心据此**跳过整个供应商**换下一个,而不是记在账号头上——
|
|
41
|
+
* 否则组合里每有一个别人家的模型,就会给无关账号攒一次错误,攒够阈值
|
|
42
|
+
* 把它冷却掉(历史上正是这个坑)。
|
|
43
|
+
*/
|
|
44
|
+
| 'no_such_model';
|
|
45
|
+
/** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却/禁用由核心叠加)。 */
|
|
46
|
+
export interface SupplierAccountNow {
|
|
47
|
+
uid: string;
|
|
48
|
+
nickname?: string;
|
|
49
|
+
credits: number;
|
|
50
|
+
/** 插件观察到的上游状态。 */
|
|
51
|
+
state: AccountState;
|
|
52
|
+
/** 状态的人类可读补充(如上游错误串),可选。 */
|
|
53
|
+
message?: string;
|
|
54
|
+
}
|
|
55
|
+
/** 插件报的供应商状态(accounts 是「现在状态」,非核心加工后的面板态)。 */
|
|
56
|
+
export interface SupplierStatusNow {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
accounts: SupplierAccountNow[];
|
|
60
|
+
}
|
|
61
|
+
/** 一次上游调用的结果。 */
|
|
62
|
+
export type ChatOnceResult =
|
|
63
|
+
/** 流式:插件已把上游协议转成 OpenAI SSE,核心只负责 pipe 到 res。 */
|
|
64
|
+
{
|
|
65
|
+
ok: true;
|
|
66
|
+
stream: ReadableStream<Uint8Array>;
|
|
67
|
+
}
|
|
68
|
+
/** 非流式:核心写 JSON。 */
|
|
69
|
+
| {
|
|
70
|
+
ok: true;
|
|
71
|
+
status: number;
|
|
72
|
+
body: string;
|
|
73
|
+
}
|
|
74
|
+
/** 失败:核心据此做冷却/禁用/换号。 */
|
|
75
|
+
| {
|
|
76
|
+
ok: false;
|
|
77
|
+
state: AccountState;
|
|
78
|
+
message: string;
|
|
79
|
+
};
|
|
29
80
|
/** 供应商模块 —— 契约(核心必须,差异化可选)。 */
|
|
30
81
|
export interface SupplierModule {
|
|
31
82
|
/** 供应商唯一 id(小写字母数字 - _)。 */
|
|
@@ -36,18 +87,23 @@ export interface SupplierModule {
|
|
|
36
87
|
readonly priority?: number;
|
|
37
88
|
/** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
|
|
38
89
|
readonly icon?: string;
|
|
39
|
-
|
|
90
|
+
/** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
|
|
91
|
+
status(): SupplierStatusNow;
|
|
40
92
|
/** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
|
|
41
93
|
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
|
|
42
94
|
/** 默认前缀(可被通用层 alias 覆盖)。 */
|
|
43
95
|
getAlias(): string;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
/**
|
|
97
|
+
* 对**单个账号**调一次上游。插件不遍历账号、不管冷却、不写 res——
|
|
98
|
+
* 选号/回退/健康判定全是核心的活。
|
|
99
|
+
* 无账号供应商(如 opencode)传空 uid,忽略即可。
|
|
100
|
+
*/
|
|
101
|
+
chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
|
|
50
102
|
dispose(): void;
|
|
103
|
+
/** 上次 chatOnce 失败的原因(诊断用)。
|
|
104
|
+
* 测试模型由 dsh-router 核心统一走 chatOnce 路径(账号池回退/冷却自动生效),
|
|
105
|
+
* 插件只需把失败原因暴露出来供核心汇总;不实现则测试失败时只有通用提示。 */
|
|
106
|
+
lastError?(): string | undefined;
|
|
51
107
|
/** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
|
|
52
108
|
generateLoginUrl?(): string | {
|
|
53
109
|
ok: boolean;
|
|
@@ -79,19 +135,12 @@ export interface SupplierModule {
|
|
|
79
135
|
removeLink?(uid: string): Promise<boolean>;
|
|
80
136
|
/** 轮询式登录标记:有它 → 面板隐藏「粘贴回调链接」步骤,登录后直接点完成(后台轮询)。 */
|
|
81
137
|
pollLogin?(): boolean;
|
|
82
|
-
/**
|
|
83
|
-
|
|
138
|
+
/** 单个链接签到。遍历所有链接 + 结果汇总是 dsh-router 核心的活,插件只管一个 uid。
|
|
139
|
+
* status: 'ok'(签到成功) / 'already'(今日已签到) / 'error'(失败)。 */
|
|
140
|
+
checkinNow?(uid: string): Promise<{
|
|
84
141
|
ok: boolean;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
already?: number;
|
|
88
|
-
error?: string;
|
|
89
|
-
results?: Array<{
|
|
90
|
-
uid: string;
|
|
91
|
-
ok: boolean;
|
|
92
|
-
status?: string;
|
|
93
|
-
message?: string;
|
|
94
|
-
}>;
|
|
142
|
+
status: string;
|
|
143
|
+
message?: string;
|
|
95
144
|
}>;
|
|
96
145
|
}
|
|
97
146
|
/** 供应商账号(复用 router 类型,供 loader 包装)。 */
|