llm-simple-router 0.7.1 → 0.8.2
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/dist/admin/proxy-enhancement.js +3 -1
- package/dist/admin/routes.d.ts +1 -0
- package/dist/admin/routes.js +3 -1
- package/dist/admin/settings-import-export.d.ts +1 -0
- package/dist/admin/settings-import-export.js +7 -0
- package/dist/admin/transform-rules.d.ts +8 -0
- package/dist/admin/transform-rules.js +38 -0
- package/dist/admin/usage.js +1 -1
- package/dist/core/container.d.ts +1 -0
- package/dist/core/container.js +1 -0
- package/dist/db/migrations/034_create_provider_transform_rules.sql +11 -0
- package/dist/db/transform-rules.d.ts +16 -0
- package/dist/db/transform-rules.js +51 -0
- package/dist/index.js +30 -1
- package/dist/metrics/sse-parser.d.ts +2 -0
- package/dist/metrics/sse-parser.js +4 -0
- package/dist/monitor/request-tracker.d.ts +2 -0
- package/dist/monitor/request-tracker.js +22 -1
- package/dist/monitor/types.d.ts +1 -1
- package/dist/proxy/enhancement/response-cleaner.js +14 -6
- package/dist/proxy/handler/openai.js +13 -4
- package/dist/proxy/handler/proxy-handler-utils.js +2 -7
- package/dist/proxy/handler/proxy-handler.js +85 -18
- package/dist/proxy/patch/deepseek/index.d.ts +15 -3
- package/dist/proxy/patch/deepseek/index.js +29 -6
- package/dist/proxy/patch/deepseek/patch-cache-control.d.ts +6 -0
- package/dist/proxy/patch/deepseek/patch-cache-control.js +30 -0
- package/dist/proxy/patch/deepseek/patch-non-deepseek-tools.d.ts +16 -0
- package/dist/proxy/patch/deepseek/patch-non-deepseek-tools.js +74 -0
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +10 -1
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +58 -15
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +5 -1
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +37 -4
- package/dist/proxy/patch/deepseek/patch-thinking-param.d.ts +6 -0
- package/dist/proxy/patch/deepseek/patch-thinking-param.js +32 -0
- package/dist/proxy/patch/deepseek/utils.d.ts +8 -0
- package/dist/proxy/patch/deepseek/utils.js +38 -0
- package/dist/proxy/patch/index.d.ts +2 -2
- package/dist/proxy/patch/index.js +50 -4
- package/dist/proxy/patch/router-cleanup.js +1 -24
- package/dist/proxy/patch/safe-sse-parser.d.ts +9 -0
- package/dist/proxy/patch/safe-sse-parser.js +16 -0
- package/dist/proxy/patch/tool-round-limiter.d.ts +38 -0
- package/dist/proxy/patch/tool-round-limiter.js +115 -0
- package/dist/proxy/pipeline-snapshot.d.ts +4 -0
- package/dist/proxy/proxy-core.js +1 -0
- package/dist/proxy/proxy-logging.d.ts +1 -1
- package/dist/proxy/proxy-logging.js +3 -3
- package/dist/proxy/routing/enhancement-config.d.ts +1 -0
- package/dist/proxy/routing/enhancement-config.js +2 -0
- package/dist/proxy/transform/id-utils.d.ts +3 -0
- package/dist/proxy/transform/id-utils.js +9 -0
- package/dist/proxy/transform/message-mapper.d.ts +15 -0
- package/dist/proxy/transform/message-mapper.js +173 -0
- package/dist/proxy/transform/plugin-registry.d.ts +23 -0
- package/dist/proxy/transform/plugin-registry.js +130 -0
- package/dist/proxy/transform/plugin-types.d.ts +46 -0
- package/dist/proxy/transform/plugin-types.js +15 -0
- package/dist/proxy/transform/provider-meta.d.ts +29 -0
- package/dist/proxy/transform/provider-meta.js +72 -0
- package/dist/proxy/transform/request-transform.d.ts +4 -0
- package/dist/proxy/transform/request-transform.js +151 -0
- package/dist/proxy/transform/response-transform.d.ts +4 -0
- package/dist/proxy/transform/response-transform.js +99 -0
- package/dist/proxy/transform/sanitize.d.ts +3 -0
- package/dist/proxy/transform/sanitize.js +24 -0
- package/dist/proxy/transform/stream-ant2oa.d.ts +20 -0
- package/dist/proxy/transform/stream-ant2oa.js +200 -0
- package/dist/proxy/transform/stream-oa2ant.d.ts +25 -0
- package/dist/proxy/transform/stream-oa2ant.js +201 -0
- package/dist/proxy/transform/stream-transform-base.d.ts +19 -0
- package/dist/proxy/transform/stream-transform-base.js +61 -0
- package/dist/proxy/transform/thinking-mapper.d.ts +4 -0
- package/dist/proxy/transform/thinking-mapper.js +15 -0
- package/dist/proxy/transform/tool-mapper.d.ts +8 -0
- package/dist/proxy/transform/tool-mapper.js +67 -0
- package/dist/proxy/transform/transform-coordinator.d.ts +11 -0
- package/dist/proxy/transform/transform-coordinator.js +32 -0
- package/dist/proxy/transform/types.d.ts +43 -0
- package/dist/proxy/transform/types.js +1 -0
- package/dist/proxy/transform/usage-mapper.d.ts +8 -0
- package/dist/proxy/transform/usage-mapper.js +46 -0
- package/dist/proxy/transport/stream.d.ts +1 -1
- package/dist/proxy/transport/stream.js +19 -10
- package/dist/proxy/transport/transport-fn.d.ts +3 -0
- package/dist/proxy/transport/transport-fn.js +11 -4
- package/dist/storage/log-file-compressor.js +5 -6
- package/dist/storage/log-file-writer.js +11 -13
- package/dist/storage/types.d.ts +2 -0
- package/dist/storage/types.js +7 -0
- package/frontend-dist/assets/{CardContent-CxOF1feY.js → CardContent-BVMQ2_pg.js} +1 -1
- package/frontend-dist/assets/{CardTitle-BSEFcEOM.js → CardTitle-GLv7QyIY.js} +1 -1
- package/frontend-dist/assets/{CascadingModelSelect-DTwksDPZ.js → CascadingModelSelect-CBhqKFDX.js} +1 -1
- package/frontend-dist/assets/{Checkbox-RfsERG07.js → Checkbox-HPVDmEdV.js} +1 -1
- package/frontend-dist/assets/{CollapsibleTrigger-Dsjo7QlC.js → CollapsibleTrigger-DhxD9tpM.js} +1 -1
- package/frontend-dist/assets/{Collection-rQ4eIYfa.js → Collection-BRt7YxN8.js} +1 -1
- package/frontend-dist/assets/{Dashboard-YejfAPiB.js → Dashboard-D1Ys8Zog.js} +1 -1
- package/frontend-dist/assets/{DialogTitle-DeFTnmgC.js → DialogTitle-23q73lwF.js} +1 -1
- package/frontend-dist/assets/{Input-CENz_g9t.js → Input-CAnKUBBK.js} +1 -1
- package/frontend-dist/assets/{Label-BAciBrrd.js → Label-DWdYtVMI.js} +1 -1
- package/frontend-dist/assets/{Login-DQkYFq7R.js → Login-w5WFOinP.js} +1 -1
- package/frontend-dist/assets/{Logs-Dol8AX7z.js → Logs-C1F1ZmWF.js} +1 -1
- package/frontend-dist/assets/{ModelMappings-VEYW1TrW.js → ModelMappings-BzmecWEH.js} +1 -1
- package/frontend-dist/assets/{Monitor-C0r9WefB.js → Monitor-DrAZFTKR.js} +1 -1
- package/frontend-dist/assets/{PopoverTrigger-Cyqik5SE.js → PopoverTrigger-Bj65uUbv.js} +1 -1
- package/frontend-dist/assets/{PopperContent-B7IuAHeq.js → PopperContent-gzzf1XHe.js} +1 -1
- package/frontend-dist/assets/Providers-DSgf4mb6.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-Bb1cCP6d.js +5 -0
- package/frontend-dist/assets/{RetryRules-F0295m4_.js → RetryRules-BwPfEZtm.js} +1 -1
- package/frontend-dist/assets/{RouterKeys-CFbPtUE_.js → RouterKeys-CzTSq1Mx.js} +1 -1
- package/frontend-dist/assets/{RovingFocusItem-D291Vjh8.js → RovingFocusItem-CXM_Yfkm.js} +1 -1
- package/frontend-dist/assets/{Schedules-DWhF3uod.js → Schedules-DVilCXrC.js} +1 -1
- package/frontend-dist/assets/{SelectValue-BWlgUZa3.js → SelectValue-C0-LzGQY.js} +1 -1
- package/frontend-dist/assets/{Settings-BnIzEF_k.js → Settings-Bpk53zVX.js} +1 -1
- package/frontend-dist/assets/{Setup-BglKyQKq.js → Setup-Dn7EgC49.js} +1 -1
- package/frontend-dist/assets/{Switch-DyCR-CPu.js → Switch-BO8Ooae6.js} +1 -1
- package/frontend-dist/assets/{TableHeader-DVUlBL35.js → TableHeader-Bded9VTC.js} +1 -1
- package/frontend-dist/assets/{TabsTrigger-BU1DY-C8.js → TabsTrigger-BzKMi9AF.js} +1 -1
- package/frontend-dist/assets/{Teleport-BQgusr9g.js → Teleport-DizRK5O3.js} +1 -1
- package/frontend-dist/assets/{TooltipTrigger-Bv_QoBns.js → TooltipTrigger-EiIy2zn8.js} +1 -1
- package/frontend-dist/assets/{UnifiedRequestDialog-f_evI835.js → UnifiedRequestDialog-BABsTaGb.js} +1 -1
- package/frontend-dist/assets/{VisuallyHidden-Con10z4F.js → VisuallyHidden-5AozJQza.js} +1 -1
- package/frontend-dist/assets/{VisuallyHiddenInput-yrDtxucb.js → VisuallyHiddenInput-DdiZrV2i.js} +1 -1
- package/frontend-dist/assets/{alert-dialog-2Db6Z7JQ.js → alert-dialog-DlKUuTPe.js} +1 -1
- package/frontend-dist/assets/arrow-down-CxWKmZ2I.js +1 -0
- package/frontend-dist/assets/{badge-DEhZfeI0.js → badge-9KJEMa53.js} +1 -1
- package/frontend-dist/assets/button-Ul8WlrM5.js +12 -0
- package/frontend-dist/assets/check-7ahK--N4.js +1 -0
- package/frontend-dist/assets/{copy-CwqZSuIG.js → copy-DzU2pAMG.js} +1 -1
- package/frontend-dist/assets/{dialog-CVMKSdPr.js → dialog-B9j-FMrd.js} +1 -1
- package/frontend-dist/assets/{file-text-D0K8Hovo.js → file-text-Bj3ZIo-E.js} +1 -1
- package/frontend-dist/assets/index-Bz_ZaXNn.css +1 -0
- package/frontend-dist/assets/{index-Ct718O93.js → index-MedWZMHB.js} +1 -1
- package/frontend-dist/assets/{lib-H3YI7EK4.js → lib-Hhs3NqfD.js} +1 -1
- package/frontend-dist/assets/loader-circle-5TJUukEe.js +1 -0
- package/frontend-dist/assets/{useClipboard-Cd7k-5Yq.js → useClipboard-BmmsNSGV.js} +1 -1
- package/frontend-dist/assets/{useFocusGuards-luoLXnwV.js → useFocusGuards-A-9V2Y-b.js} +1 -1
- package/frontend-dist/assets/useFormControl-DEO19lRe.js +1 -0
- package/frontend-dist/assets/{useLogRetention-DB4Iu6o_.js → useLogRetention-BfnBFZ5K.js} +1 -1
- package/frontend-dist/assets/useNonce-BfwUJ1Ci.js +1 -0
- package/frontend-dist/assets/x-Cfopt3QL.js +1 -0
- package/frontend-dist/index.html +20 -20
- package/package.json +1 -1
- package/frontend-dist/assets/Providers-D8Z97edN.js +0 -1
- package/frontend-dist/assets/ProxyEnhancement-Kn8r2SN6.js +0 -5
- package/frontend-dist/assets/arrow-down-WyouvE7T.js +0 -1
- package/frontend-dist/assets/button-Cnkbp_6J.js +0 -12
- package/frontend-dist/assets/check-BuqB5Nyb.js +0 -1
- package/frontend-dist/assets/index-xjdbFKXJ.css +0 -1
- package/frontend-dist/assets/loader-circle-Be82FnVY.js +0 -1
- package/frontend-dist/assets/useFormControl-Da4ViGZF.js +0 -1
- package/frontend-dist/assets/useNonce-DvAdQ48J.js +0 -1
- package/frontend-dist/assets/x-DB22csQl.js +0 -1
|
@@ -1,15 +1,61 @@
|
|
|
1
1
|
import { applyDeepSeekPatches } from "./deepseek/index.js";
|
|
2
|
+
const OPENAI_ORIGIN_HOSTS = ["api.openai.com", "openai.com"];
|
|
2
3
|
/**
|
|
3
4
|
* 根据 provider 信息分发到对应的补丁逻辑。
|
|
4
5
|
* 返回浅拷贝 body + 执行的补丁类型列表,不修改原始 body。
|
|
5
6
|
*/
|
|
6
7
|
export function applyProviderPatches(body, provider) {
|
|
8
|
+
const patches = [];
|
|
9
|
+
let cloned = false;
|
|
10
|
+
let patched;
|
|
11
|
+
const ensureCloned = () => {
|
|
12
|
+
if (!cloned) {
|
|
13
|
+
patched = JSON.parse(JSON.stringify(body));
|
|
14
|
+
cloned = true;
|
|
15
|
+
}
|
|
16
|
+
return patched;
|
|
17
|
+
};
|
|
18
|
+
// 通用补丁:OpenAI 兼容 provider(非 OpenAI 原生)不支持 developer role
|
|
19
|
+
if (provider.api_type === "openai" && !isOpenAIOrigin(provider.base_url)) {
|
|
20
|
+
if (hasDeveloperRole(body)) {
|
|
21
|
+
patchDeveloperRole(ensureCloned());
|
|
22
|
+
patches.push("developer_role");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// DeepSeek 特定补丁
|
|
7
26
|
if (needsDeepSeekPatch(body, provider)) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
27
|
+
applyDeepSeekPatches(ensureCloned(), provider.api_type);
|
|
28
|
+
patches.push("deepseek");
|
|
29
|
+
}
|
|
30
|
+
return { body: patched ?? body, meta: { types: patches } };
|
|
31
|
+
}
|
|
32
|
+
/** 判断是否为 OpenAI 官方端点 */
|
|
33
|
+
function isOpenAIOrigin(baseUrl) {
|
|
34
|
+
try {
|
|
35
|
+
const host = new URL(baseUrl).hostname;
|
|
36
|
+
return OPENAI_ORIGIN_HOSTS.some(origin => host === origin || host.endsWith(`.${origin}`));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** 检查 messages 中是否包含 developer role */
|
|
43
|
+
function hasDeveloperRole(body) {
|
|
44
|
+
const messages = body.messages;
|
|
45
|
+
if (!messages)
|
|
46
|
+
return false;
|
|
47
|
+
return messages.some(m => m.role === "developer");
|
|
48
|
+
}
|
|
49
|
+
/** 将 developer role 转换为 system */
|
|
50
|
+
function patchDeveloperRole(body) {
|
|
51
|
+
const messages = body.messages;
|
|
52
|
+
if (!messages)
|
|
53
|
+
return;
|
|
54
|
+
for (const msg of messages) {
|
|
55
|
+
if (msg.role === "developer") {
|
|
56
|
+
msg.role = "system";
|
|
57
|
+
}
|
|
11
58
|
}
|
|
12
|
-
return { body, meta: { types: [] } };
|
|
13
59
|
}
|
|
14
60
|
/** DeepSeek patch 触发条件:直连 DeepSeek,或经代理转发且模型名含 deepseek */
|
|
15
61
|
function needsDeepSeekPatch(body, provider) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TOOL_USE_ID_PREFIX } from "../enhancement/directive-parser.js";
|
|
2
|
+
import { mergeConsecutive } from "./deepseek/utils.js";
|
|
2
3
|
/**
|
|
3
4
|
* 从消息中移除 router 注入的合成 tool_use/tool_result。
|
|
4
5
|
*
|
|
@@ -61,27 +62,3 @@ export function patchRouterSyntheticToolCalls(body) {
|
|
|
61
62
|
mergeConsecutive(messages, "user");
|
|
62
63
|
mergeConsecutive(messages, "assistant");
|
|
63
64
|
}
|
|
64
|
-
function mergeConsecutive(messages, role) {
|
|
65
|
-
let i = 1;
|
|
66
|
-
while (i < messages.length) {
|
|
67
|
-
if (messages[i].role === role && messages[i - 1].role === role) {
|
|
68
|
-
const prev = messages[i - 1];
|
|
69
|
-
const curr = messages[i];
|
|
70
|
-
prev.content = [
|
|
71
|
-
...normalizeToArray(prev.content),
|
|
72
|
-
...normalizeToArray(curr.content),
|
|
73
|
-
];
|
|
74
|
-
messages.splice(i, 1);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
i++;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
function normalizeToArray(content) {
|
|
82
|
-
if (Array.isArray(content))
|
|
83
|
-
return content;
|
|
84
|
-
if (typeof content === "string")
|
|
85
|
-
return [{ type: "text", text: content }];
|
|
86
|
-
return [{ type: "text", text: String(content ?? "") }];
|
|
87
|
-
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SSEParser, type SSEEvent } from "../../metrics/sse-parser.js";
|
|
2
|
+
/**
|
|
3
|
+
* SSEParser 子类,增加未解析缓冲区上限保护。
|
|
4
|
+
* 防止畸形 SSE(无 \n\n 分隔)导致缓冲区无限增长。
|
|
5
|
+
* 只检查当前未解析缓冲区大小,已消费的事件不计入。
|
|
6
|
+
*/
|
|
7
|
+
export declare class SafeSSEParser extends SSEParser {
|
|
8
|
+
feed(chunk: string): SSEEvent[];
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SSEParser } from "../../metrics/sse-parser.js";
|
|
2
|
+
const MAX_BUFFER = 65536;
|
|
3
|
+
/**
|
|
4
|
+
* SSEParser 子类,增加未解析缓冲区上限保护。
|
|
5
|
+
* 防止畸形 SSE(无 \n\n 分隔)导致缓冲区无限增长。
|
|
6
|
+
* 只检查当前未解析缓冲区大小,已消费的事件不计入。
|
|
7
|
+
*/
|
|
8
|
+
export class SafeSSEParser extends SSEParser {
|
|
9
|
+
feed(chunk) {
|
|
10
|
+
const events = super.feed(chunk);
|
|
11
|
+
if (this.bufferLength > MAX_BUFFER) {
|
|
12
|
+
throw new Error(`SSE buffer exceeded ${MAX_BUFFER} bytes`);
|
|
13
|
+
}
|
|
14
|
+
return events;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具调用轮数限制器。
|
|
3
|
+
*
|
|
4
|
+
* 检测 messages 中连续的 "assistant(tool_use) → user(tool_result)" 轮次数量,
|
|
5
|
+
* 超过阈值时注入提示词提醒 AI 不要陷入无限循环。
|
|
6
|
+
*
|
|
7
|
+
* 与 loop-prevention/tool-loop-guard 不同:
|
|
8
|
+
* - tool-loop-guard 关注"同一工具重复调用"(N-gram 检测 input 重复)
|
|
9
|
+
* - 本模块关注"工具调用轮数过多"(不管是否同一工具,反映 AI 反复操作却无法完成)
|
|
10
|
+
*/
|
|
11
|
+
/** OpenAI 格式的 tool_call */
|
|
12
|
+
interface OpenAIToolCall {
|
|
13
|
+
type?: string;
|
|
14
|
+
function?: {
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
interface Message {
|
|
19
|
+
role?: string;
|
|
20
|
+
content?: unknown;
|
|
21
|
+
tool_calls?: OpenAIToolCall[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 统计 messages 中连续的"工具调用轮数"。
|
|
25
|
+
*
|
|
26
|
+
* 一轮定义:assistant 消息包含 tool_use → 后面紧接 user 消息包含 tool_result。
|
|
27
|
+
* 从最后一条消息向前扫描,遇到非工具轮即停止。
|
|
28
|
+
*/
|
|
29
|
+
export declare function countConsecutiveToolRounds(messages: Message[]): number;
|
|
30
|
+
/**
|
|
31
|
+
* 检测并注入提示词。返回可能修改后的 body(浅拷贝),未超阈值时原样返回。
|
|
32
|
+
*/
|
|
33
|
+
export declare function applyToolRoundLimit(body: Record<string, unknown>, apiType: "openai" | "anthropic", maxRounds?: number): {
|
|
34
|
+
body: Record<string, unknown>;
|
|
35
|
+
injected: boolean;
|
|
36
|
+
rounds: number;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具调用轮数限制器。
|
|
3
|
+
*
|
|
4
|
+
* 检测 messages 中连续的 "assistant(tool_use) → user(tool_result)" 轮次数量,
|
|
5
|
+
* 超过阈值时注入提示词提醒 AI 不要陷入无限循环。
|
|
6
|
+
*
|
|
7
|
+
* 与 loop-prevention/tool-loop-guard 不同:
|
|
8
|
+
* - tool-loop-guard 关注"同一工具重复调用"(N-gram 检测 input 重复)
|
|
9
|
+
* - 本模块关注"工具调用轮数过多"(不管是否同一工具,反映 AI 反复操作却无法完成)
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_MAX_ROUNDS = 5;
|
|
12
|
+
const LOOP_WARNING_PROMPT = "[系统提醒] 你已经连续进行了多轮工具调用但似乎还没有完成任务。请注意不要陷入无限循环,停下来总结当前进展,如果无法继续请直接告知用户。";
|
|
13
|
+
/**
|
|
14
|
+
* 统计 messages 中连续的"工具调用轮数"。
|
|
15
|
+
*
|
|
16
|
+
* 一轮定义:assistant 消息包含 tool_use → 后面紧接 user 消息包含 tool_result。
|
|
17
|
+
* 从最后一条消息向前扫描,遇到非工具轮即停止。
|
|
18
|
+
*/
|
|
19
|
+
export function countConsecutiveToolRounds(messages) {
|
|
20
|
+
let rounds = 0;
|
|
21
|
+
let i = messages.length - 1;
|
|
22
|
+
while (i >= 1) {
|
|
23
|
+
const msg = messages[i];
|
|
24
|
+
// 期望:user 消息包含 tool_result(Anthropic)或 role=tool(OpenAI)
|
|
25
|
+
if (msg.role === "user" || msg.role === "tool") {
|
|
26
|
+
const hasToolResult = hasToolResultContent(msg);
|
|
27
|
+
if (hasToolResult || msg.role === "tool") {
|
|
28
|
+
// 向前找对应的 assistant 消息
|
|
29
|
+
let j = i - 1;
|
|
30
|
+
while (j >= 0 && messages[j].role !== "assistant")
|
|
31
|
+
j--;
|
|
32
|
+
if (j >= 0 && hasToolUseContent(messages[j])) {
|
|
33
|
+
rounds++;
|
|
34
|
+
i = j - 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// assistant 消息本身可能包含 tool_use(最后一轮可能还没 tool_result)
|
|
40
|
+
if (msg.role === "assistant" && hasToolUseContent(msg)) {
|
|
41
|
+
rounds++;
|
|
42
|
+
i--;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return rounds;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 检测并注入提示词。返回可能修改后的 body(浅拷贝),未超阈值时原样返回。
|
|
51
|
+
*/
|
|
52
|
+
export function applyToolRoundLimit(body, apiType, maxRounds = DEFAULT_MAX_ROUNDS) {
|
|
53
|
+
const messages = body.messages ?? [];
|
|
54
|
+
if (messages.length === 0)
|
|
55
|
+
return { body, injected: false, rounds: 0 };
|
|
56
|
+
const rounds = countConsecutiveToolRounds(messages);
|
|
57
|
+
if (rounds <= maxRounds)
|
|
58
|
+
return { body, injected: false, rounds };
|
|
59
|
+
const cloned = { ...body, messages: [...messages] };
|
|
60
|
+
const clonedMessages = cloned.messages;
|
|
61
|
+
// 在尾部注入:修改最后一条消息的 content,不插入新消息到头部
|
|
62
|
+
// 这样不会使 LLM 的 KV cache 失效(前面的 messages 保持不变)
|
|
63
|
+
if (apiType === "anthropic") {
|
|
64
|
+
// Anthropic:将提示词作为 text block 追加到最后一条消息的 content 末尾
|
|
65
|
+
const lastMsg = clonedMessages[clonedMessages.length - 1];
|
|
66
|
+
if (lastMsg && Array.isArray(lastMsg.content)) {
|
|
67
|
+
const patched = [...lastMsg.content];
|
|
68
|
+
patched.push({ type: "text", text: LOOP_WARNING_PROMPT });
|
|
69
|
+
lastMsg.content = patched;
|
|
70
|
+
}
|
|
71
|
+
else if (lastMsg && typeof lastMsg.content === "string") {
|
|
72
|
+
lastMsg.content = [
|
|
73
|
+
{ type: "text", text: lastMsg.content },
|
|
74
|
+
{ type: "text", text: LOOP_WARNING_PROMPT },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// fallback:追加 user 消息
|
|
79
|
+
clonedMessages.push({ role: "user", content: [{ type: "text", text: LOOP_WARNING_PROMPT }] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// OpenAI 格式:将提示词追加到最后一条消息的 content 末尾
|
|
84
|
+
const lastMsg = clonedMessages[clonedMessages.length - 1];
|
|
85
|
+
if (lastMsg && typeof lastMsg.content === "string") {
|
|
86
|
+
lastMsg.content = lastMsg.content + "\n\n" + LOOP_WARNING_PROMPT;
|
|
87
|
+
}
|
|
88
|
+
else if (lastMsg && lastMsg.content != null) {
|
|
89
|
+
lastMsg.content = JSON.stringify(lastMsg.content) + "\n\n" + LOOP_WARNING_PROMPT;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// fallback:追加 user 消息
|
|
93
|
+
clonedMessages.push({ role: "user", content: LOOP_WARNING_PROMPT });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { body: cloned, injected: true, rounds };
|
|
97
|
+
}
|
|
98
|
+
// --- helpers ---
|
|
99
|
+
/** Anthropic 格式:检查 content 数组中是否包含 tool_use */
|
|
100
|
+
function hasToolUseContent(msg) {
|
|
101
|
+
if (Array.isArray(msg.content)) {
|
|
102
|
+
return msg.content.some((block) => block.type === "tool_use");
|
|
103
|
+
}
|
|
104
|
+
// OpenAI 格式
|
|
105
|
+
if (msg.tool_calls && msg.tool_calls.length > 0)
|
|
106
|
+
return true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/** Anthropic 格式:检查 content 数组中是否包含 tool_result */
|
|
110
|
+
function hasToolResultContent(msg) {
|
|
111
|
+
if (Array.isArray(msg.content)) {
|
|
112
|
+
return msg.content.some((block) => block.type === "tool_result");
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
package/dist/proxy/proxy-core.js
CHANGED
|
@@ -77,6 +77,7 @@ export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes, apiTyp
|
|
|
77
77
|
const headers = selectHeaders(clientHeaders, SKIP_UPSTREAM);
|
|
78
78
|
if (apiType === "anthropic") {
|
|
79
79
|
headers["x-api-key"] = apiKey;
|
|
80
|
+
headers["anthropic-version"] ??= "2023-06-01";
|
|
80
81
|
}
|
|
81
82
|
else {
|
|
82
83
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
@@ -4,7 +4,7 @@ import { type FailoverContext } from "./log-helpers.js";
|
|
|
4
4
|
import type { FastifyRequest } from "fastify";
|
|
5
5
|
import type { ResilienceAttempt } from "../core/types.js";
|
|
6
6
|
import type { TransportResult } from "./types.js";
|
|
7
|
-
/** 日志存储前脱敏 Authorization header,避免 API Key 被持久化 */
|
|
7
|
+
/** 日志存储前脱敏 Authorization / x-api-key header,避免 API Key 被持久化 */
|
|
8
8
|
export declare function sanitizeHeadersForLog(headers: Record<string, string>): Record<string, string>;
|
|
9
9
|
export declare function handleIntercept(db: Database.Database, apiType: "openai" | "anthropic", request: FastifyRequest, reply: import("fastify").FastifyReply, interceptResponse: {
|
|
10
10
|
statusCode: number;
|
|
@@ -7,12 +7,12 @@ import { estimateInputTokens } from "../utils/token-counter.js";
|
|
|
7
7
|
import { UPSTREAM_SUCCESS } from "./types.js";
|
|
8
8
|
import { HTTP_BAD_GATEWAY } from "../core/constants.js";
|
|
9
9
|
// ---------- Header sanitization ----------
|
|
10
|
-
const
|
|
11
|
-
/** 日志存储前脱敏 Authorization header,避免 API Key 被持久化 */
|
|
10
|
+
const SENSITIVE_HEADER_RE = /^(authorization|x-api-key)$/i;
|
|
11
|
+
/** 日志存储前脱敏 Authorization / x-api-key header,避免 API Key 被持久化 */
|
|
12
12
|
export function sanitizeHeadersForLog(headers) {
|
|
13
13
|
const sanitized = {};
|
|
14
14
|
for (const [key, value] of Object.entries(headers)) {
|
|
15
|
-
sanitized[key] =
|
|
15
|
+
sanitized[key] = SENSITIVE_HEADER_RE.test(key) ? value.replace(/(Bearer\s+)\S+/, "$1sk-***") : value;
|
|
16
16
|
}
|
|
17
17
|
return sanitized;
|
|
18
18
|
}
|
|
@@ -3,6 +3,7 @@ export interface EnhancementConfig {
|
|
|
3
3
|
claude_code_enabled: boolean;
|
|
4
4
|
tool_call_loop_enabled: boolean;
|
|
5
5
|
stream_loop_enabled: boolean;
|
|
6
|
+
tool_round_limit_enabled: boolean;
|
|
6
7
|
}
|
|
7
8
|
/** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
|
|
8
9
|
export declare function loadEnhancementConfig(db: Database.Database): EnhancementConfig;
|
|
@@ -3,6 +3,7 @@ const DEFAULT_CONFIG = {
|
|
|
3
3
|
claude_code_enabled: false,
|
|
4
4
|
tool_call_loop_enabled: false,
|
|
5
5
|
stream_loop_enabled: false,
|
|
6
|
+
tool_round_limit_enabled: true,
|
|
6
7
|
};
|
|
7
8
|
/** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
|
|
8
9
|
export function loadEnhancementConfig(db) {
|
|
@@ -15,6 +16,7 @@ export function loadEnhancementConfig(db) {
|
|
|
15
16
|
claude_code_enabled: parsed.claude_code_enabled ?? false,
|
|
16
17
|
tool_call_loop_enabled: parsed.tool_call_loop_enabled ?? false,
|
|
17
18
|
stream_loop_enabled: parsed.stream_loop_enabled ?? false,
|
|
19
|
+
tool_round_limit_enabled: parsed.tool_round_limit_enabled ?? true,
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
catch {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
const UUID_ID_LENGTH = 24;
|
|
3
|
+
export function generateMsgId() {
|
|
4
|
+
return `msg_${randomUUID().slice(0, UUID_ID_LENGTH)}`;
|
|
5
|
+
}
|
|
6
|
+
export function generateChatcmplId() {
|
|
7
|
+
return `chatcmpl-${randomUUID().slice(0, UUID_ID_LENGTH)}`;
|
|
8
|
+
}
|
|
9
|
+
export const MS_PER_SECOND = 1000;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AnthropicContentBlock } from "./types.js";
|
|
2
|
+
export declare function extractSystemMessages(messages: unknown[]): {
|
|
3
|
+
systemParts: string[];
|
|
4
|
+
nonSystemMsgs: unknown[];
|
|
5
|
+
};
|
|
6
|
+
interface AntMessage {
|
|
7
|
+
role: string;
|
|
8
|
+
content: AnthropicContentBlock[];
|
|
9
|
+
}
|
|
10
|
+
export declare function convertMessagesOA2Ant(messages: unknown[]): {
|
|
11
|
+
system?: string;
|
|
12
|
+
messages: AntMessage[];
|
|
13
|
+
};
|
|
14
|
+
export declare function convertMessagesAnt2OA(system: unknown, messages: unknown[]): unknown[];
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { sanitizeToolUseId, ensureNonEmptyContent, parseToolArguments } from "./sanitize.js";
|
|
2
|
+
// ---------- extractSystemMessages ----------
|
|
3
|
+
export function extractSystemMessages(messages) {
|
|
4
|
+
const systemParts = [];
|
|
5
|
+
const nonSystemMsgs = [];
|
|
6
|
+
for (const msg of messages) {
|
|
7
|
+
const m = msg;
|
|
8
|
+
if (m.role === "system" || m.role === "developer") {
|
|
9
|
+
systemParts.push(String(m.content ?? ""));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
nonSystemMsgs.push(msg);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return { systemParts, nonSystemMsgs };
|
|
16
|
+
}
|
|
17
|
+
// ---------- Content 归一化 ----------
|
|
18
|
+
function normalizeToTextBlocks(content) {
|
|
19
|
+
if (content == null || content === "")
|
|
20
|
+
return [];
|
|
21
|
+
if (typeof content === "string") {
|
|
22
|
+
return [{ type: "text", text: content }];
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(content)) {
|
|
25
|
+
return content.flatMap((p) => {
|
|
26
|
+
if (p.type === "text" && p.text) {
|
|
27
|
+
return [{ type: "text", text: String(p.text) }];
|
|
28
|
+
}
|
|
29
|
+
// Convert OpenAI image_url to Anthropic image source
|
|
30
|
+
if (p.type === "image_url") {
|
|
31
|
+
const imageUrl = p.image_url?.url;
|
|
32
|
+
if (imageUrl) {
|
|
33
|
+
if (imageUrl.startsWith("data:")) {
|
|
34
|
+
// base64 data URL → base64 source
|
|
35
|
+
const match = imageUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
return [{ type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [{ type: "image", source: { type: "url", url: imageUrl } }];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
export function convertMessagesOA2Ant(messages) {
|
|
49
|
+
ensureNonEmptyContent(messages);
|
|
50
|
+
const { systemParts, nonSystemMsgs } = extractSystemMessages(messages);
|
|
51
|
+
const system = systemParts.length > 0 ? systemParts.join("\n") : undefined;
|
|
52
|
+
const raw = [];
|
|
53
|
+
for (const msg of nonSystemMsgs) {
|
|
54
|
+
const m = msg;
|
|
55
|
+
if (m.role === "user") {
|
|
56
|
+
raw.push({ role: "user", content: normalizeToTextBlocks(m.content) });
|
|
57
|
+
}
|
|
58
|
+
else if (m.role === "assistant") {
|
|
59
|
+
const blocks = [];
|
|
60
|
+
// reasoning_content → thinking block (before text)
|
|
61
|
+
if (m.reasoning_content) {
|
|
62
|
+
blocks.push({ type: "thinking", thinking: String(m.reasoning_content) });
|
|
63
|
+
}
|
|
64
|
+
// text content (skip null/undefined/empty string)
|
|
65
|
+
if (m.content != null && m.content !== "") {
|
|
66
|
+
blocks.push(...normalizeToTextBlocks(m.content));
|
|
67
|
+
}
|
|
68
|
+
// tool_calls → tool_use blocks
|
|
69
|
+
const toolCalls = m.tool_calls;
|
|
70
|
+
if (toolCalls) {
|
|
71
|
+
for (const tc of toolCalls) {
|
|
72
|
+
const fn = tc.function;
|
|
73
|
+
const input = parseToolArguments(fn.arguments);
|
|
74
|
+
blocks.push({ type: "tool_use", id: sanitizeToolUseId(String(tc.id)), name: String(fn.name), input });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (blocks.length === 0)
|
|
78
|
+
blocks.push({ type: "text", text: "" });
|
|
79
|
+
raw.push({ role: "assistant", content: blocks });
|
|
80
|
+
}
|
|
81
|
+
else if (m.role === "tool") {
|
|
82
|
+
// role:"tool" → role:"user" + tool_result
|
|
83
|
+
const toolResult = {
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
tool_use_id: sanitizeToolUseId(String(m.tool_call_id ?? "")),
|
|
86
|
+
content: String(m.content ?? ""),
|
|
87
|
+
};
|
|
88
|
+
// 尝试合并到前一条 user 消息(或已有的 tool result 序列)
|
|
89
|
+
const last = raw[raw.length - 1];
|
|
90
|
+
if (last && last.role === "user" && last.content.every(b => b.type === "tool_result" || b.type === "text")) {
|
|
91
|
+
last.content.push(toolResult);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
raw.push({ role: "user", content: [toolResult] });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 强制交替:合并同 role
|
|
99
|
+
const merged = [];
|
|
100
|
+
for (const msg of raw) {
|
|
101
|
+
const prev = merged[merged.length - 1];
|
|
102
|
+
if (prev && prev.role === msg.role) {
|
|
103
|
+
prev.content = [...prev.content, ...msg.content];
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
merged.push({ ...msg, content: [...msg.content] });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 确保首条是 user
|
|
110
|
+
if (merged.length > 0 && merged[0].role !== "user") {
|
|
111
|
+
merged.unshift({ role: "user", content: [{ type: "text", text: "" }] });
|
|
112
|
+
}
|
|
113
|
+
return { system, messages: merged };
|
|
114
|
+
}
|
|
115
|
+
// ---------- Anthropic → OpenAI ----------
|
|
116
|
+
export function convertMessagesAnt2OA(system, messages) {
|
|
117
|
+
const result = [];
|
|
118
|
+
// system → role:"system"
|
|
119
|
+
if (system != null) {
|
|
120
|
+
const text = typeof system === "string"
|
|
121
|
+
? system
|
|
122
|
+
: Array.isArray(system)
|
|
123
|
+
? system.map(b => b.text ?? "").join("\n")
|
|
124
|
+
: String(system);
|
|
125
|
+
if (text)
|
|
126
|
+
result.push({ role: "system", content: text });
|
|
127
|
+
}
|
|
128
|
+
for (const msg of messages) {
|
|
129
|
+
const m = msg;
|
|
130
|
+
const content = m.content;
|
|
131
|
+
if (m.role === "user") {
|
|
132
|
+
if (!content || !Array.isArray(content))
|
|
133
|
+
continue;
|
|
134
|
+
const textParts = content.filter(b => b.type === "text");
|
|
135
|
+
const toolResults = content.filter(b => b.type === "tool_result");
|
|
136
|
+
if (textParts.length > 0) {
|
|
137
|
+
result.push({ role: "user", content: textParts.map(b => b.text ?? "").join("") });
|
|
138
|
+
}
|
|
139
|
+
for (const tr of toolResults) {
|
|
140
|
+
result.push({ role: "tool", tool_call_id: tr.tool_use_id, content: tr.content ?? "" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (m.role === "assistant") {
|
|
144
|
+
if (!content || !Array.isArray(content))
|
|
145
|
+
continue;
|
|
146
|
+
const textBlocks = content.filter(b => b.type === "text");
|
|
147
|
+
const toolBlocks = content.filter(b => b.type === "tool_use");
|
|
148
|
+
const oaiMsg = { role: "assistant" };
|
|
149
|
+
// thinking → reasoning_content(保留 DeepSeek 原生思考信息,
|
|
150
|
+
// 避免 A→O 转换后被 patchNonDeepSeekToolMessages 误判为非 DeepSeek 消息)
|
|
151
|
+
const thinkingBlocks = content.filter(b => b.type === "thinking");
|
|
152
|
+
if (thinkingBlocks.length > 0) {
|
|
153
|
+
oaiMsg.reasoning_content = thinkingBlocks.map(b => b.thinking ?? "").join("");
|
|
154
|
+
}
|
|
155
|
+
// text → content
|
|
156
|
+
if (textBlocks.length > 0) {
|
|
157
|
+
oaiMsg.content = textBlocks.map(b => b.text ?? "").join("");
|
|
158
|
+
}
|
|
159
|
+
// tool_use → tool_calls
|
|
160
|
+
if (toolBlocks.length > 0) {
|
|
161
|
+
oaiMsg.tool_calls = toolBlocks.map(b => ({
|
|
162
|
+
id: b.id,
|
|
163
|
+
type: "function",
|
|
164
|
+
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
if (oaiMsg.content || oaiMsg.tool_calls) {
|
|
168
|
+
result.push(oaiMsg);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type { TransformPlugin, RequestTransformContext, ResponseTransformContext } from "./plugin-types.js";
|
|
3
|
+
export declare class PluginRegistry {
|
|
4
|
+
private plugins;
|
|
5
|
+
private rulesCache;
|
|
6
|
+
registerPlugin(plugin: TransformPlugin): void;
|
|
7
|
+
loadFromDB(db: Database.Database): void;
|
|
8
|
+
scanPluginsDir(dir: string): string[];
|
|
9
|
+
getMatchingPlugins(provider: {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
api_type: string;
|
|
13
|
+
}): TransformPlugin[];
|
|
14
|
+
applyBeforeRequest(ctx: RequestTransformContext): void;
|
|
15
|
+
applyAfterRequest(ctx: RequestTransformContext): void;
|
|
16
|
+
applyBeforeResponse(ctx: ResponseTransformContext): void;
|
|
17
|
+
applyAfterResponse(ctx: ResponseTransformContext): void;
|
|
18
|
+
reload(db: Database.Database, pluginsDir: string): {
|
|
19
|
+
loadedPlugins: string[];
|
|
20
|
+
rulesCount: number;
|
|
21
|
+
};
|
|
22
|
+
private ruleToPlugin;
|
|
23
|
+
}
|