dsh-router-core 0.4.6 → 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/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2732 -55
- 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/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +19 -0
- package/lib/types/shared.d.ts +11 -0
- package/lib/types/suppliers/contract.d.ts +8 -0
- 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 +109 -1
- 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/index.ts +142 -23
- package/src/router/types.ts +19 -0
- package/src/shared.ts +11 -0
- package/src/suppliers/contract.ts +8 -0
package/lib/index.js
CHANGED
|
@@ -1207,7 +1207,7 @@ function top(map) {
|
|
|
1207
1207
|
//#endregion
|
|
1208
1208
|
//#region src/router/usage-tokens.ts
|
|
1209
1209
|
/** 取有限数,其余当 0(NaN 会毒穿整个累加:`Math.max(x, NaN) === NaN`)。 */
|
|
1210
|
-
function num(v) {
|
|
1210
|
+
function num$1(v) {
|
|
1211
1211
|
const n = Number(v);
|
|
1212
1212
|
return Number.isFinite(n) ? n : 0;
|
|
1213
1213
|
}
|
|
@@ -1231,10 +1231,10 @@ function num(v) {
|
|
|
1231
1231
|
function normalizeUsage(raw) {
|
|
1232
1232
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1233
1233
|
const u = raw;
|
|
1234
|
-
const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
|
|
1235
|
-
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
|
|
1236
|
-
if (u.cache_read_input_tokens !== void 0 && u.prompt_tokens === void 0) prompt += num(u.cache_read_input_tokens);
|
|
1237
|
-
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);
|
|
1238
1238
|
if (prompt === 0 && completion === 0 && cached === 0) return null;
|
|
1239
1239
|
return {
|
|
1240
1240
|
promptTokens: prompt,
|
|
@@ -1445,23 +1445,2209 @@ function tapStreamUsage(source, startedAt) {
|
|
|
1445
1445
|
};
|
|
1446
1446
|
}
|
|
1447
1447
|
/**
|
|
1448
|
-
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
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 字节流。
|
|
1449
3604
|
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
3605
|
+
* 生命周期:上游正常读完 / 上游 error / 消费方取消,三条路径都要**释放上游
|
|
3606
|
+
* reader** 并把取消传下去 —— 否则上游 fetch body 永不 cancel,长跑会漏连接
|
|
3607
|
+
* (`router/index.ts` 的 `normalizeSSEStream` 里同一个坑,判据在
|
|
3608
|
+
* `router/disconnect-leak.test.ts`)。
|
|
1452
3609
|
*/
|
|
1453
|
-
function
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
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 {}
|
|
1464
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
|
+
});
|
|
1465
3651
|
}
|
|
1466
3652
|
//#endregion
|
|
1467
3653
|
//#region src/router/index.ts
|
|
@@ -1625,28 +3811,35 @@ function reqMessages(rawBody) {
|
|
|
1625
3811
|
return;
|
|
1626
3812
|
}
|
|
1627
3813
|
}
|
|
1628
|
-
/**
|
|
1629
|
-
function sinkRes() {
|
|
1630
|
-
let status = 200;
|
|
1631
|
-
let text = "";
|
|
3814
|
+
/** 把 ResponseSink 包成一个 Router 能写的 ServerResponse。 */
|
|
3815
|
+
function sinkRes(sink) {
|
|
1632
3816
|
const self = {
|
|
1633
3817
|
headersSent: false,
|
|
1634
3818
|
writableEnded: false,
|
|
1635
|
-
|
|
1636
|
-
|
|
3819
|
+
destroyed: false,
|
|
3820
|
+
writeHead: (code, headers) => {
|
|
3821
|
+
sink.head?.(code, headers ?? {});
|
|
1637
3822
|
return self;
|
|
1638
3823
|
},
|
|
1639
3824
|
write: (chunk) => {
|
|
1640
|
-
if (chunk !== void 0)
|
|
3825
|
+
if (chunk !== void 0) sink.write(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
1641
3826
|
return true;
|
|
1642
3827
|
},
|
|
1643
3828
|
end: (chunk) => {
|
|
1644
|
-
if (chunk !== void 0)
|
|
3829
|
+
if (chunk !== void 0) self.write(chunk);
|
|
3830
|
+
sink.end();
|
|
1645
3831
|
return self;
|
|
1646
3832
|
},
|
|
1647
3833
|
flushHeaders: () => {},
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
|
1650
3843
|
};
|
|
1651
3844
|
return self;
|
|
1652
3845
|
}
|
|
@@ -2000,6 +4193,51 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0, firstB
|
|
|
2000
4193
|
}
|
|
2001
4194
|
/** SSE 终止帧(断流兜底补发用)。 */
|
|
2002
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
|
+
}
|
|
2003
4241
|
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
2004
4242
|
function extractUpstreamError(body) {
|
|
2005
4243
|
try {
|
|
@@ -2186,6 +4424,15 @@ var Router = class {
|
|
|
2186
4424
|
} catch {}
|
|
2187
4425
|
return out;
|
|
2188
4426
|
}
|
|
4427
|
+
/**
|
|
4428
|
+
* 该供应商的 `chatOnce`,已按它声明的 wire 包好协议翻译。
|
|
4429
|
+
*
|
|
4430
|
+
* 每次调用都现取(而不是构造时缓存):供应商会被开关/热重载替换,
|
|
4431
|
+
* 缓存会把旧实例的翻译绑在新实例上。
|
|
4432
|
+
*/
|
|
4433
|
+
wireOf(s) {
|
|
4434
|
+
return wireChatOnce(s);
|
|
4435
|
+
}
|
|
2189
4436
|
/** 组合列表(面板用):用户自定义组合。 */
|
|
2190
4437
|
async combos() {
|
|
2191
4438
|
return [...this.customCombos];
|
|
@@ -2424,7 +4671,17 @@ var Router = class {
|
|
|
2424
4671
|
* - model 命中组合名 → 在组合模型里按策略选一个,交给供应商;失败按回退顺序尝试剩余模型。
|
|
2425
4672
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
2426
4673
|
*/
|
|
2427
|
-
|
|
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);
|
|
2428
4685
|
const probe = {
|
|
2429
4686
|
requested: req.model,
|
|
2430
4687
|
supplier: "",
|
|
@@ -2461,34 +4718,34 @@ var Router = class {
|
|
|
2461
4718
|
const model = combo.models[i];
|
|
2462
4719
|
if (model === void 0) continue;
|
|
2463
4720
|
const trace = { attempts: 0 };
|
|
2464
|
-
if (await this.chatWithModel(req,
|
|
4721
|
+
if (await this.chatWithModel(req, out, model, probe, trace, Date.now() + this.firstByteBudgetMs)) {
|
|
2465
4722
|
settle(true);
|
|
2466
4723
|
return;
|
|
2467
4724
|
}
|
|
2468
4725
|
if (trace.lastState !== void 0 && isTransient(trace.lastState) && i < combo.models.length - 1) await new Promise((r) => setTimeout(r, TRANSIENT_SETTLE_MS));
|
|
2469
4726
|
}
|
|
2470
4727
|
settle(false, "combo: all models unavailable");
|
|
2471
|
-
writeJson$2(
|
|
4728
|
+
writeJson$2(out, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
|
|
2472
4729
|
return;
|
|
2473
4730
|
}
|
|
2474
4731
|
const slash = req.model.indexOf("/");
|
|
2475
4732
|
const alias = slash > 0 ? req.model.slice(0, slash) : "";
|
|
2476
4733
|
const target = alias === "" ? void 0 : this.supplierByAlias(alias);
|
|
2477
4734
|
if (target !== void 0) {
|
|
2478
|
-
if (await this.chatWithTarget(target, req,
|
|
4735
|
+
if (await this.chatWithTarget(target, req, out, probe)) {
|
|
2479
4736
|
settle(true);
|
|
2480
4737
|
return;
|
|
2481
4738
|
}
|
|
2482
4739
|
settle(false, `supplier ${target.id}: all accounts unavailable`);
|
|
2483
|
-
writeJson$2(
|
|
4740
|
+
writeJson$2(out, 503, openAIError("no_healthy_supplier", `supplier ${JSON.stringify(target.id)}: all accounts unavailable`));
|
|
2484
4741
|
return;
|
|
2485
4742
|
}
|
|
2486
|
-
if (await this.chatWithModel(req,
|
|
4743
|
+
if (await this.chatWithModel(req, out, req.model, probe)) {
|
|
2487
4744
|
settle(true);
|
|
2488
4745
|
return;
|
|
2489
4746
|
}
|
|
2490
4747
|
settle(false, "all suppliers unavailable");
|
|
2491
|
-
writeJson$2(
|
|
4748
|
+
writeJson$2(out, 503, openAIError("no_healthy_supplier", "all suppliers unavailable"));
|
|
2492
4749
|
}
|
|
2493
4750
|
/** 调指定供应商。
|
|
2494
4751
|
* 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
|
|
@@ -2572,8 +4829,9 @@ var Router = class {
|
|
|
2572
4829
|
async chatWithSupplier(s, req, res, trace, probe, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs, onlyUid) {
|
|
2573
4830
|
const pool = s.pool;
|
|
2574
4831
|
const cfg = this.store.get(s.id);
|
|
4832
|
+
const chatOnce = this.wireOf(s);
|
|
2575
4833
|
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
2576
|
-
const r = await
|
|
4834
|
+
const r = await chatOnce("", req.lv ?? "auto", req);
|
|
2577
4835
|
if (!r.ok) {
|
|
2578
4836
|
if (r.state === "no_such_model" || r.state === "bad_request") return false;
|
|
2579
4837
|
if (trace !== void 0) trace.lastState = r.state;
|
|
@@ -2609,7 +4867,7 @@ var Router = class {
|
|
|
2609
4867
|
}
|
|
2610
4868
|
pickWhy = pool.lastWhy;
|
|
2611
4869
|
tried.add(uid);
|
|
2612
|
-
const r = await
|
|
4870
|
+
const r = await chatOnce(uid, req.lv ?? "auto", req);
|
|
2613
4871
|
if (!r.ok) {
|
|
2614
4872
|
if (r.state === "no_such_model" || r.state === "bad_request") {
|
|
2615
4873
|
if (trace !== void 0) {
|
|
@@ -2664,7 +4922,19 @@ var Router = class {
|
|
|
2664
4922
|
ok: false,
|
|
2665
4923
|
error: `unknown supplier ${JSON.stringify(supplierId)}`
|
|
2666
4924
|
};
|
|
2667
|
-
|
|
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;
|
|
2668
4938
|
const req = {
|
|
2669
4939
|
model,
|
|
2670
4940
|
stream: false,
|
|
@@ -2688,8 +4958,8 @@ var Router = class {
|
|
|
2688
4958
|
error: err.message
|
|
2689
4959
|
};
|
|
2690
4960
|
}
|
|
2691
|
-
if (served &&
|
|
2692
|
-
const fromSink = served &&
|
|
4961
|
+
if (served && statusOf() < 400) return { ok: true };
|
|
4962
|
+
const fromSink = served && statusOf() >= 400 ? extractUpstreamError(bodyOf()) : "";
|
|
2693
4963
|
const detail = fromSink !== "" ? fromSink : trace.lastError;
|
|
2694
4964
|
return {
|
|
2695
4965
|
ok: false,
|
|
@@ -4120,7 +6390,7 @@ function guard(handler) {
|
|
|
4120
6390
|
}
|
|
4121
6391
|
};
|
|
4122
6392
|
}
|
|
4123
|
-
async function readBody$
|
|
6393
|
+
async function readBody$2(req, limit = 65536) {
|
|
4124
6394
|
const chunks = [];
|
|
4125
6395
|
let size = 0;
|
|
4126
6396
|
return await new Promise((resolve, reject) => {
|
|
@@ -4193,7 +6463,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4193
6463
|
handler: async (req, res) => {
|
|
4194
6464
|
let body;
|
|
4195
6465
|
try {
|
|
4196
|
-
body = JSON.parse(await readBody$
|
|
6466
|
+
body = JSON.parse(await readBody$2(req));
|
|
4197
6467
|
} catch {
|
|
4198
6468
|
writeJson$1(res, 400, {
|
|
4199
6469
|
ok: false,
|
|
@@ -4230,7 +6500,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4230
6500
|
}
|
|
4231
6501
|
try {
|
|
4232
6502
|
let pick = {};
|
|
4233
|
-
const raw = (await readBody$
|
|
6503
|
+
const raw = (await readBody$2(req)).trim();
|
|
4234
6504
|
if (raw !== "") {
|
|
4235
6505
|
const body = JSON.parse(raw);
|
|
4236
6506
|
pick = {
|
|
@@ -4291,7 +6561,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4291
6561
|
kind: "exact",
|
|
4292
6562
|
path: `${p}/alias`,
|
|
4293
6563
|
handler: async (req, res) => {
|
|
4294
|
-
const clean = (JSON.parse(await readBody$
|
|
6564
|
+
const clean = (JSON.parse(await readBody$2(req)).alias ?? "").trim();
|
|
4295
6565
|
if (clean !== "" && !/^[A-Za-z0-9_-]+$/.test(clean)) {
|
|
4296
6566
|
writeJson$1(res, 400, {
|
|
4297
6567
|
ok: false,
|
|
@@ -4314,7 +6584,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4314
6584
|
kind: "exact",
|
|
4315
6585
|
path: `${p}/models/toggle`,
|
|
4316
6586
|
handler: guard(async (req, res) => {
|
|
4317
|
-
const body = JSON.parse(await readBody$
|
|
6587
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4318
6588
|
store.get(s.id);
|
|
4319
6589
|
const models = await router.modelsOf(s.id);
|
|
4320
6590
|
if (!body.id || !models.some((mm) => mm.id === body.id)) {
|
|
@@ -4333,7 +6603,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4333
6603
|
kind: "exact",
|
|
4334
6604
|
path: `${p}/models/add`,
|
|
4335
6605
|
handler: guard(async (req, res) => {
|
|
4336
|
-
const id = (JSON.parse(await readBody$
|
|
6606
|
+
const id = (JSON.parse(await readBody$2(req)).id ?? "").trim();
|
|
4337
6607
|
if (id === "") {
|
|
4338
6608
|
writeJson$1(res, 400, {
|
|
4339
6609
|
ok: false,
|
|
@@ -4357,7 +6627,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4357
6627
|
kind: "exact",
|
|
4358
6628
|
path: `${p}/models/remove`,
|
|
4359
6629
|
handler: async (req, res) => {
|
|
4360
|
-
const body = JSON.parse(await readBody$
|
|
6630
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4361
6631
|
const cfg = store.get(s.id);
|
|
4362
6632
|
if (!body.id || !cfg.custom.includes(body.id)) {
|
|
4363
6633
|
writeJson$1(res, 400, {
|
|
@@ -4375,7 +6645,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4375
6645
|
kind: "exact",
|
|
4376
6646
|
path: `${p}/models/bulk`,
|
|
4377
6647
|
handler: guard(async (req, res) => {
|
|
4378
|
-
const body = JSON.parse(await readBody$
|
|
6648
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4379
6649
|
const models = await router.modelsOf(s.id);
|
|
4380
6650
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
|
|
4381
6651
|
router.invalidateModels(s.id);
|
|
@@ -4393,7 +6663,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4393
6663
|
});
|
|
4394
6664
|
return;
|
|
4395
6665
|
}
|
|
4396
|
-
const body = JSON.parse(await readBody$
|
|
6666
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4397
6667
|
if (!Array.isArray(body.uids) || body.uids.some((u) => typeof u !== "string")) {
|
|
4398
6668
|
writeJson$1(res, 400, {
|
|
4399
6669
|
ok: false,
|
|
@@ -4419,7 +6689,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4419
6689
|
kind: "exact",
|
|
4420
6690
|
path: `${p}/models/test`,
|
|
4421
6691
|
handler: async (req, res) => {
|
|
4422
|
-
const body = JSON.parse(await readBody$
|
|
6692
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4423
6693
|
const onlyUid = typeof body.uid === "string" && body.uid !== "" ? body.uid : void 0;
|
|
4424
6694
|
const result = await router.testModel(s.id, body.id ?? "", onlyUid);
|
|
4425
6695
|
writeJson$1(res, result.ok ? 200 : 400, result);
|
|
@@ -4455,7 +6725,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4455
6725
|
});
|
|
4456
6726
|
return;
|
|
4457
6727
|
}
|
|
4458
|
-
const body = JSON.parse(await readBody$
|
|
6728
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4459
6729
|
try {
|
|
4460
6730
|
writeJson$1(res, 200, {
|
|
4461
6731
|
ok: true,
|
|
@@ -4480,7 +6750,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4480
6750
|
});
|
|
4481
6751
|
return;
|
|
4482
6752
|
}
|
|
4483
|
-
const body = JSON.parse(await readBody$
|
|
6753
|
+
const body = JSON.parse(await readBody$2(req));
|
|
4484
6754
|
try {
|
|
4485
6755
|
const r = await m.addApiKey.call(loaded.supplier, {
|
|
4486
6756
|
name: (body.name ?? "").trim(),
|
|
@@ -4506,7 +6776,7 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
4506
6776
|
});
|
|
4507
6777
|
return;
|
|
4508
6778
|
}
|
|
4509
|
-
const uid = JSON.parse(await readBody$
|
|
6779
|
+
const uid = JSON.parse(await readBody$2(req)).uid ?? "";
|
|
4510
6780
|
const ok = await s.removeLink(uid);
|
|
4511
6781
|
if (ok) store.clearCredits(s.id, uid);
|
|
4512
6782
|
writeJson$1(res, ok ? 200 : 404, ok ? { ok: true } : {
|
|
@@ -4911,6 +7181,332 @@ var ExtStore = class {
|
|
|
4911
7181
|
}
|
|
4912
7182
|
};
|
|
4913
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
|
|
4914
7510
|
//#region src/index.ts
|
|
4915
7511
|
/**
|
|
4916
7512
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -4949,6 +7545,33 @@ function writeJson(res, status, body) {
|
|
|
4949
7545
|
});
|
|
4950
7546
|
res.end(payload);
|
|
4951
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
|
+
}
|
|
4952
7575
|
function readBody(req, limit) {
|
|
4953
7576
|
return new Promise((resolve, reject) => {
|
|
4954
7577
|
const chunks = [];
|
|
@@ -5138,9 +7761,61 @@ function apply(rawContext) {
|
|
|
5138
7761
|
stream: !!peek.stream,
|
|
5139
7762
|
model: typeof peek.model === "string" ? peek.model : "",
|
|
5140
7763
|
lv,
|
|
5141
|
-
...session === void 0 ? {} : { session }
|
|
7764
|
+
...session === void 0 ? {} : { session },
|
|
7765
|
+
protocol: "chat"
|
|
5142
7766
|
}, res);
|
|
5143
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
|
+
}));
|
|
5144
7819
|
route(`${ROUTER_API_BASE}/health`, (_req, res) => {
|
|
5145
7820
|
const { suppliers } = router.status();
|
|
5146
7821
|
writeJson(res, 200, {
|
|
@@ -5386,6 +8061,8 @@ function apply(rawContext) {
|
|
|
5386
8061
|
...c.detail !== void 0 ? { detail: c.detail } : {},
|
|
5387
8062
|
...typeof c.body === "string" ? { body: c.body } : {},
|
|
5388
8063
|
...c.editable === true ? { editable: true } : {},
|
|
8064
|
+
...c.group !== void 0 ? { group: c.group } : {},
|
|
8065
|
+
...c.sortable !== void 0 ? { sortable: c.sortable } : {},
|
|
5389
8066
|
custom: c.custom === true,
|
|
5390
8067
|
overridden: c.overridden === true
|
|
5391
8068
|
})) } : {},
|