dsh-router-core 0.3.3 → 0.3.5

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.
@@ -0,0 +1,222 @@
1
+ //#region src/suppliers/openrouter/plugin.ts
2
+ const id = "openrouter";
3
+ const name = "OpenRouter";
4
+ const priority = 10;
5
+ /** 面板图标。 */
6
+ const icon = "data:image/png;base64,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";
7
+ const BASE = "https://openrouter.ai/api/v1";
8
+ const MODELS_URL = `${BASE}/models`;
9
+ const CHAT_URL = `${BASE}/chat/completions`;
10
+ /** 默认前缀(用户可在面板改;loader 包装会优先用 store 里的值)。 */
11
+ /** 免费模型过滤(同 9Router openrouter-free):pricing 全 0 且 context ≥ 200k。 */
12
+ function isFreeModelMeta(m) {
13
+ return m.pricing?.prompt === "0" && m.pricing?.completion === "0" && (m.context_length ?? 0) >= 2e5;
14
+ }
15
+ /** 剥 alias 前缀(or/xxx → xxx)。 */
16
+ /** 剥本供应商 alias 前缀(只剥自己的,模型 id 自带的斜杠保留)。 */
17
+ function stripAlias(model, alias) {
18
+ return alias !== "" && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model;
19
+ }
20
+ function factory(env) {
21
+ let modelsCache;
22
+ let freeIds = /* @__PURE__ */ new Set();
23
+ /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
24
+ function listKeys() {
25
+ return env.credentials.list(id);
26
+ }
27
+ function getKey(uid) {
28
+ return env.credentials.get(id, uid);
29
+ }
30
+ /** 账号顺序:池顺序优先,未配置按凭证原始顺序。 */
31
+ function orderedKeys() {
32
+ const byUid = new Map(listKeys().map((uid) => [uid, uid]));
33
+ const order = env.store.get(id).poolOrder;
34
+ return [...order.filter((u) => byUid.has(u)), ...listKeys().filter((u) => !order.includes(u))].map((uid) => ({
35
+ uid,
36
+ acct: getKey(uid)
37
+ })).filter((x) => x.acct !== void 0);
38
+ }
39
+ /** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
40
+ function currentAlias() {
41
+ return env.store.get("openrouter").alias || "openrouter";
42
+ }
43
+ async function refreshModels() {
44
+ try {
45
+ const resp = await fetch(MODELS_URL, {
46
+ headers: {
47
+ "User-Agent": "dsh-router",
48
+ "HTTP-Referer": "https://endpoint-proxy.local",
49
+ "X-Title": "dsh-router"
50
+ },
51
+ signal: AbortSignal.timeout(15e3)
52
+ });
53
+ if (!resp.ok) return [];
54
+ const raw = (await resp.json()).data ?? [];
55
+ const models = [];
56
+ const ids = /* @__PURE__ */ new Set();
57
+ for (const m of raw) {
58
+ if (m.id === "" || !isFreeModelMeta(m)) continue;
59
+ if (ids.has(m.id)) continue;
60
+ ids.add(m.id);
61
+ const entry = { id: m.id };
62
+ if ((m.context_length ?? 0) > 0) entry.context_length = Math.round((m.context_length ?? 0) / 1e3);
63
+ models.push(entry);
64
+ }
65
+ if (models.length > 0) {
66
+ modelsCache = models;
67
+ freeIds = ids;
68
+ }
69
+ return models;
70
+ } catch {
71
+ return modelsCache ?? [];
72
+ }
73
+ }
74
+ async function allModels(force) {
75
+ return refreshModels();
76
+ }
77
+ return {
78
+ id,
79
+ name,
80
+ priority: 10,
81
+ icon,
82
+ status: () => {
83
+ const accounts = orderedKeys().map(({ uid, acct }) => ({
84
+ uid,
85
+ nickname: acct.name || "API Key",
86
+ credits: 0,
87
+ state: "ok"
88
+ }));
89
+ return {
90
+ id,
91
+ name,
92
+ accounts
93
+ };
94
+ },
95
+ listModels: (force) => allModels(!!force),
96
+ async addApiKey(input) {
97
+ const key = input.apiKey.trim();
98
+ if (key === "") return {
99
+ ok: false,
100
+ error: "API key 不能为空"
101
+ };
102
+ try {
103
+ const probe = await fetch(`${BASE}/key`, {
104
+ headers: {
105
+ Authorization: `Bearer ${key}`,
106
+ "User-Agent": "dsh-router"
107
+ },
108
+ signal: AbortSignal.timeout(15e3)
109
+ });
110
+ if (!probe.ok) {
111
+ const text = await probe.text().catch(() => "");
112
+ return {
113
+ ok: false,
114
+ error: `key 无效: ${probe.status} ${text.slice(0, 200)}`
115
+ };
116
+ }
117
+ } catch (err) {
118
+ return {
119
+ ok: false,
120
+ error: `验证失败: ${err.message}`
121
+ };
122
+ }
123
+ let n = listKeys().length + 1;
124
+ let uid = `key-${n}`;
125
+ while (getKey(uid) !== void 0) uid = `key-${++n}`;
126
+ env.credentials.save(id, uid, {
127
+ name: input.name.trim() || `Key ${n}`,
128
+ apiKey: key
129
+ });
130
+ env.log(`openrouter add api key ${uid}`);
131
+ return {
132
+ ok: true,
133
+ account: {
134
+ uid,
135
+ nickname: input.name.trim() || `Key ${n}`
136
+ }
137
+ };
138
+ },
139
+ async removeLink(uid) {
140
+ if (getKey(uid) === void 0) return false;
141
+ env.credentials.remove(id, uid);
142
+ return true;
143
+ },
144
+ /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
145
+ async chatOnce(uid, req) {
146
+ const base = stripAlias(req.model, currentAlias());
147
+ if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) return {
148
+ ok: false,
149
+ state: "no_such_model",
150
+ message: `unknown free model ${JSON.stringify(req.model)}`
151
+ };
152
+ const acct = getKey(uid);
153
+ if (acct === void 0) return {
154
+ ok: false,
155
+ state: "no_such_model",
156
+ message: `unknown account ${JSON.stringify(uid)}`
157
+ };
158
+ let body = req.rawBody;
159
+ try {
160
+ const obj = JSON.parse(body);
161
+ obj.model = base;
162
+ body = JSON.stringify(obj);
163
+ } catch {}
164
+ let upstream;
165
+ try {
166
+ upstream = await fetch(CHAT_URL, {
167
+ method: "POST",
168
+ headers: {
169
+ "Content-Type": "application/json",
170
+ Authorization: `Bearer ${acct.apiKey}`,
171
+ "HTTP-Referer": "https://endpoint-proxy.local",
172
+ "X-Title": "dsh-router"
173
+ },
174
+ body,
175
+ signal: AbortSignal.timeout(12e4)
176
+ });
177
+ } catch (err) {
178
+ return {
179
+ ok: false,
180
+ state: "transport",
181
+ message: err.message
182
+ };
183
+ }
184
+ if (upstream.status < 200 || upstream.status >= 300) {
185
+ const text = await upstream.text().catch(() => "");
186
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`;
187
+ return {
188
+ ok: false,
189
+ state: upstream.status === 429 ? "rate_limit" : upstream.status === 401 || upstream.status === 403 ? "session_dead" : upstream.status === 404 ? "unavailable" : "unknown",
190
+ message: msg
191
+ };
192
+ }
193
+ if (req.stream) {
194
+ if (!upstream.body) return {
195
+ ok: false,
196
+ state: "transport",
197
+ message: "openrouter upstream: empty stream body"
198
+ };
199
+ return {
200
+ ok: true,
201
+ stream: upstream.body
202
+ };
203
+ }
204
+ const text = await upstream.text().catch(() => "");
205
+ if (text === "") return {
206
+ ok: false,
207
+ state: "transport",
208
+ message: "openrouter upstream: empty body"
209
+ };
210
+ return {
211
+ ok: true,
212
+ status: upstream.status,
213
+ body: text
214
+ };
215
+ },
216
+ dispose: () => {
217
+ modelsCache = void 0;
218
+ }
219
+ };
220
+ }
221
+ //#endregion
222
+ export { factory as default, icon, id, name, priority };
@@ -1,5 +1,8 @@
1
- /** Plugin identity for cordis.yml rows. */
2
- export declare const name = "dsh-router";
1
+ /**
2
+ * Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
3
+ * loader 拿配置行里的 `name` 去 `import(name)`,写错就是从 npm 装完加载不到。
4
+ */
5
+ export declare const name = "dsh-router-core";
3
6
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
4
7
  export declare const inject: string[];
5
8
  /** Plugin body. */
@@ -13,6 +13,11 @@ export declare class Router {
13
13
  private store;
14
14
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
15
15
  private modelsCache;
16
+ /**
17
+ * 正在拉模型的供应商 → in-flight promise。
18
+ * 组合页会同时从多个入口触发同一个供应商的刷新,不去重上游就被按 N 倍打。
19
+ */
20
+ private modelsInflight;
16
21
  /** 请求日志出口(面板/宿主 logger)。 */
17
22
  private log;
18
23
  /** 用量统计(概览看板)。 */
@@ -50,14 +55,27 @@ export declare class Router {
50
55
  * 取某供应商的模型(核心统一缓存)。
51
56
  *
52
57
  * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
53
- * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
54
- * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
55
- * 按钮(force)触发。
58
+ * `/combos`(组合加模型)共用。
59
+ *
60
+ * **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
61
+ * 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
62
+ * 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
63
+ * 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
64
+ * 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
56
65
  *
57
- * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
58
- * 就得让供应商暴露 etag/版本号,目前没有这个需求。
66
+ * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
67
+ * (冷启动,没有旧值可给)。后者只发生一次。
59
68
  */
60
69
  modelsOf(supplierId: string, force?: boolean): Promise<ModelWithEnabled[]>;
70
+ /**
71
+ * 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
72
+ * 组合页会同时触发多个入口,不去重会把上游按 N 倍打(各家插件自己没做这层)。
73
+ *
74
+ * 失败时:**有旧值就退回旧值**(宁可显示几分钟前的列表,也别让面板空掉),
75
+ * **没旧值就把错误抛回去**——调用方(`supplierModels`)据此把该供应商整个
76
+ * 剔除,而不是显示一个「有 0 个模型」的空壳。
77
+ */
78
+ private refreshModels;
61
79
  /** 失效某供应商的模型缓存(增删改模型后调用)。 */
62
80
  invalidateModels(supplierId: string): void;
63
81
  /** 可用模型(按供应商分组,仅启用),面板加模型用。
@@ -72,8 +72,6 @@ export interface Supplier {
72
72
  accounts(): SupplierAccountNow[];
73
73
  /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
74
74
  chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
75
- /** 上次 chatOnce 失败原因(诊断用,可选)。 */
76
- lastError?(): string | undefined;
77
75
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
78
76
  removeLink?(uid: string): Promise<boolean>;
79
77
  dispose(): void;
@@ -1,10 +1,21 @@
1
1
  export type PoolStrategy = 'fallback' | 'round-robin';
2
+ /**
3
+ * 积分哨兵:插件还**没拿到过**真实额度(刚重启、积分拉取失败、或压根不支持
4
+ * 积分)时报 `-1`,核心据此保留上一次的缓存值,而不是把 0 写进去。
5
+ *
6
+ * 为什么不能用 0 表示未知:积分 0 是有意义的真值(用完了),核心无法区分
7
+ * 「拿到 0」和「没拿到」。用 0 当哨兵会把缓存冲成 0 —— 这正是 codebuddy
8
+ * 重启后面板永久显示 0 积分的原因(插件只在内存里缓存积分)。
9
+ */
10
+ export declare const CREDITS_UNKNOWN = -1;
2
11
  export interface SupplierConfig {
3
12
  alias: string;
4
13
  disabled: string[];
5
14
  custom: string[];
6
15
  poolOrder: string[];
7
16
  poolStrategy: PoolStrategy;
17
+ /** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
18
+ credits: Record<string, number>;
8
19
  }
9
20
  /** 通用供应商配置存储。 */
10
21
  export declare class SupplierConfigStore {
@@ -34,6 +45,18 @@ export declare class SupplierConfigStore {
34
45
  removeCustomModel(supplierId: string, modelId: string): void;
35
46
  setPoolOrder(supplierId: string, uids: string[]): void;
36
47
  setPoolStrategy(supplierId: string, strategy: string): void;
48
+ /**
49
+ * 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
50
+ * 显示什么——不要把 -1 当 0 展示。
51
+ */
52
+ getCredits(supplierId: string, uid: string): number;
53
+ /**
54
+ * 合并插件报的积分:非 -1 就写入并返回它;-1(未知)则回落到上次缓存值。
55
+ * @returns 面板该显示的积分(仍可能是 -1 = 从来没拿到过)
56
+ */
57
+ putCredits(supplierId: string, uid: string, reported: number): number;
58
+ /** 删除链接时清掉它的积分缓存(不写盘:下一行 removeLink 会带出保存)。 */
59
+ clearCredits(supplierId: string, uid: string): void;
37
60
  private load;
38
61
  private saveLocked;
39
62
  }
@@ -46,6 +46,12 @@ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'una
46
46
  export interface SupplierAccountNow {
47
47
  uid: string;
48
48
  nickname?: string;
49
+ /**
50
+ * 剩余额度。**拿不到就报 `-1`**,不是 0:
51
+ * 插件刚重启(内存缓存空)、积分拉取失败、或压根不支持积分时都是 `-1`。
52
+ * 核心据此保留上一次持久化下来的值 —— 报 0 会把缓存冲成 0(0 是真值
53
+ * 「用完了」,核心分不清「拿到 0」和「没拿到」)。
54
+ */
49
55
  credits: number;
50
56
  /** 插件观察到的上游状态。 */
51
57
  state: AccountState;
@@ -91,19 +97,16 @@ export interface SupplierModule {
91
97
  status(): SupplierStatusNow;
92
98
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
93
99
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
94
- /** 默认前缀(可被通用层 alias 覆盖)。 */
95
- getAlias(): string;
96
100
  /**
97
101
  * 对**单个账号**调一次上游。插件不遍历账号、不管冷却、不写 res——
98
102
  * 选号/回退/健康判定全是核心的活。
99
103
  * 无账号供应商(如 opencode)传空 uid,忽略即可。
104
+ *
105
+ * 失败原因通过返回值的 `message` 报(核心在测试模型时直接用它做诊断提示),
106
+ * 插件不必自己存一份「上次失败」的状态。
100
107
  */
101
108
  chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
102
109
  dispose(): void;
103
- /** 上次 chatOnce 失败的原因(诊断用)。
104
- * 测试模型由 dsh-router 核心统一走 chatOnce 路径(账号池回退/冷却自动生效),
105
- * 插件只需把失败原因暴露出来供核心汇总;不实现则测试失败时只有通用提示。 */
106
- lastError?(): string | undefined;
107
110
  /** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
108
111
  generateLoginUrl?(): string | {
109
112
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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": {
@@ -45,6 +45,7 @@
45
45
  "lib/index.js",
46
46
  "lib/client.js",
47
47
  "lib/client-registry.js",
48
+ "lib/suppliers/*.js",
48
49
  "lib/types/**/*.d.ts",
49
50
  "src",
50
51
  "cordis.patch.yml",
@@ -53,13 +54,15 @@
53
54
  "LICENSE"
54
55
  ],
55
56
  "scripts": {
56
- "build": "node scripts/check-css.mjs && rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client",
57
- "build:client": "DSH_CLIENT_ID=dsh-router DSH_CLIENT_FILE=client.js vite build -c client/vite.config.ts && DSH_CLIENT_ID=dsh-external/dsh-router DSH_CLIENT_FILE=client-registry.js vite build -c client/vite.config.ts",
57
+ "build": "node scripts/check-css.mjs && node scripts/check-publish.mjs && rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client && node scripts/check-publish.mjs",
58
+ "build:client": "DSH_CLIENT_ID=dsh-router-core DSH_CLIENT_FILE=client.js vite build -c client/vite.config.ts && DSH_CLIENT_ID=dsh-external/dsh-router DSH_CLIENT_FILE=client-registry.js vite build -c client/vite.config.ts",
58
59
  "typecheck": "tsc --noEmit",
59
60
  "test": "node --test \"src/**/*.test.ts\"",
60
61
  "bundle": "tsdown",
61
62
  "watch": "tsdown --watch",
62
- "check:css": "node scripts/check-css.mjs"
63
+ "check:css": "node scripts/check-css.mjs",
64
+ "check:publish": "node scripts/check-publish.mjs --pack",
65
+ "prepublishOnly": "npm run build && node scripts/check-publish.mjs --pack"
63
66
  },
64
67
  "license": "MIT",
65
68
  "peerDependencies": {
@@ -673,11 +673,16 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
673
673
  <div className="dshr-linkSub">
674
674
  <span className="dshr-mono dshr-linkUid">{account.uid}</span>
675
675
  <span className="dshr-linkDot">·</span>
676
- <span className="dshr-mono dshr-linkCredits">{Math.ceil(account.credits)} 积分</span>
676
+ {/* credits < 0 = 插件还没拉到过积分(缓存也没值),
677
+ 别显示成「-1 积分」或直接吃掉整段 */}
678
+ <span className="dshr-mono dshr-linkCredits">
679
+ {account.credits < 0 ? '积分未知' : `${Math.ceil(account.credits)} 积分`}
680
+ </span>
681
+ {/* err_count 是限流退避等级(反复被限流的号递增,成功后清零) */}
677
682
  {account.err_count !== undefined && account.err_count > 0 && (
678
683
  <>
679
684
  <span className="dshr-linkDot">·</span>
680
- <span className="dshr-linkErr">连续错误 {account.err_count}</span>
685
+ <span className="dshr-linkErr">退避 {account.err_count} 级</span>
681
686
  </>
682
687
  )}
683
688
  {coolingText !== '' && <span className="dshr-reason dshr-linkCooling">{coolingText}</span>}
package/src/index.ts CHANGED
@@ -41,8 +41,11 @@ import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
41
41
  import { SupplierConfigStore } from './supplier-config.ts'
42
42
  import { CredentialStore } from './credential-store.ts'
43
43
 
44
- /** Plugin identity for cordis.yml rows. */
45
- export const name = 'dsh-router'
44
+ /**
45
+ * Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
46
+ * loader 拿配置行里的 `name` 去 `import(name)`,写错就是从 npm 装完加载不到。
47
+ */
48
+ export const name = 'dsh-router-core'
46
49
 
47
50
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
48
51
  export const inject = ['webServer', 'llm']
@@ -26,8 +26,15 @@ interface ChatTrace {
26
26
  lastState?: AccountState
27
27
  }
28
28
 
29
- /** 模型列表缓存有效期。 */
30
- const MODELS_TTL_MS = 60 * 1000
29
+ /**
30
+ * 模型列表缓存有效期。
31
+ *
32
+ * 取 10 分钟而不是 60s:模型列表本来很少变(上游上新/下线才动),而每次
33
+ * TTL 过期都会穿透到插件真打一次上游——实测冷路径 0.3~1.0s/供应商,组合页
34
+ * 取最慢那家就是 1s+ 的白屏。拉长 TTL 直接把这个穿透频率降一个量级;真要
35
+ * 立刻看新模型,点「获取模型」走 force 即可。
36
+ */
37
+ const MODELS_TTL_MS = 10 * 60 * 1000
31
38
 
32
39
  function openAIError(code: string, msg: string): Record<string, unknown> {
33
40
  return { error: { message: msg, type: 'api_error', code } }
@@ -444,6 +451,11 @@ export class Router {
444
451
  private store: SupplierConfigStore
445
452
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
446
453
  private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
454
+ /**
455
+ * 正在拉模型的供应商 → in-flight promise。
456
+ * 组合页会同时从多个入口触发同一个供应商的刷新,不去重上游就被按 N 倍打。
457
+ */
458
+ private modelsInflight = new Map<string, Promise<ModelWithEnabled[]>>()
447
459
  /** 请求日志出口(面板/宿主 logger)。 */
448
460
  private log: (msg: string) => void
449
461
  /** 用量统计(概览看板)。 */
@@ -592,37 +604,80 @@ export class Router {
592
604
  * 取某供应商的模型(核心统一缓存)。
593
605
  *
594
606
  * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
595
- * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
596
- * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
597
- * 按钮(force)触发。
607
+ * `/combos`(组合加模型)共用。
598
608
  *
599
- * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
600
- * 就得让供应商暴露 etag/版本号,目前没有这个需求。
609
+ * **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
610
+ * 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
611
+ * 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
612
+ * 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
613
+ * 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
614
+ *
615
+ * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
616
+ * (冷启动,没有旧值可给)。后者只发生一次。
601
617
  */
602
618
  async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
603
619
  const s = this.suppliers.find((x) => x.id === supplierId)
604
620
  if (s === undefined) return []
605
621
  const hit = this.modelsCache.get(supplierId)
606
- if (!force && hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
607
- const cfg = this.store.get(supplierId)
608
- const custom = new Set(cfg.custom)
609
- const disabled = new Set(cfg.disabled)
610
- const list = await Promise.resolve(s.listModels())
611
- const models: ModelWithEnabled[] = list.map((mm) => ({
612
- ...mm,
613
- enabled: !disabled.has(mm.id),
614
- custom: custom.has(mm.id) ? true : undefined,
615
- }))
616
- // 自定义模型(listModels 之外的)并入显示
617
- const seen = new Set(models.map((mm) => mm.id))
618
- for (const id of custom) {
619
- if (!seen.has(id)) {
620
- seen.add(id)
621
- models.push({ id, enabled: !disabled.has(id), custom: true })
622
- }
622
+ const fresh = hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS
623
+ if (!force && fresh) return hit!.models
624
+ // 有旧值(只是过期了):立刻返回旧值,后台刷新
625
+ if (!force && hit !== undefined) {
626
+ // 后台刷新的失败不能冒出来:这是 fire-and-forget,没人接
627
+ void this.refreshModels(supplierId, s).catch(() => {})
628
+ return hit.models
623
629
  }
624
- this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
625
- return models
630
+ return await this.refreshModels(supplierId, s)
631
+ }
632
+
633
+ /**
634
+ * 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
635
+ * 组合页会同时触发多个入口,不去重会把上游按 N 倍打(各家插件自己没做这层)。
636
+ *
637
+ * 失败时:**有旧值就退回旧值**(宁可显示几分钟前的列表,也别让面板空掉),
638
+ * **没旧值就把错误抛回去**——调用方(`supplierModels`)据此把该供应商整个
639
+ * 剔除,而不是显示一个「有 0 个模型」的空壳。
640
+ */
641
+ private refreshModels(supplierId: string, s: Supplier): Promise<ModelWithEnabled[]> {
642
+ const inflight = this.modelsInflight.get(supplierId)
643
+ if (inflight !== undefined) return inflight
644
+ const p = (async (): Promise<ModelWithEnabled[]> => {
645
+ const cfg = this.store.get(supplierId)
646
+ const custom = new Set(cfg.custom)
647
+ const disabled = new Set(cfg.disabled)
648
+ const list = await Promise.resolve(s.listModels())
649
+ const models: ModelWithEnabled[] = list.map((mm) => ({
650
+ ...mm,
651
+ enabled: !disabled.has(mm.id),
652
+ custom: custom.has(mm.id) ? true : undefined,
653
+ }))
654
+ // 自定义模型(listModels 之外的)并入显示
655
+ const seen = new Set(models.map((mm) => mm.id))
656
+ for (const id of custom) {
657
+ if (!seen.has(id)) {
658
+ seen.add(id)
659
+ models.push({ id, enabled: !disabled.has(id), custom: true })
660
+ }
661
+ }
662
+ const stale = this.modelsCache.get(supplierId)
663
+ // 拉到空列表当「上游抖了一下」:有旧值就保住旧值并返回它,别把面板清空。
664
+ // 真的一家模型都没有的供应商,第一次(无旧值)就该拿到空,不受影响。
665
+ if (models.length === 0 && stale !== undefined && stale.models.length > 0) {
666
+ return stale.models
667
+ }
668
+ this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
669
+ return models
670
+ })()
671
+ .catch((err: unknown) => {
672
+ const stale = this.modelsCache.get(supplierId)
673
+ if (stale !== undefined) return stale.models
674
+ throw err instanceof Error ? err : new Error(String(err))
675
+ })
676
+ .finally(() => {
677
+ this.modelsInflight.delete(supplierId)
678
+ })
679
+ this.modelsInflight.set(supplierId, p)
680
+ return p
626
681
  }
627
682
 
628
683
  /** 失效某供应商的模型缓存(增删改模型后调用)。 */
@@ -973,15 +1028,20 @@ export class Router {
973
1028
  rawBody: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
974
1029
  }
975
1030
  let served = false
1031
+ const trace: ChatTrace = { attempts: 0 }
976
1032
  try {
977
- served = await this.chatWithSupplier(s, req, sink)
1033
+ served = await this.chatWithSupplier(s, req, sink, trace)
978
1034
  } catch (err) {
979
1035
  return { ok: false, error: (err as Error).message }
980
1036
  }
981
1037
  // 供应商可能「服务了但写出错误响应」(如 opencode:唯一能处理该模型的供应商,错误自己上报)
982
1038
  if (served && sink.status() < 400) return { ok: true }
983
1039
  const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : ''
984
- const detail = fromSink !== '' ? fromSink : s.lastError?.()
1040
+ // 失败原因优先从上游响应体取,其次用 chatOnce 返回的 message(trace.lastError)。
1041
+ // 以前这里问插件的 lastError()——那是「最后一次失败」的可变全局,既可能串到
1042
+ // 别的账号/别的请求,也有插件压根没实现(codebuddy 就漏了,于是永远拿不到兜底)。
1043
+ // chatOnce 的返回值本身就带 message,用它更准,插件也不必再维护一份状态。
1044
+ const detail = fromSink !== '' ? fromSink : trace.lastError
985
1045
  return {
986
1046
  ok: false,
987
1047
  error: detail !== undefined && detail !== ''