dsh-router-core 0.4.5 → 0.4.7
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 +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/docs/suppliers.md +2 -2
- package/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2764 -63
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +7 -1
- package/lib/types/ext-prompt/mount.d.ts +12 -1
- package/lib/types/ext-prompt/plugin.d.ts +10 -0
- package/lib/types/ext-prompt/render.d.ts +14 -3
- package/lib/types/ext-prompt/sections.d.ts +95 -0
- package/lib/types/protocol/anthropic.d.ts +35 -0
- package/lib/types/protocol/chat.d.ts +25 -0
- package/lib/types/protocol/entry.d.ts +137 -0
- package/lib/types/protocol/gemini.d.ts +57 -0
- package/lib/types/protocol/index.d.ts +13 -0
- package/lib/types/protocol/responses.d.ts +41 -0
- package/lib/types/protocol/sse.d.ts +41 -0
- package/lib/types/protocol/stream.d.ts +92 -0
- package/lib/types/protocol/types.d.ts +224 -0
- package/lib/types/router/account-pool.d.ts +7 -2
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +26 -3
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +24 -0
- package/lib/types/suppliers/contract.d.ts +15 -1
- package/package.json +6 -6
- package/src/client/ExtDetail.tsx +25 -5
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/router.css +20 -1
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +8 -10
- package/src/ext-prompt/ext-prompt.test.ts +38 -28
- package/src/ext-prompt/index.ts +50 -2
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +22 -7
- package/src/ext-prompt/plugin.ts +64 -1
- package/src/ext-prompt/render.test.ts +1 -1
- package/src/ext-prompt/render.ts +17 -6
- package/src/ext-prompt/section-controls.test.ts +148 -0
- package/src/ext-prompt/section-filter.test.ts +140 -0
- package/src/ext-prompt/sections.test.ts +123 -0
- package/src/ext-prompt/sections.ts +193 -0
- package/src/ext-route.test.ts +16 -0
- package/src/index.ts +117 -6
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/protocol/anthropic.ts +662 -0
- package/src/protocol/chat.ts +272 -0
- package/src/protocol/entry.ts +440 -0
- package/src/protocol/gemini.ts +548 -0
- package/src/protocol/index.ts +31 -0
- package/src/protocol/protocol.test.ts +728 -0
- package/src/protocol/responses.ts +551 -0
- package/src/protocol/sse.ts +94 -0
- package/src/protocol/stream.ts +195 -0
- package/src/protocol/types.ts +249 -0
- package/src/protocol/wire-e2e.test.ts +208 -0
- package/src/protocol/wiring.test.ts +225 -0
- package/src/router/account-pool.test.ts +35 -0
- package/src/router/account-pool.ts +15 -4
- package/src/router/index.ts +143 -24
- package/src/router/types.ts +26 -3
- package/src/router/usage-store.ts +4 -2
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +33 -0
- package/src/suppliers/contract.ts +15 -2
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
package/lib/index.js
CHANGED
|
@@ -10,6 +10,19 @@ import { homedir } from "node:os";
|
|
|
10
10
|
//#region src/shared.ts
|
|
11
11
|
/** Same-origin API base the client panel fetches (host half serves it). */
|
|
12
12
|
const ROUTER_API_BASE = "/router/api";
|
|
13
|
+
/**
|
|
14
|
+
* 外部 OpenAI 兼容客户端的归因标签(`/v1` 没有宿主内部头 `x-dsh-router-session`
|
|
15
|
+
* 时的兜底)——否则 usage 记录里全是匿名流量,出事反查不出是谁在打
|
|
16
|
+
* (2026-10-15 实测:296 条匿名记录对不上任何会话)。
|
|
17
|
+
*
|
|
18
|
+
* 取值优先级:`user`(OpenAI 标准终端用户字段)→ `prompt_cache_key`
|
|
19
|
+
* (Zed 等客户端的缓存键,常带线程粒度)→ `User-Agent`(保底到客户端粒度)。
|
|
20
|
+
* 值截断到 64 字符;三者皆无返回 undefined(保持匿名)。
|
|
21
|
+
*/
|
|
22
|
+
function clientIdentityOf(body, userAgent) {
|
|
23
|
+
const tag = (kind, v) => typeof v === "string" && v !== "" ? `ext/${kind}:${v.slice(0, 64)}` : void 0;
|
|
24
|
+
return tag("user", body.user) ?? tag("cache", body.prompt_cache_key) ?? (userAgent !== void 0 && userAgent !== "" ? `ext/ua:${userAgent.slice(0, 64)}` : void 0);
|
|
25
|
+
}
|
|
13
26
|
//#endregion
|
|
14
27
|
//#region src/router/prefix-affinity.ts
|
|
15
28
|
/**
|
|
@@ -116,7 +129,9 @@ const MINUTE = 6e4;
|
|
|
116
129
|
*
|
|
117
130
|
* 冷却策略对齐 9router 的 accountFallback / errorConfig:
|
|
118
131
|
* - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
|
|
119
|
-
* 短冷,持续限流越退越久,比固定 1
|
|
132
|
+
* 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。上游自己声明了
|
|
133
|
+
* **重置时刻**(resetAt,如 6004「将在 … 重置」)时冷到该点——频率配额是
|
|
134
|
+
* 小时级的,退避上限(5min)只会到点回来再撞。
|
|
120
135
|
* - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
|
|
121
136
|
* 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
|
|
122
137
|
*
|
|
@@ -473,8 +488,13 @@ var AccountPool = class {
|
|
|
473
488
|
blockKey(modelId) {
|
|
474
489
|
return `${this.supplierId}${SEP}${modelId}`;
|
|
475
490
|
}
|
|
476
|
-
/**
|
|
477
|
-
|
|
491
|
+
/**
|
|
492
|
+
* 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。
|
|
493
|
+
* `resetAt` = 上游声明的重置时刻(epoch ms):限流退避优先冷到该点——
|
|
494
|
+
* 频率配额是小时级的,5min 上限的指数退避只会到点回来再撞 6004,
|
|
495
|
+
* 把号轮流打冷(2026-10-15 的 11 号全灭事故)。
|
|
496
|
+
*/
|
|
497
|
+
noteFailure(uid, modelId, state, message, resetAt) {
|
|
478
498
|
const rule = RULES[state];
|
|
479
499
|
if (rule.cooldown === 0) return;
|
|
480
500
|
if (state === "session_dead") {
|
|
@@ -488,7 +508,9 @@ var AccountPool = class {
|
|
|
488
508
|
e.reason = message;
|
|
489
509
|
if (rule.cooldown === "backoff") {
|
|
490
510
|
e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL);
|
|
491
|
-
|
|
511
|
+
const backoffUntil = Date.now() + backoffMs(e.backoffLevel);
|
|
512
|
+
const until = resetAt !== void 0 && resetAt > Date.now() ? resetAt : backoffUntil;
|
|
513
|
+
e.until = Math.max(e.until, until);
|
|
492
514
|
return;
|
|
493
515
|
}
|
|
494
516
|
if (rule.cooldown === "transient") {
|
|
@@ -1185,7 +1207,7 @@ function top(map) {
|
|
|
1185
1207
|
//#endregion
|
|
1186
1208
|
//#region src/router/usage-tokens.ts
|
|
1187
1209
|
/** 取有限数,其余当 0(NaN 会毒穿整个累加:`Math.max(x, NaN) === NaN`)。 */
|
|
1188
|
-
function num(v) {
|
|
1210
|
+
function num$1(v) {
|
|
1189
1211
|
const n = Number(v);
|
|
1190
1212
|
return Number.isFinite(n) ? n : 0;
|
|
1191
1213
|
}
|
|
@@ -1209,10 +1231,10 @@ function num(v) {
|
|
|
1209
1231
|
function normalizeUsage(raw) {
|
|
1210
1232
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1211
1233
|
const u = raw;
|
|
1212
|
-
const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
|
|
1213
|
-
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
|
|
1214
|
-
if (u.cache_read_input_tokens !== void 0 && u.prompt_tokens === void 0) prompt += num(u.cache_read_input_tokens);
|
|
1215
|
-
const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount);
|
|
1234
|
+
const cached = num$1(u.cached_tokens) + num$1(u.cache_read_input_tokens) + num$1(u.cachedContentTokenCount) + num$1(u.prompt_tokens_details?.cached_tokens);
|
|
1235
|
+
let prompt = num$1(u.prompt_tokens) + num$1(u.input_tokens) + num$1(u.promptTokenCount);
|
|
1236
|
+
if (u.cache_read_input_tokens !== void 0 && u.prompt_tokens === void 0) prompt += num$1(u.cache_read_input_tokens);
|
|
1237
|
+
const completion = num$1(u.completion_tokens) + num$1(u.output_tokens) + num$1(u.candidatesTokenCount);
|
|
1216
1238
|
if (prompt === 0 && completion === 0 && cached === 0) return null;
|
|
1217
1239
|
return {
|
|
1218
1240
|
promptTokens: prompt,
|
|
@@ -1423,23 +1445,2209 @@ function tapStreamUsage(source, startedAt) {
|
|
|
1423
1445
|
};
|
|
1424
1446
|
}
|
|
1425
1447
|
/**
|
|
1426
|
-
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
1448
|
+
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
1449
|
+
*
|
|
1450
|
+
* `requestChars` = 请求体字符数(估输入),`outputChars` = 输出字符数(估输出)。
|
|
1451
|
+
* 上游给了的字段**不覆盖**——估算只填空,不抢真数据。
|
|
1452
|
+
*/
|
|
1453
|
+
function withEstimates(usage, requestChars, outputChars) {
|
|
1454
|
+
const prompt = usage?.promptTokens ?? 0;
|
|
1455
|
+
const completion = usage?.completionTokens ?? 0;
|
|
1456
|
+
const inputEstimated = prompt === 0;
|
|
1457
|
+
const outputEstimated = completion === 0;
|
|
1458
|
+
return {
|
|
1459
|
+
promptTokens: inputEstimated ? estimateTokens(requestChars) : prompt,
|
|
1460
|
+
completionTokens: outputEstimated ? estimateTokens(outputChars) : completion,
|
|
1461
|
+
cachedTokens: usage?.cachedTokens ?? 0,
|
|
1462
|
+
inputEstimated,
|
|
1463
|
+
outputEstimated
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
//#endregion
|
|
1467
|
+
//#region src/protocol/types.ts
|
|
1468
|
+
/** 取对象字段(不是对象则 undefined)。 */
|
|
1469
|
+
function obj(v) {
|
|
1470
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
|
|
1471
|
+
}
|
|
1472
|
+
/** 取数组字段(不是数组则 undefined)。 */
|
|
1473
|
+
function arr(v) {
|
|
1474
|
+
return Array.isArray(v) ? v : void 0;
|
|
1475
|
+
}
|
|
1476
|
+
/** 取非空字符串字段。 */
|
|
1477
|
+
function str(v) {
|
|
1478
|
+
return typeof v === "string" && v !== "" ? v : void 0;
|
|
1479
|
+
}
|
|
1480
|
+
/** 取有限数字字段。 */
|
|
1481
|
+
function num(v) {
|
|
1482
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
1483
|
+
}
|
|
1484
|
+
/** 把 `finish` 原因在各协议间对齐(各家词汇不同,这里是唯一的对照表)。 */
|
|
1485
|
+
function finishOf(v) {
|
|
1486
|
+
switch (v) {
|
|
1487
|
+
case "length":
|
|
1488
|
+
case "max_tokens":
|
|
1489
|
+
case "MAX_TOKENS": return "length";
|
|
1490
|
+
case "tool_calls":
|
|
1491
|
+
case "tool_use":
|
|
1492
|
+
case "function_call": return "tool-calls";
|
|
1493
|
+
case "content_filter":
|
|
1494
|
+
case "SAFETY":
|
|
1495
|
+
case "safety": return "content-filter";
|
|
1496
|
+
case "error": return "error";
|
|
1497
|
+
default: return "stop";
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/protocol/sse.ts
|
|
1502
|
+
/**
|
|
1503
|
+
* 增量分帧器:喂字节进去,吐出完整帧。
|
|
1504
|
+
*
|
|
1505
|
+
* 用类而不是纯函数,是因为「半个帧」必须留在实例里 —— 每次调用都是无状态的
|
|
1506
|
+
* 函数做不到这件事,而每个流都要一个独立缓冲。
|
|
1507
|
+
*/
|
|
1508
|
+
var SseSplitter = class {
|
|
1509
|
+
buf = "";
|
|
1510
|
+
/** 喂入一块字节,返回其中包含的完整帧(可能 0..n 个)。 */
|
|
1511
|
+
push(bytes) {
|
|
1512
|
+
this.buf += new TextDecoder().decode(bytes, { stream: true });
|
|
1513
|
+
return this.drain();
|
|
1514
|
+
}
|
|
1515
|
+
/** 流结束时冲刷:把残留的、不以空行结尾的尾帧也吐出来(上游不发尾空行是有的)。 */
|
|
1516
|
+
end() {
|
|
1517
|
+
this.buf += new TextDecoder().decode();
|
|
1518
|
+
const out = this.drain();
|
|
1519
|
+
if (this.buf.trim() !== "") {
|
|
1520
|
+
const frame = parseFrame(this.buf);
|
|
1521
|
+
this.buf = "";
|
|
1522
|
+
if (frame.data !== "") out.push(frame);
|
|
1523
|
+
}
|
|
1524
|
+
return out;
|
|
1525
|
+
}
|
|
1526
|
+
/** 把已缓冲的完整帧全部切出来。 */
|
|
1527
|
+
drain() {
|
|
1528
|
+
const out = [];
|
|
1529
|
+
let sep;
|
|
1530
|
+
while ((sep = this.buf.search(/\r?\n\r?\n/)) >= 0) {
|
|
1531
|
+
const match = /\r?\n\r?\n/.exec(this.buf);
|
|
1532
|
+
const raw = this.buf.slice(0, sep);
|
|
1533
|
+
this.buf = this.buf.slice(sep + (match?.[0].length ?? 2));
|
|
1534
|
+
if (raw.trim() !== "") out.push(parseFrame(raw));
|
|
1535
|
+
}
|
|
1536
|
+
return out;
|
|
1537
|
+
}
|
|
1538
|
+
};
|
|
1539
|
+
/** 把一帧原文拆成 `event` + `data`。空 data 的帧(注释/keep-alive)返回空 data。 */
|
|
1540
|
+
function parseFrame(raw) {
|
|
1541
|
+
let event = "";
|
|
1542
|
+
const dataLines = [];
|
|
1543
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1544
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
1545
|
+
const colon = line.indexOf(":");
|
|
1546
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
1547
|
+
let value = colon < 0 ? "" : line.slice(colon + 1);
|
|
1548
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
1549
|
+
if (field === "event") event = value;
|
|
1550
|
+
else if (field === "data") dataLines.push(value);
|
|
1551
|
+
}
|
|
1552
|
+
return {
|
|
1553
|
+
event,
|
|
1554
|
+
data: dataLines.join("\n")
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
/** 拼一帧字节(`data:` 帧;不带 `event:`)。 */
|
|
1558
|
+
function dataFrame(data) {
|
|
1559
|
+
return `data: ${data}\n\n`;
|
|
1560
|
+
}
|
|
1561
|
+
/** 拼一帧字节(带 `event:`)。 */
|
|
1562
|
+
function eventFrame(event, data) {
|
|
1563
|
+
return `event: ${event}\ndata: ${data}\n\n`;
|
|
1564
|
+
}
|
|
1565
|
+
/** 把若干帧原文拼成字节。 */
|
|
1566
|
+
function framesToBytes(frames) {
|
|
1567
|
+
return new TextEncoder().encode(frames.join(""));
|
|
1568
|
+
}
|
|
1569
|
+
//#endregion
|
|
1570
|
+
//#region src/protocol/chat.ts
|
|
1571
|
+
/**
|
|
1572
|
+
* OpenAI Chat Completions 协议 —— **规范形态本身**,所以编解码是恒等变换。
|
|
1573
|
+
*
|
|
1574
|
+
* 这个文件承担两件别处不做的事:
|
|
1575
|
+
*
|
|
1576
|
+
* 1. **请求侧恒等**:`toChat`/`fromChat` 原样返回。供应商契约
|
|
1577
|
+
* (`suppliers/contract.ts` 的 `chatOnce`)吃的就是 Chat Completions 请求体,
|
|
1578
|
+
* 多一层拷贝只会让「哪一层改的」变难查。
|
|
1579
|
+
*
|
|
1580
|
+
* 2. **响应侧的规范事件翻译**:OpenAI SSE → `ChatEvent`,以及反向。这是**所有
|
|
1581
|
+
* 协议共用的脊柱** —— Anthropic/Gemini 的 `decodeFrame` 拿到自己的帧后,
|
|
1582
|
+
* 也要靠这里的逻辑把内容映射成同一组事件,才能让「Anthropic 客户端问、
|
|
1583
|
+
* Gemini 供应商答」这条路只有两个模块参与,而不是 12 条路径。
|
|
1584
|
+
*
|
|
1585
|
+
* ⚠️ 这里的映射规则与 `llm/adapter.ts` 的 `translateSse` 是**同一套语义的两种
|
|
1586
|
+
* 落点**:那边翻译成 DSH 的 `StreamChunk`(给宿主 agent loop 用),这边翻译
|
|
1587
|
+
* 成 `ChatEvent`(给协议矩阵用)。两处都留着,因为消费方不同、且
|
|
1588
|
+
* `translateSse` 里那些踩过坑的处理(残缺 tool_call 收尾、空串 name 剥除)
|
|
1589
|
+
* 只对 DSH 有意义;但**改任何一处规则都必须看另一处**,否则同一个上游在
|
|
1590
|
+
* 两个入口表现不一致。
|
|
1591
|
+
*/
|
|
1592
|
+
const chatCodec = {
|
|
1593
|
+
id: "chat",
|
|
1594
|
+
toChat: (body) => body,
|
|
1595
|
+
fromChat: (body) => body,
|
|
1596
|
+
decodeFrame(frame) {
|
|
1597
|
+
let chunk;
|
|
1598
|
+
try {
|
|
1599
|
+
chunk = JSON.parse(frame);
|
|
1600
|
+
} catch {
|
|
1601
|
+
return [];
|
|
1602
|
+
}
|
|
1603
|
+
return chatBodyEvents(chunk);
|
|
1604
|
+
},
|
|
1605
|
+
/**
|
|
1606
|
+
* 非流式响应体 → 规范事件。
|
|
1607
|
+
*
|
|
1608
|
+
* 复用同一个 `chatBodyEvents`:一个非流式 body 就是一帧(内容在
|
|
1609
|
+
* `choices[0].message` 里、`finish_reason` 已给、`usage` 在外层)。单独实现
|
|
1610
|
+
* 一份只会让「流式与非流式行为不一致」有地方可长。
|
|
1611
|
+
*/
|
|
1612
|
+
decodeJson(body) {
|
|
1613
|
+
return chatBodyEvents(body);
|
|
1614
|
+
},
|
|
1615
|
+
encodeFrame(event) {
|
|
1616
|
+
switch (event.type) {
|
|
1617
|
+
case "text": return [dataFrame(JSON.stringify(choiceDelta({ content: event.text })))];
|
|
1618
|
+
case "reasoning": return [dataFrame(JSON.stringify(choiceDelta({ reasoning_content: event.text })))];
|
|
1619
|
+
case "tool-start": return [dataFrame(JSON.stringify(choiceDelta({ tool_calls: [{
|
|
1620
|
+
index: event.index,
|
|
1621
|
+
...event.id === void 0 ? {} : { id: event.id },
|
|
1622
|
+
type: "function",
|
|
1623
|
+
function: {
|
|
1624
|
+
name: event.name ?? "",
|
|
1625
|
+
arguments: ""
|
|
1626
|
+
}
|
|
1627
|
+
}] })))];
|
|
1628
|
+
case "tool-args": return [dataFrame(JSON.stringify(choiceDelta({ tool_calls: [{
|
|
1629
|
+
index: event.index,
|
|
1630
|
+
function: { arguments: event.delta }
|
|
1631
|
+
}] })))];
|
|
1632
|
+
case "usage":
|
|
1633
|
+
if (event.promptTokens === 0 && event.completionTokens === 0 && event.cachedTokens === 0) return [];
|
|
1634
|
+
return [dataFrame(JSON.stringify({
|
|
1635
|
+
choices: [],
|
|
1636
|
+
usage: {
|
|
1637
|
+
prompt_tokens: event.promptTokens,
|
|
1638
|
+
completion_tokens: event.completionTokens,
|
|
1639
|
+
total_tokens: event.promptTokens + event.completionTokens
|
|
1640
|
+
}
|
|
1641
|
+
}))];
|
|
1642
|
+
case "error": return [dataFrame(JSON.stringify({ error: {
|
|
1643
|
+
message: event.message,
|
|
1644
|
+
type: "api_error"
|
|
1645
|
+
} }))];
|
|
1646
|
+
case "finish": return [dataFrame(JSON.stringify(choiceDelta({}, finishToOpenAi(event.reason)))), "data: [DONE]\n\n"];
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
startFrames: () => [],
|
|
1650
|
+
endFrames: () => [],
|
|
1651
|
+
/** 规范事件 → 非流式 body。 */
|
|
1652
|
+
encodeJson(events, ctx) {
|
|
1653
|
+
let content = "";
|
|
1654
|
+
let reasoning = "";
|
|
1655
|
+
let finish = "stop";
|
|
1656
|
+
let usage;
|
|
1657
|
+
const calls = /* @__PURE__ */ new Map();
|
|
1658
|
+
for (const ev of events) switch (ev.type) {
|
|
1659
|
+
case "text":
|
|
1660
|
+
content += ev.text;
|
|
1661
|
+
break;
|
|
1662
|
+
case "reasoning":
|
|
1663
|
+
reasoning += ev.text;
|
|
1664
|
+
break;
|
|
1665
|
+
case "tool-start":
|
|
1666
|
+
calls.set(ev.index, {
|
|
1667
|
+
...ev.id === void 0 ? {} : { id: ev.id },
|
|
1668
|
+
name: ev.name ?? "",
|
|
1669
|
+
args: ""
|
|
1670
|
+
});
|
|
1671
|
+
break;
|
|
1672
|
+
case "tool-args": {
|
|
1673
|
+
const c = calls.get(ev.index) ?? {
|
|
1674
|
+
name: "",
|
|
1675
|
+
args: ""
|
|
1676
|
+
};
|
|
1677
|
+
c.args += ev.delta;
|
|
1678
|
+
calls.set(ev.index, c);
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
case "finish":
|
|
1682
|
+
finish = ev.reason;
|
|
1683
|
+
break;
|
|
1684
|
+
case "usage": usage = ev;
|
|
1685
|
+
}
|
|
1686
|
+
const message = {
|
|
1687
|
+
role: "assistant",
|
|
1688
|
+
content
|
|
1689
|
+
};
|
|
1690
|
+
if (reasoning !== "") message.reasoning_content = reasoning;
|
|
1691
|
+
const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).filter(([, c]) => c.name !== "").map(([i, c]) => ({
|
|
1692
|
+
id: c.id ?? `call_${i}`,
|
|
1693
|
+
type: "function",
|
|
1694
|
+
function: {
|
|
1695
|
+
name: c.name,
|
|
1696
|
+
arguments: c.args
|
|
1697
|
+
}
|
|
1698
|
+
}));
|
|
1699
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
1700
|
+
const body = {
|
|
1701
|
+
id: `chatcmpl-${Date.now().toString(36)}`,
|
|
1702
|
+
object: "chat.completion",
|
|
1703
|
+
created: Math.floor(Date.now() / 1e3),
|
|
1704
|
+
model: ctx.model,
|
|
1705
|
+
choices: [{
|
|
1706
|
+
index: 0,
|
|
1707
|
+
message,
|
|
1708
|
+
finish_reason: finishToOpenAi(finish)
|
|
1709
|
+
}]
|
|
1710
|
+
};
|
|
1711
|
+
if (usage !== void 0) body.usage = {
|
|
1712
|
+
prompt_tokens: usage.promptTokens,
|
|
1713
|
+
completion_tokens: usage.completionTokens,
|
|
1714
|
+
total_tokens: usage.promptTokens + usage.completionTokens
|
|
1715
|
+
};
|
|
1716
|
+
return body;
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
/** OpenAI 的一帧 `choices[0].delta`。 */
|
|
1720
|
+
function choiceDelta(delta, finish) {
|
|
1721
|
+
return {
|
|
1722
|
+
id: "chatcmpl-stream",
|
|
1723
|
+
object: "chat.completion.chunk",
|
|
1724
|
+
choices: [{
|
|
1725
|
+
index: 0,
|
|
1726
|
+
delta,
|
|
1727
|
+
finish_reason: finish ?? null
|
|
1728
|
+
}]
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* 一个 OpenAI 响应体(流式帧或非流式 body,两者形状一致)→ 规范事件。
|
|
1733
|
+
*
|
|
1734
|
+
* 流式与非流式共用:非流式 body 就是「一帧」(`choices[0].message` 带全量
|
|
1735
|
+
* 内容、`finish_reason` 已给、`usage` 在外层)。分开写两份只会让两边行为
|
|
1736
|
+
* 漂移 —— 而漂移的表现是「同一个上游在流式下工具名对、非流式下为空」。
|
|
1737
|
+
*/
|
|
1738
|
+
function chatBodyEvents(chunk) {
|
|
1739
|
+
const out = [];
|
|
1740
|
+
const usage = decodeUsage(chunk);
|
|
1741
|
+
if (usage !== void 0) out.push(usage);
|
|
1742
|
+
const err = obj(chunk.error);
|
|
1743
|
+
if (err !== void 0) {
|
|
1744
|
+
out.push({
|
|
1745
|
+
type: "error",
|
|
1746
|
+
message: str(err.message) ?? "upstream error"
|
|
1747
|
+
});
|
|
1748
|
+
return out;
|
|
1749
|
+
}
|
|
1750
|
+
const ch = obj(arr(chunk.choices)?.[0]);
|
|
1751
|
+
if (ch === void 0) return out;
|
|
1752
|
+
const delta = obj(ch.delta) ?? obj(ch.message);
|
|
1753
|
+
if (delta !== void 0) {
|
|
1754
|
+
const text = str(delta.content);
|
|
1755
|
+
if (text !== void 0) out.push({
|
|
1756
|
+
type: "text",
|
|
1757
|
+
text
|
|
1758
|
+
});
|
|
1759
|
+
const reasoning = str(delta.reasoning_content) ?? str(obj(delta.reasoning)?.text);
|
|
1760
|
+
if (reasoning !== void 0) out.push({
|
|
1761
|
+
type: "reasoning",
|
|
1762
|
+
text: reasoning
|
|
1763
|
+
});
|
|
1764
|
+
for (const ev of decodeToolCalls(delta.tool_calls)) out.push(ev);
|
|
1765
|
+
}
|
|
1766
|
+
const finish = ch.finish_reason;
|
|
1767
|
+
if (finish !== void 0 && finish !== null) out.push({
|
|
1768
|
+
type: "finish",
|
|
1769
|
+
reason: finishOf(finish)
|
|
1770
|
+
});
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
1773
|
+
function finishToOpenAi(reason) {
|
|
1774
|
+
switch (reason) {
|
|
1775
|
+
case "length": return "length";
|
|
1776
|
+
case "tool-calls": return "tool_calls";
|
|
1777
|
+
case "content-filter": return "content_filter";
|
|
1778
|
+
case "error": return "stop";
|
|
1779
|
+
default: return "stop";
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
/** OpenAI SSE 帧 → 工具调用增量。 */
|
|
1783
|
+
function decodeToolCalls(v) {
|
|
1784
|
+
const list = arr(v);
|
|
1785
|
+
if (list === void 0) return [];
|
|
1786
|
+
const out = [];
|
|
1787
|
+
for (const item of list) {
|
|
1788
|
+
const tc = obj(item);
|
|
1789
|
+
if (tc === void 0) continue;
|
|
1790
|
+
const index = num(tc.index) ?? 0;
|
|
1791
|
+
const fn = obj(tc.function);
|
|
1792
|
+
const name = fn === void 0 ? void 0 : str(fn.name);
|
|
1793
|
+
const id = str(tc.id);
|
|
1794
|
+
if (name !== void 0 || id !== void 0) out.push({
|
|
1795
|
+
type: "tool-start",
|
|
1796
|
+
index,
|
|
1797
|
+
...id === void 0 ? {} : { id },
|
|
1798
|
+
...name === void 0 ? {} : { name }
|
|
1799
|
+
});
|
|
1800
|
+
const args = fn === void 0 ? void 0 : str(fn.arguments);
|
|
1801
|
+
if (args !== void 0 && args !== "") out.push({
|
|
1802
|
+
type: "tool-args",
|
|
1803
|
+
index,
|
|
1804
|
+
delta: args
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
return out;
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* 从一帧 OpenAI 响应里取用量。
|
|
1811
|
+
*
|
|
1812
|
+
* 口径与 `router/usage-tokens.ts` 的 `normalizeUsage` 一致:`prompt_tokens`
|
|
1813
|
+
* **含**缓存(OpenAI 系如此),`cachedTokens` 是它的子集。
|
|
1814
|
+
*/
|
|
1815
|
+
function decodeUsage(chunk) {
|
|
1816
|
+
const u = obj(chunk.usage);
|
|
1817
|
+
if (u === void 0) return void 0;
|
|
1818
|
+
return {
|
|
1819
|
+
type: "usage",
|
|
1820
|
+
promptTokens: num(u.prompt_tokens) ?? num(u.input_tokens) ?? 0,
|
|
1821
|
+
completionTokens: num(u.completion_tokens) ?? num(u.output_tokens) ?? 0,
|
|
1822
|
+
cachedTokens: num(u.cached_tokens) ?? num(obj(u.prompt_tokens_details)?.cached_tokens) ?? 0
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
//#endregion
|
|
1826
|
+
//#region src/protocol/anthropic.ts
|
|
1827
|
+
/**
|
|
1828
|
+
* Anthropic Messages 协议。
|
|
1829
|
+
*
|
|
1830
|
+
* ════════════════════════════════════════════════════════════════════
|
|
1831
|
+
* 为什么第一个做它
|
|
1832
|
+
* ════════════════════════════════════════════════════════════════════
|
|
1833
|
+
* Claude Code 只会说这一种协议,而它对 `ANTHROPIC_BASE_URL` 的支持是现成的:
|
|
1834
|
+
* 把它指到我们的端点,就**直接**能用上账号池/组合/用量看板。其余三种协议
|
|
1835
|
+
* (Responses/Gemini)没有这样的「一个客户端在等」的理由。
|
|
1836
|
+
*
|
|
1837
|
+
* ════════════════════════════════════════════════════════════════════
|
|
1838
|
+
* 翻译表(唯一的事实来源)
|
|
1839
|
+
* ════════════════════════════════════════════════════════════════════
|
|
1840
|
+
* 请求 system → messages[0] 的 system-role 消息(字符串或块数组都要吃)
|
|
1841
|
+
* tools[].input_schema → tools[].function.parameters
|
|
1842
|
+
* tool_use / tool_result 块 → assistant.tool_calls / role:"tool"
|
|
1843
|
+
* max_tokens / temperature / stop_sequences → 同名(下划线差异两处)
|
|
1844
|
+
* 响应 message_start → usage(input_tokens 在这帧)
|
|
1845
|
+
* content_block_start(text) → text 增量开始
|
|
1846
|
+
* content_block_delta(text_delta) → text 增量
|
|
1847
|
+
* content_block_delta(input_json_delta) → tool-args 增量
|
|
1848
|
+
* content_block_delta(thinking_delta) → reasoning 增量
|
|
1849
|
+
* content_block_stop → 该 index 结束(无对应规范事件,吃掉)
|
|
1850
|
+
* message_delta → usage + finish_reason
|
|
1851
|
+
* message_stop → 无对应事件(finish 已由 message_delta 给)
|
|
1852
|
+
*
|
|
1853
|
+
* 丢弃(明确不做翻译,见下):
|
|
1854
|
+
* - `cache_control`:Anthropic 的提示缓存标记。跨到 Chat 后失去意义
|
|
1855
|
+
* (Chat 的缓存是上游自动的),硬造一个 `prompt_cache_key` 反而是猜。
|
|
1856
|
+
* - `thinking.budget_tokens`:只在响应侧保留为 reasoning 增量。
|
|
1857
|
+
* - `mcp_servers` / `container` / `context_management`:客户端侧特性,
|
|
1858
|
+
* 转发到只懂 Chat 的上游只会得到 400。
|
|
1859
|
+
*/
|
|
1860
|
+
const anthropicCodec = {
|
|
1861
|
+
id: "anthropic",
|
|
1862
|
+
/** Anthropic 请求 → Chat Completions 请求。 */
|
|
1863
|
+
toChat(body, ctx) {
|
|
1864
|
+
const out = { ...body };
|
|
1865
|
+
delete out.system;
|
|
1866
|
+
delete out.tools;
|
|
1867
|
+
delete out.max_tokens;
|
|
1868
|
+
delete out.stop_sequences;
|
|
1869
|
+
const messages = [];
|
|
1870
|
+
const system = body.system;
|
|
1871
|
+
if (system !== void 0) {
|
|
1872
|
+
const text = systemText$1(system);
|
|
1873
|
+
if (text !== "") messages.push({
|
|
1874
|
+
role: "system",
|
|
1875
|
+
content: text
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
for (const m of messagePairs(body.messages)) messages.push(m);
|
|
1879
|
+
out.messages = messages;
|
|
1880
|
+
if (body.max_tokens !== void 0) out.max_tokens = body.max_tokens;
|
|
1881
|
+
if (body.stop_sequences !== void 0) out.stop = body.stop_sequences;
|
|
1882
|
+
if (body.temperature !== void 0) out.temperature = body.temperature;
|
|
1883
|
+
if (body.top_p !== void 0) out.top_p = body.top_p;
|
|
1884
|
+
const tools = convertTools$2(body.tools);
|
|
1885
|
+
if (tools.length > 0) out.tools = tools;
|
|
1886
|
+
if (ctx.stream) out.stream = true;
|
|
1887
|
+
return out;
|
|
1888
|
+
},
|
|
1889
|
+
/** Chat Completions 请求 → Anthropic 请求(供应商说 Anthropic 时用)。 */
|
|
1890
|
+
fromChat(body, ctx) {
|
|
1891
|
+
const out = { ...body };
|
|
1892
|
+
delete out.messages;
|
|
1893
|
+
delete out.tools;
|
|
1894
|
+
delete out.stop;
|
|
1895
|
+
delete out.stream;
|
|
1896
|
+
const systemParts = [];
|
|
1897
|
+
const messages = [];
|
|
1898
|
+
for (const m of body.messages) {
|
|
1899
|
+
const msg = obj(m);
|
|
1900
|
+
if (msg === void 0) continue;
|
|
1901
|
+
const role = str(msg.role);
|
|
1902
|
+
if (role === "system") {
|
|
1903
|
+
const t = contentText$2(msg.content);
|
|
1904
|
+
if (t !== "") systemParts.push(t);
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
if (role === "assistant") {
|
|
1908
|
+
messages.push(assistantMessage(msg));
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
if (role === "tool") {
|
|
1912
|
+
messages.push({
|
|
1913
|
+
role: "user",
|
|
1914
|
+
content: [{
|
|
1915
|
+
type: "tool_result",
|
|
1916
|
+
tool_use_id: str(msg.tool_call_id) ?? "",
|
|
1917
|
+
content: contentText$2(msg.content)
|
|
1918
|
+
}]
|
|
1919
|
+
});
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
messages.push({
|
|
1923
|
+
role: "user",
|
|
1924
|
+
content: contentText$2(msg.content)
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
if (systemParts.length > 0) out.system = systemParts.join("\n\n");
|
|
1928
|
+
out.messages = messages;
|
|
1929
|
+
out.max_tokens = num(body.max_tokens) ?? 4096;
|
|
1930
|
+
if (body.stop !== void 0) out.stop_sequences = body.stop;
|
|
1931
|
+
const tools = body.tools?.map(chatToolToAnthropic).filter((t) => t !== void 0);
|
|
1932
|
+
if (tools !== void 0 && tools.length > 0) out.tools = tools;
|
|
1933
|
+
if (ctx.stream) out.stream = true;
|
|
1934
|
+
return out;
|
|
1935
|
+
},
|
|
1936
|
+
decodeFrame(frame, event) {
|
|
1937
|
+
let payload;
|
|
1938
|
+
try {
|
|
1939
|
+
payload = JSON.parse(frame);
|
|
1940
|
+
} catch {
|
|
1941
|
+
return [];
|
|
1942
|
+
}
|
|
1943
|
+
switch (event) {
|
|
1944
|
+
case "message_start": return messageStartEvents(payload);
|
|
1945
|
+
case "content_block_start": return contentBlockStartEvents(payload);
|
|
1946
|
+
case "content_block_delta": return contentDeltaEvents(payload);
|
|
1947
|
+
case "message_delta": return messageDeltaEvents(payload);
|
|
1948
|
+
case "error": return [{
|
|
1949
|
+
type: "error",
|
|
1950
|
+
message: str(obj(payload.error)?.message) ?? "upstream error"
|
|
1951
|
+
}];
|
|
1952
|
+
default: return [];
|
|
1953
|
+
}
|
|
1954
|
+
},
|
|
1955
|
+
encodeFrame(event, session) {
|
|
1956
|
+
switch (event.type) {
|
|
1957
|
+
case "text": return [eventFrame("content_block_delta", JSON.stringify({
|
|
1958
|
+
type: "content_block_delta",
|
|
1959
|
+
index: 0,
|
|
1960
|
+
delta: {
|
|
1961
|
+
type: "text_delta",
|
|
1962
|
+
text: event.text
|
|
1963
|
+
}
|
|
1964
|
+
}))];
|
|
1965
|
+
case "reasoning": return [eventFrame("content_block_delta", JSON.stringify({
|
|
1966
|
+
type: "content_block_delta",
|
|
1967
|
+
index: 0,
|
|
1968
|
+
delta: {
|
|
1969
|
+
type: "thinking_delta",
|
|
1970
|
+
thinking: event.text
|
|
1971
|
+
}
|
|
1972
|
+
}))];
|
|
1973
|
+
case "tool-start": return [eventFrame("content_block_start", JSON.stringify({
|
|
1974
|
+
type: "content_block_start",
|
|
1975
|
+
index: event.index,
|
|
1976
|
+
content_block: {
|
|
1977
|
+
type: "tool_use",
|
|
1978
|
+
id: event.id ?? `toolu_${event.index}`,
|
|
1979
|
+
name: event.name ?? "",
|
|
1980
|
+
input: {}
|
|
1981
|
+
}
|
|
1982
|
+
}))];
|
|
1983
|
+
case "tool-args": return [eventFrame("content_block_delta", JSON.stringify({
|
|
1984
|
+
type: "content_block_delta",
|
|
1985
|
+
index: event.index,
|
|
1986
|
+
delta: {
|
|
1987
|
+
type: "input_json_delta",
|
|
1988
|
+
partial_json: event.delta
|
|
1989
|
+
}
|
|
1990
|
+
}))];
|
|
1991
|
+
case "usage": return [];
|
|
1992
|
+
case "error": return [eventFrame("error", JSON.stringify({
|
|
1993
|
+
type: "error",
|
|
1994
|
+
error: {
|
|
1995
|
+
type: "api_error",
|
|
1996
|
+
message: event.message
|
|
1997
|
+
}
|
|
1998
|
+
}))];
|
|
1999
|
+
case "finish": {
|
|
2000
|
+
const tokens = session?.usage?.completionTokens ?? 0;
|
|
2001
|
+
return [eventFrame("message_delta", JSON.stringify({
|
|
2002
|
+
type: "message_delta",
|
|
2003
|
+
delta: {
|
|
2004
|
+
stop_reason: finishToAnthropic(event.reason),
|
|
2005
|
+
stop_sequence: null
|
|
2006
|
+
},
|
|
2007
|
+
usage: { output_tokens: tokens }
|
|
2008
|
+
}))];
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
startFrames(ctx) {
|
|
2013
|
+
return [eventFrame("message_start", JSON.stringify({
|
|
2014
|
+
type: "message_start",
|
|
2015
|
+
message: {
|
|
2016
|
+
id: `msg_${Date.now().toString(36)}`,
|
|
2017
|
+
type: "message",
|
|
2018
|
+
role: "assistant",
|
|
2019
|
+
content: [],
|
|
2020
|
+
model: ctx.model,
|
|
2021
|
+
stop_reason: null,
|
|
2022
|
+
stop_sequence: null,
|
|
2023
|
+
usage: {
|
|
2024
|
+
input_tokens: 0,
|
|
2025
|
+
output_tokens: 0
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}))];
|
|
2029
|
+
},
|
|
2030
|
+
endFrames() {
|
|
2031
|
+
return [eventFrame("message_stop", JSON.stringify({ type: "message_stop" }))];
|
|
2032
|
+
},
|
|
2033
|
+
/**
|
|
2034
|
+
* 非流式响应体 → 规范事件。
|
|
2035
|
+
*
|
|
2036
|
+
* Anthropic 非流式 body 形状:`{content:[{type:'text',text}|{type:'tool_use',...}],
|
|
2037
|
+
* stop_reason, usage:{input_tokens,output_tokens,cache_read_input_tokens}}`。
|
|
2038
|
+
* 与流式共用同一组 block→事件 的映射,见 `blockEvents`。
|
|
2039
|
+
*/
|
|
2040
|
+
decodeJson(body) {
|
|
2041
|
+
const out = [];
|
|
2042
|
+
let index = 0;
|
|
2043
|
+
let text = "";
|
|
2044
|
+
for (const b of arr(body.content) ?? []) {
|
|
2045
|
+
const block = obj(b);
|
|
2046
|
+
if (block === void 0) continue;
|
|
2047
|
+
if (block.type === "text") {
|
|
2048
|
+
const t = str(block.text);
|
|
2049
|
+
if (t !== void 0) {
|
|
2050
|
+
text += t;
|
|
2051
|
+
index += 1;
|
|
2052
|
+
}
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
if (block.type === "thinking") {
|
|
2056
|
+
const t = str(block.thinking);
|
|
2057
|
+
if (t !== void 0) {
|
|
2058
|
+
out.push({
|
|
2059
|
+
type: "reasoning",
|
|
2060
|
+
text: t
|
|
2061
|
+
});
|
|
2062
|
+
index += 1;
|
|
2063
|
+
}
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
if (block.type === "tool_use") {
|
|
2067
|
+
out.push({
|
|
2068
|
+
type: "tool-start",
|
|
2069
|
+
index,
|
|
2070
|
+
...str(block.id) === void 0 ? {} : { id: str(block.id) },
|
|
2071
|
+
name: str(block.name) ?? ""
|
|
2072
|
+
});
|
|
2073
|
+
out.push({
|
|
2074
|
+
type: "tool-args",
|
|
2075
|
+
index,
|
|
2076
|
+
delta: JSON.stringify(block.input ?? {})
|
|
2077
|
+
});
|
|
2078
|
+
index += 1;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
if (text !== "") out.unshift({
|
|
2082
|
+
type: "text",
|
|
2083
|
+
text
|
|
2084
|
+
});
|
|
2085
|
+
const usage = obj(body.usage);
|
|
2086
|
+
if (usage !== void 0) out.push({
|
|
2087
|
+
type: "usage",
|
|
2088
|
+
promptTokens: num(usage.input_tokens) ?? 0,
|
|
2089
|
+
completionTokens: num(usage.output_tokens) ?? 0,
|
|
2090
|
+
cachedTokens: num(usage.cache_read_input_tokens) ?? 0
|
|
2091
|
+
});
|
|
2092
|
+
const stop = body.stop_reason;
|
|
2093
|
+
if (stop !== void 0 && stop !== null) out.push({
|
|
2094
|
+
type: "finish",
|
|
2095
|
+
reason: finishOf(stop === "max_tokens" ? "length" : stop)
|
|
2096
|
+
});
|
|
2097
|
+
return out;
|
|
2098
|
+
},
|
|
2099
|
+
/** 规范事件 → 非流式 body。 */
|
|
2100
|
+
encodeJson(events, ctx) {
|
|
2101
|
+
const content = [];
|
|
2102
|
+
let stop = "end_turn";
|
|
2103
|
+
let usage = {
|
|
2104
|
+
input: 0,
|
|
2105
|
+
output: 0,
|
|
2106
|
+
cached: 0
|
|
2107
|
+
};
|
|
2108
|
+
for (const ev of events) switch (ev.type) {
|
|
2109
|
+
case "text":
|
|
2110
|
+
if (content.length > 0 && content[content.length - 1]?.type === "text") content[content.length - 1].text += ev.text;
|
|
2111
|
+
else content.push({
|
|
2112
|
+
type: "text",
|
|
2113
|
+
text: ev.text
|
|
2114
|
+
});
|
|
2115
|
+
break;
|
|
2116
|
+
case "reasoning":
|
|
2117
|
+
if (content.length > 0 && content[content.length - 1]?.type === "thinking") content[content.length - 1].thinking += ev.text;
|
|
2118
|
+
else content.push({
|
|
2119
|
+
type: "thinking",
|
|
2120
|
+
thinking: ev.text
|
|
2121
|
+
});
|
|
2122
|
+
break;
|
|
2123
|
+
case "tool-start":
|
|
2124
|
+
content.push({
|
|
2125
|
+
type: "tool_use",
|
|
2126
|
+
id: ev.id ?? `toolu_${ev.index}`,
|
|
2127
|
+
name: ev.name ?? "",
|
|
2128
|
+
input: {}
|
|
2129
|
+
});
|
|
2130
|
+
break;
|
|
2131
|
+
case "tool-args": {
|
|
2132
|
+
const last = content[content.length - 1];
|
|
2133
|
+
if (last?.type === "tool_use") last.input = parseArgs$1((str(last.input) ?? "") + ev.delta);
|
|
2134
|
+
break;
|
|
2135
|
+
}
|
|
2136
|
+
case "finish":
|
|
2137
|
+
stop = finishToAnthropic(ev.reason);
|
|
2138
|
+
break;
|
|
2139
|
+
case "usage": usage = {
|
|
2140
|
+
input: ev.promptTokens,
|
|
2141
|
+
output: ev.completionTokens,
|
|
2142
|
+
cached: ev.cachedTokens
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
return {
|
|
2146
|
+
id: `msg_${Date.now().toString(36)}`,
|
|
2147
|
+
type: "message",
|
|
2148
|
+
role: "assistant",
|
|
2149
|
+
content,
|
|
2150
|
+
model: ctx.model,
|
|
2151
|
+
stop_reason: stop,
|
|
2152
|
+
stop_sequence: null,
|
|
2153
|
+
usage: {
|
|
2154
|
+
input_tokens: usage.input,
|
|
2155
|
+
output_tokens: usage.output,
|
|
2156
|
+
...usage.cached > 0 ? { cache_read_input_tokens: usage.cached } : {}
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
};
|
|
2161
|
+
/** Anthropic 的 system 字段:字符串或 [{type:'text',text}] 数组。 */
|
|
2162
|
+
function systemText$1(system) {
|
|
2163
|
+
if (typeof system === "string") return system;
|
|
2164
|
+
const parts = [];
|
|
2165
|
+
for (const b of arr(system) ?? []) {
|
|
2166
|
+
const t = str(obj(b)?.text);
|
|
2167
|
+
if (t !== void 0) parts.push(t);
|
|
2168
|
+
}
|
|
2169
|
+
return parts.join("\n\n");
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Anthropic messages → Chat messages。
|
|
2173
|
+
*
|
|
2174
|
+
* 关键差异:Anthropic 把「模型发起的工具调用」与「用户回传的工具结果」都放在
|
|
2175
|
+
* content 块里,而 Chat 把它们拆成 `assistant.tool_calls` 与 `role:'tool'` 消息。
|
|
2176
|
+
* 且 Anthropic 允许 tool_result 单独成一条 user 消息,Chat 也允许 —— 但**连续
|
|
2177
|
+
* 多条 tool_result 必须各自一条**,合并不了(每个 tool_use_id 要能对上)。
|
|
2178
|
+
*/
|
|
2179
|
+
function messagePairs(messages) {
|
|
2180
|
+
const out = [];
|
|
2181
|
+
for (const m of arr(messages) ?? []) {
|
|
2182
|
+
const msg = obj(m);
|
|
2183
|
+
if (msg === void 0) continue;
|
|
2184
|
+
const role = str(msg.role);
|
|
2185
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
2186
|
+
const blocks = arr(msg.content);
|
|
2187
|
+
if (blocks === void 0) {
|
|
2188
|
+
out.push({
|
|
2189
|
+
role,
|
|
2190
|
+
content: typeof msg.content === "string" ? msg.content : ""
|
|
2191
|
+
});
|
|
2192
|
+
continue;
|
|
2193
|
+
}
|
|
2194
|
+
const texts = [];
|
|
2195
|
+
const toolCalls = [];
|
|
2196
|
+
const toolResults = [];
|
|
2197
|
+
for (const b of blocks) {
|
|
2198
|
+
const block = obj(b);
|
|
2199
|
+
if (block === void 0) continue;
|
|
2200
|
+
switch (block.type) {
|
|
2201
|
+
case "text": {
|
|
2202
|
+
const t = str(block.text);
|
|
2203
|
+
if (t !== void 0) texts.push(t);
|
|
2204
|
+
break;
|
|
2205
|
+
}
|
|
2206
|
+
case "tool_use":
|
|
2207
|
+
toolCalls.push({
|
|
2208
|
+
id: str(block.id) ?? "",
|
|
2209
|
+
type: "function",
|
|
2210
|
+
function: {
|
|
2211
|
+
name: str(block.name) ?? "",
|
|
2212
|
+
arguments: JSON.stringify(block.input ?? {})
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
break;
|
|
2216
|
+
case "tool_result": toolResults.push({
|
|
2217
|
+
role: "tool",
|
|
2218
|
+
tool_call_id: str(block.tool_use_id) ?? "",
|
|
2219
|
+
content: blockContentText(block.content)
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
if (role === "assistant") {
|
|
2224
|
+
const msg2 = {
|
|
2225
|
+
role: "assistant",
|
|
2226
|
+
content: texts.join("\n\n")
|
|
2227
|
+
};
|
|
2228
|
+
if (toolCalls.length > 0) msg2.tool_calls = toolCalls;
|
|
2229
|
+
out.push(msg2);
|
|
2230
|
+
} else {
|
|
2231
|
+
if (texts.length > 0) out.push({
|
|
2232
|
+
role: "user",
|
|
2233
|
+
content: texts.join("\n\n")
|
|
2234
|
+
});
|
|
2235
|
+
for (const tr of toolResults) out.push(tr);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
return out;
|
|
2239
|
+
}
|
|
2240
|
+
/** tool_result 的 content:字符串或 [{type:'text'}] 数组。 */
|
|
2241
|
+
function blockContentText(content) {
|
|
2242
|
+
if (typeof content === "string") return content;
|
|
2243
|
+
const parts = [];
|
|
2244
|
+
for (const b of arr(content) ?? []) {
|
|
2245
|
+
const t = str(obj(b)?.text);
|
|
2246
|
+
if (t !== void 0) parts.push(t);
|
|
2247
|
+
}
|
|
2248
|
+
return parts.join("\n\n");
|
|
2249
|
+
}
|
|
2250
|
+
/** Chat 的 content(字符串或 parts 数组)→ 纯文本。 */
|
|
2251
|
+
function contentText$2(content) {
|
|
2252
|
+
if (typeof content === "string") return content;
|
|
2253
|
+
const parts = [];
|
|
2254
|
+
for (const p of arr(content) ?? []) {
|
|
2255
|
+
const part = obj(p);
|
|
2256
|
+
if (part === void 0) continue;
|
|
2257
|
+
const t = str(part.text) ?? str(part.content);
|
|
2258
|
+
if (t !== void 0) parts.push(t);
|
|
2259
|
+
}
|
|
2260
|
+
return parts.join("\n\n");
|
|
2261
|
+
}
|
|
2262
|
+
/** Chat assistant 消息 → Anthropic assistant 消息(tool_calls → tool_use 块)。 */
|
|
2263
|
+
function assistantMessage(msg) {
|
|
2264
|
+
const content = [];
|
|
2265
|
+
const text = contentText$2(msg.content);
|
|
2266
|
+
if (text !== "") content.push({
|
|
2267
|
+
type: "text",
|
|
2268
|
+
text
|
|
2269
|
+
});
|
|
2270
|
+
for (const tc of arr(msg.tool_calls) ?? []) {
|
|
2271
|
+
const call = obj(tc);
|
|
2272
|
+
if (call === void 0) continue;
|
|
2273
|
+
const fn = obj(call.function);
|
|
2274
|
+
content.push({
|
|
2275
|
+
type: "tool_use",
|
|
2276
|
+
id: str(call.id) ?? "",
|
|
2277
|
+
name: str(fn?.name) ?? "",
|
|
2278
|
+
input: parseArgs$1(str(fn?.arguments))
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
return {
|
|
2282
|
+
role: "assistant",
|
|
2283
|
+
content
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
/** 解析工具参数字符串;坏 JSON 返回空对象(上游偶发半截参数,别让整条消息失败)。 */
|
|
2287
|
+
function parseArgs$1(s) {
|
|
2288
|
+
if (s === void 0 || s === "") return {};
|
|
2289
|
+
try {
|
|
2290
|
+
return obj(JSON.parse(s)) ?? {};
|
|
2291
|
+
} catch {
|
|
2292
|
+
return {};
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Anthropic tool → Chat tool(`toChat` 方向)。
|
|
2297
|
+
*
|
|
2298
|
+
* Anthropic 的工具是**扁平**的 `{ name, description, input_schema }`,而 Chat
|
|
2299
|
+
* 要多包一层 `function`、并把 `input_schema` 改名为 `parameters`。两件事都要做。
|
|
2300
|
+
*
|
|
2301
|
+
* ⚠️ 曾经这里读的是 `tool.function.*`(Chat 的形状)—— 方向搞反了,于是从
|
|
2302
|
+
* Anthropic 客户端进来的工具**被静默丢光**,Claude Code 接上来后模型会说
|
|
2303
|
+
* 「我没有这个工具」(2026-10-21 在 3080 实测)。现按 Anthropic 结构读,
|
|
2304
|
+
* 并兼容已经带 `function` 包装的输入(中间层可能提前转过)。
|
|
2305
|
+
*/
|
|
2306
|
+
function convertTools$2(tools) {
|
|
2307
|
+
const out = [];
|
|
2308
|
+
for (const t of arr(tools) ?? []) {
|
|
2309
|
+
const tool = obj(t);
|
|
2310
|
+
if (tool === void 0) continue;
|
|
2311
|
+
const fn = obj(tool.function) ?? tool;
|
|
2312
|
+
const name = str(fn.name);
|
|
2313
|
+
if (name === void 0) continue;
|
|
2314
|
+
out.push({
|
|
2315
|
+
type: "function",
|
|
2316
|
+
function: {
|
|
2317
|
+
name,
|
|
2318
|
+
description: str(fn.description) ?? "",
|
|
2319
|
+
parameters: obj(fn.input_schema) ?? obj(fn.parameters) ?? {
|
|
2320
|
+
type: "object",
|
|
2321
|
+
properties: {}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
return out;
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Chat tool → Anthropic tool(`fromChat` 方向,与上面的 `convertTools` 反着走)。
|
|
2330
|
+
*
|
|
2331
|
+
* Chat 的具名工具是 `{ type:'function', function:{ name, description, parameters } }`
|
|
2332
|
+
* —— **多包了一层** `function`。直接读 `tool.name` 会永远 undefined,于是所有
|
|
2333
|
+
* 工具被静默丢掉(Claude Code 接上来就看不到任何 tool)。所以这里先解包,
|
|
2334
|
+
* 再兼容已经扁平化的输入(有些中间层会提前拍平)。
|
|
2335
|
+
*/
|
|
2336
|
+
function chatToolToAnthropic(t) {
|
|
2337
|
+
const tool = obj(t);
|
|
2338
|
+
if (tool === void 0) return void 0;
|
|
2339
|
+
const fn = obj(tool.function) ?? tool;
|
|
2340
|
+
const name = str(fn.name);
|
|
2341
|
+
if (name === void 0) return void 0;
|
|
2342
|
+
return {
|
|
2343
|
+
name,
|
|
2344
|
+
description: str(fn.description) ?? "",
|
|
2345
|
+
input_schema: obj(fn.parameters) ?? {
|
|
2346
|
+
type: "object",
|
|
2347
|
+
properties: {}
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
/** message_start:把 input_tokens 带出来(Anthropic 只在这帧给输入用量)。 */
|
|
2352
|
+
function messageStartEvents(payload) {
|
|
2353
|
+
const usage = obj(obj(payload.message)?.usage);
|
|
2354
|
+
return [{
|
|
2355
|
+
type: "usage",
|
|
2356
|
+
promptTokens: num(usage?.input_tokens) ?? 0,
|
|
2357
|
+
completionTokens: 0,
|
|
2358
|
+
cachedTokens: num(usage?.cache_read_input_tokens) ?? 0
|
|
2359
|
+
}];
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* content_block_start:`tool_use` 块的**名字**只在这一帧给,后面的
|
|
2363
|
+
* `input_json_delta` 只有参数增量。所以这里必须产出 `tool-start`,否则
|
|
2364
|
+
* tool-start 永远丢失,客户端收到一堆没有名字的参数。
|
|
2365
|
+
*
|
|
2366
|
+
* `text` 块与 `thinking` 块不产出事件:它们的内容全在后续 delta 里,
|
|
2367
|
+
* 这里发空文本只会造成一个多余的空增量。
|
|
2368
|
+
*/
|
|
2369
|
+
function contentBlockStartEvents(payload) {
|
|
2370
|
+
const block = obj(payload.content_block);
|
|
2371
|
+
if (block === void 0 || block.type !== "tool_use") return [];
|
|
2372
|
+
const index = num(payload.index) ?? 0;
|
|
2373
|
+
const name = str(block.name);
|
|
2374
|
+
const id = str(block.id);
|
|
2375
|
+
return [{
|
|
2376
|
+
type: "tool-start",
|
|
2377
|
+
index,
|
|
2378
|
+
...id === void 0 ? {} : { id },
|
|
2379
|
+
...name === void 0 ? {} : { name }
|
|
2380
|
+
}];
|
|
2381
|
+
}
|
|
2382
|
+
/** content_block_delta:四种 delta 各对应一种规范事件。 */
|
|
2383
|
+
function contentDeltaEvents(payload) {
|
|
2384
|
+
const delta = obj(payload.delta);
|
|
2385
|
+
if (delta === void 0) return [];
|
|
2386
|
+
const index = num(payload.index) ?? 0;
|
|
2387
|
+
switch (delta.type) {
|
|
2388
|
+
case "text_delta": {
|
|
2389
|
+
const text = str(delta.text);
|
|
2390
|
+
return text === void 0 ? [] : [{
|
|
2391
|
+
type: "text",
|
|
2392
|
+
text
|
|
2393
|
+
}];
|
|
2394
|
+
}
|
|
2395
|
+
case "thinking_delta":
|
|
2396
|
+
case "signature_delta": {
|
|
2397
|
+
const text = str(delta.thinking);
|
|
2398
|
+
return text === void 0 ? [] : [{
|
|
2399
|
+
type: "reasoning",
|
|
2400
|
+
text
|
|
2401
|
+
}];
|
|
2402
|
+
}
|
|
2403
|
+
case "input_json_delta": {
|
|
2404
|
+
const partial = str(delta.partial_json);
|
|
2405
|
+
return partial === void 0 || partial === "" ? [] : [{
|
|
2406
|
+
type: "tool-args",
|
|
2407
|
+
index,
|
|
2408
|
+
delta: partial
|
|
2409
|
+
}];
|
|
2410
|
+
}
|
|
2411
|
+
default: return [];
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
/** message_delta:finish_reason + 输出用量。 */
|
|
2415
|
+
function messageDeltaEvents(payload) {
|
|
2416
|
+
const out = [];
|
|
2417
|
+
const stop = obj(payload.delta)?.stop_reason;
|
|
2418
|
+
if (stop !== void 0 && stop !== null) out.push({
|
|
2419
|
+
type: "finish",
|
|
2420
|
+
reason: finishOf(stop === "max_tokens" ? "length" : stop)
|
|
2421
|
+
});
|
|
2422
|
+
const usage = obj(payload.usage);
|
|
2423
|
+
if (usage !== void 0) {
|
|
2424
|
+
const prompt = num(usage.input_tokens);
|
|
2425
|
+
const completion = num(usage.output_tokens);
|
|
2426
|
+
if (prompt !== void 0 || completion !== void 0) out.push({
|
|
2427
|
+
type: "usage",
|
|
2428
|
+
promptTokens: prompt ?? 0,
|
|
2429
|
+
completionTokens: completion ?? 0,
|
|
2430
|
+
cachedTokens: num(usage.cache_read_input_tokens) ?? 0
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
return out;
|
|
2434
|
+
}
|
|
2435
|
+
function finishToAnthropic(reason) {
|
|
2436
|
+
switch (reason) {
|
|
2437
|
+
case "length": return "max_tokens";
|
|
2438
|
+
case "tool-calls": return "tool_use";
|
|
2439
|
+
case "content-filter": return "refusal";
|
|
2440
|
+
default: return "end_turn";
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
//#endregion
|
|
2444
|
+
//#region src/protocol/responses.ts
|
|
2445
|
+
/**
|
|
2446
|
+
* OpenAI Responses 协议(Codex 用的是它)。
|
|
2447
|
+
*
|
|
2448
|
+
* ════════════════════════════════════════════════════════════════════
|
|
2449
|
+
* 与 Chat Completions 的关键差异
|
|
2450
|
+
* ════════════════════════════════════════════════════════════════════
|
|
2451
|
+
* 请求 `input` 是**消息数组**(与 messages 同构)而不是 messages 本身
|
|
2452
|
+
* `instructions` 替代 `system` 消息
|
|
2453
|
+
* `max_output_tokens` 替代 `max_tokens`
|
|
2454
|
+
* `tools` 项是扁平的 `{type:'function', name, parameters}`,
|
|
2455
|
+
* 没有 `function` 这一层包装
|
|
2456
|
+
* 响应 事件名带语义:`response.output_text.delta` / `response.function_call_arguments.delta`
|
|
2457
|
+
* / `response.reasoning_summary_text.delta`,全部带 `sequence_number`
|
|
2458
|
+
* 收尾是 `response.completed`(带完整 response 对象),不是 finish_reason
|
|
2459
|
+
*
|
|
2460
|
+
* 丢弃(明确不做):
|
|
2461
|
+
* - `previous_response_id` / `store`:服务端会话态。我们的供应商契约是
|
|
2462
|
+
* 无状态的(`chatOnce` 一次一请求),转发过去只会拿到错误。
|
|
2463
|
+
* - `reasoning.effort` 之外的 `reasoning.summary`:摘要粒度由上游定,
|
|
2464
|
+
* 我们只透传 reasoning 增量。
|
|
2465
|
+
* - `include` / `text.format`(结构化输出):跨协议没有对应物。
|
|
2466
|
+
*/
|
|
2467
|
+
const responsesCodec = {
|
|
2468
|
+
id: "responses",
|
|
2469
|
+
/** Responses 请求 → Chat Completions 请求。 */
|
|
2470
|
+
toChat(body, ctx) {
|
|
2471
|
+
const out = { ...body };
|
|
2472
|
+
delete out.input;
|
|
2473
|
+
delete out.instructions;
|
|
2474
|
+
delete out.tools;
|
|
2475
|
+
delete out.max_output_tokens;
|
|
2476
|
+
delete out.previous_response_id;
|
|
2477
|
+
delete out.store;
|
|
2478
|
+
delete out.include;
|
|
2479
|
+
delete out.reasoning;
|
|
2480
|
+
const messages = [];
|
|
2481
|
+
const instructions = str(body.instructions);
|
|
2482
|
+
if (instructions !== void 0) messages.push({
|
|
2483
|
+
role: "system",
|
|
2484
|
+
content: instructions
|
|
2485
|
+
});
|
|
2486
|
+
for (const m of arr(body.input) ?? []) messages.push(inputItem(m));
|
|
2487
|
+
out.messages = messages;
|
|
2488
|
+
if (body.max_output_tokens !== void 0) out.max_tokens = body.max_output_tokens;
|
|
2489
|
+
if (body.temperature !== void 0) out.temperature = body.temperature;
|
|
2490
|
+
if (body.top_p !== void 0) out.top_p = body.top_p;
|
|
2491
|
+
const tools = convertTools$1(body.tools);
|
|
2492
|
+
if (tools.length > 0) out.tools = tools;
|
|
2493
|
+
if (ctx.stream) out.stream = true;
|
|
2494
|
+
return out;
|
|
2495
|
+
},
|
|
2496
|
+
/** Chat Completions 请求 → Responses 请求。 */
|
|
2497
|
+
fromChat(body, ctx) {
|
|
2498
|
+
const out = { ...body };
|
|
2499
|
+
delete out.messages;
|
|
2500
|
+
delete out.tools;
|
|
2501
|
+
delete out.stream;
|
|
2502
|
+
delete out.max_tokens;
|
|
2503
|
+
const input = [];
|
|
2504
|
+
const instructions = [];
|
|
2505
|
+
for (const m of arr(body.messages) ?? []) {
|
|
2506
|
+
const msg = obj(m);
|
|
2507
|
+
if (msg === void 0) continue;
|
|
2508
|
+
const role = str(msg.role);
|
|
2509
|
+
if (role === "system" || role === "developer") {
|
|
2510
|
+
const t = contentText$1(msg.content);
|
|
2511
|
+
if (t !== "") instructions.push(t);
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
if (role === "tool") {
|
|
2515
|
+
input.push({
|
|
2516
|
+
type: "function_call_output",
|
|
2517
|
+
call_id: str(msg.tool_call_id) ?? "",
|
|
2518
|
+
output: contentText$1(msg.content)
|
|
2519
|
+
});
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
const text = contentText$1(msg.content);
|
|
2523
|
+
if (text !== "") input.push({
|
|
2524
|
+
type: "message",
|
|
2525
|
+
role: role === "assistant" ? "assistant" : "user",
|
|
2526
|
+
content: [{
|
|
2527
|
+
type: role === "assistant" ? "output_text" : "input_text",
|
|
2528
|
+
text
|
|
2529
|
+
}]
|
|
2530
|
+
});
|
|
2531
|
+
if (role === "assistant") for (const tc of arr(msg.tool_calls) ?? []) {
|
|
2532
|
+
const call = obj(tc);
|
|
2533
|
+
const fn = obj(call?.function);
|
|
2534
|
+
if (call === void 0 || fn === void 0) continue;
|
|
2535
|
+
const name = str(fn.name);
|
|
2536
|
+
if (name === void 0) continue;
|
|
2537
|
+
input.push({
|
|
2538
|
+
type: "function_call",
|
|
2539
|
+
call_id: str(call.id) ?? "",
|
|
2540
|
+
name,
|
|
2541
|
+
arguments: str(fn.arguments) ?? "{}"
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
if (instructions.length > 0) out.instructions = instructions.join("\n\n");
|
|
2546
|
+
out.input = input;
|
|
2547
|
+
if (body.max_tokens !== void 0) out.max_output_tokens = body.max_tokens;
|
|
2548
|
+
const tools = body.tools?.map(chatToolToResponses).filter((t) => t !== void 0);
|
|
2549
|
+
if (tools !== void 0 && tools.length > 0) out.tools = tools;
|
|
2550
|
+
if (ctx.stream) out.stream = true;
|
|
2551
|
+
return out;
|
|
2552
|
+
},
|
|
2553
|
+
decodeFrame(frame, event) {
|
|
2554
|
+
let payload;
|
|
2555
|
+
try {
|
|
2556
|
+
payload = JSON.parse(frame);
|
|
2557
|
+
} catch {
|
|
2558
|
+
return [];
|
|
2559
|
+
}
|
|
2560
|
+
switch (event) {
|
|
2561
|
+
case "response.output_text.delta": {
|
|
2562
|
+
const delta = str(payload.delta);
|
|
2563
|
+
return delta === void 0 ? [] : [{
|
|
2564
|
+
type: "text",
|
|
2565
|
+
text: delta
|
|
2566
|
+
}];
|
|
2567
|
+
}
|
|
2568
|
+
case "response.reasoning_summary_text.delta":
|
|
2569
|
+
case "response.reasoning_text.delta": {
|
|
2570
|
+
const delta = str(payload.delta);
|
|
2571
|
+
return delta === void 0 ? [] : [{
|
|
2572
|
+
type: "reasoning",
|
|
2573
|
+
text: delta
|
|
2574
|
+
}];
|
|
2575
|
+
}
|
|
2576
|
+
case "response.function_call_arguments.delta": {
|
|
2577
|
+
const delta = str(payload.delta);
|
|
2578
|
+
if (delta === void 0 || delta === "") return [];
|
|
2579
|
+
return [{
|
|
2580
|
+
type: "tool-args",
|
|
2581
|
+
index: num(payload.output_index) ?? 0,
|
|
2582
|
+
delta
|
|
2583
|
+
}];
|
|
2584
|
+
}
|
|
2585
|
+
case "response.output_item.added": return outputItemEvents(payload);
|
|
2586
|
+
case "response.completed": return completedEvents(payload);
|
|
2587
|
+
case "response.failed":
|
|
2588
|
+
case "error": return [{
|
|
2589
|
+
type: "error",
|
|
2590
|
+
message: str((obj(obj(payload.response)?.error) ?? obj(payload.error))?.message) ?? str(payload.message) ?? "upstream error"
|
|
2591
|
+
}];
|
|
2592
|
+
default: return [];
|
|
2593
|
+
}
|
|
2594
|
+
},
|
|
2595
|
+
encodeFrame(event) {
|
|
2596
|
+
switch (event.type) {
|
|
2597
|
+
case "text": return [eventFrame("response.output_text.delta", JSON.stringify({
|
|
2598
|
+
type: "response.output_text.delta",
|
|
2599
|
+
output_index: 0,
|
|
2600
|
+
delta: event.text
|
|
2601
|
+
}))];
|
|
2602
|
+
case "reasoning": return [eventFrame("response.reasoning_summary_text.delta", JSON.stringify({
|
|
2603
|
+
type: "response.reasoning_summary_text.delta",
|
|
2604
|
+
output_index: 0,
|
|
2605
|
+
delta: event.text
|
|
2606
|
+
}))];
|
|
2607
|
+
case "tool-start": return [eventFrame("response.output_item.added", JSON.stringify({
|
|
2608
|
+
type: "response.output_item.added",
|
|
2609
|
+
output_index: event.index,
|
|
2610
|
+
item: {
|
|
2611
|
+
type: "function_call",
|
|
2612
|
+
id: event.id ?? `fc_${event.index}`,
|
|
2613
|
+
call_id: event.id ?? `call_${event.index}`,
|
|
2614
|
+
name: event.name ?? "",
|
|
2615
|
+
arguments: ""
|
|
2616
|
+
}
|
|
2617
|
+
}))];
|
|
2618
|
+
case "tool-args": return [eventFrame("response.function_call_arguments.delta", JSON.stringify({
|
|
2619
|
+
type: "response.function_call_arguments.delta",
|
|
2620
|
+
item_id: `fc_${event.index}`,
|
|
2621
|
+
output_index: event.index,
|
|
2622
|
+
delta: event.delta
|
|
2623
|
+
}))];
|
|
2624
|
+
case "usage": return [];
|
|
2625
|
+
case "error": return [eventFrame("error", JSON.stringify({
|
|
2626
|
+
type: "error",
|
|
2627
|
+
code: "server_error",
|
|
2628
|
+
message: event.message
|
|
2629
|
+
}))];
|
|
2630
|
+
case "finish": return [];
|
|
2631
|
+
}
|
|
2632
|
+
},
|
|
2633
|
+
startFrames() {
|
|
2634
|
+
return [eventFrame("response.created", JSON.stringify({
|
|
2635
|
+
type: "response.created",
|
|
2636
|
+
response: {
|
|
2637
|
+
id: `resp_${Date.now().toString(36)}`,
|
|
2638
|
+
status: "in_progress",
|
|
2639
|
+
output: []
|
|
2640
|
+
}
|
|
2641
|
+
}))];
|
|
2642
|
+
},
|
|
2643
|
+
/**
|
|
2644
|
+
* 流结束帧:`response.completed` 带一个完整的 response 对象。
|
|
2645
|
+
*
|
|
2646
|
+
* 用量与收尾原因都从 `ctx` 来(`StreamState.finishStream` 填),因为 Codex
|
|
2647
|
+
* 读的是 `response.completed` 里的 `status`/`usage`,而不是一个 finish_reason
|
|
2648
|
+
* 字段 —— 少了这一帧客户端会一直等流结束。
|
|
2649
|
+
*/
|
|
2650
|
+
endFrames(ctx) {
|
|
2651
|
+
return [completedFrame(ctx.model, ctx.stream ? ctx.usage : void 0, ctx.finish)];
|
|
2652
|
+
},
|
|
2653
|
+
/**
|
|
2654
|
+
* 非流式响应体 → 规范事件。
|
|
2655
|
+
*
|
|
2656
|
+
* 非流式 Responses body 形状:`{output:[{type:'message',content:[{type:'output_text',text}]}
|
|
2657
|
+
* |{type:'function_call',name,arguments}], status, usage:{input_tokens,output_tokens}}`。
|
|
2658
|
+
* 与流式的 `response.output_item.added` / `response.completed` 共用同一组
|
|
2659
|
+
* item→事件 映射,见 `outputItemEvents`。
|
|
2660
|
+
*/
|
|
2661
|
+
decodeJson(body) {
|
|
2662
|
+
const out = [];
|
|
2663
|
+
let index = 0;
|
|
2664
|
+
let text = "";
|
|
2665
|
+
for (const item of arr(body.output) ?? []) {
|
|
2666
|
+
const it = obj(item);
|
|
2667
|
+
if (it === void 0) continue;
|
|
2668
|
+
if (it.type === "message") {
|
|
2669
|
+
for (const p of arr(it.content) ?? []) {
|
|
2670
|
+
const t = str(obj(p)?.text);
|
|
2671
|
+
if (t !== void 0) text += t;
|
|
2672
|
+
}
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2675
|
+
if (it.type === "function_call") {
|
|
2676
|
+
out.push({
|
|
2677
|
+
type: "tool-start",
|
|
2678
|
+
index,
|
|
2679
|
+
name: str(it.name) ?? ""
|
|
2680
|
+
});
|
|
2681
|
+
out.push({
|
|
2682
|
+
type: "tool-args",
|
|
2683
|
+
index,
|
|
2684
|
+
delta: str(it.arguments) ?? ""
|
|
2685
|
+
});
|
|
2686
|
+
index += 1;
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
if (it.type === "reasoning") for (const s of arr(it.summary) ?? []) {
|
|
2690
|
+
const t = str(obj(s)?.text);
|
|
2691
|
+
if (t !== void 0) out.push({
|
|
2692
|
+
type: "reasoning",
|
|
2693
|
+
text: t
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
if (text !== "") out.unshift({
|
|
2698
|
+
type: "text",
|
|
2699
|
+
text
|
|
2700
|
+
});
|
|
2701
|
+
const usage = obj(body.usage);
|
|
2702
|
+
if (usage !== void 0) out.push({
|
|
2703
|
+
type: "usage",
|
|
2704
|
+
promptTokens: num(usage.input_tokens) ?? 0,
|
|
2705
|
+
completionTokens: num(usage.output_tokens) ?? 0,
|
|
2706
|
+
cachedTokens: num(obj(usage.input_tokens_details)?.cached_tokens) ?? 0
|
|
2707
|
+
});
|
|
2708
|
+
const status = str(body.status);
|
|
2709
|
+
if (status === "incomplete") out.push({
|
|
2710
|
+
type: "finish",
|
|
2711
|
+
reason: "length"
|
|
2712
|
+
});
|
|
2713
|
+
else if (status === "failed") out.push({
|
|
2714
|
+
type: "finish",
|
|
2715
|
+
reason: "error"
|
|
2716
|
+
});
|
|
2717
|
+
else out.push({
|
|
2718
|
+
type: "finish",
|
|
2719
|
+
reason: "stop"
|
|
2720
|
+
});
|
|
2721
|
+
return out;
|
|
2722
|
+
},
|
|
2723
|
+
/** 规范事件 → 非流式 body。 */
|
|
2724
|
+
encodeJson(events, ctx) {
|
|
2725
|
+
const output = [];
|
|
2726
|
+
let status = "completed";
|
|
2727
|
+
let usage = {
|
|
2728
|
+
input: 0,
|
|
2729
|
+
output: 0
|
|
2730
|
+
};
|
|
2731
|
+
for (const ev of events) switch (ev.type) {
|
|
2732
|
+
case "text":
|
|
2733
|
+
if (output.length > 0 && output[output.length - 1]?.type === "message") {
|
|
2734
|
+
const c = output[output.length - 1].content;
|
|
2735
|
+
if (c.length > 0 && c[c.length - 1]?.type === "output_text") {
|
|
2736
|
+
c[c.length - 1].text += ev.text;
|
|
2737
|
+
break;
|
|
2738
|
+
}
|
|
2739
|
+
c.push({
|
|
2740
|
+
type: "output_text",
|
|
2741
|
+
text: ev.text
|
|
2742
|
+
});
|
|
2743
|
+
break;
|
|
2744
|
+
}
|
|
2745
|
+
output.push({
|
|
2746
|
+
type: "message",
|
|
2747
|
+
role: "assistant",
|
|
2748
|
+
content: [{
|
|
2749
|
+
type: "output_text",
|
|
2750
|
+
text: ev.text
|
|
2751
|
+
}]
|
|
2752
|
+
});
|
|
2753
|
+
break;
|
|
2754
|
+
case "reasoning": {
|
|
2755
|
+
const last = output[output.length - 1];
|
|
2756
|
+
if (last?.type === "reasoning") last.summary[0].text += ev.text;
|
|
2757
|
+
else output.push({
|
|
2758
|
+
type: "reasoning",
|
|
2759
|
+
summary: [{
|
|
2760
|
+
type: "summary_text",
|
|
2761
|
+
text: ev.text
|
|
2762
|
+
}]
|
|
2763
|
+
});
|
|
2764
|
+
break;
|
|
2765
|
+
}
|
|
2766
|
+
case "tool-start":
|
|
2767
|
+
output.push({
|
|
2768
|
+
type: "function_call",
|
|
2769
|
+
id: ev.id ?? `fc_${ev.index}`,
|
|
2770
|
+
call_id: ev.id ?? `call_${ev.index}`,
|
|
2771
|
+
name: ev.name ?? "",
|
|
2772
|
+
arguments: ""
|
|
2773
|
+
});
|
|
2774
|
+
break;
|
|
2775
|
+
case "tool-args": {
|
|
2776
|
+
const last = output[output.length - 1];
|
|
2777
|
+
if (last?.type === "function_call") last.arguments += ev.delta;
|
|
2778
|
+
break;
|
|
2779
|
+
}
|
|
2780
|
+
case "finish":
|
|
2781
|
+
if (ev.reason === "length") status = "incomplete";
|
|
2782
|
+
else if (ev.reason === "error") status = "failed";
|
|
2783
|
+
break;
|
|
2784
|
+
case "usage": usage = {
|
|
2785
|
+
input: ev.promptTokens,
|
|
2786
|
+
output: ev.completionTokens
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
return {
|
|
2790
|
+
id: `resp_${Date.now().toString(36)}`,
|
|
2791
|
+
object: "response",
|
|
2792
|
+
status,
|
|
2793
|
+
model: ctx.model,
|
|
2794
|
+
output,
|
|
2795
|
+
usage: {
|
|
2796
|
+
input_tokens: usage.input,
|
|
2797
|
+
output_tokens: usage.output,
|
|
2798
|
+
total_tokens: usage.input + usage.output
|
|
2799
|
+
}
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
/** Responses 的 input 项 → Chat message。 */
|
|
2804
|
+
function inputItem(item) {
|
|
2805
|
+
const it = obj(item);
|
|
2806
|
+
if (it === void 0) return void 0;
|
|
2807
|
+
const type = str(it.type);
|
|
2808
|
+
if (type === "message" || type === void 0) {
|
|
2809
|
+
const role = str(it.role) ?? "user";
|
|
2810
|
+
const content = it.content;
|
|
2811
|
+
if (typeof content === "string") return {
|
|
2812
|
+
role,
|
|
2813
|
+
content
|
|
2814
|
+
};
|
|
2815
|
+
const text = [];
|
|
2816
|
+
for (const p of arr(content) ?? []) {
|
|
2817
|
+
const part = obj(p);
|
|
2818
|
+
const t = part === void 0 ? void 0 : str(part.text) ?? str(part.output_text);
|
|
2819
|
+
if (t !== void 0) text.push(t);
|
|
2820
|
+
}
|
|
2821
|
+
return {
|
|
2822
|
+
role,
|
|
2823
|
+
content: text.join("\n\n")
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
if (type === "function_call") return {
|
|
2827
|
+
role: "assistant",
|
|
2828
|
+
content: "",
|
|
2829
|
+
tool_calls: [{
|
|
2830
|
+
id: str(it.call_id) ?? str(it.id) ?? "",
|
|
2831
|
+
type: "function",
|
|
2832
|
+
function: {
|
|
2833
|
+
name: str(it.name) ?? "",
|
|
2834
|
+
arguments: str(it.arguments) ?? ""
|
|
2835
|
+
}
|
|
2836
|
+
}]
|
|
2837
|
+
};
|
|
2838
|
+
if (type === "function_call_output") return {
|
|
2839
|
+
role: "tool",
|
|
2840
|
+
tool_call_id: str(it.call_id) ?? "",
|
|
2841
|
+
content: str(it.output) ?? ""
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
/** Chat 的 content(字符串或 parts)→ 纯文本。 */
|
|
2845
|
+
function contentText$1(content) {
|
|
2846
|
+
if (typeof content === "string") return content;
|
|
2847
|
+
const parts = [];
|
|
2848
|
+
for (const p of arr(content) ?? []) {
|
|
2849
|
+
const t = str(obj(p)?.text);
|
|
2850
|
+
if (t !== void 0) parts.push(t);
|
|
2851
|
+
}
|
|
2852
|
+
return parts.join("\n\n");
|
|
2853
|
+
}
|
|
2854
|
+
/** Chat tools → Responses tools(扁平形态)。 */
|
|
2855
|
+
function convertTools$1(tools) {
|
|
2856
|
+
const out = [];
|
|
2857
|
+
for (const t of arr(tools) ?? []) {
|
|
2858
|
+
const tool = obj(t);
|
|
2859
|
+
const fn = tool === void 0 ? void 0 : obj(tool.function) ?? tool;
|
|
2860
|
+
if (fn === void 0) continue;
|
|
2861
|
+
const name = str(fn.name);
|
|
2862
|
+
if (name === void 0) continue;
|
|
2863
|
+
out.push({
|
|
2864
|
+
type: "function",
|
|
2865
|
+
function: {
|
|
2866
|
+
name,
|
|
2867
|
+
description: str(fn.description) ?? "",
|
|
2868
|
+
parameters: obj(fn.parameters) ?? {
|
|
2869
|
+
type: "object",
|
|
2870
|
+
properties: {}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
return out;
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Chat tool → Responses tool(`fromChat` 方向)。Responses 的 tools 是**扁平**的
|
|
2879
|
+
* `{ type:'function', name, description, parameters }`,而 Chat 的具名工具多包了
|
|
2880
|
+
* 一层 `function`。不解包的话 `tool.name` 恒为 undefined,工具被静默全部丢掉。
|
|
2881
|
+
*/
|
|
2882
|
+
function chatToolToResponses(t) {
|
|
2883
|
+
const tool = obj(t);
|
|
2884
|
+
if (tool === void 0) return void 0;
|
|
2885
|
+
const fn = obj(tool.function) ?? tool;
|
|
2886
|
+
const name = str(fn.name);
|
|
2887
|
+
if (name === void 0) return void 0;
|
|
2888
|
+
return {
|
|
2889
|
+
type: "function",
|
|
2890
|
+
name,
|
|
2891
|
+
description: str(fn.description) ?? "",
|
|
2892
|
+
parameters: obj(fn.parameters) ?? {
|
|
2893
|
+
type: "object",
|
|
2894
|
+
properties: {}
|
|
2895
|
+
}
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
2898
|
+
/** `response.output_item.added`:函数调用项给出名字。 */
|
|
2899
|
+
function outputItemEvents(payload) {
|
|
2900
|
+
const item = obj(payload.item);
|
|
2901
|
+
if (item === void 0 || item.type !== "function_call") return [];
|
|
2902
|
+
return [{
|
|
2903
|
+
type: "tool-start",
|
|
2904
|
+
index: num(payload.output_index) ?? 0,
|
|
2905
|
+
...str(item.id) === void 0 ? {} : { id: str(item.id) },
|
|
2906
|
+
name: str(item.name) ?? ""
|
|
2907
|
+
}];
|
|
2908
|
+
}
|
|
2909
|
+
/** `response.completed`:收尾 + 用量都在这帧。 */
|
|
2910
|
+
function completedEvents(payload) {
|
|
2911
|
+
const response = obj(payload.response);
|
|
2912
|
+
const out = [];
|
|
2913
|
+
const usage = obj(response?.usage);
|
|
2914
|
+
if (usage !== void 0) out.push({
|
|
2915
|
+
type: "usage",
|
|
2916
|
+
promptTokens: num(usage.input_tokens) ?? 0,
|
|
2917
|
+
completionTokens: num(usage.output_tokens) ?? 0,
|
|
2918
|
+
cachedTokens: num(obj(usage.input_tokens_details)?.cached_tokens) ?? 0
|
|
2919
|
+
});
|
|
2920
|
+
const status = str(response?.status);
|
|
2921
|
+
if (status === "failed") out.push({
|
|
2922
|
+
type: "finish",
|
|
2923
|
+
reason: "error"
|
|
2924
|
+
});
|
|
2925
|
+
else if (status === "incomplete") out.push({
|
|
2926
|
+
type: "finish",
|
|
2927
|
+
reason: finishOf(str(obj(response?.incomplete_details)?.reason))
|
|
2928
|
+
});
|
|
2929
|
+
else out.push({
|
|
2930
|
+
type: "finish",
|
|
2931
|
+
reason: outputHasToolCall(response?.output) ? "tool-calls" : "stop"
|
|
2932
|
+
});
|
|
2933
|
+
return out;
|
|
2934
|
+
}
|
|
2935
|
+
/** `response.output` 里有没有 `function_call` 项。 */
|
|
2936
|
+
function outputHasToolCall(output) {
|
|
2937
|
+
for (const item of Array.isArray(output) ? output : []) if (obj(item)?.type === "function_call") return true;
|
|
2938
|
+
return false;
|
|
2939
|
+
}
|
|
2940
|
+
/**
|
|
2941
|
+
* 拼 `response.completed` 帧(收尾时由调用方带用量与收尾原因调)。
|
|
2942
|
+
*
|
|
2943
|
+
* `output` 里要写一条 `function_call` 占位项(当 finish 是 tool-calls 时)——
|
|
2944
|
+
* Responses 没有 finish_reason 字段,客户端(以及我们自己的 `completedEvents`)
|
|
2945
|
+
* 只能靠 `output` 里有没有 function_call 判断「这轮是调工具还是说完」。
|
|
2946
|
+
* 少了这条,工具轮在往返后会被认成普通结束。
|
|
2947
|
+
*/
|
|
2948
|
+
function completedFrame(model, usage, finish) {
|
|
2949
|
+
const response = {
|
|
2950
|
+
id: `resp_${Date.now().toString(36)}`,
|
|
2951
|
+
object: "response",
|
|
2952
|
+
status: finish === "length" ? "incomplete" : "completed",
|
|
2953
|
+
model,
|
|
2954
|
+
output: finish === "tool-calls" ? [{
|
|
2955
|
+
type: "function_call",
|
|
2956
|
+
id: `fc_${Date.now().toString(36)}`,
|
|
2957
|
+
call_id: `call_${Date.now().toString(36)}`,
|
|
2958
|
+
name: "",
|
|
2959
|
+
arguments: ""
|
|
2960
|
+
}] : []
|
|
2961
|
+
};
|
|
2962
|
+
if (finish === "length") response.incomplete_details = { reason: "max_output_tokens" };
|
|
2963
|
+
if (usage !== void 0) response.usage = {
|
|
2964
|
+
input_tokens: usage.promptTokens,
|
|
2965
|
+
output_tokens: usage.completionTokens,
|
|
2966
|
+
total_tokens: usage.promptTokens + usage.completionTokens,
|
|
2967
|
+
...usage.cachedTokens !== void 0 && usage.cachedTokens > 0 ? { input_tokens_details: { cached_tokens: usage.cachedTokens } } : {}
|
|
2968
|
+
};
|
|
2969
|
+
return eventFrame("response.completed", JSON.stringify({
|
|
2970
|
+
type: "response.completed",
|
|
2971
|
+
response
|
|
2972
|
+
}));
|
|
2973
|
+
}
|
|
2974
|
+
//#endregion
|
|
2975
|
+
//#region src/protocol/gemini.ts
|
|
2976
|
+
/**
|
|
2977
|
+
* Google Gemini 协议(`generateContent` / `streamGenerateContent`)。
|
|
2978
|
+
*
|
|
2979
|
+
* ════════════════════════════════════════════════════════════════════
|
|
2980
|
+
* 与其他三种的差异
|
|
2981
|
+
* ════════════════════════════════════════════════════════════════════
|
|
2982
|
+
* 1. **模型名在 URL 路径里**:`/v1beta/models/<model>:generateContent`,
|
|
2983
|
+
* 请求体里没有 `model` 字段。
|
|
2984
|
+
* 2. **角色名不同**:`user` / `model`(不是 assistant)。
|
|
2985
|
+
* 3. **内容是 parts**:`{parts:[{text}]}`,不是字符串。
|
|
2986
|
+
* 4. **工具调用是整块**:`functionCall` part 一次给全 `{name,args}`,
|
|
2987
|
+
* 没有增量形态 —— 故 decode 时拆成 tool-start + tool-args 一段。
|
|
2988
|
+
* 5. **鉴权走 query 参数 `?key=`** 或 `x-goog-api-key` 头(不是 Bearer)。
|
|
2989
|
+
* 6. **流式响应是 JSON 数组分片**(`[{...},{...}]`),不是 SSE ——
|
|
2990
|
+
* `streamGenerateContent?alt=sse` 才给 SSE。我们只支持 `alt=sse`,
|
|
2991
|
+
* 非 SSE 形态明确不支持(见文件尾 `geminiStreamSse` 的说明)。
|
|
2992
|
+
*
|
|
2993
|
+
* 丢弃:`safetySettings` / `tools[].googleSearch` / `cachedContent` /
|
|
2994
|
+
* `systemInstruction` 之外的 `generationConfig` 大部分字段(只留
|
|
2995
|
+
* temperature / maxOutputTokens / stopSequences)。
|
|
2996
|
+
*/
|
|
2997
|
+
const geminiCodec = {
|
|
2998
|
+
id: "gemini",
|
|
2999
|
+
/** Gemini 请求 → Chat Completions 请求。 */
|
|
3000
|
+
toChat(body, ctx) {
|
|
3001
|
+
const out = {};
|
|
3002
|
+
const messages = geminiMessages(body);
|
|
3003
|
+
const sys = systemText(body.systemInstruction);
|
|
3004
|
+
if (sys !== "") messages.unshift({
|
|
3005
|
+
role: "system",
|
|
3006
|
+
content: sys
|
|
3007
|
+
});
|
|
3008
|
+
out.messages = messages;
|
|
3009
|
+
const gen = obj(body.generationConfig);
|
|
3010
|
+
if (gen !== void 0) {
|
|
3011
|
+
if (num(gen.temperature) !== void 0) out.temperature = gen.temperature;
|
|
3012
|
+
if (num(gen.maxOutputTokens) !== void 0) out.max_tokens = gen.maxOutputTokens;
|
|
3013
|
+
if (arr(gen.stopSequences) !== void 0) out.stop = gen.stopSequences;
|
|
3014
|
+
}
|
|
3015
|
+
const tools = convertTools(body.tools);
|
|
3016
|
+
if (tools.length > 0) out.tools = tools;
|
|
3017
|
+
if (ctx.stream) out.stream = true;
|
|
3018
|
+
out.model = ctx.model;
|
|
3019
|
+
return out;
|
|
3020
|
+
},
|
|
3021
|
+
/** Chat Completions 请求 → Gemini 请求。 */
|
|
3022
|
+
fromChat(body, ctx) {
|
|
3023
|
+
const out = {};
|
|
3024
|
+
const systemParts = [];
|
|
3025
|
+
const contents = [];
|
|
3026
|
+
for (const m of arr(body.messages) ?? []) {
|
|
3027
|
+
const msg = obj(m);
|
|
3028
|
+
if (msg === void 0) continue;
|
|
3029
|
+
const role = str(msg.role);
|
|
3030
|
+
if (role === "system" || role === "developer") {
|
|
3031
|
+
const t = contentText(msg.content);
|
|
3032
|
+
if (t !== "") systemParts.push(t);
|
|
3033
|
+
continue;
|
|
3034
|
+
}
|
|
3035
|
+
if (role === "assistant") {
|
|
3036
|
+
const parts = [];
|
|
3037
|
+
const text = contentText(msg.content);
|
|
3038
|
+
if (text !== "") parts.push({ text });
|
|
3039
|
+
for (const tc of arr(msg.tool_calls) ?? []) {
|
|
3040
|
+
const fn = obj(obj(tc)?.function);
|
|
3041
|
+
parts.push({ functionCall: {
|
|
3042
|
+
name: str(fn?.name) ?? "",
|
|
3043
|
+
args: parseArgs(str(fn?.arguments))
|
|
3044
|
+
} });
|
|
3045
|
+
}
|
|
3046
|
+
contents.push({
|
|
3047
|
+
role: "model",
|
|
3048
|
+
parts
|
|
3049
|
+
});
|
|
3050
|
+
continue;
|
|
3051
|
+
}
|
|
3052
|
+
if (role === "tool") {
|
|
3053
|
+
contents.push({
|
|
3054
|
+
role: "user",
|
|
3055
|
+
parts: [{ functionResponse: {
|
|
3056
|
+
name: str(msg.name) ?? "",
|
|
3057
|
+
response: { result: contentText(msg.content) }
|
|
3058
|
+
} }]
|
|
3059
|
+
});
|
|
3060
|
+
continue;
|
|
3061
|
+
}
|
|
3062
|
+
contents.push({
|
|
3063
|
+
role: "user",
|
|
3064
|
+
parts: [{ text: contentText(msg.content) }]
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
out.contents = contents;
|
|
3068
|
+
if (systemParts.length > 0) out.systemInstruction = { parts: [{ text: systemParts.join("\n\n") }] };
|
|
3069
|
+
if (num(body.temperature) !== void 0 || num(body.max_tokens) !== void 0) out.generationConfig = {
|
|
3070
|
+
...num(body.temperature) === void 0 ? {} : { temperature: body.temperature },
|
|
3071
|
+
...num(body.max_tokens) === void 0 ? {} : { maxOutputTokens: body.max_tokens }
|
|
3072
|
+
};
|
|
3073
|
+
if (arr(body.stop) !== void 0) out.generationConfig.stopSequences = body.stop;
|
|
3074
|
+
const tools = body.tools?.map(chatToolToGemini).filter((t) => t !== void 0);
|
|
3075
|
+
if (tools !== void 0 && tools.length > 0) out.tools = [{ functionDeclarations: tools }];
|
|
3076
|
+
return out;
|
|
3077
|
+
},
|
|
3078
|
+
createSession() {
|
|
3079
|
+
return { state: newGeminiToolMap() };
|
|
3080
|
+
},
|
|
3081
|
+
decodeFrame(frame, _event, session) {
|
|
3082
|
+
let payload;
|
|
3083
|
+
try {
|
|
3084
|
+
payload = JSON.parse(frame);
|
|
3085
|
+
} catch {
|
|
3086
|
+
return [];
|
|
3087
|
+
}
|
|
3088
|
+
return geminiBodyEvents(payload, mapOf(session));
|
|
3089
|
+
},
|
|
3090
|
+
encodeFrame(event, session) {
|
|
3091
|
+
const toolMap = mapOf(session);
|
|
3092
|
+
const chunk = (parts, finish) => [dataFrame(JSON.stringify({ candidates: [{
|
|
3093
|
+
content: {
|
|
3094
|
+
role: "model",
|
|
3095
|
+
parts
|
|
3096
|
+
},
|
|
3097
|
+
...finish === void 0 ? {} : { finishReason: finish }
|
|
3098
|
+
}] }))];
|
|
3099
|
+
switch (event.type) {
|
|
3100
|
+
case "text": return chunk([{ text: event.text }]);
|
|
3101
|
+
case "reasoning": return chunk([{
|
|
3102
|
+
text: event.text,
|
|
3103
|
+
thought: true
|
|
3104
|
+
}]);
|
|
3105
|
+
case "tool-start": {
|
|
3106
|
+
const name = event.name ?? "";
|
|
3107
|
+
if (name !== "") toolMap.remember(event.index, name);
|
|
3108
|
+
return [];
|
|
3109
|
+
}
|
|
3110
|
+
case "tool-args":
|
|
3111
|
+
toolMap.appendArgs(event.index, event.delta);
|
|
3112
|
+
return [];
|
|
3113
|
+
case "usage": return [dataFrame(JSON.stringify({ usageMetadata: {
|
|
3114
|
+
promptTokenCount: event.promptTokens,
|
|
3115
|
+
candidatesTokenCount: event.completionTokens,
|
|
3116
|
+
totalTokenCount: event.promptTokens + event.completionTokens,
|
|
3117
|
+
...event.cachedTokens > 0 ? { cachedContentTokenCount: event.cachedTokens } : {}
|
|
3118
|
+
} }))];
|
|
3119
|
+
case "error": return [dataFrame(JSON.stringify({ error: {
|
|
3120
|
+
code: 500,
|
|
3121
|
+
message: event.message,
|
|
3122
|
+
status: "INTERNAL"
|
|
3123
|
+
} }))];
|
|
3124
|
+
case "finish": return [];
|
|
3125
|
+
}
|
|
3126
|
+
},
|
|
3127
|
+
startFrames: () => [],
|
|
3128
|
+
/**
|
|
3129
|
+
* 流末一次性发出**完整的** functionCall。
|
|
3130
|
+
*
|
|
3131
|
+
* 为什么集中在这里:Gemini 没有参数增量形态,一个调用必须「名字+完整参数」
|
|
3132
|
+
* 用一个 part 给出。上游(OpenAI 系)逐帧发参数增量,`tool-args` 只能攒;
|
|
3133
|
+
* 攒到流末参数才完整,此时才发得出合法的 `functionCall`。
|
|
3134
|
+
*
|
|
3135
|
+
* 半截 JSON(上游断流)**不发**:发出去客户端会拿坏参数执行工具,
|
|
3136
|
+
* 比少一次调用危险得多。
|
|
3137
|
+
*/
|
|
3138
|
+
endFrames(ctx, session) {
|
|
3139
|
+
const toolMap = mapOf(session);
|
|
3140
|
+
const out = [];
|
|
3141
|
+
for (const index of toolMap.pendingIndexes()) {
|
|
3142
|
+
const name = toolMap.nameOf(index);
|
|
3143
|
+
if (name === "") continue;
|
|
3144
|
+
const args = toolMap.completeArgs(index);
|
|
3145
|
+
if (args === void 0) continue;
|
|
3146
|
+
out.push(dataFrame(JSON.stringify({ candidates: [{ content: {
|
|
3147
|
+
role: "model",
|
|
3148
|
+
parts: [{ functionCall: {
|
|
3149
|
+
name,
|
|
3150
|
+
args
|
|
3151
|
+
} }]
|
|
3152
|
+
} }] })));
|
|
3153
|
+
}
|
|
3154
|
+
out.push(...chunkOf([], finishToGemini(ctx.finish ?? "stop")));
|
|
3155
|
+
return out;
|
|
3156
|
+
},
|
|
3157
|
+
/**
|
|
3158
|
+
* 非流式响应体 → 规范事件。
|
|
3159
|
+
*
|
|
3160
|
+
* Gemini 非流式 body 与流式一帧同形(`{candidates:[{content:{parts},finishReason}],
|
|
3161
|
+
* usageMetadata}`),所以直接复用 `decodeFrame` —— 这是四种协议里唯一
|
|
3162
|
+
* 「流与非流形状一致」的,没有第二份映射可写。
|
|
3163
|
+
*/
|
|
3164
|
+
decodeJson(body) {
|
|
3165
|
+
return geminiBodyEvents(body, newGeminiToolMap());
|
|
3166
|
+
},
|
|
3167
|
+
/** 规范事件 → 非流式 body。 */
|
|
3168
|
+
encodeJson(events, ctx) {
|
|
3169
|
+
const toolMap = newGeminiToolMap();
|
|
3170
|
+
const parts = [];
|
|
3171
|
+
let finish = "STOP";
|
|
3172
|
+
let usage = {
|
|
3173
|
+
prompt: 0,
|
|
3174
|
+
output: 0,
|
|
3175
|
+
cached: 0
|
|
3176
|
+
};
|
|
3177
|
+
for (const ev of events) switch (ev.type) {
|
|
3178
|
+
case "text":
|
|
3179
|
+
parts.push({ text: ev.text });
|
|
3180
|
+
break;
|
|
3181
|
+
case "reasoning":
|
|
3182
|
+
parts.push({
|
|
3183
|
+
text: ev.text,
|
|
3184
|
+
thought: true
|
|
3185
|
+
});
|
|
3186
|
+
break;
|
|
3187
|
+
case "tool-start": {
|
|
3188
|
+
const name = ev.name ?? "";
|
|
3189
|
+
if (name !== "") toolMap.remember(ev.index, name);
|
|
3190
|
+
break;
|
|
3191
|
+
}
|
|
3192
|
+
case "tool-args": {
|
|
3193
|
+
const name = toolMap.nameOf(ev.index);
|
|
3194
|
+
if (name !== "") parts.push({ functionCall: {
|
|
3195
|
+
name,
|
|
3196
|
+
args: parseArgs(ev.delta)
|
|
3197
|
+
} });
|
|
3198
|
+
break;
|
|
3199
|
+
}
|
|
3200
|
+
case "finish":
|
|
3201
|
+
finish = finishToGemini(ev.reason);
|
|
3202
|
+
break;
|
|
3203
|
+
case "usage": usage = {
|
|
3204
|
+
prompt: ev.promptTokens,
|
|
3205
|
+
output: ev.completionTokens,
|
|
3206
|
+
cached: ev.cachedTokens
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
return {
|
|
3210
|
+
candidates: [{
|
|
3211
|
+
content: {
|
|
3212
|
+
role: "model",
|
|
3213
|
+
parts
|
|
3214
|
+
},
|
|
3215
|
+
finishReason: finish,
|
|
3216
|
+
index: 0
|
|
3217
|
+
}],
|
|
3218
|
+
usageMetadata: {
|
|
3219
|
+
promptTokenCount: usage.prompt,
|
|
3220
|
+
candidatesTokenCount: usage.output,
|
|
3221
|
+
totalTokenCount: usage.prompt + usage.output,
|
|
3222
|
+
...usage.cached > 0 ? { cachedContentTokenCount: usage.cached } : {}
|
|
3223
|
+
}
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
};
|
|
3227
|
+
/** 一帧 `candidates`。与 `encodeFrame` 里的闭包同形,供 `endFrames` 用。 */
|
|
3228
|
+
function chunkOf(parts, finish) {
|
|
3229
|
+
return [dataFrame(JSON.stringify({ candidates: [{
|
|
3230
|
+
content: {
|
|
3231
|
+
role: "model",
|
|
3232
|
+
parts
|
|
3233
|
+
},
|
|
3234
|
+
...finish === void 0 ? {} : { finishReason: finish }
|
|
3235
|
+
}] }))];
|
|
3236
|
+
}
|
|
3237
|
+
/** 新建一份空的工具名映射。 */
|
|
3238
|
+
function newGeminiToolMap() {
|
|
3239
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3240
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
3241
|
+
/** index → 已累积的参数片段(跨帧拼接后才是完整 JSON)。 */
|
|
3242
|
+
const argsByIndex = /* @__PURE__ */ new Map();
|
|
3243
|
+
let seq = 0;
|
|
3244
|
+
return {
|
|
3245
|
+
indexOf(name) {
|
|
3246
|
+
const existing = byName.get(name);
|
|
3247
|
+
if (existing !== void 0) return existing;
|
|
3248
|
+
const index = seq++;
|
|
3249
|
+
byName.set(name, index);
|
|
3250
|
+
byIndex.set(index, name);
|
|
3251
|
+
return index;
|
|
3252
|
+
},
|
|
3253
|
+
nameOf(index) {
|
|
3254
|
+
return byIndex.get(index) ?? "";
|
|
3255
|
+
},
|
|
3256
|
+
remember(index, name) {
|
|
3257
|
+
byIndex.set(index, name);
|
|
3258
|
+
if (!byName.has(name)) byName.set(name, index);
|
|
3259
|
+
},
|
|
3260
|
+
appendArgs(index, delta) {
|
|
3261
|
+
const next = (argsByIndex.get(index) ?? "") + delta;
|
|
3262
|
+
argsByIndex.set(index, next);
|
|
3263
|
+
return next;
|
|
3264
|
+
},
|
|
3265
|
+
completeArgs(index) {
|
|
3266
|
+
const raw = argsByIndex.get(index);
|
|
3267
|
+
if (raw === void 0 || raw === "") return {};
|
|
3268
|
+
try {
|
|
3269
|
+
return obj(JSON.parse(raw)) ?? void 0;
|
|
3270
|
+
} catch {
|
|
3271
|
+
return;
|
|
3272
|
+
}
|
|
3273
|
+
},
|
|
3274
|
+
pendingIndexes() {
|
|
3275
|
+
return [...argsByIndex.keys()].sort((a, b) => a - b);
|
|
3276
|
+
}
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* 取本次调用的工具名映射:有会话用会话的,没有就临时建一份。
|
|
3281
|
+
*
|
|
3282
|
+
* 「没有会话」= 单帧独立转换(测试、非流式一次性)。结果与无状态等价 ——
|
|
3283
|
+
* 名字在自己的调用里总能拿到,只是不跨帧复用。
|
|
3284
|
+
*/
|
|
3285
|
+
function mapOf(session) {
|
|
3286
|
+
const st = session?.state;
|
|
3287
|
+
return st instanceof Object && "indexOf" in st ? st : newGeminiToolMap();
|
|
3288
|
+
}
|
|
3289
|
+
/**
|
|
3290
|
+
* 一个 Gemini 响应体(流式一帧或非流式 body,两者同形)→ 规范事件。
|
|
3291
|
+
*
|
|
3292
|
+
* Gemini 是四种协议里唯一「流与非流形状一致」的,所以只有这一份映射。
|
|
3293
|
+
*/
|
|
3294
|
+
function geminiBodyEvents(payload, tools) {
|
|
3295
|
+
const out = [];
|
|
3296
|
+
const candidate = obj(arr(payload.candidates)?.[0]);
|
|
3297
|
+
if (candidate !== void 0) {
|
|
3298
|
+
const finish = candidate.finishReason;
|
|
3299
|
+
if (finish !== void 0 && finish !== null) out.push({
|
|
3300
|
+
type: "finish",
|
|
3301
|
+
reason: finishOf(finish)
|
|
3302
|
+
});
|
|
3303
|
+
for (const p of arr(obj(candidate.content)?.parts) ?? []) {
|
|
3304
|
+
const part = obj(p);
|
|
3305
|
+
if (part === void 0) continue;
|
|
3306
|
+
const text = str(part.text);
|
|
3307
|
+
if (text !== void 0) {
|
|
3308
|
+
if (part.thought === true) out.push({
|
|
3309
|
+
type: "reasoning",
|
|
3310
|
+
text
|
|
3311
|
+
});
|
|
3312
|
+
else out.push({
|
|
3313
|
+
type: "text",
|
|
3314
|
+
text
|
|
3315
|
+
});
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
const fc = obj(part.functionCall);
|
|
3319
|
+
if (fc !== void 0) {
|
|
3320
|
+
const name = str(fc.name) ?? "";
|
|
3321
|
+
if (name === "") continue;
|
|
3322
|
+
const index = tools.indexOf(name);
|
|
3323
|
+
out.push({
|
|
3324
|
+
type: "tool-start",
|
|
3325
|
+
index,
|
|
3326
|
+
name
|
|
3327
|
+
});
|
|
3328
|
+
out.push({
|
|
3329
|
+
type: "tool-args",
|
|
3330
|
+
index,
|
|
3331
|
+
delta: JSON.stringify(fc.args ?? {})
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
const usage = obj(payload.usageMetadata);
|
|
3337
|
+
if (usage !== void 0) out.push({
|
|
3338
|
+
type: "usage",
|
|
3339
|
+
promptTokens: num(usage.promptTokenCount) ?? 0,
|
|
3340
|
+
completionTokens: num(usage.candidatesTokenCount) ?? 0,
|
|
3341
|
+
cachedTokens: num(usage.cachedContentTokenCount) ?? 0
|
|
3342
|
+
});
|
|
3343
|
+
return out;
|
|
3344
|
+
}
|
|
3345
|
+
/** Gemini 的 systemInstruction → 纯文本。 */
|
|
3346
|
+
function systemText(sys) {
|
|
3347
|
+
const parts = [];
|
|
3348
|
+
for (const p of arr(obj(sys)?.parts) ?? []) {
|
|
3349
|
+
const t = str(obj(p)?.text);
|
|
3350
|
+
if (t !== void 0) parts.push(t);
|
|
3351
|
+
}
|
|
3352
|
+
return parts.join("\n\n");
|
|
3353
|
+
}
|
|
3354
|
+
/** Gemini contents → Chat messages。 */
|
|
3355
|
+
function geminiMessages(body) {
|
|
3356
|
+
const out = [];
|
|
3357
|
+
for (const c of arr(body.contents) ?? []) {
|
|
3358
|
+
const item = obj(c);
|
|
3359
|
+
if (item === void 0) continue;
|
|
3360
|
+
const role = str(item.role) === "model" ? "assistant" : "user";
|
|
3361
|
+
const text = [];
|
|
3362
|
+
const toolCalls = [];
|
|
3363
|
+
for (const p of arr(item.parts) ?? []) {
|
|
3364
|
+
const part = obj(p);
|
|
3365
|
+
if (part === void 0) continue;
|
|
3366
|
+
const t = str(part.text);
|
|
3367
|
+
if (t !== void 0) {
|
|
3368
|
+
text.push(t);
|
|
3369
|
+
continue;
|
|
3370
|
+
}
|
|
3371
|
+
const fc = obj(part.functionCall);
|
|
3372
|
+
if (fc !== void 0) {
|
|
3373
|
+
toolCalls.push({
|
|
3374
|
+
id: `call_${str(fc.name) ?? ""}`,
|
|
3375
|
+
type: "function",
|
|
3376
|
+
function: {
|
|
3377
|
+
name: str(fc.name) ?? "",
|
|
3378
|
+
arguments: JSON.stringify(fc.args ?? {})
|
|
3379
|
+
}
|
|
3380
|
+
});
|
|
3381
|
+
continue;
|
|
3382
|
+
}
|
|
3383
|
+
const fr = obj(part.functionResponse);
|
|
3384
|
+
if (fr !== void 0) out.push({
|
|
3385
|
+
role: "tool",
|
|
3386
|
+
tool_call_id: `call_${str(fr.name) ?? ""}`,
|
|
3387
|
+
content: JSON.stringify(obj(fr.response)?.result ?? {})
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
if (role === "assistant") {
|
|
3391
|
+
const msg = {
|
|
3392
|
+
role: "assistant",
|
|
3393
|
+
content: text.join("\n\n")
|
|
3394
|
+
};
|
|
3395
|
+
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
3396
|
+
out.push(msg);
|
|
3397
|
+
} else if (text.length > 0) out.push({
|
|
3398
|
+
role: "user",
|
|
3399
|
+
content: text.join("\n\n")
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
return out;
|
|
3403
|
+
}
|
|
3404
|
+
/** Chat 的 content(字符串或 parts)→ 纯文本。 */
|
|
3405
|
+
function contentText(content) {
|
|
3406
|
+
if (typeof content === "string") return content;
|
|
3407
|
+
const parts = [];
|
|
3408
|
+
for (const p of arr(content) ?? []) {
|
|
3409
|
+
const t = str(obj(p)?.text);
|
|
3410
|
+
if (t !== void 0) parts.push(t);
|
|
3411
|
+
}
|
|
3412
|
+
return parts.join("\n\n");
|
|
3413
|
+
}
|
|
3414
|
+
function parseArgs(s) {
|
|
3415
|
+
if (s === void 0 || s === "") return {};
|
|
3416
|
+
try {
|
|
3417
|
+
return obj(JSON.parse(s)) ?? {};
|
|
3418
|
+
} catch {
|
|
3419
|
+
return {};
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
/** Chat tools → Gemini functionDeclarations。 */
|
|
3423
|
+
function chatToolToGemini(t) {
|
|
3424
|
+
const fn = obj(obj(t)?.function);
|
|
3425
|
+
const name = fn === void 0 ? void 0 : str(fn.name);
|
|
3426
|
+
if (fn === void 0 || name === void 0) return void 0;
|
|
3427
|
+
return {
|
|
3428
|
+
name,
|
|
3429
|
+
description: str(fn.description) ?? "",
|
|
3430
|
+
parameters: obj(fn.parameters) ?? {
|
|
3431
|
+
type: "object",
|
|
3432
|
+
properties: {}
|
|
3433
|
+
}
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
/** Gemini tools → Chat tools。 */
|
|
3437
|
+
function convertTools(tools) {
|
|
3438
|
+
const out = [];
|
|
3439
|
+
for (const t of arr(tools) ?? []) for (const d of arr(obj(t)?.functionDeclarations) ?? []) {
|
|
3440
|
+
const decl = obj(d);
|
|
3441
|
+
if (decl === void 0) continue;
|
|
3442
|
+
const name = str(decl.name);
|
|
3443
|
+
if (name === void 0) continue;
|
|
3444
|
+
out.push({
|
|
3445
|
+
type: "function",
|
|
3446
|
+
function: {
|
|
3447
|
+
name,
|
|
3448
|
+
description: str(decl.description) ?? "",
|
|
3449
|
+
parameters: obj(decl.parameters) ?? {
|
|
3450
|
+
type: "object",
|
|
3451
|
+
properties: {}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
});
|
|
3455
|
+
}
|
|
3456
|
+
return out;
|
|
3457
|
+
}
|
|
3458
|
+
function finishToGemini(reason) {
|
|
3459
|
+
switch (reason) {
|
|
3460
|
+
case "length": return "MAX_TOKENS";
|
|
3461
|
+
case "tool-calls": return "STOP";
|
|
3462
|
+
case "content-filter": return "SAFETY";
|
|
3463
|
+
case "error": return "OTHER";
|
|
3464
|
+
default: return "STOP";
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
//#endregion
|
|
3468
|
+
//#region src/protocol/index.ts
|
|
3469
|
+
const CODECS = {
|
|
3470
|
+
chat: chatCodec,
|
|
3471
|
+
anthropic: anthropicCodec,
|
|
3472
|
+
responses: responsesCodec,
|
|
3473
|
+
gemini: geminiCodec
|
|
3474
|
+
};
|
|
3475
|
+
/** 取编解码器。id 已由 `isProtocolId` 校验过,这里不再兜底。 */
|
|
3476
|
+
function codecOf(id) {
|
|
3477
|
+
return CODECS[id];
|
|
3478
|
+
}
|
|
3479
|
+
//#endregion
|
|
3480
|
+
//#region src/protocol/stream.ts
|
|
3481
|
+
/** 一次流式转码的有状态累加。 */
|
|
3482
|
+
var StreamState = class {
|
|
3483
|
+
/** 上游协议(解码用)。 */
|
|
3484
|
+
from;
|
|
3485
|
+
/** 客户端协议(编码用)。 */
|
|
3486
|
+
to;
|
|
3487
|
+
/** 上游 usage(跨帧合并,字段级 max —— 见 mergeUsage 的注释)。 */
|
|
3488
|
+
usage = {
|
|
3489
|
+
promptTokens: 0,
|
|
3490
|
+
completionTokens: 0,
|
|
3491
|
+
cachedTokens: 0
|
|
3492
|
+
};
|
|
3493
|
+
/** 已经吐过起始帧没有(起始帧只能在流最前发一次)。 */
|
|
3494
|
+
started = false;
|
|
3495
|
+
/** 收尾原因(用于补 endFrames;缺省 stop)。 */
|
|
3496
|
+
finish;
|
|
3497
|
+
/** 模型名(回显进 startFrames/endFrames,如 Anthropic 的 message_start)。 */
|
|
3498
|
+
model = "";
|
|
3499
|
+
/**
|
|
3500
|
+
* 解码侧会话与编码侧会话,各一份。
|
|
3501
|
+
*
|
|
3502
|
+
* **必须每帧复用同一个**:Gemini 的工具名↔index 映射就存在这里;每帧新建
|
|
3503
|
+
* 会让 `tool-args` 找不到 `tool-start` 给的名字(参数被丢),而放模块级会
|
|
3504
|
+
* 跨请求串名。会话在 `StreamState` 构造时建、随流结束丢弃 —— 生命周期与
|
|
3505
|
+
* 「一条流」严格对齐。
|
|
3506
|
+
*/
|
|
3507
|
+
decodeSession;
|
|
3508
|
+
encodeSession;
|
|
3509
|
+
constructor(from, to, model = "") {
|
|
3510
|
+
this.from = from;
|
|
3511
|
+
this.to = to;
|
|
3512
|
+
this.model = model;
|
|
3513
|
+
this.decodeSession = codecOf(from).createSession?.();
|
|
3514
|
+
this.encodeSession = codecOf(to).createSession?.();
|
|
3515
|
+
}
|
|
3516
|
+
/** 当前累计的用量。 */
|
|
3517
|
+
currentUsage() {
|
|
3518
|
+
return { ...this.usage };
|
|
3519
|
+
}
|
|
3520
|
+
/** 把上游一帧原文(`data:` 载荷)翻成出站帧原文列表。 */
|
|
3521
|
+
translateFrame(frame, event) {
|
|
3522
|
+
const events = codecOf(this.from).decodeFrame(frame, event, this.decodeSession);
|
|
3523
|
+
const out = [];
|
|
3524
|
+
if (!this.started) {
|
|
3525
|
+
this.started = true;
|
|
3526
|
+
out.push(...codecOf(this.to).startFrames({
|
|
3527
|
+
model: this.model,
|
|
3528
|
+
stream: true
|
|
3529
|
+
}));
|
|
3530
|
+
}
|
|
3531
|
+
for (const ev of events) {
|
|
3532
|
+
if (ev.type === "usage") this.mergeUsage(ev);
|
|
3533
|
+
if (ev.type === "finish") this.finish = ev;
|
|
3534
|
+
if (ev.type === "finish" || ev.type === "usage") continue;
|
|
3535
|
+
out.push(...codecOf(this.to).encodeFrame(ev, this.encodeSession));
|
|
3536
|
+
}
|
|
3537
|
+
return out;
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* 上游流结束:补发收尾帧。
|
|
3541
|
+
*
|
|
3542
|
+
* **顺序固定**:finish → usage → endFrames。各协议的收尾帧都要求这个次序
|
|
3543
|
+
* (Anthropic 的 message_delta 必须在 message_stop 前,Responses 的
|
|
3544
|
+
* `response.completed` 是最后一件且自带 usage)。
|
|
3545
|
+
*
|
|
3546
|
+
* `endFrames` 拿得到 finish 与 usage —— 有的协议(Responses)只有收尾帧
|
|
3547
|
+
* 里才有地方放 usage 与「是否被截断」。
|
|
3548
|
+
*/
|
|
3549
|
+
finishStream() {
|
|
3550
|
+
const enc = codecOf(this.to);
|
|
3551
|
+
const ctx = {
|
|
3552
|
+
model: this.model,
|
|
3553
|
+
stream: true,
|
|
3554
|
+
finish: this.finish?.reason ?? "stop",
|
|
3555
|
+
usage: { ...this.usage }
|
|
3556
|
+
};
|
|
3557
|
+
if (!this.started) {
|
|
3558
|
+
this.started = true;
|
|
3559
|
+
return [...enc.startFrames(ctx), ...enc.endFrames(ctx, this.encodeSession)];
|
|
3560
|
+
}
|
|
3561
|
+
const finishSession = this.finishSessionWithUsage();
|
|
3562
|
+
const out = [];
|
|
3563
|
+
out.push(...enc.encodeFrame(this.finish ?? {
|
|
3564
|
+
type: "finish",
|
|
3565
|
+
reason: "stop"
|
|
3566
|
+
}, finishSession));
|
|
3567
|
+
out.push(...enc.encodeFrame({
|
|
3568
|
+
type: "usage",
|
|
3569
|
+
...this.usage
|
|
3570
|
+
}, finishSession));
|
|
3571
|
+
out.push(...enc.endFrames(ctx, finishSession));
|
|
3572
|
+
return out;
|
|
3573
|
+
}
|
|
3574
|
+
/**
|
|
3575
|
+
* 带当前用量派生的编码会话。
|
|
3576
|
+
*
|
|
3577
|
+
* 不能只在 `encodeSession` 存在时才派生:`createSession` 是**可选**的
|
|
3578
|
+
* (只有 Gemini 这类有跨帧映射的协议实现它),Anthropic 没有 —— 按
|
|
3579
|
+
* `encodeSession === undefined` 就返回 undefined 会让 usage 永远送不进去
|
|
3580
|
+
* (2026-10-21 实测:output_tokens 恒为 0)。所以无论如何都造一个,
|
|
3581
|
+
* 有 state 就带上。
|
|
3582
|
+
*/
|
|
3583
|
+
finishSessionWithUsage() {
|
|
3584
|
+
return {
|
|
3585
|
+
...this.encodeSession,
|
|
3586
|
+
usage: { ...this.usage }
|
|
3587
|
+
};
|
|
3588
|
+
}
|
|
3589
|
+
/**
|
|
3590
|
+
* usage 合并:字段级 max。
|
|
3591
|
+
*
|
|
3592
|
+
* 与 `router/usage-tokens.ts` 的 `mergeUsage` 同规则(那里照 9router):
|
|
3593
|
+
* Claude 把输入用量放 `message_start`、输出放 `message_delta`,两帧都给
|
|
3594
|
+
* `usage` 字段,**覆盖**会让后一帧把前面的输入清零。
|
|
3595
|
+
*/
|
|
3596
|
+
mergeUsage(ev) {
|
|
3597
|
+
this.usage.promptTokens = Math.max(this.usage.promptTokens, ev.promptTokens);
|
|
3598
|
+
this.usage.completionTokens = Math.max(this.usage.completionTokens, ev.completionTokens);
|
|
3599
|
+
this.usage.cachedTokens = Math.max(this.usage.cachedTokens, ev.cachedTokens);
|
|
3600
|
+
}
|
|
3601
|
+
};
|
|
3602
|
+
/**
|
|
3603
|
+
* 把上游 SSE 字节流转成客户端协议的 SSE 字节流。
|
|
1427
3604
|
*
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
3605
|
+
* 生命周期:上游正常读完 / 上游 error / 消费方取消,三条路径都要**释放上游
|
|
3606
|
+
* reader** 并把取消传下去 —— 否则上游 fetch body 永不 cancel,长跑会漏连接
|
|
3607
|
+
* (`router/index.ts` 的 `normalizeSSEStream` 里同一个坑,判据在
|
|
3608
|
+
* `router/disconnect-leak.test.ts`)。
|
|
1430
3609
|
*/
|
|
1431
|
-
function
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
outputEstimated
|
|
3610
|
+
function translateStream(upstream, state) {
|
|
3611
|
+
const splitter = new SseSplitter();
|
|
3612
|
+
let reader;
|
|
3613
|
+
let released = false;
|
|
3614
|
+
const release = () => {
|
|
3615
|
+
if (released) return;
|
|
3616
|
+
released = true;
|
|
3617
|
+
try {
|
|
3618
|
+
reader?.releaseLock();
|
|
3619
|
+
} catch {}
|
|
1442
3620
|
};
|
|
3621
|
+
return new ReadableStream({
|
|
3622
|
+
async start(ctrl) {
|
|
3623
|
+
reader = upstream.getReader();
|
|
3624
|
+
try {
|
|
3625
|
+
for (;;) {
|
|
3626
|
+
const { done, value } = await reader.read();
|
|
3627
|
+
if (done) break;
|
|
3628
|
+
const frames = splitter.push(value);
|
|
3629
|
+
for (const f of frames) {
|
|
3630
|
+
if (f.data === "[DONE]" || f.data === "") continue;
|
|
3631
|
+
const out = state.translateFrame(f.data, f.event);
|
|
3632
|
+
if (out.length > 0) ctrl.enqueue(framesToBytes(out));
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
for (const f of splitter.end()) {
|
|
3636
|
+
if (f.data === "[DONE]" || f.data === "") continue;
|
|
3637
|
+
const out = state.translateFrame(f.data, f.event);
|
|
3638
|
+
if (out.length > 0) ctrl.enqueue(framesToBytes(out));
|
|
3639
|
+
}
|
|
3640
|
+
const tail = state.finishStream();
|
|
3641
|
+
if (tail.length > 0) ctrl.enqueue(framesToBytes(tail));
|
|
3642
|
+
} catch {} finally {
|
|
3643
|
+
release();
|
|
3644
|
+
}
|
|
3645
|
+
try {
|
|
3646
|
+
ctrl.close();
|
|
3647
|
+
} catch {}
|
|
3648
|
+
},
|
|
3649
|
+
cancel: (reason) => reader?.cancel(reason)
|
|
3650
|
+
});
|
|
1443
3651
|
}
|
|
1444
3652
|
//#endregion
|
|
1445
3653
|
//#region src/router/index.ts
|
|
@@ -1603,28 +3811,35 @@ function reqMessages(rawBody) {
|
|
|
1603
3811
|
return;
|
|
1604
3812
|
}
|
|
1605
3813
|
}
|
|
1606
|
-
/**
|
|
1607
|
-
function sinkRes() {
|
|
1608
|
-
let status = 200;
|
|
1609
|
-
let text = "";
|
|
3814
|
+
/** 把 ResponseSink 包成一个 Router 能写的 ServerResponse。 */
|
|
3815
|
+
function sinkRes(sink) {
|
|
1610
3816
|
const self = {
|
|
1611
3817
|
headersSent: false,
|
|
1612
3818
|
writableEnded: false,
|
|
1613
|
-
|
|
1614
|
-
|
|
3819
|
+
destroyed: false,
|
|
3820
|
+
writeHead: (code, headers) => {
|
|
3821
|
+
sink.head?.(code, headers ?? {});
|
|
1615
3822
|
return self;
|
|
1616
3823
|
},
|
|
1617
3824
|
write: (chunk) => {
|
|
1618
|
-
if (chunk !== void 0)
|
|
3825
|
+
if (chunk !== void 0) sink.write(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
1619
3826
|
return true;
|
|
1620
3827
|
},
|
|
1621
3828
|
end: (chunk) => {
|
|
1622
|
-
if (chunk !== void 0)
|
|
3829
|
+
if (chunk !== void 0) self.write(chunk);
|
|
3830
|
+
sink.end();
|
|
1623
3831
|
return self;
|
|
1624
3832
|
},
|
|
1625
3833
|
flushHeaders: () => {},
|
|
1626
|
-
|
|
1627
|
-
|
|
3834
|
+
once: () => self,
|
|
3835
|
+
on: () => self,
|
|
3836
|
+
removeListener: () => self,
|
|
3837
|
+
destroy: () => {
|
|
3838
|
+
self.destroyed = true;
|
|
3839
|
+
},
|
|
3840
|
+
setHeader: () => self,
|
|
3841
|
+
removeHeader: () => self,
|
|
3842
|
+
writeableEnded: false
|
|
1628
3843
|
};
|
|
1629
3844
|
return self;
|
|
1630
3845
|
}
|
|
@@ -1978,6 +4193,51 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0, firstB
|
|
|
1978
4193
|
}
|
|
1979
4194
|
/** SSE 终止帧(断流兜底补发用)。 */
|
|
1980
4195
|
const DONE_FRAME = new TextEncoder().encode("data: [DONE]\n\n");
|
|
4196
|
+
/**
|
|
4197
|
+
* 取该供应商的 `chatOnce`,按它声明的 wire 包一层协议翻译。
|
|
4198
|
+
*
|
|
4199
|
+
* ════════════════════════════════════════════════════════════════════
|
|
4200
|
+
* 为什么翻译放在核心、而不是让插件自己做
|
|
4201
|
+
* ════════════════════════════════════════════════════════════════════
|
|
4202
|
+
* 插件自己翻译意味着「每个只会说 Anthropic 的供应商各写一份 Anthropic→OpenAI
|
|
4203
|
+
* 转换」,与账号池/冷却表/积分落盘是同一个错误:N 份互相不一致的实现,核心
|
|
4204
|
+
* 也就无从判断该不该换号。放在核心,插件契约不变(仍只吐 OpenAI SSE),
|
|
4205
|
+
* 换号/冷却/用量全部照旧生效。
|
|
4206
|
+
*
|
|
4207
|
+
* 翻译**只碰请求体与响应流**,不碰 `chatOnce` 的返回值语义:失败仍然返回
|
|
4208
|
+
* `{ok:false, state, message}`,核心的冷却/回退逻辑一行不改。
|
|
4209
|
+
*
|
|
4210
|
+
* 非流式同理:上游给 JSON 时,先把它翻成规范 JSON 再交给核心(`writeChatResult`
|
|
4211
|
+
* 的 `!('stream' in r)` 分支),客户端要流式而上游只给 JSON 时由现有的
|
|
4212
|
+
* `aggregateSSE` 兜底(那条路径本来就要把 JSON 折成流)。
|
|
4213
|
+
*/
|
|
4214
|
+
function wireChatOnce(s) {
|
|
4215
|
+
const wire = s.wire ?? "chat";
|
|
4216
|
+
if (wire === "chat") return (uid, lv, req) => s.chatOnce(uid, lv, req);
|
|
4217
|
+
const codec = codecOf(wire);
|
|
4218
|
+
return async (uid, lv, req) => {
|
|
4219
|
+
let outReq = req;
|
|
4220
|
+
let body;
|
|
4221
|
+
try {
|
|
4222
|
+
body = JSON.parse(req.rawBody);
|
|
4223
|
+
} catch {
|
|
4224
|
+
body = void 0;
|
|
4225
|
+
}
|
|
4226
|
+
if (body !== void 0) outReq = {
|
|
4227
|
+
...req,
|
|
4228
|
+
rawBody: JSON.stringify(codec.fromChat(body, {
|
|
4229
|
+
model: req.model,
|
|
4230
|
+
stream: req.stream
|
|
4231
|
+
}))
|
|
4232
|
+
};
|
|
4233
|
+
const r = await s.chatOnce(uid, lv, outReq);
|
|
4234
|
+
if (!r.ok || !("stream" in r)) return r;
|
|
4235
|
+
return {
|
|
4236
|
+
ok: true,
|
|
4237
|
+
stream: translateStream(r.stream, new StreamState(wire, "chat"))
|
|
4238
|
+
};
|
|
4239
|
+
};
|
|
4240
|
+
}
|
|
1981
4241
|
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
1982
4242
|
function extractUpstreamError(body) {
|
|
1983
4243
|
try {
|
|
@@ -2164,6 +4424,15 @@ var Router = class {
|
|
|
2164
4424
|
} catch {}
|
|
2165
4425
|
return out;
|
|
2166
4426
|
}
|
|
4427
|
+
/**
|
|
4428
|
+
* 该供应商的 `chatOnce`,已按它声明的 wire 包好协议翻译。
|
|
4429
|
+
*
|
|
4430
|
+
* 每次调用都现取(而不是构造时缓存):供应商会被开关/热重载替换,
|
|
4431
|
+
* 缓存会把旧实例的翻译绑在新实例上。
|
|
4432
|
+
*/
|
|
4433
|
+
wireOf(s) {
|
|
4434
|
+
return wireChatOnce(s);
|
|
4435
|
+
}
|
|
2167
4436
|
/** 组合列表(面板用):用户自定义组合。 */
|
|
2168
4437
|
async combos() {
|
|
2169
4438
|
return [...this.customCombos];
|
|
@@ -2402,7 +4671,17 @@ var Router = class {
|
|
|
2402
4671
|
* - model 命中组合名 → 在组合模型里按策略选一个,交给供应商;失败按回退顺序尝试剩余模型。
|
|
2403
4672
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
2404
4673
|
*/
|
|
2405
|
-
|
|
4674
|
+
/**
|
|
4675
|
+
* 规范请求入口。
|
|
4676
|
+
*
|
|
4677
|
+
* @param res 真实的客户端响应。协议非 chat 时**不直接用它**,而是经
|
|
4678
|
+
* `sink` 先收集再翻译(见 `protocol/entry.ts`)—— 因为 Router 只知道
|
|
4679
|
+
* 怎么写 OpenAI 形态,而客户端说的可能是 Anthropic/Gemini。
|
|
4680
|
+
* @param sink 可选的响应收集器:给了就把「写给 res 的东西」改成写给 sink,
|
|
4681
|
+
* 由调用方翻译后自己写真正的 res。只给非 chat 协议用。
|
|
4682
|
+
*/
|
|
4683
|
+
async chatCompletions(req, res, sink) {
|
|
4684
|
+
const out = sink === void 0 ? res : sinkRes(sink);
|
|
2406
4685
|
const probe = {
|
|
2407
4686
|
requested: req.model,
|
|
2408
4687
|
supplier: "",
|
|
@@ -2439,34 +4718,34 @@ var Router = class {
|
|
|
2439
4718
|
const model = combo.models[i];
|
|
2440
4719
|
if (model === void 0) continue;
|
|
2441
4720
|
const trace = { attempts: 0 };
|
|
2442
|
-
if (await this.chatWithModel(req,
|
|
4721
|
+
if (await this.chatWithModel(req, out, model, probe, trace, Date.now() + this.firstByteBudgetMs)) {
|
|
2443
4722
|
settle(true);
|
|
2444
4723
|
return;
|
|
2445
4724
|
}
|
|
2446
4725
|
if (trace.lastState !== void 0 && isTransient(trace.lastState) && i < combo.models.length - 1) await new Promise((r) => setTimeout(r, TRANSIENT_SETTLE_MS));
|
|
2447
4726
|
}
|
|
2448
4727
|
settle(false, "combo: all models unavailable");
|
|
2449
|
-
writeJson$2(
|
|
4728
|
+
writeJson$2(out, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
|
|
2450
4729
|
return;
|
|
2451
4730
|
}
|
|
2452
4731
|
const slash = req.model.indexOf("/");
|
|
2453
4732
|
const alias = slash > 0 ? req.model.slice(0, slash) : "";
|
|
2454
4733
|
const target = alias === "" ? void 0 : this.supplierByAlias(alias);
|
|
2455
4734
|
if (target !== void 0) {
|
|
2456
|
-
if (await this.chatWithTarget(target, req,
|
|
4735
|
+
if (await this.chatWithTarget(target, req, out, probe)) {
|
|
2457
4736
|
settle(true);
|
|
2458
4737
|
return;
|
|
2459
4738
|
}
|
|
2460
4739
|
settle(false, `supplier ${target.id}: all accounts unavailable`);
|
|
2461
|
-
writeJson$2(
|
|
4740
|
+
writeJson$2(out, 503, openAIError("no_healthy_supplier", `supplier ${JSON.stringify(target.id)}: all accounts unavailable`));
|
|
2462
4741
|
return;
|
|
2463
4742
|
}
|
|
2464
|
-
if (await this.chatWithModel(req,
|
|
4743
|
+
if (await this.chatWithModel(req, out, req.model, probe)) {
|
|
2465
4744
|
settle(true);
|
|
2466
4745
|
return;
|
|
2467
4746
|
}
|
|
2468
4747
|
settle(false, "all suppliers unavailable");
|
|
2469
|
-
writeJson$2(
|
|
4748
|
+
writeJson$2(out, 503, openAIError("no_healthy_supplier", "all suppliers unavailable"));
|
|
2470
4749
|
}
|
|
2471
4750
|
/** 调指定供应商。
|
|
2472
4751
|
* 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
|
|
@@ -2550,8 +4829,9 @@ var Router = class {
|
|
|
2550
4829
|
async chatWithSupplier(s, req, res, trace, probe, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs, onlyUid) {
|
|
2551
4830
|
const pool = s.pool;
|
|
2552
4831
|
const cfg = this.store.get(s.id);
|
|
4832
|
+
const chatOnce = this.wireOf(s);
|
|
2553
4833
|
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
2554
|
-
const r = await
|
|
4834
|
+
const r = await chatOnce("", req.lv ?? "auto", req);
|
|
2555
4835
|
if (!r.ok) {
|
|
2556
4836
|
if (r.state === "no_such_model" || r.state === "bad_request") return false;
|
|
2557
4837
|
if (trace !== void 0) trace.lastState = r.state;
|
|
@@ -2587,7 +4867,7 @@ var Router = class {
|
|
|
2587
4867
|
}
|
|
2588
4868
|
pickWhy = pool.lastWhy;
|
|
2589
4869
|
tried.add(uid);
|
|
2590
|
-
const r = await
|
|
4870
|
+
const r = await chatOnce(uid, req.lv ?? "auto", req);
|
|
2591
4871
|
if (!r.ok) {
|
|
2592
4872
|
if (r.state === "no_such_model" || r.state === "bad_request") {
|
|
2593
4873
|
if (trace !== void 0) {
|
|
@@ -2596,7 +4876,7 @@ var Router = class {
|
|
|
2596
4876
|
}
|
|
2597
4877
|
return false;
|
|
2598
4878
|
}
|
|
2599
|
-
pool.noteFailure(uid, req.model, r.state, r.message);
|
|
4879
|
+
pool.noteFailure(uid, req.model, r.state, r.message, r.resetAt);
|
|
2600
4880
|
pool.dropBlock(req.model);
|
|
2601
4881
|
if (trace !== void 0) {
|
|
2602
4882
|
trace.attempts += 1;
|
|
@@ -2642,7 +4922,19 @@ var Router = class {
|
|
|
2642
4922
|
ok: false,
|
|
2643
4923
|
error: `unknown supplier ${JSON.stringify(supplierId)}`
|
|
2644
4924
|
};
|
|
2645
|
-
|
|
4925
|
+
let sinkStatus = 200;
|
|
4926
|
+
let sinkBody = "";
|
|
4927
|
+
const sink = sinkRes({
|
|
4928
|
+
head: (code) => {
|
|
4929
|
+
sinkStatus = code;
|
|
4930
|
+
},
|
|
4931
|
+
write: (bytes) => {
|
|
4932
|
+
sinkBody += new TextDecoder().decode(bytes);
|
|
4933
|
+
},
|
|
4934
|
+
end: () => {}
|
|
4935
|
+
});
|
|
4936
|
+
const statusOf = () => sinkStatus;
|
|
4937
|
+
const bodyOf = () => sinkBody;
|
|
2646
4938
|
const req = {
|
|
2647
4939
|
model,
|
|
2648
4940
|
stream: false,
|
|
@@ -2666,8 +4958,8 @@ var Router = class {
|
|
|
2666
4958
|
error: err.message
|
|
2667
4959
|
};
|
|
2668
4960
|
}
|
|
2669
|
-
if (served &&
|
|
2670
|
-
const fromSink = served &&
|
|
4961
|
+
if (served && statusOf() < 400) return { ok: true };
|
|
4962
|
+
const fromSink = served && statusOf() >= 400 ? extractUpstreamError(bodyOf()) : "";
|
|
2671
4963
|
const detail = fromSink !== "" ? fromSink : trace.lastError;
|
|
2672
4964
|
return {
|
|
2673
4965
|
ok: false,
|
|
@@ -3036,16 +5328,18 @@ async function* translateSse(payloads) {
|
|
|
3036
5328
|
usage
|
|
3037
5329
|
};
|
|
3038
5330
|
}
|
|
5331
|
+
const hasCompleteToolCall = complete.some((block) => block.kind === "tool-call");
|
|
3039
5332
|
const reason = pendingFinish ?? { kind: "stop" };
|
|
5333
|
+
const resolved = reason.kind === "stop" && hasCompleteToolCall ? { kind: "tool-calls" } : reason;
|
|
3040
5334
|
yield {
|
|
3041
5335
|
type: "finish",
|
|
3042
|
-
reason:
|
|
5336
|
+
reason: resolved.kind === "stop" && order.length === 0 ? {
|
|
3043
5337
|
kind: "error",
|
|
3044
5338
|
failure: {
|
|
3045
5339
|
message: "model returned a completed response with no content",
|
|
3046
5340
|
code: EMPTY_RESPONSE_CODE
|
|
3047
5341
|
}
|
|
3048
|
-
} :
|
|
5342
|
+
} : resolved
|
|
3049
5343
|
};
|
|
3050
5344
|
return;
|
|
3051
5345
|
}
|
|
@@ -4096,7 +6390,7 @@ function guard(handler) {
|
|
|
4096
6390
|
}
|
|
4097
6391
|
};
|
|
4098
6392
|
}
|
|
4099
|
-
async function readBody$
|
|
6393
|
+
async function readBody$2(req, limit = 65536) {
|
|
4100
6394
|
const chunks = [];
|
|
4101
6395
|
let size = 0;
|
|
4102
6396
|
return await new Promise((resolve, reject) => {
|
|
@@ -4169,7 +6463,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4169
6463
|
handler: async (req, res) => {
|
|
4170
6464
|
let body;
|
|
4171
6465
|
try {
|
|
4172
|
-
body = JSON.parse(await readBody$
|
|
6466
|
+
body = JSON.parse(await readBody$2(req));
|
|
4173
6467
|
} catch {
|
|
4174
6468
|
writeJson$1(res, 400, {
|
|
4175
6469
|
ok: false,
|
|
@@ -4206,7 +6500,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4206
6500
|
}
|
|
4207
6501
|
try {
|
|
4208
6502
|
let pick = {};
|
|
4209
|
-
const raw = (await readBody$
|
|
6503
|
+
const raw = (await readBody$2(req)).trim();
|
|
4210
6504
|
if (raw !== "") {
|
|
4211
6505
|
const body = JSON.parse(raw);
|
|
4212
6506
|
pick = {
|
|
@@ -4267,7 +6561,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4267
6561
|
kind: "exact",
|
|
4268
6562
|
path: `${p}/alias`,
|
|
4269
6563
|
handler: async (req, res) => {
|
|
4270
|
-
const clean = (JSON.parse(await readBody$
|
|
6564
|
+
const clean = (JSON.parse(await readBody$2(req)).alias ?? "").trim();
|
|
4271
6565
|
if (clean !== "" && !/^[A-Za-z0-9_-]+$/.test(clean)) {
|
|
4272
6566
|
writeJson$1(res, 400, {
|
|
4273
6567
|
ok: false,
|
|
@@ -4290,7 +6584,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4290
6584
|
kind: "exact",
|
|
4291
6585
|
path: `${p}/models/toggle`,
|
|
4292
6586
|
handler: guard(async (req, res) => {
|
|
4293
|
-
const body = JSON.parse(await readBody$
|
|
6587
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4294
6588
|
store.get(s.id);
|
|
4295
6589
|
const models = await router.modelsOf(s.id);
|
|
4296
6590
|
if (!body.id || !models.some((mm) => mm.id === body.id)) {
|
|
@@ -4309,7 +6603,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4309
6603
|
kind: "exact",
|
|
4310
6604
|
path: `${p}/models/add`,
|
|
4311
6605
|
handler: guard(async (req, res) => {
|
|
4312
|
-
const id = (JSON.parse(await readBody$
|
|
6606
|
+
const id = (JSON.parse(await readBody$2(req)).id ?? "").trim();
|
|
4313
6607
|
if (id === "") {
|
|
4314
6608
|
writeJson$1(res, 400, {
|
|
4315
6609
|
ok: false,
|
|
@@ -4333,7 +6627,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4333
6627
|
kind: "exact",
|
|
4334
6628
|
path: `${p}/models/remove`,
|
|
4335
6629
|
handler: async (req, res) => {
|
|
4336
|
-
const body = JSON.parse(await readBody$
|
|
6630
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4337
6631
|
const cfg = store.get(s.id);
|
|
4338
6632
|
if (!body.id || !cfg.custom.includes(body.id)) {
|
|
4339
6633
|
writeJson$1(res, 400, {
|
|
@@ -4351,7 +6645,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4351
6645
|
kind: "exact",
|
|
4352
6646
|
path: `${p}/models/bulk`,
|
|
4353
6647
|
handler: guard(async (req, res) => {
|
|
4354
|
-
const body = JSON.parse(await readBody$
|
|
6648
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4355
6649
|
const models = await router.modelsOf(s.id);
|
|
4356
6650
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
|
|
4357
6651
|
router.invalidateModels(s.id);
|
|
@@ -4369,7 +6663,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4369
6663
|
});
|
|
4370
6664
|
return;
|
|
4371
6665
|
}
|
|
4372
|
-
const body = JSON.parse(await readBody$
|
|
6666
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4373
6667
|
if (!Array.isArray(body.uids) || body.uids.some((u) => typeof u !== "string")) {
|
|
4374
6668
|
writeJson$1(res, 400, {
|
|
4375
6669
|
ok: false,
|
|
@@ -4395,7 +6689,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4395
6689
|
kind: "exact",
|
|
4396
6690
|
path: `${p}/models/test`,
|
|
4397
6691
|
handler: async (req, res) => {
|
|
4398
|
-
const body = JSON.parse(await readBody$
|
|
6692
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4399
6693
|
const onlyUid = typeof body.uid === "string" && body.uid !== "" ? body.uid : void 0;
|
|
4400
6694
|
const result = await router.testModel(s.id, body.id ?? "", onlyUid);
|
|
4401
6695
|
writeJson$1(res, result.ok ? 200 : 400, result);
|
|
@@ -4431,7 +6725,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4431
6725
|
});
|
|
4432
6726
|
return;
|
|
4433
6727
|
}
|
|
4434
|
-
const body = JSON.parse(await readBody$
|
|
6728
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4435
6729
|
try {
|
|
4436
6730
|
writeJson$1(res, 200, {
|
|
4437
6731
|
ok: true,
|
|
@@ -4456,7 +6750,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4456
6750
|
});
|
|
4457
6751
|
return;
|
|
4458
6752
|
}
|
|
4459
|
-
const body = JSON.parse(await readBody$
|
|
6753
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4460
6754
|
try {
|
|
4461
6755
|
const r = await m.addApiKey.call(loaded.supplier, {
|
|
4462
6756
|
name: (body.name ?? "").trim(),
|
|
@@ -4482,7 +6776,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4482
6776
|
});
|
|
4483
6777
|
return;
|
|
4484
6778
|
}
|
|
4485
|
-
const uid = JSON.parse(await readBody$
|
|
6779
|
+
const uid = JSON.parse(await readBody$2(req)).uid ?? "";
|
|
4486
6780
|
const ok = await s.removeLink(uid);
|
|
4487
6781
|
if (ok) store.clearCredits(s.id, uid);
|
|
4488
6782
|
writeJson$1(res, ok ? 200 : 404, ok ? { ok: true } : {
|
|
@@ -4887,6 +7181,332 @@ var ExtStore = class {
|
|
|
4887
7181
|
}
|
|
4888
7182
|
};
|
|
4889
7183
|
//#endregion
|
|
7184
|
+
//#region src/protocol/entry.ts
|
|
7185
|
+
/**
|
|
7186
|
+
* 读请求体 + 按协议翻成规范请求。
|
|
7187
|
+
*
|
|
7188
|
+
* 模型名从哪来因协议而异:Chat/Anthropic/Responses 在 body 的 `model`,
|
|
7189
|
+
* Gemini 在 URL 路径(`:generateContent` 之前那段)—— 由 `modelFromPath`
|
|
7190
|
+
* 调用方先解析好传进来。
|
|
7191
|
+
*/
|
|
7192
|
+
async function parseInbound(req, protocol, opts) {
|
|
7193
|
+
const raw = await readBody$1(req, opts.limitBytes);
|
|
7194
|
+
if (raw === void 0) return {
|
|
7195
|
+
error: "request body too large",
|
|
7196
|
+
status: 413
|
|
7197
|
+
};
|
|
7198
|
+
let body;
|
|
7199
|
+
try {
|
|
7200
|
+
body = JSON.parse(raw);
|
|
7201
|
+
} catch {
|
|
7202
|
+
return {
|
|
7203
|
+
error: "invalid JSON body",
|
|
7204
|
+
status: 400
|
|
7205
|
+
};
|
|
7206
|
+
}
|
|
7207
|
+
const codec = codecOf(protocol);
|
|
7208
|
+
const model = opts.modelFromPath ?? str(body.model) ?? "";
|
|
7209
|
+
const stream = opts.streamFromPath ?? body.stream === true;
|
|
7210
|
+
const canonical = codec.toChat(body, {
|
|
7211
|
+
model,
|
|
7212
|
+
stream
|
|
7213
|
+
});
|
|
7214
|
+
canonical.stream = stream;
|
|
7215
|
+
canonical.model = model;
|
|
7216
|
+
return {
|
|
7217
|
+
body: canonical,
|
|
7218
|
+
stream,
|
|
7219
|
+
model,
|
|
7220
|
+
lv: opts.lvFromBody?.(body) ?? "auto",
|
|
7221
|
+
protocol
|
|
7222
|
+
};
|
|
7223
|
+
}
|
|
7224
|
+
/**
|
|
7225
|
+
* 一段上游字节像不像 SSE。
|
|
7226
|
+
*
|
|
7227
|
+
* 只在 content-type **没有**任何提示时才用(流式路径的 `head` 可能一次都不来)。
|
|
7228
|
+
* 判据是「第一段非空白内容是否 SSE 行」,不是「包含 data:」—— 后者会让一个
|
|
7229
|
+
* 错误 JSON(`{"error":"data: bad"}`)被误判成流。
|
|
7230
|
+
*/
|
|
7231
|
+
function looksLikeSse(text) {
|
|
7232
|
+
const head = text.replace(/^\s+/, "");
|
|
7233
|
+
return head.startsWith("data:") || head.startsWith("event:") || head.startsWith(":");
|
|
7234
|
+
}
|
|
7235
|
+
/** 上游字节 → 文本(逐字节流式解码,跨 chunk 的多字节字符不会被打断)。 */
|
|
7236
|
+
const decoder = new TextDecoder();
|
|
7237
|
+
/** 文本 → 字节。 */
|
|
7238
|
+
const encoder = new TextEncoder();
|
|
7239
|
+
/**
|
|
7240
|
+
* 给 Router 的**非 chat 出口**:收集 OpenAI 形态的响应,翻译成客户端协议。
|
|
7241
|
+
*
|
|
7242
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7243
|
+
* 两条路径分开处理,因为时序完全不同
|
|
7244
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7245
|
+
* 非流式:Router 一次 `head(200) + end(json)`。整份 JSON 已在手里,
|
|
7246
|
+
* `decodeJson` → `encodeJson` 一个来回就完。
|
|
7247
|
+
* 流式: Router 分很多次 `write(bytes)`,且**第一个字节前可能失败换号**。
|
|
7248
|
+
* 不能等收齐再翻(那就没有流式了);每收到一段就翻一段。
|
|
7249
|
+
*
|
|
7250
|
+
* 流式那条为什么插一层 sink 而不是让 Router 直接写翻译后的字节:Router 的
|
|
7251
|
+
* `writeChatResult` 里有一整套「客户端断开就取消上游流」「断流补 `[DONE]`」
|
|
7252
|
+
* 的逻辑,那些必须继续对真实的 `res` 生效。sink 只是**转发的中继**:
|
|
7253
|
+
* 它翻译字节、写给真正的 res;断开时调用方取消本层(见 index.ts 挂载处),
|
|
7254
|
+
* Router 那侧的取消链路原样传到上游。
|
|
7255
|
+
*/
|
|
7256
|
+
function outboundSink(protocol, model, res) {
|
|
7257
|
+
const codec = codecOf(protocol);
|
|
7258
|
+
let headSent = false;
|
|
7259
|
+
/** 流式:转码器(收到第一段 SSE 时初始化,跨 write 保持分帧状态)。 */
|
|
7260
|
+
let translator;
|
|
7261
|
+
/** 非流式:攒起来的 JSON(`undefined` = 还没收到过 body)。 */
|
|
7262
|
+
let jsonBuf;
|
|
7263
|
+
let status = 200;
|
|
7264
|
+
/**
|
|
7265
|
+
* 上游给的 content-type —— **判断流还是 JSON 只看它**,不看内容。
|
|
7266
|
+
*
|
|
7267
|
+
* 早先靠 `text.startsWith('data:')` 猜:那是错的。非流式 JSON body 里如果
|
|
7268
|
+
* 有以 `data:` 开头的字符串值(错误信息里带 "data: ...")、或者上游先发一段
|
|
7269
|
+
* 空白,就会被误判成 SSE,然后 JSON 被当帧喂给翻译器 —— 客户端拿到一个
|
|
7270
|
+
* 空流而不是错误信息。content-type 是 Router/上游**明确给出的**事实。
|
|
7271
|
+
*/
|
|
7272
|
+
let contentType = "";
|
|
7273
|
+
const sendHead = (code, contentType) => {
|
|
7274
|
+
if (headSent) return;
|
|
7275
|
+
headSent = true;
|
|
7276
|
+
res.writeHead(code, {
|
|
7277
|
+
"Content-Type": contentType,
|
|
7278
|
+
"Cache-Control": "no-store"
|
|
7279
|
+
});
|
|
7280
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
7281
|
+
};
|
|
7282
|
+
return {
|
|
7283
|
+
head(code, headers) {
|
|
7284
|
+
status = code;
|
|
7285
|
+
const ct = headers["content-type"] ?? headers["Content-Type"] ?? "";
|
|
7286
|
+
if (ct !== "") contentType = ct.toLowerCase();
|
|
7287
|
+
if (code !== 200) sendHead(code, ct !== "" ? ct : "application/json; charset=utf-8");
|
|
7288
|
+
},
|
|
7289
|
+
write(bytes) {
|
|
7290
|
+
const text = decoder.decode(bytes, { stream: true });
|
|
7291
|
+
if (text === "") return;
|
|
7292
|
+
if (translator === void 0 && (contentType.includes("text/event-stream") || contentType === "" && looksLikeSse(text))) translator = new ChunkTranslator(new StreamState("chat", protocol, model));
|
|
7293
|
+
if (translator !== void 0) {
|
|
7294
|
+
const frames = translator.push(text);
|
|
7295
|
+
if (frames.length > 0) {
|
|
7296
|
+
sendHead(200, "text/event-stream");
|
|
7297
|
+
res.write(encoder.encode(frames.join("")));
|
|
7298
|
+
} else if (!headSent) sendHead(200, "text/event-stream");
|
|
7299
|
+
return;
|
|
7300
|
+
}
|
|
7301
|
+
jsonBuf = (jsonBuf ?? "") + text;
|
|
7302
|
+
},
|
|
7303
|
+
end() {
|
|
7304
|
+
const tailChunk = decoder.decode();
|
|
7305
|
+
if (tailChunk !== "") {
|
|
7306
|
+
if (translator !== void 0) {
|
|
7307
|
+
const frames = translator.push(tailChunk);
|
|
7308
|
+
if (frames.length > 0) res.write(encoder.encode(frames.join("")));
|
|
7309
|
+
} else jsonBuf = (jsonBuf ?? "") + tailChunk;
|
|
7310
|
+
}
|
|
7311
|
+
if (jsonBuf !== void 0 && jsonBuf !== "") {
|
|
7312
|
+
finishJson(codec, protocol, model, res, sendHead, status, jsonBuf);
|
|
7313
|
+
return;
|
|
7314
|
+
}
|
|
7315
|
+
if (translator !== void 0) {
|
|
7316
|
+
const tail = translator.end();
|
|
7317
|
+
if (tail.length > 0) {
|
|
7318
|
+
sendHead(200, "text/event-stream");
|
|
7319
|
+
res.write(encoder.encode(tail.join("")));
|
|
7320
|
+
}
|
|
7321
|
+
if (headSent) res.end();
|
|
7322
|
+
else finishEmptyStream(codec, protocol, model, res, sendHead);
|
|
7323
|
+
return;
|
|
7324
|
+
}
|
|
7325
|
+
finishEmptyStream(codec, protocol, model, res, sendHead);
|
|
7326
|
+
}
|
|
7327
|
+
};
|
|
7328
|
+
}
|
|
7329
|
+
/**
|
|
7330
|
+
* 非流式:整份 JSON 翻译后写出。
|
|
7331
|
+
*
|
|
7332
|
+
* ⚠️ 方向在这里:Router 吐的是 **OpenAI Chat** 形态(`choices[0].message`),
|
|
7333
|
+
* 而客户端要的是 `protocol` 说的形态。所以**解码用 chat codec、编码用客户端
|
|
7334
|
+
* codec** —— 曾经两处都用客户端的 codec,于是 Chat JSON 被当成 Anthropic
|
|
7335
|
+
* body 解(`choices` 字段根本不存在),客户端拿到一个 content 为空的回复。
|
|
7336
|
+
*/
|
|
7337
|
+
function finishJson(clientCodec, protocol, model, res, sendHead, status, jsonBuf) {
|
|
7338
|
+
let events;
|
|
7339
|
+
try {
|
|
7340
|
+
events = codecOf("chat").decodeJson(JSON.parse(jsonBuf));
|
|
7341
|
+
} catch {
|
|
7342
|
+
sendHead(status, "application/json; charset=utf-8");
|
|
7343
|
+
res.end(jsonBuf);
|
|
7344
|
+
return;
|
|
7345
|
+
}
|
|
7346
|
+
const err = events.find((e) => e.type === "error");
|
|
7347
|
+
if (err !== void 0 && err.type === "error") {
|
|
7348
|
+
sendHead(502, "application/json; charset=utf-8");
|
|
7349
|
+
res.end(JSON.stringify(errBody(protocol, err.message)));
|
|
7350
|
+
return;
|
|
7351
|
+
}
|
|
7352
|
+
const body = clientCodec.encodeJson(events, {
|
|
7353
|
+
model,
|
|
7354
|
+
stream: false
|
|
7355
|
+
});
|
|
7356
|
+
sendHead(status, "application/json; charset=utf-8");
|
|
7357
|
+
res.end(JSON.stringify(body));
|
|
7358
|
+
}
|
|
7359
|
+
/**
|
|
7360
|
+
* 流式但一个内容帧都没翻译出来(上游刚连上就断 / 首字节超时前就失败)。
|
|
7361
|
+
*
|
|
7362
|
+
* 给客户端一个**协议合法的空流**,别让 HTTP 连接裸断:客户端会把「没有响应」
|
|
7363
|
+
* 当成网络故障重试,而「上游没答」才是事实——用协议的 error 收尾说出来。
|
|
7364
|
+
*/
|
|
7365
|
+
function finishEmptyStream(codec, protocol, model, res, sendHead) {
|
|
7366
|
+
const ctx = {
|
|
7367
|
+
model,
|
|
7368
|
+
stream: true,
|
|
7369
|
+
finish: "error"
|
|
7370
|
+
};
|
|
7371
|
+
const frames = [
|
|
7372
|
+
...codec.startFrames(ctx),
|
|
7373
|
+
...codec.encodeFrame({
|
|
7374
|
+
type: "error",
|
|
7375
|
+
message: "upstream closed before any content"
|
|
7376
|
+
}),
|
|
7377
|
+
...codec.encodeFrame({
|
|
7378
|
+
type: "finish",
|
|
7379
|
+
reason: "error"
|
|
7380
|
+
}),
|
|
7381
|
+
...codec.endFrames(ctx)
|
|
7382
|
+
];
|
|
7383
|
+
sendHead(200, "text/event-stream");
|
|
7384
|
+
res.end(new TextEncoder().encode(frames.join("")));
|
|
7385
|
+
}
|
|
7386
|
+
/** 每种协议的 error body 形状。 */
|
|
7387
|
+
function errBody(protocol, message) {
|
|
7388
|
+
if (protocol === "anthropic") return {
|
|
7389
|
+
type: "error",
|
|
7390
|
+
error: {
|
|
7391
|
+
type: "api_error",
|
|
7392
|
+
message
|
|
7393
|
+
}
|
|
7394
|
+
};
|
|
7395
|
+
if (protocol === "gemini") return { error: {
|
|
7396
|
+
code: 502,
|
|
7397
|
+
message,
|
|
7398
|
+
status: "UNAVAILABLE"
|
|
7399
|
+
} };
|
|
7400
|
+
return { error: {
|
|
7401
|
+
message,
|
|
7402
|
+
type: "api_error",
|
|
7403
|
+
code: "upstream_error"
|
|
7404
|
+
} };
|
|
7405
|
+
}
|
|
7406
|
+
/**
|
|
7407
|
+
* 一段上游 OpenAI SSE 文本的转码器 —— **有状态**。
|
|
7408
|
+
*
|
|
7409
|
+
* 上游一帧被拆在两个 TCP 段里是常态(Router 按拿到的字节原样转交),所以
|
|
7410
|
+
* 分帧缓冲必须跨 `write()` 调用存活。这里用 `sse.ts` 的 `SseSplitter`(它内部
|
|
7411
|
+
* 留着半个帧)而不是每次现切 —— 早先每次现切会把不完整帧当完整帧解析,
|
|
7412
|
+
* 表现是「文本丢一半、工具参数截断」,而 HTTP 200,客户端看不到任何错误。
|
|
7413
|
+
*
|
|
7414
|
+
* 生命周期与 sink 一致:一个请求一个实例。
|
|
7415
|
+
*/
|
|
7416
|
+
var ChunkTranslator = class {
|
|
7417
|
+
splitter = new SseSplitter();
|
|
7418
|
+
state;
|
|
7419
|
+
constructor(state) {
|
|
7420
|
+
this.state = state;
|
|
7421
|
+
}
|
|
7422
|
+
/** 喂一段文本,返回可写给客户端的帧。 */
|
|
7423
|
+
push(text) {
|
|
7424
|
+
return this.emit(this.splitter.push(new TextEncoder().encode(text)));
|
|
7425
|
+
}
|
|
7426
|
+
/** 流结束:冲刷残留帧(上游不发尾空行是有的),返回收尾帧。 */
|
|
7427
|
+
end() {
|
|
7428
|
+
return [...this.emit(this.splitter.end()), ...this.state.finishStream()];
|
|
7429
|
+
}
|
|
7430
|
+
emit(frames) {
|
|
7431
|
+
const out = [];
|
|
7432
|
+
for (const f of frames) {
|
|
7433
|
+
if (f.data === "" || f.data === "[DONE]") continue;
|
|
7434
|
+
out.push(...this.state.translateFrame(f.data, f.event));
|
|
7435
|
+
}
|
|
7436
|
+
return out;
|
|
7437
|
+
}
|
|
7438
|
+
};
|
|
7439
|
+
/** 读请求体(带大小上限)。超限返回 undefined。 */
|
|
7440
|
+
function readBody$1(req, limit) {
|
|
7441
|
+
return new Promise((resolve) => {
|
|
7442
|
+
const chunks = [];
|
|
7443
|
+
let size = 0;
|
|
7444
|
+
req.on("data", (chunk) => {
|
|
7445
|
+
size += chunk.length;
|
|
7446
|
+
if (size > limit) {
|
|
7447
|
+
req.destroy();
|
|
7448
|
+
resolve(void 0);
|
|
7449
|
+
return;
|
|
7450
|
+
}
|
|
7451
|
+
chunks.push(chunk);
|
|
7452
|
+
});
|
|
7453
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
7454
|
+
req.on("error", () => resolve(void 0));
|
|
7455
|
+
});
|
|
7456
|
+
}
|
|
7457
|
+
/**
|
|
7458
|
+
* 从 Gemini 的 URL 路径里取模型名。
|
|
7459
|
+
*
|
|
7460
|
+
* 形如 `/v1beta/models/<model>:generateContent`(或 `:streamGenerateContent`),
|
|
7461
|
+
* `<model>` 本身可以含斜杠,所以取 `models/` 之后到最后一个 `:` 之前那段。
|
|
7462
|
+
*
|
|
7463
|
+
* **必须先切掉 query**:Gemini CLI 默认带 `?alt=sse`,不切的话 `$` 锚点匹配
|
|
7464
|
+
* 不上,模型名变空 —— 表现为「404 model not found」而不是参数错误,很难查。
|
|
7465
|
+
* 顺便支持 `/v1beta/models/<model>` 这种带版本号但无方法的形态(走 GET 列表时
|
|
7466
|
+
* 会撞上,这里取不到 `/` 就是取不到,返回 undefined 让上层报 400)。
|
|
7467
|
+
*/
|
|
7468
|
+
function geminiModelFromPath(pathname) {
|
|
7469
|
+
const path = pathname.split("?")[0] ?? "";
|
|
7470
|
+
return /\/models\/(.+?):(?:streamGenerateContent|generateContent|countTokens)$/.exec(path)?.[1];
|
|
7471
|
+
}
|
|
7472
|
+
/**
|
|
7473
|
+
* Gemini 的流式意图在**路径尾部**:`:streamGenerateContent` 是流式、
|
|
7474
|
+
* `:generateContent` 是非流式。返回 `undefined` 表示路径没表态(不是 Gemini
|
|
7475
|
+
* 形态,或路径不可识别),交给 body 的 `stream` 决定。
|
|
7476
|
+
*
|
|
7477
|
+
* 为什么不能只看 body:Gemini 的请求体里**没有** `stream` 字段 —— 它的官方
|
|
7478
|
+
* 契约就是靠 URL 区分两种方法。不认这个,流式请求会被当成非流式,客户端按
|
|
7479
|
+
* SSE 解析一整个 JSON ⇒ 空响应(2026-10-21 在 3080 实测到)。
|
|
7480
|
+
*/
|
|
7481
|
+
function geminiStreamFromPath(pathname) {
|
|
7482
|
+
const path = pathname.split("?")[0] ?? "";
|
|
7483
|
+
if (/:streamGenerateContent$/.test(path)) return true;
|
|
7484
|
+
if (/:generateContent$/.test(path)) return false;
|
|
7485
|
+
}
|
|
7486
|
+
/**
|
|
7487
|
+
* 从请求体取推理等级(各协议字段名不同)。
|
|
7488
|
+
*
|
|
7489
|
+
* 只认「明确说了要多少思考」的形态,其余一律 'auto':把「有这个字段」当成
|
|
7490
|
+
* 「要高档推理」会让所有带 thinking 块的请求都按 high 走,费用与延迟都变。
|
|
7491
|
+
*/
|
|
7492
|
+
function lvOf(body, protocol) {
|
|
7493
|
+
const direct = str(body.reasoning_effort);
|
|
7494
|
+
if (direct !== void 0) return direct;
|
|
7495
|
+
const effort = str(body.reasoning) ?? str(body.effort);
|
|
7496
|
+
if (effort !== void 0) return effort;
|
|
7497
|
+
const budget = body.generationConfig;
|
|
7498
|
+
if (budget !== void 0) {
|
|
7499
|
+
const tb = budget.thinkingConfig?.thinkingBudget;
|
|
7500
|
+
if (typeof tb === "number" && tb > 0) return "high";
|
|
7501
|
+
}
|
|
7502
|
+
const thinking = body.thinking;
|
|
7503
|
+
if (thinking !== void 0) {
|
|
7504
|
+
const tb = thinking.budget_tokens;
|
|
7505
|
+
if (typeof tb === "number" && tb > 0) return "high";
|
|
7506
|
+
}
|
|
7507
|
+
return "auto";
|
|
7508
|
+
}
|
|
7509
|
+
//#endregion
|
|
4890
7510
|
//#region src/index.ts
|
|
4891
7511
|
/**
|
|
4892
7512
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -4925,6 +7545,33 @@ function writeJson(res, status, body) {
|
|
|
4925
7545
|
});
|
|
4926
7546
|
res.end(payload);
|
|
4927
7547
|
}
|
|
7548
|
+
/**
|
|
7549
|
+
* 每种协议的 error body 形状。
|
|
7550
|
+
*
|
|
7551
|
+
* 为什么必须有:非 chat 入口的 400/413/501 若照 OpenAI 的
|
|
7552
|
+
* `{error:{message,type,code}}` 写,Claude Code 解析不到 `error.message`
|
|
7553
|
+
* 会显示「未知错误」——用户看到的是一个空错误,而真因(如 body 太大)
|
|
7554
|
+
* 就在手边却送不出去。
|
|
7555
|
+
*/
|
|
7556
|
+
function errBodyFor(protocol, message) {
|
|
7557
|
+
if (protocol === "anthropic") return {
|
|
7558
|
+
type: "error",
|
|
7559
|
+
error: {
|
|
7560
|
+
type: "invalid_request_error",
|
|
7561
|
+
message
|
|
7562
|
+
}
|
|
7563
|
+
};
|
|
7564
|
+
if (protocol === "gemini") return { error: {
|
|
7565
|
+
code: 400,
|
|
7566
|
+
message,
|
|
7567
|
+
status: "INVALID_ARGUMENT"
|
|
7568
|
+
} };
|
|
7569
|
+
return { error: {
|
|
7570
|
+
message,
|
|
7571
|
+
type: "api_error",
|
|
7572
|
+
code: "invalid_request"
|
|
7573
|
+
} };
|
|
7574
|
+
}
|
|
4928
7575
|
function readBody(req, limit) {
|
|
4929
7576
|
return new Promise((resolve, reject) => {
|
|
4930
7577
|
const chunks = [];
|
|
@@ -5108,15 +7755,67 @@ function apply(rawContext) {
|
|
|
5108
7755
|
}
|
|
5109
7756
|
const lv = typeof peek.reasoning_effort === "string" && peek.reasoning_effort !== "" ? peek.reasoning_effort : "auto";
|
|
5110
7757
|
const sessionRaw = req.headers["x-dsh-router-session"];
|
|
5111
|
-
const session = typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0;
|
|
7758
|
+
const session = (typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0) ?? clientIdentityOf(peek, req.headers["user-agent"]);
|
|
5112
7759
|
await router.chatCompletions({
|
|
5113
7760
|
rawBody: body,
|
|
5114
7761
|
stream: !!peek.stream,
|
|
5115
7762
|
model: typeof peek.model === "string" ? peek.model : "",
|
|
5116
7763
|
lv,
|
|
5117
|
-
...session === void 0 ? {} : { session }
|
|
7764
|
+
...session === void 0 ? {} : { session },
|
|
7765
|
+
protocol: "chat"
|
|
5118
7766
|
}, res);
|
|
5119
7767
|
}));
|
|
7768
|
+
/**
|
|
7769
|
+
* Anthropic / Responses / Gemini 三个非 chat 入口的**公共前半段**:
|
|
7770
|
+
* 鉴权 → 读 body → 翻成规范请求 → 交给 Router → 把响应翻回客户端协议。
|
|
7771
|
+
*
|
|
7772
|
+
* 抽出来是因为三者的差别只有「协议 id 是什么、模型名从哪取」,而鉴权/限流/
|
|
7773
|
+
* 归因/错误形状必须完全一致 —— 各写一份就会出现「Anthropic 入口认了
|
|
7774
|
+
* x-dsh-router-session、Gemini 入口不认」这种只能靠实测发现的漂移。
|
|
7775
|
+
*/
|
|
7776
|
+
const enterProtocol = (protocol) => withV1Auth(async (req, res) => {
|
|
7777
|
+
const parsed = await parseInbound(req, protocol, {
|
|
7778
|
+
limitBytes: 8 << 20,
|
|
7779
|
+
modelFromPath: protocol === "gemini" ? geminiModelFromPath(req.url ?? "") : void 0,
|
|
7780
|
+
streamFromPath: protocol === "gemini" ? geminiStreamFromPath(req.url ?? "") : void 0,
|
|
7781
|
+
lvFromBody: (body) => lvOf(body, protocol)
|
|
7782
|
+
});
|
|
7783
|
+
if ("error" in parsed) {
|
|
7784
|
+
writeJson(res, parsed.status, errBodyFor(protocol, parsed.error));
|
|
7785
|
+
return;
|
|
7786
|
+
}
|
|
7787
|
+
if (parsed.model === "") {
|
|
7788
|
+
writeJson(res, 400, errBodyFor(protocol, "missing model"));
|
|
7789
|
+
return;
|
|
7790
|
+
}
|
|
7791
|
+
const sessionRaw = req.headers["x-dsh-router-session"];
|
|
7792
|
+
const session = (typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0) ?? clientIdentityOf(parsed.body, req.headers["user-agent"]);
|
|
7793
|
+
const sink = outboundSink(protocol, parsed.model, res);
|
|
7794
|
+
await router.chatCompletions({
|
|
7795
|
+
rawBody: JSON.stringify(parsed.body),
|
|
7796
|
+
stream: parsed.stream,
|
|
7797
|
+
model: parsed.model,
|
|
7798
|
+
lv: parsed.lv,
|
|
7799
|
+
...session === void 0 ? {} : { session },
|
|
7800
|
+
protocol
|
|
7801
|
+
}, res, sink);
|
|
7802
|
+
});
|
|
7803
|
+
route("/v1/messages", enterProtocol("anthropic"));
|
|
7804
|
+
route("/v1/messages/count_tokens", withV1Auth(async (_req, res) => {
|
|
7805
|
+
writeJson(res, 501, {
|
|
7806
|
+
type: "error",
|
|
7807
|
+
error: {
|
|
7808
|
+
type: "not_supported",
|
|
7809
|
+
message: "count_tokens is not supported; send the message instead"
|
|
7810
|
+
}
|
|
7811
|
+
});
|
|
7812
|
+
}));
|
|
7813
|
+
route("/v1/responses", enterProtocol("responses"));
|
|
7814
|
+
disposers.push(ctx.webServer.register({
|
|
7815
|
+
kind: "prefix",
|
|
7816
|
+
path: "/v1beta/models",
|
|
7817
|
+
handler: enterProtocol("gemini")
|
|
7818
|
+
}));
|
|
5120
7819
|
route(`${ROUTER_API_BASE}/health`, (_req, res) => {
|
|
5121
7820
|
const { suppliers } = router.status();
|
|
5122
7821
|
writeJson(res, 200, {
|
|
@@ -5362,6 +8061,8 @@ function apply(rawContext) {
|
|
|
5362
8061
|
...c.detail !== void 0 ? { detail: c.detail } : {},
|
|
5363
8062
|
...typeof c.body === "string" ? { body: c.body } : {},
|
|
5364
8063
|
...c.editable === true ? { editable: true } : {},
|
|
8064
|
+
...c.group !== void 0 ? { group: c.group } : {},
|
|
8065
|
+
...c.sortable !== void 0 ? { sortable: c.sortable } : {},
|
|
5365
8066
|
custom: c.custom === true,
|
|
5366
8067
|
overridden: c.overridden === true
|
|
5367
8068
|
})) } : {},
|