dsh-router-core 0.3.15 → 0.3.16
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/docs/suppliers.md +11 -0
- package/lib/index.js +65 -28
- package/lib/suppliers/nvidia.js +2 -1
- package/lib/suppliers/opencode.js +2 -1
- package/lib/suppliers/openrouter.js +2 -1
- package/lib/types/suppliers/contract.d.ts +11 -1
- package/package.json +1 -1
- package/src/llm/adapter.test.ts +106 -4
- package/src/llm/adapter.ts +57 -18
- package/src/router/account-pool.test.ts +16 -0
- package/src/router/account-pool.ts +3 -0
- package/src/router/index.ts +12 -5
- package/src/suppliers/contract.ts +10 -0
- package/src/suppliers/http-state.test.ts +36 -0
- package/src/suppliers/http-state.ts +31 -0
- package/src/suppliers/nvidia/plugin.ts +2 -6
- package/src/suppliers/opencode/plugin.ts +3 -4
- package/src/suppliers/openrouter/plugin.ts +3 -7
package/docs/suppliers.md
CHANGED
|
@@ -80,12 +80,22 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
|
|
|
80
80
|
| `transport` | 网络/连接层失败(没拿到 HTTP 状态) |
|
|
81
81
|
| `unknown` | 说不清是什么错 |
|
|
82
82
|
| `no_such_model` | **这个模型不属于本供应商**(不是账号的失败) |
|
|
83
|
+
| `bad_request` | **请求本身不合法,被上游拒**(参数错/图片认不出/tool_call 配对断裂;不是账号的失败) |
|
|
83
84
|
|
|
84
85
|
**`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里会
|
|
85
86
|
把**这个号 × 这个模型**冷掉——组合里每有一个别人家的模型,js 就会对每个账号
|
|
86
87
|
都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
|
|
87
88
|
不属于我」一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
|
|
88
89
|
|
|
90
|
+
**`bad_request` 同理,而且更容易踩。** 同一个非法请求对池里**每个号**结果都相同
|
|
91
|
+
——冷号只会把「这条请求有问题」放大成「这个模型 30 秒内谁都别用」。2026-09-15
|
|
92
|
+
读图事故就是这么来的:适配器把带图的 tool-result 排错序导致 tool_call 配对断裂,
|
|
93
|
+
上游回 400/11148,插件当时归 `rate_limit` → money 组合两条腿同时被冷 30s →
|
|
94
|
+
**之后连纯文本请求**也全灭 503。判据:上游明确说「请求参数/形态不对」(如
|
|
95
|
+
CodeBuddy 的 `extError.type = invalid_request_error`,涵盖 11133/11135/11148/
|
|
96
|
+
11128)就报 `bad_request`;只是「上游忙/临时不可用」(11134)仍归 `rate_limit`,
|
|
97
|
+
那种情况换号确实管用。
|
|
98
|
+
|
|
89
99
|
**js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、要不要换号
|
|
90
100
|
都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
|
|
91
101
|
互相不一致的实现。
|
|
@@ -127,6 +137,7 @@ type ChatOnceResult =
|
|
|
127
137
|
| `session_dead` | **连接级冷却 30 分钟**(该号所有模型都停用,到期自愈;没有永久禁用——403 分不清凭证死活与风控误伤) |
|
|
128
138
|
| `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
|
|
129
139
|
| `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
|
|
140
|
+
| `bad_request` | **不惩罚账号**(请求非法不是号的错),核心直接跳到下一个模型/供应商 |
|
|
130
141
|
|
|
131
142
|
- 非流式若聚合后发现**空响应**(无 content / reasoning / tool_calls),请返回
|
|
132
143
|
`{ ok:false, state:'unknown' }` 而不是 `ok:true`——空响应交给核心换号/换模型回退,
|
package/lib/index.js
CHANGED
|
@@ -135,6 +135,10 @@ const RULES = {
|
|
|
135
135
|
no_such_model: {
|
|
136
136
|
cooldown: 0,
|
|
137
137
|
counts: false
|
|
138
|
+
},
|
|
139
|
+
bad_request: {
|
|
140
|
+
cooldown: 0,
|
|
141
|
+
counts: false
|
|
138
142
|
}
|
|
139
143
|
};
|
|
140
144
|
/**
|
|
@@ -1325,6 +1329,7 @@ function stripAlias(model, alias) {
|
|
|
1325
1329
|
*
|
|
1326
1330
|
* 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
|
|
1327
1331
|
* 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
|
|
1332
|
+
* 请求本身非法(bad_request)同理:换哪个模型都是同一个 400,等也是白等。
|
|
1328
1333
|
*/
|
|
1329
1334
|
const TRANSIENT_SETTLE_MS = 2e3;
|
|
1330
1335
|
function isTransient(state) {
|
|
@@ -2166,7 +2171,7 @@ var Router = class {
|
|
|
2166
2171
|
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
2167
2172
|
const r = await s.chatOnce("", req.lv ?? "auto", req);
|
|
2168
2173
|
if (!r.ok) {
|
|
2169
|
-
if (r.state === "no_such_model") return false;
|
|
2174
|
+
if (r.state === "no_such_model" || r.state === "bad_request") return false;
|
|
2170
2175
|
if (trace !== void 0) trace.lastState = r.state;
|
|
2171
2176
|
pool.noteFailure("", req.model, r.state, r.message);
|
|
2172
2177
|
return false;
|
|
@@ -2187,8 +2192,11 @@ var Router = class {
|
|
|
2187
2192
|
tried.add(uid);
|
|
2188
2193
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
2189
2194
|
if (!r.ok) {
|
|
2190
|
-
if (r.state === "no_such_model") {
|
|
2191
|
-
if (trace !== void 0)
|
|
2195
|
+
if (r.state === "no_such_model" || r.state === "bad_request") {
|
|
2196
|
+
if (trace !== void 0) {
|
|
2197
|
+
trace.lastError = r.state;
|
|
2198
|
+
trace.lastState = r.state;
|
|
2199
|
+
}
|
|
2192
2200
|
return false;
|
|
2193
2201
|
}
|
|
2194
2202
|
pool.noteFailure(uid, req.model, r.state, r.message);
|
|
@@ -2298,14 +2306,40 @@ var Router = class {
|
|
|
2298
2306
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2299
2307
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2300
2308
|
*/
|
|
2309
|
+
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
2310
|
+
const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
|
|
2301
2311
|
async function wireMessages(options, system, attachments) {
|
|
2302
2312
|
const out = [];
|
|
2313
|
+
/**
|
|
2314
|
+
* 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
|
|
2315
|
+
* user 消息补在后面。
|
|
2316
|
+
*
|
|
2317
|
+
* 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
|
|
2318
|
+
* 自己的 user-role 消息**里(读两张图 = 两条消息)。若每条消息各发一个
|
|
2319
|
+
* `user(图)`,wire 就成 `assistant → tool → user(图) → tool → user(图)`——
|
|
2320
|
+
* 中间那条 user 又把 tool_call/tool_result 配对接断了,上游照样 400/11148
|
|
2321
|
+
* (实测:tool,user,tool,user = 400;tool,tool,user(两图) = 200)。
|
|
2322
|
+
* 官方 dsh-llm-deepseek 的 serializeMessagesWithImages 同样这么做。
|
|
2323
|
+
*/
|
|
2324
|
+
let pendingToolImages = [];
|
|
2325
|
+
const flushToolImages = () => {
|
|
2326
|
+
if (pendingToolImages.length === 0) return;
|
|
2327
|
+
out.push({
|
|
2328
|
+
role: "user",
|
|
2329
|
+
content: [{
|
|
2330
|
+
type: "text",
|
|
2331
|
+
text: TOOL_RESULT_IMAGE_TEXT
|
|
2332
|
+
}, ...pendingToolImages]
|
|
2333
|
+
});
|
|
2334
|
+
pendingToolImages = [];
|
|
2335
|
+
};
|
|
2303
2336
|
if (typeof system === "string" && system !== "") out.push({
|
|
2304
2337
|
role: "system",
|
|
2305
2338
|
content: system
|
|
2306
2339
|
});
|
|
2307
2340
|
for (const message of options.messages) {
|
|
2308
2341
|
if (message.role === "system") {
|
|
2342
|
+
flushToolImages();
|
|
2309
2343
|
out.push({
|
|
2310
2344
|
role: "system",
|
|
2311
2345
|
content: flattenText(message.content)
|
|
@@ -2313,6 +2347,7 @@ async function wireMessages(options, system, attachments) {
|
|
|
2313
2347
|
continue;
|
|
2314
2348
|
}
|
|
2315
2349
|
if (message.role === "assistant") {
|
|
2350
|
+
flushToolImages();
|
|
2316
2351
|
const text = flattenText(message.content);
|
|
2317
2352
|
const toolCalls = message.content.filter((b) => b.type === "tool-call").map((b) => ({
|
|
2318
2353
|
id: b.id,
|
|
@@ -2332,32 +2367,42 @@ async function wireMessages(options, system, attachments) {
|
|
|
2332
2367
|
continue;
|
|
2333
2368
|
}
|
|
2334
2369
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
content: parts
|
|
2340
|
-
});
|
|
2341
|
-
else if (toolResults.length === 0) out.push({
|
|
2370
|
+
const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
|
|
2371
|
+
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
2372
|
+
flushToolImages();
|
|
2373
|
+
out.push({
|
|
2342
2374
|
role: "user",
|
|
2343
|
-
content:
|
|
2375
|
+
content: compactUserContent(ownParts)
|
|
2344
2376
|
});
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2377
|
+
}
|
|
2378
|
+
for (const result of toolResults) {
|
|
2379
|
+
const parts = await contentParts(result.content, attachments, options.signal);
|
|
2380
|
+
const imageParts = parts.filter((p) => p.type !== "text");
|
|
2381
|
+
const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
|
|
2382
|
+
out.push({
|
|
2383
|
+
role: "tool",
|
|
2384
|
+
tool_call_id: result.toolCallId,
|
|
2349
2385
|
content: text
|
|
2350
2386
|
});
|
|
2387
|
+
pendingToolImages.push(...imageParts);
|
|
2351
2388
|
}
|
|
2352
|
-
for (const result of toolResults) out.push({
|
|
2353
|
-
role: "tool",
|
|
2354
|
-
tool_call_id: result.toolCallId,
|
|
2355
|
-
content: flattenText(result.content)
|
|
2356
|
-
});
|
|
2357
2389
|
}
|
|
2390
|
+
flushToolImages();
|
|
2358
2391
|
return out;
|
|
2359
2392
|
}
|
|
2360
2393
|
/**
|
|
2394
|
+
* 纯文本 parts 收成紧凑字符串 wire 形式(无图时不回归成数组)。
|
|
2395
|
+
* 与官方适配器的 userContent 同义:只要有非 text part 就保持数组,否则 join。
|
|
2396
|
+
*/
|
|
2397
|
+
function compactUserContent(parts) {
|
|
2398
|
+
const texts = [];
|
|
2399
|
+
for (const p of parts) {
|
|
2400
|
+
if (p.type !== "text") return parts;
|
|
2401
|
+
texts.push(p.text ?? "");
|
|
2402
|
+
}
|
|
2403
|
+
return texts.join("");
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2361
2406
|
* 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
|
|
2362
2407
|
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
2363
2408
|
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
@@ -2470,14 +2515,6 @@ function applyReasoning(body, effort) {
|
|
|
2470
2515
|
function flattenText(blocks) {
|
|
2471
2516
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
2472
2517
|
}
|
|
2473
|
-
/** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
|
|
2474
|
-
function hasImageBlock(blocks) {
|
|
2475
|
-
for (const b of blocks) {
|
|
2476
|
-
if (b.type === "image") return true;
|
|
2477
|
-
if (b.type === "tool-result" && hasImageBlock(b.content)) return true;
|
|
2478
|
-
}
|
|
2479
|
-
return false;
|
|
2480
|
-
}
|
|
2481
2518
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
2482
2519
|
/**
|
|
2483
2520
|
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
package/lib/suppliers/nvidia.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
4
|
//#region src/suppliers/nvidia/plugin.ts
|
|
@@ -308,7 +309,7 @@ function factory(env) {
|
|
|
308
309
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`;
|
|
309
310
|
return {
|
|
310
311
|
ok: false,
|
|
311
|
-
state: upstream.status
|
|
312
|
+
state: stateFromHttpStatus(upstream.status),
|
|
312
313
|
message: msg
|
|
313
314
|
};
|
|
314
315
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
|
|
1
2
|
//#region src/suppliers/opencode/plugin.ts
|
|
2
3
|
const id = "opencode";
|
|
3
4
|
const name = "OpenCode Free";
|
|
@@ -106,7 +107,7 @@ function factory(env) {
|
|
|
106
107
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`;
|
|
107
108
|
return {
|
|
108
109
|
ok: false,
|
|
109
|
-
state: upstream.status
|
|
110
|
+
state: stateFromHttpStatus(upstream.status),
|
|
110
111
|
message: msg
|
|
111
112
|
};
|
|
112
113
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
|
|
1
2
|
//#region src/suppliers/openrouter/plugin.ts
|
|
2
3
|
const id = "openrouter";
|
|
3
4
|
const name = "OpenRouter";
|
|
@@ -196,7 +197,7 @@ function factory(env) {
|
|
|
196
197
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`;
|
|
197
198
|
return {
|
|
198
199
|
ok: false,
|
|
199
|
-
state: upstream.status
|
|
200
|
+
state: stateFromHttpStatus(upstream.status),
|
|
200
201
|
message: msg
|
|
201
202
|
};
|
|
202
203
|
}
|
|
@@ -56,7 +56,17 @@ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'una
|
|
|
56
56
|
* 否则组合里每有一个别人家的模型,就会给无关账号攒一次错误,攒够阈值
|
|
57
57
|
* 把它冷却掉(历史上正是这个坑)。
|
|
58
58
|
*/
|
|
59
|
-
| 'no_such_model'
|
|
59
|
+
| 'no_such_model'
|
|
60
|
+
/**
|
|
61
|
+
* 请求本身不合法,被上游拒(参数错、图片格式不支持、tool_call 配对断裂…)。
|
|
62
|
+
*
|
|
63
|
+
* **不是账号的错**:同一个请求对池里每个号都会失败,冷号只会让好号白白
|
|
64
|
+
* 失效,把一次「这条请求有问题」放大成「这个模型 30 秒内谁都别用」——
|
|
65
|
+
* 组合里两条腿同时被冷就是这个机制(2026-09-15 读图 11148 事故)。
|
|
66
|
+
* 核心据此**不惩罚账号**,直接跳出本供应商换下一个模型/供应商。
|
|
67
|
+
* 与 `no_such_model` 的区别:那个是「模型不归我」,这个是「请求我不认」。
|
|
68
|
+
*/
|
|
69
|
+
| 'bad_request';
|
|
60
70
|
/** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
|
|
61
71
|
export interface SupplierAccountNow {
|
|
62
72
|
uid: 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.16",
|
|
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": {
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -620,8 +620,110 @@ test('wire:无 attachments 时图片回退占位文本,绝不静默丢图',
|
|
|
620
620
|
messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }],
|
|
621
621
|
}, undefined)
|
|
622
622
|
const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
assert.
|
|
626
|
-
assert.ok(
|
|
623
|
+
// 读不到字节时 parts 只剩一个 text 占位 → 收成紧凑字符串(无图可发,别硬撑数组)
|
|
624
|
+
const text = typeof user?.content === 'string' ? user.content : JSON.stringify(user?.content)
|
|
625
|
+
assert.ok(text.includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
|
|
626
|
+
assert.ok(!text.includes('image_url'), '读不到字节时不能伪造图片 part')
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
test('wire:一步两个 read_image(每条一个 user 消息,同现场)→ tool 消息紧跟 assistant,图片合并到末尾', async () => {
|
|
630
|
+
const ref = {
|
|
631
|
+
attachmentId: 'sha256:abcdef1234567890',
|
|
632
|
+
mediaType: 'image/png',
|
|
633
|
+
bytes: 11,
|
|
634
|
+
width: 4,
|
|
635
|
+
height: 2,
|
|
636
|
+
}
|
|
637
|
+
// **关键**:harness 把每个 tool 结果放**各自的** user-role 消息里(现场就是
|
|
638
|
+
// 两条独立消息)。这是第一版修复漏掉的形态——按消息各发一条 user(图) 会得到
|
|
639
|
+
// `tool,user,tool,user`,上游照样 400/11148。必须跨消息攒图、发完整串再补一条。
|
|
640
|
+
const body = await captureBodyWith({
|
|
641
|
+
messages: [
|
|
642
|
+
{ role: 'user', content: [{ type: 'text', text: '读这两张图' }] },
|
|
643
|
+
{
|
|
644
|
+
role: 'assistant',
|
|
645
|
+
content: [
|
|
646
|
+
{ type: 'tool-call', id: 'c1', name: 'read_image', arguments: '{}' },
|
|
647
|
+
{ type: 'tool-call', id: 'c2', name: 'read_image', arguments: '{}' },
|
|
648
|
+
],
|
|
649
|
+
},
|
|
650
|
+
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'tree_assembly.png' }, { type: 'image', attachment: ref }] }] },
|
|
651
|
+
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ type: 'text', text: 'tree_part.png' }, { type: 'image', attachment: ref }] }] },
|
|
652
|
+
],
|
|
653
|
+
}, fakeAttachments())
|
|
654
|
+
const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }>; content: unknown }>
|
|
655
|
+
assertToolPairingIntact(messages)
|
|
656
|
+
|
|
657
|
+
// 两条 tool 消息必须**连续**紧跟 assistant,各自的文本标签留在自己的 tool 里
|
|
658
|
+
const ai = messages.findIndex((m) => m.role === 'assistant')
|
|
659
|
+
assert.deepEqual(messages.slice(ai + 1, ai + 3).map((m) => m.role), ['tool', 'tool'], '两条 tool 必须连续,中间不得插 user')
|
|
660
|
+
assert.equal(messages[ai + 1]?.content, 'tree_assembly.png', '第一条 tool 带自己的标签')
|
|
661
|
+
assert.equal(messages[ai + 2]?.content, 'tree_part.png', '第二条 tool 带自己的标签')
|
|
662
|
+
|
|
663
|
+
// 图片合并到全部 tool 之后的那**一条** user 消息里(两张都在,不静默丢图)
|
|
664
|
+
const userWithImage = messages.find((m) => m.role === 'user' && Array.isArray(m.content))
|
|
665
|
+
assert.ok(userWithImage !== undefined, '带图内容必须序列化成 user 消息的 content 数组')
|
|
666
|
+
const parts = userWithImage.content as Array<{ type: string; text?: string }>
|
|
667
|
+
assert.equal(parts.filter((p) => p.type === 'image_url').length, 2, '两张图都要发出去')
|
|
668
|
+
const lastTool = messages.map((m) => m.role).lastIndexOf('tool')
|
|
669
|
+
const imageAt = messages.indexOf(userWithImage)
|
|
670
|
+
assert.ok(imageAt > lastTool, `带图 user 消息必须排在全部 tool 消息之后(实际 tool 末位 ${lastTool},图在 ${imageAt})`)
|
|
671
|
+
// 整段 wire 里 user(带图) 只出现一次:插两次就会把 tool 串切断
|
|
672
|
+
const imageUsers = messages.filter((m) => m.role === 'user' && Array.isArray(m.content))
|
|
673
|
+
assert.equal(imageUsers.length, 1, '带图 user 消息只能有一条(多条 = 切断了 tool 配对)')
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 为什么要有这一组:`read_image` 的结果是**带图的 tool-result**。适配器曾把
|
|
678
|
+
* 图片和文本一起提成一条 `user` 消息、排在 `tool` 消息**之前**,于是 wire 里
|
|
679
|
+
* 出现 `assistant(tool_calls) → user(image) → tool`。上游判定 tool_call 与
|
|
680
|
+
* tool_result 失配,回 400 网关码 11148「tool calls and tool results do not
|
|
681
|
+
* match」(实测直连复现,把 tool 提前即 200)。
|
|
682
|
+
*
|
|
683
|
+
* 后果被池策略放大:11148 当时归 `unknown` → 瞬时冷却 30s;组合 money 两条腿
|
|
684
|
+
* 都用同一份历史,一条带图请求就把它俩同时冷掉,之后**连纯文本请求**也全灭
|
|
685
|
+
* 503(用户看到的现象)。所以这条不变式必须在 wire 层锁死:
|
|
686
|
+
* **assistant 的每个 tool_call 后面必须紧跟配套 tool 消息,中间不得插任何
|
|
687
|
+
* 其它角色**。
|
|
688
|
+
*/
|
|
689
|
+
|
|
690
|
+
/** 断言 wire 里 tool_call / tool_result 严格配对且不被插队。 */
|
|
691
|
+
function assertToolPairingIntact(messages: Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>): void {
|
|
692
|
+
for (let i = 0; i < messages.length; i++) {
|
|
693
|
+
const m = messages[i]
|
|
694
|
+
if (m?.role !== 'assistant' || (m.tool_calls ?? []).length === 0) continue
|
|
695
|
+
const wanted = new Set((m.tool_calls ?? []).map((c) => c.id))
|
|
696
|
+
// 紧随其后的必须**连续**是配套 tool 消息,中途混入任何别的角色都算失配
|
|
697
|
+
// (现场踩的就是 user(图) 插在两条 tool 之间,wire 成 tool,user,tool,user)
|
|
698
|
+
let j = i + 1
|
|
699
|
+
const satisfied = new Set<string>()
|
|
700
|
+
while (j < messages.length && messages[j]?.role === 'tool') {
|
|
701
|
+
const id = messages[j]?.tool_call_id ?? ''
|
|
702
|
+
assert.ok(wanted.has(id), `第 ${j} 条 tool 消息的 tool_call_id ${JSON.stringify(id)} 不在上一条 assistant 的 tool_calls 里`)
|
|
703
|
+
satisfied.add(id)
|
|
704
|
+
j += 1
|
|
705
|
+
}
|
|
706
|
+
assert.equal(
|
|
707
|
+
satisfied.size,
|
|
708
|
+
wanted.size,
|
|
709
|
+
`assistant 的 tool_calls 未被紧邻的 tool 消息配齐`
|
|
710
|
+
+ `(中间插了别的角色:${messages.slice(i + 1, j).map((x) => x.role).join(',') || '—'})`,
|
|
711
|
+
)
|
|
712
|
+
for (const id of wanted) {
|
|
713
|
+
assert.ok(satisfied.has(id), `tool_call ${id} 没有配套 tool 消息(上游会回 11148)`)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
test('wire:纯文本 tool-result 不因图片逻辑改变顺序(tool 紧跟 assistant)', async () => {
|
|
719
|
+
const body = await captureBodyWith({
|
|
720
|
+
messages: [
|
|
721
|
+
{ role: 'user', content: [{ type: 'text', text: '跑一下' }] },
|
|
722
|
+
{ role: 'assistant', content: [{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
723
|
+
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] }] },
|
|
724
|
+
],
|
|
725
|
+
}, fakeAttachments())
|
|
726
|
+
const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>
|
|
727
|
+
assertToolPairingIntact(messages)
|
|
728
|
+
assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool 结果仍紧跟 assistant')
|
|
627
729
|
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -78,18 +78,40 @@ export interface RouterAdapterSource {
|
|
|
78
78
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
79
79
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
80
80
|
*/
|
|
81
|
+
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
82
|
+
const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
|
|
83
|
+
|
|
81
84
|
async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
|
|
82
85
|
const out: Array<Record<string, unknown>> = []
|
|
86
|
+
/**
|
|
87
|
+
* 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
|
|
88
|
+
* user 消息补在后面。
|
|
89
|
+
*
|
|
90
|
+
* 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
|
|
91
|
+
* 自己的 user-role 消息**里(读两张图 = 两条消息)。若每条消息各发一个
|
|
92
|
+
* `user(图)`,wire 就成 `assistant → tool → user(图) → tool → user(图)`——
|
|
93
|
+
* 中间那条 user 又把 tool_call/tool_result 配对接断了,上游照样 400/11148
|
|
94
|
+
* (实测:tool,user,tool,user = 400;tool,tool,user(两图) = 200)。
|
|
95
|
+
* 官方 dsh-llm-deepseek 的 serializeMessagesWithImages 同样这么做。
|
|
96
|
+
*/
|
|
97
|
+
let pendingToolImages: Array<Record<string, unknown>> = []
|
|
98
|
+
const flushToolImages = (): void => {
|
|
99
|
+
if (pendingToolImages.length === 0) return
|
|
100
|
+
out.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
|
|
101
|
+
pendingToolImages = []
|
|
102
|
+
}
|
|
83
103
|
if (typeof system === 'string' && system !== '') {
|
|
84
104
|
out.push({ role: 'system', content: system })
|
|
85
105
|
}
|
|
86
106
|
for (const message of options.messages) {
|
|
87
107
|
if (message.role === 'system') {
|
|
88
108
|
// 手写调用也可能把 system 塞进 messages(one-shot 场景),照旧支持
|
|
109
|
+
flushToolImages()
|
|
89
110
|
out.push({ role: 'system', content: flattenText(message.content) })
|
|
90
111
|
continue
|
|
91
112
|
}
|
|
92
113
|
if (message.role === 'assistant') {
|
|
114
|
+
flushToolImages()
|
|
93
115
|
const text = flattenText(message.content)
|
|
94
116
|
const toolCalls = message.content
|
|
95
117
|
.filter((b) => b.type === 'tool-call')
|
|
@@ -108,24 +130,50 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
108
130
|
}
|
|
109
131
|
// user / tool-result
|
|
110
132
|
const toolResults = message.content.filter((b) => b.type === 'tool-result')
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
133
|
+
// 这条消息自己的(非 tool-result 的)user 部分:文本 + 图片。
|
|
134
|
+
const ownParts = await contentParts(
|
|
135
|
+
message.content.filter((b) => b.type !== 'tool-result'),
|
|
136
|
+
attachments,
|
|
137
|
+
options.signal,
|
|
138
|
+
)
|
|
139
|
+
// 有实质 user 内容(或压根没有 tool 结果)就先发这条 user 消息;
|
|
140
|
+
// 只有 tool 结果的「纯工具」消息不发空 user,图片留到 pending 里合并。
|
|
141
|
+
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
142
|
+
flushToolImages()
|
|
143
|
+
out.push({ role: 'user', content: compactUserContent(ownParts) })
|
|
119
144
|
}
|
|
120
145
|
for (const result of toolResults) {
|
|
146
|
+
// **tool 消息必须紧跟 assistant(tool_calls)**:中间插进任何 user 消息,
|
|
147
|
+
// 上游都会判定 tool_call 与 tool_result 失配 —— codebuddy 直接 400 网关码
|
|
148
|
+
// 11148「tool calls and tool results do not match」。
|
|
149
|
+
// 图片不能放进 tool 消息(实测模型会把两张不同的图认成同一张),
|
|
150
|
+
// 也不能插在两条 tool 之间(同样 400),所以先攒起来、发完整串再补一条 user。
|
|
151
|
+
const parts = await contentParts(result.content, attachments, options.signal)
|
|
152
|
+
const imageParts = parts.filter((p) => p.type !== 'text')
|
|
153
|
+
const text = parts.filter((p) => p.type === 'text').map((p) => (p as { text?: string }).text ?? '').join('')
|
|
121
154
|
// 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
|
|
122
155
|
// 以为工具真的打印了那句话(9router 也是补 content: "")。
|
|
123
|
-
out.push({ role: 'tool', tool_call_id: result.toolCallId, content:
|
|
156
|
+
out.push({ role: 'tool', tool_call_id: result.toolCallId, content: text })
|
|
157
|
+
pendingToolImages.push(...imageParts)
|
|
124
158
|
}
|
|
125
159
|
}
|
|
160
|
+
flushToolImages()
|
|
126
161
|
return out
|
|
127
162
|
}
|
|
128
163
|
|
|
164
|
+
/**
|
|
165
|
+
* 纯文本 parts 收成紧凑字符串 wire 形式(无图时不回归成数组)。
|
|
166
|
+
* 与官方适配器的 userContent 同义:只要有非 text part 就保持数组,否则 join。
|
|
167
|
+
*/
|
|
168
|
+
function compactUserContent(parts: Array<Record<string, unknown>>): string | Array<Record<string, unknown>> {
|
|
169
|
+
const texts: string[] = []
|
|
170
|
+
for (const p of parts) {
|
|
171
|
+
if (p.type !== 'text') return parts
|
|
172
|
+
texts.push((p as { text?: string }).text ?? '')
|
|
173
|
+
}
|
|
174
|
+
return texts.join('')
|
|
175
|
+
}
|
|
176
|
+
|
|
129
177
|
/**
|
|
130
178
|
* 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
|
|
131
179
|
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
@@ -238,15 +286,6 @@ function flattenText(blocks: readonly { type: string; text?: string }[]): string
|
|
|
238
286
|
return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
|
|
239
287
|
}
|
|
240
288
|
|
|
241
|
-
/** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
|
|
242
|
-
function hasImageBlock(blocks: readonly { type: string }[]): boolean {
|
|
243
|
-
for (const b of blocks) {
|
|
244
|
-
if (b.type === 'image') return true
|
|
245
|
-
if (b.type === 'tool-result' && hasImageBlock((b as unknown as { content: ReadonlyArray<{ type: string }> }).content)) return true
|
|
246
|
-
}
|
|
247
|
-
return false
|
|
248
|
-
}
|
|
249
|
-
|
|
250
289
|
interface OpenBlock {
|
|
251
290
|
index: number
|
|
252
291
|
kind: 'text' | 'reasoning' | 'tool-call'
|
|
@@ -380,6 +380,22 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
|
|
|
380
380
|
assert.equal(out?.err_count, 0)
|
|
381
381
|
})
|
|
382
382
|
|
|
383
|
+
/**
|
|
384
|
+
* 为什么要有这一条:请求本身非法(如 11133 参数错、11148 tool_call 配对断裂)
|
|
385
|
+
* 对池里**每个号**结果都相同。曾把这类归 rate_limit → 瞬冷 30s,于是一次
|
|
386
|
+
* 带图请求把 money 组合两条腿同时冷掉,之后连纯文本请求也全灭 503。
|
|
387
|
+
* 不惩罚账号,好号才不会替一条坏请求陪葬。
|
|
388
|
+
*/
|
|
389
|
+
test('bad_request 不惩罚账号:不冷却、不计数(一次坏请求不该冷掉整池)', () => {
|
|
390
|
+
const p = pool()
|
|
391
|
+
const list = accs(['a'])
|
|
392
|
+
for (let i = 0; i < 10; i++) p.noteFailure('a', 'm1', 'bad_request', 'codebuddy 11148: tool calls do not match')
|
|
393
|
+
assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a', '号必须仍然可用')
|
|
394
|
+
const out = p.decorate(list, 'm1')[0]
|
|
395
|
+
assert.equal(out?.cooling, false, '请求非法不该把号标成冷却')
|
|
396
|
+
assert.equal(out?.err_count, 0)
|
|
397
|
+
})
|
|
398
|
+
|
|
383
399
|
test('状态表:各 AccountState 的处置符合预期', () => {
|
|
384
400
|
const p = pool()
|
|
385
401
|
const list = accs(['rate', 'quota', 'dead', 'unavail'])
|
|
@@ -76,6 +76,9 @@ const RULES: Record<AccountState, Rule> = {
|
|
|
76
76
|
unknown: { cooldown: 'transient', counts: false },
|
|
77
77
|
// 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
|
|
78
78
|
no_such_model: { cooldown: 0, counts: false },
|
|
79
|
+
// 请求本身非法(上游拒收这个 payload):同一个请求对池里每个号都会失败,
|
|
80
|
+
// 冷号只会把「这条请求有问题」放大成「这个模型谁都别用」。不惩罚账号。
|
|
81
|
+
bad_request: { cooldown: 0, counts: false },
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/**
|
package/src/router/index.ts
CHANGED
|
@@ -71,6 +71,7 @@ function stripAlias(model: string, alias: string): string {
|
|
|
71
71
|
*
|
|
72
72
|
* 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
|
|
73
73
|
* 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
|
|
74
|
+
* 请求本身非法(bad_request)同理:换哪个模型都是同一个 400,等也是白等。
|
|
74
75
|
*/
|
|
75
76
|
const TRANSIENT_SETTLE_MS = 2_000
|
|
76
77
|
|
|
@@ -998,7 +999,8 @@ export class Router {
|
|
|
998
999
|
if (s.accounts !== undefined && s.accounts().length === 0) {
|
|
999
1000
|
const r = await s.chatOnce('', req.lv ?? 'auto', req)
|
|
1000
1001
|
if (!r.ok) {
|
|
1001
|
-
|
|
1002
|
+
// 模型不属于本供应商、或请求本身被上游拒收:换供应商,不记账
|
|
1003
|
+
if (r.state === 'no_such_model' || r.state === 'bad_request') return false
|
|
1002
1004
|
if (trace !== undefined) trace.lastState = r.state
|
|
1003
1005
|
pool.noteFailure('', req.model, r.state, r.message)
|
|
1004
1006
|
return false
|
|
@@ -1028,10 +1030,15 @@ export class Router {
|
|
|
1028
1030
|
tried.add(uid)
|
|
1029
1031
|
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
1030
1032
|
if (!r.ok) {
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1033
|
+
// 模型不属于本供应商、或请求本身被上游拒收:整个供应商都跳过,
|
|
1034
|
+
// 换号重试没有意义,也不能记在账号头上(否则无关账号会被冷掉)。
|
|
1035
|
+
// bad_request(如 tool_call 配对断裂、图片格式不认)对每个号结果相同,
|
|
1036
|
+
// 冷号只会把一次「请求有问题」放大成「这个模型谁都别用」。
|
|
1037
|
+
if (r.state === 'no_such_model' || r.state === 'bad_request') {
|
|
1038
|
+
if (trace !== undefined) {
|
|
1039
|
+
trace.lastError = r.state
|
|
1040
|
+
trace.lastState = r.state
|
|
1041
|
+
}
|
|
1035
1042
|
return false
|
|
1036
1043
|
}
|
|
1037
1044
|
pool.noteFailure(uid, req.model, r.state, r.message)
|
|
@@ -67,6 +67,16 @@ export type AccountState =
|
|
|
67
67
|
* 把它冷却掉(历史上正是这个坑)。
|
|
68
68
|
*/
|
|
69
69
|
| 'no_such_model'
|
|
70
|
+
/**
|
|
71
|
+
* 请求本身不合法,被上游拒(参数错、图片格式不支持、tool_call 配对断裂…)。
|
|
72
|
+
*
|
|
73
|
+
* **不是账号的错**:同一个请求对池里每个号都会失败,冷号只会让好号白白
|
|
74
|
+
* 失效,把一次「这条请求有问题」放大成「这个模型 30 秒内谁都别用」——
|
|
75
|
+
* 组合里两条腿同时被冷就是这个机制(2026-09-15 读图 11148 事故)。
|
|
76
|
+
* 核心据此**不惩罚账号**,直接跳出本供应商换下一个模型/供应商。
|
|
77
|
+
* 与 `no_such_model` 的区别:那个是「模型不归我」,这个是「请求我不认」。
|
|
78
|
+
*/
|
|
79
|
+
| 'bad_request'
|
|
70
80
|
|
|
71
81
|
/** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
|
|
72
82
|
export interface SupplierAccountNow {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 状态码 → AccountState 公共映射测试。
|
|
3
|
+
*
|
|
4
|
+
* 存在的原因:这条映射曾在内置三家供应商里各写一份,于是「哪个错误算账号的错」
|
|
5
|
+
* 有三份实现——bad_request 加固差点只补上 codebuddy,三家内置仍是 4xx→unknown
|
|
6
|
+
* →瞬冷 30s(同型事故)。收敛成一处后,这一份测试同时守住三家。
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { stateFromHttpStatus } from './http-state.ts'
|
|
11
|
+
|
|
12
|
+
test('映射:账号侧错误(换号会好的)归各自的冷却状态', () => {
|
|
13
|
+
assert.equal(stateFromHttpStatus(429), 'rate_limit', '限流 = 换号有用')
|
|
14
|
+
assert.equal(stateFromHttpStatus(401), 'session_dead', '凭证失效')
|
|
15
|
+
assert.equal(stateFromHttpStatus(403), 'session_dead', '403 分不清死活与风控,按连接级冷却')
|
|
16
|
+
assert.equal(stateFromHttpStatus(402), 'quota', '要钱/额度不足')
|
|
17
|
+
assert.equal(stateFromHttpStatus(404), 'unavailable', '上游没有这个端点/服务下线')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('映射:请求侧 4xx 归 bad_request —— 同一个请求对每个号都一样失败,不该冷号', () => {
|
|
21
|
+
for (const status of [400, 405, 409, 413, 422, 499]) {
|
|
22
|
+
assert.equal(stateFromHttpStatus(status), 'bad_request', `${status} 是请求形态问题,不是账号的错`)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('映射:5xx 与异常码仍归 unknown(上游故障侧,瞬冷换号是对的)', () => {
|
|
27
|
+
for (const status of [500, 502, 503, 504, 599]) {
|
|
28
|
+
assert.equal(stateFromHttpStatus(status), 'unknown', `${status} 是上游故障`)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('映射:绝不返回 ok —— 这个函数只在非 2xx 分支被调用', () => {
|
|
33
|
+
for (const status of [400, 401, 402, 403, 404, 429, 500]) {
|
|
34
|
+
assert.notEqual(stateFromHttpStatus(status), 'ok')
|
|
35
|
+
}
|
|
36
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 状态码 → AccountState 的公共映射(内置三家供应商共用)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么收敛成一处:opencode / openrouter / nvidia 本来各写一份逐字相同的
|
|
5
|
+
* 三元表达式,于是「哪个状态算谁的错」这件事有三份实现,改一处就漏两处
|
|
6
|
+
* (bad_request 就是这么差点只补了 codebuddy)。
|
|
7
|
+
*
|
|
8
|
+
* 关键判据:**这个错误换一个账号会不会好?**
|
|
9
|
+
* - 会好(限流/额度/凭证)→ 是账号的错,交给核心冷却/换号
|
|
10
|
+
* - 不会好(请求参数、模型不存在)→ 不是账号的错,绝不能冷号
|
|
11
|
+
* 否则一次坏请求会把整个池冷掉(2026-09-15 codebuddy 读图事故的同型:
|
|
12
|
+
* 组合两条腿同时被冷,之后连正常文本请求也全灭 503)
|
|
13
|
+
*/
|
|
14
|
+
import type { AccountState } from './contract.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 非 2xx 上游响应 → 语义状态。
|
|
18
|
+
*
|
|
19
|
+
* 4xx 里除了「限流/凭证/不存在」这三种明确归属账号的,剩下的都是**请求本身
|
|
20
|
+
* 有问题**(400 参数错、413 太大、422 语义错…)——同一个请求对池里每个号结果
|
|
21
|
+
* 都相同,归 `bad_request`(核心不惩罚账号,直接换下一个模型)。
|
|
22
|
+
* 5xx / 说不清的仍归 `unknown`(上游故障侧,瞬冷换号是对的)。
|
|
23
|
+
*/
|
|
24
|
+
export function stateFromHttpStatus(status: number): AccountState {
|
|
25
|
+
if (status === 429) return 'rate_limit'
|
|
26
|
+
if (status === 401 || status === 403) return 'session_dead'
|
|
27
|
+
if (status === 404) return 'unavailable'
|
|
28
|
+
if (status === 402) return 'quota'
|
|
29
|
+
if (status >= 400 && status < 500) return 'bad_request'
|
|
30
|
+
return 'unknown'
|
|
31
|
+
}
|
|
@@ -18,6 +18,7 @@ import { join } from 'node:path'
|
|
|
18
18
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
19
19
|
import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
|
|
20
20
|
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
21
|
+
import { stateFromHttpStatus } from '../http-state.ts'
|
|
21
22
|
|
|
22
23
|
export const id = 'nvidia'
|
|
23
24
|
export const name = 'NVIDIA NIM'
|
|
@@ -287,12 +288,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
287
288
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
288
289
|
const text = await upstream.text().catch(() => '')
|
|
289
290
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
290
|
-
|
|
291
|
-
upstream.status === 429 ? 'rate_limit'
|
|
292
|
-
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
293
|
-
: upstream.status === 404 ? 'unavailable'
|
|
294
|
-
: 'unknown'
|
|
295
|
-
return { ok: false, state, message: msg }
|
|
291
|
+
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
296
292
|
}
|
|
297
293
|
|
|
298
294
|
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 面板只显示模型卡片(连接池、签到由能力检测自动隐藏)。
|
|
10
10
|
*/
|
|
11
11
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
12
|
-
import type {
|
|
12
|
+
import type { ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
|
|
13
|
+
import { stateFromHttpStatus } from '../http-state.ts'
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
export const id = 'opencode'
|
|
@@ -125,9 +126,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
125
126
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
126
127
|
const text = await upstream.text().catch(() => '')
|
|
127
128
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`
|
|
128
|
-
|
|
129
|
-
upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
|
|
130
|
-
return { ok: false, state, message: msg }
|
|
129
|
+
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
// 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
13
13
|
import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
|
|
14
14
|
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
15
|
+
import { stateFromHttpStatus } from '../http-state.ts'
|
|
15
16
|
|
|
16
17
|
export const id = 'openrouter'
|
|
17
18
|
export const name = 'OpenRouter'
|
|
@@ -202,13 +203,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
202
203
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
203
204
|
const text = await upstream.text().catch(() => '')
|
|
204
205
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
205
|
-
// 401/403 = 这个 key 本身不可用;429 =
|
|
206
|
-
|
|
207
|
-
upstream.status === 429 ? 'rate_limit'
|
|
208
|
-
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
209
|
-
: upstream.status === 404 ? 'unavailable'
|
|
210
|
-
: 'unknown'
|
|
211
|
-
return { ok: false, state, message: msg }
|
|
206
|
+
// 401/403 = 这个 key 本身不可用;429 = 限流;其余见 stateFromHttpStatus
|
|
207
|
+
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
212
208
|
}
|
|
213
209
|
|
|
214
210
|
// 流式:上游已是 OpenAI SSE,原样交回核心写
|