llm-simple-router 0.5.2 → 0.5.4
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/config/recommended-providers.json +234 -19
- package/dist/admin/api-response.d.ts +0 -1
- package/dist/admin/api-response.js +8 -4
- package/dist/admin/groups.js +35 -0
- package/dist/admin/monitor.js +2 -0
- package/dist/admin/providers.js +188 -22
- package/dist/admin/proxy-enhancement.js +9 -9
- package/dist/config/model-context.d.ts +10 -0
- package/dist/config/model-context.js +105 -0
- package/dist/db/index.d.ts +3 -1
- package/dist/db/index.js +2 -1
- package/dist/db/logs.d.ts +4 -0
- package/dist/db/logs.js +7 -3
- package/dist/db/mappings.d.ts +2 -1
- package/dist/db/mappings.js +2 -2
- package/dist/db/migrations/023_create_provider_model_info.sql +8 -0
- package/dist/db/migrations/024_add_mapping_groups_is_active.sql +1 -0
- package/dist/db/migrations/025_add_client_status_code.sql +3 -0
- package/dist/db/model-info.d.ts +14 -0
- package/dist/db/model-info.js +27 -0
- package/dist/db/providers.d.ts +1 -0
- package/dist/db/providers.js +1 -1
- package/dist/index.js +15 -3
- package/dist/middleware/auth.js +1 -1
- package/dist/monitor/request-tracker.d.ts +2 -0
- package/dist/monitor/request-tracker.js +18 -0
- package/dist/proxy/anthropic.js +13 -0
- package/dist/proxy/enhancement/directive-parser.d.ts +8 -2
- package/dist/proxy/enhancement/directive-parser.js +44 -17
- package/dist/proxy/enhancement/enhancement-handler.js +184 -54
- package/dist/proxy/enhancement/index.d.ts +1 -1
- package/dist/proxy/enhancement/index.js +1 -1
- package/dist/proxy/enhancement-config.d.ts +6 -0
- package/dist/proxy/enhancement-config.js +19 -0
- package/dist/proxy/openai.js +40 -3
- package/dist/proxy/overflow.d.ts +18 -0
- package/dist/proxy/overflow.js +128 -0
- package/dist/proxy/patch/deepseek/index.d.ts +6 -0
- package/dist/proxy/patch/deepseek/index.js +11 -0
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +12 -0
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +90 -0
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +6 -0
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +24 -0
- package/dist/proxy/patch/index.d.ts +9 -0
- package/dist/proxy/patch/index.js +17 -0
- package/dist/proxy/proxy-core.d.ts +9 -2
- package/dist/proxy/proxy-core.js +24 -2
- package/dist/proxy/proxy-handler.js +34 -9
- package/dist/proxy/proxy-logging.js +23 -2
- package/dist/proxy/resilience.d.ts +4 -0
- package/dist/proxy/resilience.js +8 -1
- package/dist/proxy/strategy/types.d.ts +2 -0
- package/dist/proxy/stream-proxy.js +2 -1
- package/dist/proxy/transport-fn.js +3 -2
- package/dist/proxy/transport.js +3 -2
- package/dist/proxy/types.d.ts +3 -1
- package/dist/proxy/types.js +5 -1
- package/dist/upgrade/checker.js +5 -2
- package/dist/utils/time-range.js +28 -13
- package/frontend-dist/assets/CardContent-GNY_j_L3.js +1 -0
- package/frontend-dist/assets/CardTitle-BhXJbSoh.js +1 -0
- package/frontend-dist/assets/Checkbox-n_sh6Lvx.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-DDCUOXDR.js +1 -0
- package/frontend-dist/assets/Collection-DbtqQ1jF.js +1 -0
- package/frontend-dist/assets/Dashboard-Dy9frcgO.js +3 -0
- package/frontend-dist/assets/DialogTitle-BEWUnuJQ.js +1 -0
- package/frontend-dist/assets/{Input-O0ebU-Va.js → Input-CmibY9Fx.js} +1 -1
- package/frontend-dist/assets/Label-Cs__wFH0.js +1 -0
- package/frontend-dist/assets/Login-BciEc1TW.js +1 -0
- package/frontend-dist/assets/Logs-BkqwWW0-.js +1 -0
- package/frontend-dist/assets/ModelMappings-DrCJ_TCf.js +1 -0
- package/frontend-dist/assets/Monitor-C-b4qyuI.js +1 -0
- package/frontend-dist/assets/PopoverTrigger-DaKOMSVs.js +1 -0
- package/frontend-dist/assets/PopperContent-DZ6plcjf.js +1 -0
- package/frontend-dist/assets/Providers-u8utX74M.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-8_xhndGt.js +5 -0
- package/frontend-dist/assets/RetryRules-D1psYDEP.js +1 -0
- package/frontend-dist/assets/RouterKeys-ovPFGhjy.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-Dsv9AkP7.js +1 -0
- package/frontend-dist/assets/SelectValue-BoUWfZAg.js +1 -0
- package/frontend-dist/assets/Settings-DXF-6A8C.js +6 -0
- package/frontend-dist/assets/Setup-rVLqiz0d.js +1 -0
- package/frontend-dist/assets/Switch-po5ZVBE3.js +1 -0
- package/frontend-dist/assets/TableHeader-Zyvq_0p2.js +1 -0
- package/frontend-dist/assets/{TabsTrigger-CPCi2HIa.js → TabsTrigger-CgDhZGkT.js} +1 -1
- package/frontend-dist/assets/Teleport-CgTHarey.js +3 -0
- package/frontend-dist/assets/TooltipTrigger-C2qO21dQ.js +1 -0
- package/frontend-dist/assets/UnifiedRequestDialog-Dksad8eN.js +3 -0
- package/frontend-dist/assets/{VisuallyHidden-Cyk-jWwh.js → VisuallyHidden-fbPmoMwi.js} +1 -1
- package/frontend-dist/assets/VisuallyHiddenInput-7j8wkPrW.js +1 -0
- package/frontend-dist/assets/alert-dialog-DbT3PzoF.js +1 -0
- package/frontend-dist/assets/badge-BVxnlnsH.js +1 -0
- package/frontend-dist/assets/{button-BQ3s7yNh.js → button-BCrIpNwA.js} +2 -2
- package/frontend-dist/assets/chevron-down-CWBwGxSp.js +1 -0
- package/frontend-dist/assets/circle-question-mark-DRkkqjgG.js +1 -0
- package/frontend-dist/assets/dialog-BNlCZpHK.js +1 -0
- package/frontend-dist/assets/file-text-BavS6SrF.js +1 -0
- package/frontend-dist/assets/format-K3VR67cG.js +1 -0
- package/frontend-dist/assets/index-BP4imfye.css +1 -0
- package/frontend-dist/assets/index-DrBJPq6d.js +1 -0
- package/frontend-dist/assets/lib-CGpNhf06.js +1 -0
- package/frontend-dist/assets/loader-circle-Cpd89XQ7.js +1 -0
- package/frontend-dist/assets/ohash.D__AXeF1-DkJnWU8a.js +1 -0
- package/frontend-dist/assets/{useClipboard-Cnnz6AAN.js → useClipboard-Bq8yZunx.js} +1 -1
- package/frontend-dist/assets/useLogRetention-BWPm3G_A.js +1 -0
- package/frontend-dist/assets/useNonce-D5lpSPNk.js +1 -0
- package/frontend-dist/assets/x-BFIp7DLt.js +1 -0
- package/frontend-dist/index.html +20 -17
- package/package.json +2 -1
- package/frontend-dist/assets/CardContent-WrBnGhTg.js +0 -1
- package/frontend-dist/assets/CardTitle-BcDYk7cq.js +0 -1
- package/frontend-dist/assets/Checkbox-MZf0YsDG.js +0 -1
- package/frontend-dist/assets/CollapsibleTrigger-CrOH9HlW.js +0 -1
- package/frontend-dist/assets/Collection-DcTx_Y54.js +0 -1
- package/frontend-dist/assets/Dashboard-D0oDrSLr.js +0 -3
- package/frontend-dist/assets/DialogTitle-Cl5Cd7QH.js +0 -1
- package/frontend-dist/assets/Label-C_S0y7Um.js +0 -1
- package/frontend-dist/assets/Login-DGY7uF8P.js +0 -1
- package/frontend-dist/assets/Logs-ls8pv89b.js +0 -1
- package/frontend-dist/assets/ModelMappings-DGlf0S4s.js +0 -1
- package/frontend-dist/assets/Monitor-BSI87grz.js +0 -1
- package/frontend-dist/assets/PopperContent-C6Q7hDmf.js +0 -1
- package/frontend-dist/assets/Providers-ZkRpj8_m.js +0 -1
- package/frontend-dist/assets/ProxyEnhancement-DFPI1W6Z.js +0 -5
- package/frontend-dist/assets/RetryRules-DtM31qsl.js +0 -1
- package/frontend-dist/assets/RouterKeys-D63tRFKm.js +0 -1
- package/frontend-dist/assets/RovingFocusItem-BJoylAKU.js +0 -1
- package/frontend-dist/assets/SelectValue-CLp5z6_I.js +0 -1
- package/frontend-dist/assets/Settings-DSgRKbTQ.js +0 -6
- package/frontend-dist/assets/Setup-BDmj6CRk.js +0 -1
- package/frontend-dist/assets/Switch-Wz-t_zkv.js +0 -1
- package/frontend-dist/assets/TableHeader-DGtcqGkw.js +0 -1
- package/frontend-dist/assets/Teleport-DdjYHlNK.js +0 -3
- package/frontend-dist/assets/TooltipTrigger-H_QoPY1n.js +0 -1
- package/frontend-dist/assets/UnifiedRequestDialog-BAAfMJJl.js +0 -3
- package/frontend-dist/assets/VisuallyHiddenInput-CYjNe_H8.js +0 -1
- package/frontend-dist/assets/alert-dialog-Bi3dliLl.js +0 -1
- package/frontend-dist/assets/badge-Kkta3e9W.js +0 -1
- package/frontend-dist/assets/createLucideIcon-D1tkPDOQ.js +0 -1
- package/frontend-dist/assets/dialog-DoIATUYw.js +0 -1
- package/frontend-dist/assets/file-text-Dt6QP1bZ.js +0 -1
- package/frontend-dist/assets/format-DOVIVsQC.js +0 -1
- package/frontend-dist/assets/index-BY0E7CHR.js +0 -1
- package/frontend-dist/assets/index-Bnrh1mFY.css +0 -1
- package/frontend-dist/assets/lib-CxwxnlwW.js +0 -1
- package/frontend-dist/assets/ohash.D__AXeF1-b0PiKZB_.js +0 -1
- package/frontend-dist/assets/useLogRetention-DYP5LOAc.js +0 -1
- package/frontend-dist/assets/useNonce-DKbOCfgM.js +0 -1
- package/frontend-dist/assets/x-CAoitXRt.js +0 -1
package/dist/proxy/openai.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
1
2
|
import fp from "fastify-plugin";
|
|
2
|
-
import { getActiveProviders } from "../db/index.js";
|
|
3
|
+
import { getActiveProviders, insertRequestLog } from "../db/index.js";
|
|
3
4
|
import { getSetting } from "../db/settings.js";
|
|
4
5
|
import { decrypt } from "../utils/crypto.js";
|
|
5
6
|
import { proxyGetRequest, createErrorFormatter } from "./proxy-core.js";
|
|
@@ -16,6 +17,7 @@ const OPENAI_ERROR_META = {
|
|
|
16
17
|
upstreamConnectionFailed: { type: "upstream_error", code: "upstream_connection_failed" },
|
|
17
18
|
concurrencyQueueFull: { type: "server_error", code: "concurrency_queue_full" },
|
|
18
19
|
concurrencyTimeout: { type: "server_error", code: "concurrency_timeout" },
|
|
20
|
+
promptTooLong: { type: "invalid_request_error", code: "context_window_exceeded" },
|
|
19
21
|
};
|
|
20
22
|
const openaiErrors = createErrorFormatter((kind, message) => ({ error: { message, ...OPENAI_ERROR_META[kind] } }));
|
|
21
23
|
function sendError(reply, e) {
|
|
@@ -25,8 +27,18 @@ const openaiProxyRaw = (app, opts, done) => {
|
|
|
25
27
|
const { db, streamTimeoutMs, retryBaseDelayMs, matcher, semaphoreManager, tracker, usageWindowTracker } = opts;
|
|
26
28
|
const orchestrator = createOrchestrator(semaphoreManager, tracker);
|
|
27
29
|
app.post(CHAT_COMPLETIONS_PATH, async (request, reply) => {
|
|
28
|
-
if (!orchestrator)
|
|
30
|
+
if (!orchestrator) {
|
|
31
|
+
const body = request.body;
|
|
32
|
+
insertRequestLog(db, {
|
|
33
|
+
id: randomUUID(), api_type: "openai", model: body?.model || null,
|
|
34
|
+
provider_id: null, status_code: HTTP_BAD_GATEWAY, latency_ms: 0, is_stream: 0,
|
|
35
|
+
error_message: "Orchestrator not available (missing semaphore or tracker)",
|
|
36
|
+
created_at: new Date().toISOString(),
|
|
37
|
+
client_request: JSON.stringify({ headers: request.headers }),
|
|
38
|
+
router_key_id: request.routerKey?.id ?? null,
|
|
39
|
+
});
|
|
29
40
|
return sendError(reply, openaiErrors.providerUnavailable());
|
|
41
|
+
}
|
|
30
42
|
const deps = { db, streamTimeoutMs, retryBaseDelayMs, matcher, tracker, orchestrator, usageWindowTracker };
|
|
31
43
|
return handleProxyRequest(request, reply, "openai", CHAT_COMPLETIONS_PATH, openaiErrors, deps, {
|
|
32
44
|
beforeSendProxy: (body, isStream) => {
|
|
@@ -37,22 +49,47 @@ const openaiProxyRaw = (app, opts, done) => {
|
|
|
37
49
|
});
|
|
38
50
|
});
|
|
39
51
|
app.get(MODELS_PATH, async (request, reply) => {
|
|
52
|
+
const startTime = Date.now();
|
|
40
53
|
const providers = getActiveProviders(db, "openai");
|
|
41
|
-
if (providers.length === 0)
|
|
54
|
+
if (providers.length === 0) {
|
|
55
|
+
insertRequestLog(db, {
|
|
56
|
+
id: randomUUID(), api_type: "openai", model: null,
|
|
57
|
+
provider_id: null, status_code: HTTP_NOT_FOUND, latency_ms: Date.now() - startTime, is_stream: 0,
|
|
58
|
+
error_message: "No active OpenAI provider configured",
|
|
59
|
+
created_at: new Date().toISOString(),
|
|
60
|
+
client_request: JSON.stringify({ headers: request.headers }),
|
|
61
|
+
router_key_id: request.routerKey?.id ?? null,
|
|
62
|
+
});
|
|
42
63
|
return sendError(reply, {
|
|
43
64
|
statusCode: HTTP_NOT_FOUND,
|
|
44
65
|
body: { error: { message: "No active OpenAI provider configured", type: "invalid_request_error", code: "no_provider" } },
|
|
45
66
|
});
|
|
67
|
+
}
|
|
46
68
|
const provider = providers[0];
|
|
47
69
|
const apiKey = decrypt(provider.api_key, getSetting(db, "encryption_key"));
|
|
48
70
|
const cliHdrs = request.headers;
|
|
49
71
|
try {
|
|
50
72
|
const result = await proxyGetRequest(provider, apiKey, cliHdrs, MODELS_PATH);
|
|
73
|
+
insertRequestLog(db, {
|
|
74
|
+
id: randomUUID(), api_type: "openai", model: null,
|
|
75
|
+
provider_id: provider.id, status_code: result.statusCode, latency_ms: Date.now() - startTime, is_stream: 0,
|
|
76
|
+
error_message: null, created_at: new Date().toISOString(),
|
|
77
|
+
client_request: JSON.stringify({ headers: request.headers }),
|
|
78
|
+
router_key_id: request.routerKey?.id ?? null,
|
|
79
|
+
});
|
|
51
80
|
for (const [k, v] of Object.entries(result.headers))
|
|
52
81
|
reply.header(k, v);
|
|
53
82
|
return reply.code(result.statusCode).send(result.body);
|
|
54
83
|
}
|
|
55
84
|
catch (err) {
|
|
85
|
+
insertRequestLog(db, {
|
|
86
|
+
id: randomUUID(), api_type: "openai", model: null,
|
|
87
|
+
provider_id: provider.id, status_code: HTTP_BAD_GATEWAY, latency_ms: Date.now() - startTime, is_stream: 0,
|
|
88
|
+
error_message: err instanceof Error ? err.message : String(err),
|
|
89
|
+
created_at: new Date().toISOString(),
|
|
90
|
+
client_request: JSON.stringify({ headers: request.headers }),
|
|
91
|
+
router_key_id: request.routerKey?.id ?? null,
|
|
92
|
+
});
|
|
56
93
|
request.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to reach OpenAI backend for /v1/models");
|
|
57
94
|
return sendError(reply, {
|
|
58
95
|
statusCode: HTTP_BAD_GATEWAY,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { Target } from "./strategy/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* 估算请求的 token 消耗。
|
|
5
|
+
* 覆盖 messages、system prompt、tools 的全部文本内容,
|
|
6
|
+
* 并加乘格式化开销安全系数。
|
|
7
|
+
*/
|
|
8
|
+
export declare function estimateTokens(body: Record<string, unknown>): number;
|
|
9
|
+
interface OverflowResult {
|
|
10
|
+
provider_id: string;
|
|
11
|
+
backend_model: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 检查请求是否超出当前模型的上下文窗口,若超出且配置了溢出目标,则返回重定向信息。
|
|
15
|
+
* 返回 null 表示无需溢出。
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyOverflowRedirect(target: Target, db: Database.Database, body: Record<string, unknown>): OverflowResult | null;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { encode } from "gpt-tokenizer";
|
|
2
|
+
import { getModelContextWindowOverride } from "../db/model-info.js";
|
|
3
|
+
import { lookupContextWindow } from "../config/model-context.js";
|
|
4
|
+
const ESTIMATED_TOKENS_PER_IMAGE = 2000;
|
|
5
|
+
// 安全系数:覆盖格式化开销(role 标签、分隔符)+ 不同模型 tokenizer 的差异
|
|
6
|
+
const FORMAT_OVERHEAD_RATIO = 1.3;
|
|
7
|
+
// 上下文窗口使用阈值:当估算 token 超过上下文窗口的 90% 时即触发溢出,
|
|
8
|
+
// 留出余量覆盖不同模型 tokenizer 差异和难以精确估算的格式开销
|
|
9
|
+
const CONTEXT_WINDOW_USAGE_THRESHOLD = 0.9;
|
|
10
|
+
// 采样编码的最大字符数:对大文本只编码样本再外推,避免 BPE 全量编码耗时过长
|
|
11
|
+
const SAMPLE_SIZE = 4000;
|
|
12
|
+
/** 从 message content 中提取文本(兼容 OpenAI 和 Anthropic 格式) */
|
|
13
|
+
function extractTextFromContent(content) {
|
|
14
|
+
if (typeof content === "string")
|
|
15
|
+
return content;
|
|
16
|
+
if (!Array.isArray(content))
|
|
17
|
+
return "";
|
|
18
|
+
return content
|
|
19
|
+
.filter((block) => typeof block === "object" && block !== null && "type" in block)
|
|
20
|
+
.map(block => {
|
|
21
|
+
if (block.type === "text" && typeof block.text === "string")
|
|
22
|
+
return block.text;
|
|
23
|
+
if (block.type === "tool_result") {
|
|
24
|
+
if (typeof block.content === "string")
|
|
25
|
+
return block.content;
|
|
26
|
+
if (Array.isArray(block.content))
|
|
27
|
+
return extractTextFromContent(block.content);
|
|
28
|
+
}
|
|
29
|
+
if (block.type === "tool_use" && typeof block.input === "object" && block.input !== null) {
|
|
30
|
+
return JSON.stringify(block.input);
|
|
31
|
+
}
|
|
32
|
+
return "";
|
|
33
|
+
})
|
|
34
|
+
.join(" ");
|
|
35
|
+
}
|
|
36
|
+
/** 从请求体中提取所有需要计算 token 的文本 */
|
|
37
|
+
function extractAllText(body) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
// messages(OpenAI 和 Anthropic 共有)
|
|
40
|
+
const messages = body.messages;
|
|
41
|
+
if (Array.isArray(messages)) {
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
parts.push(extractTextFromContent(msg.content));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Anthropic 格式的 system prompt
|
|
47
|
+
if (typeof body.system === "string") {
|
|
48
|
+
parts.push(body.system);
|
|
49
|
+
}
|
|
50
|
+
else if (Array.isArray(body.system)) {
|
|
51
|
+
parts.push(extractTextFromContent(body.system));
|
|
52
|
+
}
|
|
53
|
+
// tools(OpenAI 格式带 function 字段,Anthropic 格式带 name + input_schema)
|
|
54
|
+
if (Array.isArray(body.tools)) {
|
|
55
|
+
for (const tool of body.tools) {
|
|
56
|
+
const t = tool;
|
|
57
|
+
const fn = t.function;
|
|
58
|
+
if (fn) {
|
|
59
|
+
parts.push(fn.name ?? "");
|
|
60
|
+
parts.push(fn.description ?? "");
|
|
61
|
+
if (fn.parameters)
|
|
62
|
+
parts.push(JSON.stringify(fn.parameters));
|
|
63
|
+
}
|
|
64
|
+
else if (t.name) {
|
|
65
|
+
parts.push(t.name);
|
|
66
|
+
if (t.description)
|
|
67
|
+
parts.push(t.description);
|
|
68
|
+
if (t.input_schema)
|
|
69
|
+
parts.push(JSON.stringify(t.input_schema));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return parts.join(" ");
|
|
74
|
+
}
|
|
75
|
+
/** 统计 messages 中的图片块数量 */
|
|
76
|
+
function countImageBlocks(obj) {
|
|
77
|
+
if (Array.isArray(obj))
|
|
78
|
+
return obj.reduce((sum, item) => sum + countImageBlocks(item), 0);
|
|
79
|
+
if (obj && typeof obj === "object") {
|
|
80
|
+
const r = obj;
|
|
81
|
+
if (r.type === "image" || r.type === "image_url")
|
|
82
|
+
return 1;
|
|
83
|
+
return Object.values(r).reduce((sum, v) => sum + countImageBlocks(v), 0);
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 使用 gpt-tokenizer (o200k_base) 估算 token 数。
|
|
89
|
+
* 对长文本采用采样策略:只编码前 SAMPLE_SIZE 个字符,按比率外推。
|
|
90
|
+
*/
|
|
91
|
+
function countTokens(text) {
|
|
92
|
+
if (text.length === 0)
|
|
93
|
+
return 0;
|
|
94
|
+
if (text.length <= SAMPLE_SIZE)
|
|
95
|
+
return encode(text).length;
|
|
96
|
+
const sample = text.slice(0, SAMPLE_SIZE);
|
|
97
|
+
const sampleTokens = encode(sample).length;
|
|
98
|
+
return Math.ceil((sampleTokens / sample.length) * text.length);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 估算请求的 token 消耗。
|
|
102
|
+
* 覆盖 messages、system prompt、tools 的全部文本内容,
|
|
103
|
+
* 并加乘格式化开销安全系数。
|
|
104
|
+
*/
|
|
105
|
+
export function estimateTokens(body) {
|
|
106
|
+
const allText = extractAllText(body);
|
|
107
|
+
const textTokens = Math.ceil(countTokens(allText) * FORMAT_OVERHEAD_RATIO);
|
|
108
|
+
const messages = (body.messages ?? []);
|
|
109
|
+
const imageTokens = countImageBlocks(messages) * ESTIMATED_TOKENS_PER_IMAGE;
|
|
110
|
+
return textTokens + imageTokens;
|
|
111
|
+
}
|
|
112
|
+
function getContextWindow(db, providerId, modelName) {
|
|
113
|
+
return getModelContextWindowOverride(db, providerId, modelName) ?? lookupContextWindow(modelName);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 检查请求是否超出当前模型的上下文窗口,若超出且配置了溢出目标,则返回重定向信息。
|
|
117
|
+
* 返回 null 表示无需溢出。
|
|
118
|
+
*/
|
|
119
|
+
export function applyOverflowRedirect(target, db, body) {
|
|
120
|
+
if (!target.overflow_provider_id || !target.overflow_model)
|
|
121
|
+
return null;
|
|
122
|
+
const estimated = estimateTokens(body);
|
|
123
|
+
const contextWindow = getContextWindow(db, target.provider_id, target.backend_model);
|
|
124
|
+
if (estimated > contextWindow * CONTEXT_WINDOW_USAGE_THRESHOLD) {
|
|
125
|
+
return { provider_id: target.overflow_provider_id, backend_model: target.overflow_model };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { patchMissingThinkingBlocks } from "./patch-thinking-blocks.js";
|
|
2
|
+
import { patchOrphanToolResults } from "./patch-orphan-tool-results.js";
|
|
3
|
+
/**
|
|
4
|
+
* 按序执行所有 DeepSeek 特定补丁。
|
|
5
|
+
* thinking 补丁先执行(影响 assistant 消息结构),
|
|
6
|
+
* tool_result 配对修复后执行。
|
|
7
|
+
*/
|
|
8
|
+
export function applyDeepSeekPatches(body) {
|
|
9
|
+
patchMissingThinkingBlocks(body);
|
|
10
|
+
patchOrphanToolResults(body);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 修复孤儿 tool_result 块——Claude Code 的 context management 截断历史消息时
|
|
3
|
+
* 可能丢失 tool_use 块但保留对应的 tool_result,导致 DeepSeek 严格校验失败。
|
|
4
|
+
*
|
|
5
|
+
* 算法:
|
|
6
|
+
* 1. 收集所有 assistant 消息中的 tool_use ID
|
|
7
|
+
* 2. 移除 tool_use_id 不在集合中的 tool_result 块
|
|
8
|
+
* 3. 移除清空后的空 user 消息
|
|
9
|
+
* 4. 合并相邻的 user 消息(Anthropic API 不允许连续 user 消息)
|
|
10
|
+
* 5. 合并相邻的 assistant 消息(同理)
|
|
11
|
+
*/
|
|
12
|
+
export declare function patchOrphanToolResults(body: Record<string, unknown>): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 修复孤儿 tool_result 块——Claude Code 的 context management 截断历史消息时
|
|
3
|
+
* 可能丢失 tool_use 块但保留对应的 tool_result,导致 DeepSeek 严格校验失败。
|
|
4
|
+
*
|
|
5
|
+
* 算法:
|
|
6
|
+
* 1. 收集所有 assistant 消息中的 tool_use ID
|
|
7
|
+
* 2. 移除 tool_use_id 不在集合中的 tool_result 块
|
|
8
|
+
* 3. 移除清空后的空 user 消息
|
|
9
|
+
* 4. 合并相邻的 user 消息(Anthropic API 不允许连续 user 消息)
|
|
10
|
+
* 5. 合并相邻的 assistant 消息(同理)
|
|
11
|
+
*/
|
|
12
|
+
export function patchOrphanToolResults(body) {
|
|
13
|
+
if (!body.messages)
|
|
14
|
+
return;
|
|
15
|
+
const messages = body.messages;
|
|
16
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
17
|
+
return;
|
|
18
|
+
// Step 1: 收集所有已知的 tool_use ID
|
|
19
|
+
const knownToolUseIds = new Set();
|
|
20
|
+
for (const msg of messages) {
|
|
21
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content))
|
|
22
|
+
continue;
|
|
23
|
+
for (const block of msg.content) {
|
|
24
|
+
if (block?.type === "tool_use" && typeof block.id === "string") {
|
|
25
|
+
knownToolUseIds.add(block.id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (knownToolUseIds.size === 0)
|
|
30
|
+
return;
|
|
31
|
+
// Step 2: 移除孤儿 tool_result 块
|
|
32
|
+
let removedAny = false;
|
|
33
|
+
for (const msg of messages) {
|
|
34
|
+
if (msg.role !== "user" || !Array.isArray(msg.content))
|
|
35
|
+
continue;
|
|
36
|
+
const blocks = msg.content;
|
|
37
|
+
const before = blocks.length;
|
|
38
|
+
const filtered = blocks.filter(block => {
|
|
39
|
+
if (block?.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
40
|
+
return knownToolUseIds.has(block.tool_use_id);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
if (filtered.length < before) {
|
|
45
|
+
msg.content = filtered;
|
|
46
|
+
removedAny = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!removedAny)
|
|
50
|
+
return;
|
|
51
|
+
// Step 3: 移除清空后的空 user 消息(向后遍历避免索引错乱)
|
|
52
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
53
|
+
const msg = messages[i];
|
|
54
|
+
if (msg.role !== "user")
|
|
55
|
+
continue;
|
|
56
|
+
if (Array.isArray(msg.content) && msg.content.length === 0) {
|
|
57
|
+
messages.splice(i, 1);
|
|
58
|
+
}
|
|
59
|
+
else if (typeof msg.content === "string" && msg.content.trim() === "") {
|
|
60
|
+
messages.splice(i, 1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Step 4: 合并相邻的 user 消息
|
|
64
|
+
mergeConsecutive(messages, "user");
|
|
65
|
+
// Step 5: 合并相邻的 assistant 消息(删除空 user 消息后可能产生)
|
|
66
|
+
mergeConsecutive(messages, "assistant");
|
|
67
|
+
}
|
|
68
|
+
function mergeConsecutive(messages, role) {
|
|
69
|
+
let i = 1;
|
|
70
|
+
while (i < messages.length) {
|
|
71
|
+
if (messages[i].role === role && messages[i - 1].role === role) {
|
|
72
|
+
const prev = messages[i - 1];
|
|
73
|
+
const curr = messages[i];
|
|
74
|
+
const prevContent = normalizeToArray(prev.content);
|
|
75
|
+
const currContent = normalizeToArray(curr.content);
|
|
76
|
+
prev.content = [...prevContent, ...currContent];
|
|
77
|
+
messages.splice(i, 1);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function normalizeToArray(content) {
|
|
85
|
+
if (Array.isArray(content))
|
|
86
|
+
return content;
|
|
87
|
+
if (typeof content === "string")
|
|
88
|
+
return [{ type: "text", text: content }];
|
|
89
|
+
return [{ type: "text", text: String(content ?? "") }];
|
|
90
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek thinking 协议实现不完整:开启 thinking 模式后部分轮次不返回 thinking block,
|
|
3
|
+
* 但后续请求要求历史 assistant 消息必须携带 thinking block。
|
|
4
|
+
* 在 content 数组开头补一个空 thinking block 以绕过上游校验。
|
|
5
|
+
*/
|
|
6
|
+
export function patchMissingThinkingBlocks(body) {
|
|
7
|
+
if (!body.messages)
|
|
8
|
+
return;
|
|
9
|
+
const messages = body.messages;
|
|
10
|
+
// DeepSeek 可能在不传 thinking 参数时也启用 thinking 模式(从历史推断),
|
|
11
|
+
// 所以只要历史中存在任何 thinking block,就视为 thinking 模式激活。
|
|
12
|
+
const thinkingActive = !!body.thinking || messages.some((msg) => msg.role === "assistant" && Array.isArray(msg.content)
|
|
13
|
+
&& msg.content.some((b) => b && typeof b === "object" && b.type === "thinking"));
|
|
14
|
+
if (!thinkingActive)
|
|
15
|
+
return;
|
|
16
|
+
for (const msg of messages) {
|
|
17
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content))
|
|
18
|
+
continue;
|
|
19
|
+
const hasThinking = msg.content.some((b) => b && typeof b === "object" && b.type === "thinking");
|
|
20
|
+
if (!hasThinking) {
|
|
21
|
+
msg.content.unshift({ type: "thinking", thinking: "", signature: "" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { applyDeepSeekPatches } from "./deepseek/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* 根据 provider 信息分发到对应的补丁逻辑。
|
|
4
|
+
* 每个补丁直接修改 body,不返回新对象。
|
|
5
|
+
*/
|
|
6
|
+
export function applyProviderPatches(body, provider) {
|
|
7
|
+
if (needsDeepSeekPatch(body, provider)) {
|
|
8
|
+
applyDeepSeekPatches(body);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** DeepSeek patch 触发条件:直连 DeepSeek,或经代理转发且模型名含 deepseek */
|
|
12
|
+
function needsDeepSeekPatch(body, provider) {
|
|
13
|
+
if (provider.base_url.includes("deepseek"))
|
|
14
|
+
return true;
|
|
15
|
+
const model = body.model ?? "";
|
|
16
|
+
return model.includes("deepseek");
|
|
17
|
+
}
|
|
@@ -13,15 +13,22 @@ export interface ProxyErrorFormatter {
|
|
|
13
13
|
upstreamConnectionFailed(): ProxyErrorResponse;
|
|
14
14
|
concurrencyQueueFull(providerId: string): ProxyErrorResponse;
|
|
15
15
|
concurrencyTimeout(providerId: string, timeoutMs: number): ProxyErrorResponse;
|
|
16
|
+
promptTooLong(): ProxyErrorResponse;
|
|
16
17
|
}
|
|
17
|
-
export type ErrorKind = "modelNotFound" | "modelNotAllowed" | "providerUnavailable" | "providerTypeMismatch" | "upstreamConnectionFailed" | "concurrencyQueueFull" | "concurrencyTimeout";
|
|
18
|
+
export type ErrorKind = "modelNotFound" | "modelNotAllowed" | "providerUnavailable" | "providerTypeMismatch" | "upstreamConnectionFailed" | "concurrencyQueueFull" | "concurrencyTimeout" | "promptTooLong";
|
|
18
19
|
/**
|
|
19
20
|
* 工厂函数,消除 openai/anthropic 错误格式化的重复代码。
|
|
20
21
|
* statusCode 和 message 两个 provider 完全一致,仅 body 格式不同,
|
|
21
22
|
* 由 formatBody 回调根据 kind 参数映射各自的 type/code 并组装 body。
|
|
22
23
|
*/
|
|
23
24
|
export declare function createErrorFormatter(formatBody: (kind: ErrorKind, message: string) => Record<string, unknown>): ProxyErrorFormatter;
|
|
25
|
+
/**
|
|
26
|
+
* 拼接上游 URL,自动处理 base_url 已包含 API 路径的情况。
|
|
27
|
+
* 用户可能将 base_url 配置为 `https://host/v1/messages`,
|
|
28
|
+
* 此时不应再追加 upstreamPath(`/v1/messages`),否则路径重复。
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildUpstreamUrl(baseUrl: string, upstreamPath: string): string;
|
|
24
31
|
export declare const SKIP_UPSTREAM: Set<string>;
|
|
25
32
|
export declare function selectHeaders(raw: RawHeaders, skip: Set<string>): Record<string, string>;
|
|
26
|
-
export declare function buildUpstreamHeaders(clientHeaders: RawHeaders, apiKey: string, payloadBytes?: number): Record<string, string>;
|
|
33
|
+
export declare function buildUpstreamHeaders(clientHeaders: RawHeaders, apiKey: string, payloadBytes?: number, apiType?: "openai" | "anthropic"): Record<string, string>;
|
|
27
34
|
export declare function proxyGetRequest(backend: Provider, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string): Promise<GetTransportResult>;
|
package/dist/proxy/proxy-core.js
CHANGED
|
@@ -34,14 +34,31 @@ export function createErrorFormatter(formatBody) {
|
|
|
34
34
|
statusCode: 504,
|
|
35
35
|
body: formatBody("concurrencyTimeout", `Provider '${providerId}' concurrency wait timeout (${timeoutMs}ms)`),
|
|
36
36
|
}),
|
|
37
|
+
promptTooLong: () => ({
|
|
38
|
+
statusCode: 400,
|
|
39
|
+
body: formatBody("promptTooLong", "Prompt is too long: the input tokens exceed the model context window limit."),
|
|
40
|
+
}),
|
|
37
41
|
};
|
|
38
42
|
}
|
|
43
|
+
// ---------- URL utilities ----------
|
|
44
|
+
/**
|
|
45
|
+
* 拼接上游 URL,自动处理 base_url 已包含 API 路径的情况。
|
|
46
|
+
* 用户可能将 base_url 配置为 `https://host/v1/messages`,
|
|
47
|
+
* 此时不应再追加 upstreamPath(`/v1/messages`),否则路径重复。
|
|
48
|
+
*/
|
|
49
|
+
export function buildUpstreamUrl(baseUrl, upstreamPath) {
|
|
50
|
+
const normalized = baseUrl.replace(/\/+$/, "");
|
|
51
|
+
if (normalized.endsWith(upstreamPath))
|
|
52
|
+
return normalized;
|
|
53
|
+
return `${normalized}${upstreamPath}`;
|
|
54
|
+
}
|
|
39
55
|
// ---------- Header utilities ----------
|
|
40
56
|
export const SKIP_UPSTREAM = new Set([
|
|
41
57
|
"host",
|
|
42
58
|
"content-length",
|
|
43
59
|
"accept-encoding",
|
|
44
60
|
"authorization",
|
|
61
|
+
"x-api-key",
|
|
45
62
|
"connection",
|
|
46
63
|
"keep-alive",
|
|
47
64
|
"transfer-encoding",
|
|
@@ -56,9 +73,14 @@ export function selectHeaders(raw, skip) {
|
|
|
56
73
|
}
|
|
57
74
|
return out;
|
|
58
75
|
}
|
|
59
|
-
export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes) {
|
|
76
|
+
export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes, apiType) {
|
|
60
77
|
const headers = selectHeaders(clientHeaders, SKIP_UPSTREAM);
|
|
61
|
-
|
|
78
|
+
if (apiType === "anthropic") {
|
|
79
|
+
headers["x-api-key"] = apiKey;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
83
|
+
}
|
|
62
84
|
if (payloadBytes !== undefined) {
|
|
63
85
|
headers["Content-Type"] = "application/json";
|
|
64
86
|
headers["Content-Length"] = String(payloadBytes);
|
|
@@ -6,12 +6,16 @@ import { resolveMapping } from "./mapping-resolver.js";
|
|
|
6
6
|
import { applyEnhancement } from "./enhancement/enhancement-handler.js";
|
|
7
7
|
import { SemaphoreQueueFullError, SemaphoreTimeoutError } from "./semaphore.js";
|
|
8
8
|
import { logResilienceResult, collectTransportMetrics, handleIntercept, sanitizeHeadersForLog, } from "./proxy-logging.js";
|
|
9
|
-
import { buildUpstreamHeaders } from "./proxy-core.js";
|
|
9
|
+
import { buildUpstreamHeaders, buildUpstreamUrl } from "./proxy-core.js";
|
|
10
10
|
import { ProviderSwitchNeeded } from "./types.js";
|
|
11
|
-
import { updateLogStreamContent } from "../db/index.js";
|
|
11
|
+
import { updateLogStreamContent, updateLogClientStatus } from "../db/index.js";
|
|
12
12
|
import { insertRejectedLog } from "./log-helpers.js";
|
|
13
13
|
import { buildTransportFn } from "./transport-fn.js";
|
|
14
|
+
import { applyOverflowRedirect } from "./overflow.js";
|
|
15
|
+
import { applyProviderPatches } from "./patch/index.js";
|
|
14
16
|
const HTTP_ERROR_THRESHOLD = 400;
|
|
17
|
+
const MAX_LOG_FIELD_LENGTH = 80;
|
|
18
|
+
const UPSTREAM_ERROR_STATUS = 502;
|
|
15
19
|
function rejectAndReply(reply, params, error, errorMessage, providerId) {
|
|
16
20
|
insertRejectedLog({
|
|
17
21
|
db: params.db, logId: params.logId, apiType: params.apiType, model: params.model,
|
|
@@ -84,7 +88,7 @@ async function executeFailoverLoop(ctx) {
|
|
|
84
88
|
startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel,
|
|
85
89
|
isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
|
|
86
90
|
};
|
|
87
|
-
|
|
91
|
+
let resolved = resolveMapping(deps.db, effectiveModel, { now: new Date(), excludeTargets });
|
|
88
92
|
request.log.debug({ logId, model: effectiveModel, apiType, isStream, action: "resolve_mapping", resolved: !!resolved });
|
|
89
93
|
if (!resolved) {
|
|
90
94
|
if (isFailover && excludeTargets.length > 0) {
|
|
@@ -102,11 +106,11 @@ async function executeFailoverLoop(ctx) {
|
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
catch {
|
|
105
|
-
request.log.warn({ allowedModels: allowedModels?.slice(0,
|
|
106
|
-
}
|
|
109
|
+
request.log.warn({ allowedModels: allowedModels?.slice(0, MAX_LOG_FIELD_LENGTH) }, "Invalid allowed_models JSON, allowing all models");
|
|
110
|
+
}
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
|
-
|
|
113
|
+
let provider = getProviderById(deps.db, resolved.provider_id);
|
|
110
114
|
if (!provider || !provider.is_active) {
|
|
111
115
|
return rejectAndReply(reply, rCtx, errors.providerUnavailable(), `Provider '${resolved.provider_id}' unavailable`, resolved.provider_id);
|
|
112
116
|
}
|
|
@@ -114,13 +118,24 @@ async function executeFailoverLoop(ctx) {
|
|
|
114
118
|
return rejectAndReply(reply, rCtx, errors.providerTypeMismatch(), `API type mismatch: expected '${apiType}'`, resolved.provider_id);
|
|
115
119
|
}
|
|
116
120
|
body.model = resolved.backend_model;
|
|
121
|
+
// --- 溢出重定向:上下文超出时切换到更大模型 ---
|
|
122
|
+
const overflowResult = applyOverflowRedirect(resolved, deps.db, body);
|
|
123
|
+
if (overflowResult) {
|
|
124
|
+
const overflowProvider = getProviderById(deps.db, overflowResult.provider_id);
|
|
125
|
+
if (overflowProvider && overflowProvider.is_active && overflowProvider.api_type === apiType) {
|
|
126
|
+
resolved = { ...resolved, provider_id: overflowResult.provider_id, backend_model: overflowResult.backend_model };
|
|
127
|
+
provider = overflowProvider;
|
|
128
|
+
body.model = overflowResult.backend_model;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
applyProviderPatches(body, provider);
|
|
117
132
|
const apiKey = decrypt(provider.api_key, getSetting(deps.db, "encryption_key"));
|
|
118
133
|
options?.beforeSendProxy?.(body, isStream);
|
|
119
134
|
const reqBodyStr = JSON.stringify(body);
|
|
120
135
|
const clientReq = JSON.stringify({ headers: cliHdrs, body: originalBody });
|
|
121
136
|
const upstreamReqBase = JSON.stringify({
|
|
122
|
-
url:
|
|
123
|
-
headers: sanitizeHeadersForLog(buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr))),
|
|
137
|
+
url: buildUpstreamUrl(provider.base_url, upstreamPath),
|
|
138
|
+
headers: sanitizeHeadersForLog(buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr), apiType)),
|
|
124
139
|
body: reqBodyStr,
|
|
125
140
|
});
|
|
126
141
|
const transportFn = buildTransportFn({
|
|
@@ -165,6 +180,7 @@ async function executeFailoverLoop(ctx) {
|
|
|
165
180
|
const tr = resilienceResult.result;
|
|
166
181
|
if (tr.kind === "throw" || (tr.kind === "error" && tr.statusCode >= HTTP_ERROR_THRESHOLD)) {
|
|
167
182
|
const err = errors.upstreamConnectionFailed();
|
|
183
|
+
updateLogClientStatus(deps.db, lastLogId, err.statusCode);
|
|
168
184
|
return reply.code(err.statusCode).send(err.body);
|
|
169
185
|
}
|
|
170
186
|
}
|
|
@@ -172,6 +188,15 @@ async function executeFailoverLoop(ctx) {
|
|
|
172
188
|
}
|
|
173
189
|
catch (e) {
|
|
174
190
|
if (e instanceof ProviderSwitchNeeded) {
|
|
191
|
+
// 跨 provider failover:resilience 层携带了 attempts 数据,补写失败日志
|
|
192
|
+
if (e.attempts && e.attempts.length > 0) {
|
|
193
|
+
const fakeResult = e.lastResult ?? { kind: "throw", error: new Error("provider switch") };
|
|
194
|
+
logResilienceResult(deps.db, {
|
|
195
|
+
apiType, model: effectiveModel, providerId: provider.id, isStream,
|
|
196
|
+
clientReq, upstreamReqBase, logId, routerKeyId, originalModel, sessionId,
|
|
197
|
+
failover: { isFailoverIteration, rootLogId: rootLogId },
|
|
198
|
+
}, e.attempts, fakeResult, startTime);
|
|
199
|
+
}
|
|
175
200
|
request.log.debug({ logId, action: "provider_switch", targetProviderId: e.targetProviderId });
|
|
176
201
|
excludeTargets.push(resolved);
|
|
177
202
|
continue;
|
|
@@ -186,7 +211,7 @@ async function executeFailoverLoop(ctx) {
|
|
|
186
211
|
request.log.debug({ logId, error: errMsg, action: "upstream_error" });
|
|
187
212
|
insertRequestLog(deps.db, {
|
|
188
213
|
id: logId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
|
|
189
|
-
status_code:
|
|
214
|
+
status_code: UPSTREAM_ERROR_STATUS, latency_ms: Date.now() - startTime, is_stream: isStream ? 1 : 0,
|
|
190
215
|
error_message: errMsg || "Upstream connection failed", created_at: new Date().toISOString(),
|
|
191
216
|
client_request: clientReq, upstream_request: upstreamReqBase,
|
|
192
217
|
is_failover: isFailoverIteration ? 1 : 0, original_request_id: isFailoverIteration ? rootLogId : null,
|
|
@@ -44,7 +44,25 @@ export function logResilienceResult(db, params, attempts, result, startTime) {
|
|
|
44
44
|
const attemptLogId = isOriginal ? params.logId : randomUUID();
|
|
45
45
|
const isFailoverLog = isOriginal && isFailoverIteration;
|
|
46
46
|
const parentId = isOriginal ? (isFailoverIteration ? rootLogId : null) : params.logId;
|
|
47
|
-
|
|
47
|
+
// stream_error + statusCode 200: 上游返回 200 但 body 包含错误内容(如 early error detection)
|
|
48
|
+
// 非 200 的 stream_error(如上游 429/500)走下方的正常错误路径
|
|
49
|
+
if (attempt.resultKind === "stream_error" && attempt.statusCode === UPSTREAM_SUCCESS) {
|
|
50
|
+
insertRequestLog(db, {
|
|
51
|
+
id: attemptLogId, api_type: params.apiType, model: params.model,
|
|
52
|
+
provider_id: attempt.target.provider_id,
|
|
53
|
+
status_code: HTTP_BAD_GATEWAY, latency_ms: attempt.latencyMs,
|
|
54
|
+
is_stream: params.isStream ? 1 : 0,
|
|
55
|
+
error_message: "stream_error: upstream returned 200 but body contains error",
|
|
56
|
+
created_at: new Date().toISOString(),
|
|
57
|
+
client_request: params.clientReq, upstream_request: params.upstreamReqBase,
|
|
58
|
+
upstream_response: JSON.stringify({ statusCode: attempt.statusCode, headers: attempt.responseHeaders, body: attempt.responseBody }),
|
|
59
|
+
is_retry: isOriginal ? 0 : 1, is_failover: isFailoverLog ? 1 : 0,
|
|
60
|
+
original_request_id: parentId,
|
|
61
|
+
router_key_id: params.routerKeyId, original_model: params.originalModel,
|
|
62
|
+
session_id: params.sessionId,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else if (attempt.error) {
|
|
48
66
|
insertRequestLog(db, {
|
|
49
67
|
id: attemptLogId, api_type: params.apiType, model: params.model,
|
|
50
68
|
provider_id: attempt.target.provider_id,
|
|
@@ -52,6 +70,9 @@ export function logResilienceResult(db, params, attempts, result, startTime) {
|
|
|
52
70
|
is_stream: params.isStream ? 1 : 0, error_message: attempt.error,
|
|
53
71
|
created_at: new Date().toISOString(),
|
|
54
72
|
client_request: params.clientReq, upstream_request: params.upstreamReqBase,
|
|
73
|
+
upstream_response: attempt.responseHeaders
|
|
74
|
+
? JSON.stringify({ statusCode: HTTP_BAD_GATEWAY, headers: attempt.responseHeaders, error: attempt.error })
|
|
75
|
+
: null,
|
|
55
76
|
is_retry: isOriginal ? 0 : 1, is_failover: isFailoverLog ? 1 : 0,
|
|
56
77
|
original_request_id: parentId,
|
|
57
78
|
router_key_id: params.routerKeyId, original_model: params.originalModel,
|
|
@@ -66,7 +87,7 @@ export function logResilienceResult(db, params, attempts, result, startTime) {
|
|
|
66
87
|
is_stream: params.isStream ? 1 : 0, error_message: null,
|
|
67
88
|
created_at: new Date().toISOString(),
|
|
68
89
|
client_request: params.clientReq, upstream_request: params.upstreamReqBase,
|
|
69
|
-
upstream_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
|
|
90
|
+
upstream_response: JSON.stringify({ statusCode: attempt.statusCode, headers: attempt.responseHeaders, body: attempt.responseBody }),
|
|
70
91
|
is_retry: isOriginal ? 0 : 1, is_failover: isFailoverLog ? 1 : 0,
|
|
71
92
|
original_request_id: parentId,
|
|
72
93
|
router_key_id: params.routerKeyId, original_model: params.originalModel,
|
|
@@ -37,6 +37,10 @@ export interface ResilienceAttempt {
|
|
|
37
37
|
error: string | null;
|
|
38
38
|
latencyMs: number;
|
|
39
39
|
responseBody: string | null;
|
|
40
|
+
/** 上游响应 headers(throw 和 stream_success/stream_abort 时为 null) */
|
|
41
|
+
responseHeaders: Record<string, string> | null;
|
|
42
|
+
/** TransportResult.kind,用于区分 stream_error 等特殊类型 */
|
|
43
|
+
resultKind: TransportResult["kind"];
|
|
40
44
|
}
|
|
41
45
|
export interface ResilienceResult {
|
|
42
46
|
result: TransportResult;
|