llm-simple-router 0.10.13 → 0.11.0
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/model-directory.json +1 -0
- package/config/recommended-providers.json +6 -5
- package/dist/admin/groups.js +25 -0
- package/dist/admin/monitor.js +15 -6
- package/dist/admin/providers.js +22 -3
- package/dist/admin/recommended.js +13 -1
- package/dist/config/model-context.d.ts +12 -0
- package/dist/config/model-context.js +96 -2
- package/dist/config/model-directory.json +1 -0
- package/dist/config/recommended-providers.json +355 -0
- package/dist/config/recommended-retry-rules.json +12 -0
- package/dist/config/recommended.d.ts +2 -0
- package/dist/config/version.json +1 -0
- package/dist/core/monitor/request-tracker.d.ts +1 -1
- package/dist/core/monitor/request-tracker.js +2 -1
- package/dist/core/monitor/types.d.ts +1 -0
- package/dist/core/types.d.ts +1 -0
- package/dist/index.js +17 -1
- package/dist/metrics/metrics-extractor.js +3 -0
- package/dist/proxy/handler/create-proxy-handler.js +15 -0
- package/dist/proxy/handler/failover-loop.js +88 -63
- package/dist/proxy/pipeline-snapshot.d.ts +9 -1
- package/dist/proxy/proxy-logging.js +2 -2
- package/dist/proxy/routing/modality-redirect.d.ts +22 -0
- package/dist/proxy/routing/modality-redirect.js +252 -0
- package/dist/proxy/routing/overflow.d.ts +11 -0
- package/dist/proxy/routing/overflow.js +24 -0
- package/dist/proxy/transform/plugin-registry.js +1 -1
- package/dist/proxy/transform/stream-oa2ant.js +3 -0
- package/dist/proxy/transport/http.js +6 -0
- package/dist/proxy/transport/proxy-agent.js +20 -8
- package/dist/proxy/transport/stream.js +8 -1
- package/dist/proxy/transport/transport-fn.js +12 -0
- package/frontend-dist/assets/CardContent-yiYaxAko.js +1 -0
- package/frontend-dist/assets/CardTitle-CzqSlrtn.js +1 -0
- package/frontend-dist/assets/Checkbox-2voapLgE.js +1 -0
- package/frontend-dist/assets/CollapsibleContent-DHkVSWt2.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-DbVCeTdD.js +1 -0
- package/frontend-dist/assets/Dashboard-xT1CEwOR.js +3 -0
- package/frontend-dist/assets/{Input-Ey_q_5_r.js → Input-DEfnoFS3.js} +1 -1
- package/frontend-dist/assets/Label-CjUuzGNQ.js +1 -0
- package/frontend-dist/assets/Login-CJDEk-tO.js +1 -0
- package/frontend-dist/assets/Logs-CzdPCIYV.js +1 -0
- package/frontend-dist/assets/MappingEntryEditor-GejG6FYv.js +1 -0
- package/frontend-dist/assets/ModelCard-DdQtySPM.js +1 -0
- package/frontend-dist/assets/ModelMappings-DffY7Izx.js +1 -0
- package/frontend-dist/assets/Monitor-y6d6LInm.js +1 -0
- package/frontend-dist/assets/Providers-Cb-CB1yf.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-CywRxDop.js +1 -0
- package/frontend-dist/assets/QuickSetup-Nj_ysAdc.js +1 -0
- package/frontend-dist/assets/RetryRules-DRdeZUPt.js +1 -0
- package/frontend-dist/assets/RouterKeys-BHOhDgXZ.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-NxZWBEpr.js +1 -0
- package/frontend-dist/assets/Schedules-C4jRCbnI.js +1 -0
- package/frontend-dist/assets/Settings-Cn0qnqMY.js +6 -0
- package/frontend-dist/assets/Setup-BjN6KU0y.js +1 -0
- package/frontend-dist/assets/Switch-bk3eQSZ_.js +1 -0
- package/frontend-dist/assets/TooltipTrigger-DmYucHtv.js +1 -0
- package/frontend-dist/assets/TransformRulesForm-Bo-zFABv.js +1 -0
- package/frontend-dist/assets/UnifiedRequestDialog-5-vBmVMH.js +3 -0
- package/frontend-dist/assets/VisuallyHiddenInput-BflIWQCW.js +1 -0
- package/frontend-dist/assets/{button-C7HO6Dyb.js → button-DZwflOXO.js} +2 -2
- package/frontend-dist/assets/{copy-DxwFlq2A.js → copy-zQQvOqam.js} +1 -1
- package/frontend-dist/assets/dialog-C7v6Gaak.js +1 -0
- package/frontend-dist/assets/index-ClQS69Or.css +1 -0
- package/frontend-dist/assets/index-PMAQyWJb.js +3 -0
- package/frontend-dist/assets/mappings-BpkOqnsu.js +1 -0
- package/frontend-dist/assets/mappings-D7Qy46v_.js +1 -0
- package/frontend-dist/assets/{providers-Bcea72GK.js → providers-BI5dO-j0.js} +1 -1
- package/frontend-dist/assets/{providers-DNICB6Kg.js → providers-BzxbZ85B.js} +1 -1
- package/frontend-dist/assets/{trash-2-D2SrfECO.js → trash-2-CrcHK-G_.js} +1 -1
- package/frontend-dist/assets/{useClipboard-CttzUerj.js → useClipboard-B4K3eogm.js} +1 -1
- package/frontend-dist/assets/{useLogRetention-Dv0deAan.js → useLogRetention-BNbFXLBO.js} +1 -1
- package/frontend-dist/index.html +3 -3
- package/package.json +2 -2
- package/frontend-dist/assets/CardContent-DfVo-N85.js +0 -1
- package/frontend-dist/assets/CardTitle-npwJSAlz.js +0 -1
- package/frontend-dist/assets/Checkbox-Ddnzkh_i.js +0 -1
- package/frontend-dist/assets/CollapsibleContent-BTVazeoQ.js +0 -1
- package/frontend-dist/assets/CollapsibleTrigger-DCQeyHrt.js +0 -1
- package/frontend-dist/assets/Dashboard-DjnImtwH.js +0 -3
- package/frontend-dist/assets/Label-Dw5HcYsL.js +0 -1
- package/frontend-dist/assets/Login-CSrfhhm9.js +0 -1
- package/frontend-dist/assets/Logs-HR1DZs1M.js +0 -1
- package/frontend-dist/assets/MappingEntryEditor-C9pgNL0Q.js +0 -1
- package/frontend-dist/assets/ModelCard-IQMwlnCm.js +0 -1
- package/frontend-dist/assets/ModelMappings-kRx-GL_7.js +0 -1
- package/frontend-dist/assets/Monitor-y1ofDNK7.js +0 -1
- package/frontend-dist/assets/Providers-C1bP2PoM.js +0 -1
- package/frontend-dist/assets/ProxyEnhancement-DQx4coxn.js +0 -1
- package/frontend-dist/assets/QuickSetup-DHX9-CnO.js +0 -1
- package/frontend-dist/assets/RetryRules-zdJE0bFL.js +0 -1
- package/frontend-dist/assets/RouterKeys-CD0rI4kv.js +0 -1
- package/frontend-dist/assets/RovingFocusItem-CFmjbm49.js +0 -1
- package/frontend-dist/assets/Schedules-BUm3cC6w.js +0 -1
- package/frontend-dist/assets/Settings-D7z5IRkY.js +0 -6
- package/frontend-dist/assets/Setup-i9inmgjB.js +0 -1
- package/frontend-dist/assets/Switch-C9DeYAnK.js +0 -1
- package/frontend-dist/assets/TooltipTrigger-Dr6kqGSH.js +0 -1
- package/frontend-dist/assets/TransformRulesForm-CyXh4jHa.js +0 -1
- package/frontend-dist/assets/UnifiedRequestDialog-6ZRBfjko.js +0 -3
- package/frontend-dist/assets/VisuallyHiddenInput-CwE9jREu.js +0 -1
- package/frontend-dist/assets/constants-yM0YwP2s.js +0 -1
- package/frontend-dist/assets/dialog-BWB1aLcT.js +0 -1
- package/frontend-dist/assets/index-DeeDpH_W.css +0 -1
- package/frontend-dist/assets/index-itL9--Q_.js +0 -3
- package/frontend-dist/assets/mappings-6w7mc8YK.js +0 -1
- package/frontend-dist/assets/mappings-C1fK_e70.js +0 -1
- /package/frontend-dist/assets/{common-D96jEq-h.js → common-Bvxev9Ev.js} +0 -0
- /package/frontend-dist/assets/{common-BpwAv-lj.js → common-Cn0QcrnY.js} +0 -0
- /package/frontend-dist/assets/{dashboard-DjgmcUG5.js → dashboard-Cejt1wVQ.js} +0 -0
- /package/frontend-dist/assets/{dashboard-COCyp2p_.js → dashboard-DLTOR0fN.js} +0 -0
- /package/frontend-dist/assets/{login-BTNL5nN5.js → login-BkOvA7gg.js} +0 -0
- /package/frontend-dist/assets/{login-Sef1i0de.js → login-DWRFsEu3.js} +0 -0
- /package/frontend-dist/assets/{logs-CBRLywRw.js → logs-CA8USnXG.js} +0 -0
- /package/frontend-dist/assets/{logs-B-6cgV12.js → logs-QPt2Ybwy.js} +0 -0
- /package/frontend-dist/assets/{monitor-CaDMr_KG.js → monitor-CcPZdXUM.js} +0 -0
- /package/frontend-dist/assets/{monitor-C9j7ppMj.js → monitor-D-0KOVTC.js} +0 -0
- /package/frontend-dist/assets/{proxyEnhancement-DpIVSv-g.js → proxyEnhancement-B6vdsMeK.js} +0 -0
- /package/frontend-dist/assets/{proxyEnhancement-rSM6KhbN.js → proxyEnhancement-UuPFs4M3.js} +0 -0
- /package/frontend-dist/assets/{quickSetup-CCxaqY3U.js → quickSetup-CSpWmAy-.js} +0 -0
- /package/frontend-dist/assets/{quickSetup-DgDENHE4.js → quickSetup-D8ruRelW.js} +0 -0
- /package/frontend-dist/assets/{requestDetail-DZ55ph4h.js → requestDetail-8Sp9tWNb.js} +0 -0
- /package/frontend-dist/assets/{requestDetail-3KCtYe1N.js → requestDetail-CcHzzKYr.js} +0 -0
- /package/frontend-dist/assets/{retryRules-BXrRL52J.js → retryRules-C--dd-y8.js} +0 -0
- /package/frontend-dist/assets/{retryRules-CToGC6cR.js → retryRules-CzLnagW_.js} +0 -0
- /package/frontend-dist/assets/{routerKeys-DbTg4OP1.js → routerKeys-CB2l_V7w.js} +0 -0
- /package/frontend-dist/assets/{routerKeys-Be7OZCn0.js → routerKeys-p_ioAckE.js} +0 -0
- /package/frontend-dist/assets/{schedules-Bd66RL7P.js → schedules-Cz_-Wfa_.js} +0 -0
- /package/frontend-dist/assets/{schedules-HDwMuDgX.js → schedules-DTgk603B.js} +0 -0
- /package/frontend-dist/assets/{settings-DCS-RTKl.js → settings-B5Mq1HN8.js} +0 -0
- /package/frontend-dist/assets/{settings-C4zZB9GY.js → settings-j3dzVXzy.js} +0 -0
- /package/frontend-dist/assets/{setup-CrjgRrYP.js → setup-DaeEG9ll.js} +0 -0
- /package/frontend-dist/assets/{setup-DmgXvgkY.js → setup-Dryg-9wL.js} +0 -0
- /package/frontend-dist/assets/{sidebar-3c8D7l60.js → sidebar-BQWT-QZb.js} +0 -0
- /package/frontend-dist/assets/{sidebar-vj4kQ6t1.js → sidebar-DYwEKca3.js} +0 -0
|
@@ -18,7 +18,8 @@ import { getProviderById, updateLogClientStatus, insertRequestLog, updateLogStre
|
|
|
18
18
|
import { getSetting } from "../../db/settings.js";
|
|
19
19
|
import { decrypt } from "../../utils/crypto.js";
|
|
20
20
|
import { resolveMapping, filterExcluded } from "../routing/mapping-resolver.js";
|
|
21
|
-
import {
|
|
21
|
+
import { expandOverflowTargets } from "../routing/overflow.js";
|
|
22
|
+
import { computeModalityRedirectTargets } from "../routing/modality-redirect.js";
|
|
22
23
|
import { getConfig } from "../../config/index.js";
|
|
23
24
|
import { insertRejectedLog } from "../log-helpers.js";
|
|
24
25
|
import { logResilienceResult, collectTransportMetrics, sanitizeHeadersForLog } from "../proxy-logging.js";
|
|
@@ -88,7 +89,6 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
88
89
|
const enhancementConfig = loadEnhancementConfig(db);
|
|
89
90
|
const excludeTargets = [];
|
|
90
91
|
let rootLogId = null;
|
|
91
|
-
let toolErrorsLogged = false;
|
|
92
92
|
let pendingToolErrors = null;
|
|
93
93
|
const flushToolErrors = (providerId, model, reqLogId) => {
|
|
94
94
|
if (!pendingToolErrors)
|
|
@@ -112,9 +112,77 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
112
112
|
// BP-H3: 请求级 API Key 缓存,避免同一 provider 重复解密
|
|
113
113
|
const decryptedApiKeys = new Map();
|
|
114
114
|
const encryptionKey = getSetting(db, "encryption_key");
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
// === 循环前:路由决策(resolveMapping → IR → OF 分层预计算) ===
|
|
116
|
+
const precomputeSnapshot = new PipelineSnapshot();
|
|
117
|
+
// 1. resolveMapping — 只调一次,不传 excludeTargets(exclude 在循环内处理)
|
|
118
|
+
const resolveResult = resolveMapping(db, clientModel, { now: new Date() });
|
|
119
|
+
// resolveMapping 返回 null 时,需要用占位 snapshot 做错误日志
|
|
120
|
+
const rejectSnapshot = new PipelineSnapshot();
|
|
121
|
+
if (!resolveResult) {
|
|
122
|
+
const logId = randomUUID();
|
|
123
|
+
const startTime = Date.now();
|
|
124
|
+
const isStream = ctx.body.stream === true;
|
|
125
|
+
const rCtx = {
|
|
126
|
+
db, logId, apiType: ctx.apiType, model: clientModel,
|
|
127
|
+
startTime, isStream, routerKeyId: request.routerKey?.id ?? null, originalBody: rawBody, clientHeaders: cliHdrs,
|
|
128
|
+
isFailover: false, originalRequestId: null,
|
|
129
|
+
sessionId: ctx.metadata.get("session_id"),
|
|
130
|
+
pipelineSnapshot: rejectSnapshot.toJSON(),
|
|
131
|
+
matcher, logFileWriter,
|
|
132
|
+
};
|
|
133
|
+
return rejectAndReply(reply, rCtx, errors.modelNotFound(clientModel), `No mapping found for model '${clientModel}'`);
|
|
134
|
+
}
|
|
135
|
+
let allTargets = resolveResult.allTargets ?? [resolveResult.target];
|
|
136
|
+
const concurrencyOverride = resolveResult.concurrency_override;
|
|
137
|
+
// 2. modality-redirect 层:模态重定向 → 可能 prepend fallback target
|
|
138
|
+
allTargets = computeModalityRedirectTargets(db, allTargets, clientModel, ctx.body, precomputeSnapshot);
|
|
139
|
+
// 3. OF 层:为每个 target 预计算 overflow
|
|
140
|
+
const targetsBeforeOF = allTargets.length;
|
|
141
|
+
const ofResult = expandOverflowTargets(allTargets, db, ctx.body);
|
|
142
|
+
allTargets = ofResult.targets;
|
|
143
|
+
precomputeSnapshot.add({ stage: "overflow", triggered: allTargets.length > targetsBeforeOF });
|
|
144
|
+
// 4. allowed_models 过滤:MRL fallback 和 overflow 扩展的 target 也必须受约束
|
|
145
|
+
const allowedModels = request.routerKey?.allowed_models;
|
|
146
|
+
let overflowIndices = ofResult.overflowIndices;
|
|
147
|
+
if (allowedModels && allowedModels.length > 0) {
|
|
148
|
+
// 重建 overflowIndices:filter 会改变 index,需同步更新
|
|
149
|
+
const newOverflowIndices = new Set();
|
|
150
|
+
const filtered = [];
|
|
151
|
+
for (let i = 0; i < allTargets.length; i++) {
|
|
152
|
+
if (allowedModels.includes(allTargets[i].backend_model)) {
|
|
153
|
+
if (overflowIndices.has(i))
|
|
154
|
+
newOverflowIndices.add(filtered.length);
|
|
155
|
+
filtered.push(allTargets[i]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
allTargets = filtered;
|
|
159
|
+
overflowIndices = newOverflowIndices;
|
|
160
|
+
if (allTargets.length === 0) {
|
|
161
|
+
const logId = randomUUID();
|
|
162
|
+
const startTime = Date.now();
|
|
163
|
+
const isStream = ctx.body.stream === true;
|
|
164
|
+
const rCtx = {
|
|
165
|
+
db, logId, apiType: ctx.apiType, model: clientModel,
|
|
166
|
+
startTime, isStream, routerKeyId: request.routerKey?.id ?? null, originalBody: rawBody, clientHeaders: cliHdrs,
|
|
167
|
+
isFailover: false, originalRequestId: null,
|
|
168
|
+
sessionId: ctx.metadata.get("session_id"),
|
|
169
|
+
pipelineSnapshot: precomputeSnapshot.toJSON(),
|
|
170
|
+
matcher, logFileWriter,
|
|
171
|
+
};
|
|
172
|
+
return rejectAndReply(reply, rCtx, errors.modelNotAllowed(clientModel), `No allowed model available for '${clientModel}'`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// 预计算完成,缓存到循环外
|
|
176
|
+
const cachedTargets = allTargets;
|
|
177
|
+
// 工具错误日志提取(循环外一次性执行)
|
|
178
|
+
if (enhancementConfig.tool_error_logging_enabled) {
|
|
179
|
+
const failures = extractFailedToolResults(ctx.body);
|
|
180
|
+
if (failures.length > 0) {
|
|
181
|
+
request.log.info({ failures: failures.length, sessionId: ctx.metadata.get("session_id") }, "Tool error results detected");
|
|
182
|
+
pendingToolErrors = failures;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// === while(true):纯执行循环 ===
|
|
118
186
|
let failoverIteration = 0;
|
|
119
187
|
while (true) {
|
|
120
188
|
// 请求被 kill 后 reply 已销毁,直接退出避免浪费 failover 迭代
|
|
@@ -132,10 +200,9 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
132
200
|
const isFailoverIteration = rootLogId !== logId;
|
|
133
201
|
const routerKeyId = request.routerKey?.id ?? null;
|
|
134
202
|
// 浅拷贝:后续操作只修改顶层属性(model),嵌套对象不被修改
|
|
135
|
-
// 如果需要修改嵌套属性,在使用点做深拷贝(applyProviderPatches 已有 CoW)
|
|
136
203
|
let currentBody = { ...ctx.body };
|
|
137
204
|
const isStream = currentBody.stream === true;
|
|
138
|
-
const iterationSnapshot = new PipelineSnapshot();
|
|
205
|
+
const iterationSnapshot = new PipelineSnapshot(precomputeSnapshot.getStages());
|
|
139
206
|
const rCtx = {
|
|
140
207
|
db, logId, apiType: ctx.apiType, model: clientModel,
|
|
141
208
|
startTime, isStream, routerKeyId, originalBody: rawBody, clientHeaders: cliHdrs,
|
|
@@ -144,64 +211,17 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
144
211
|
pipelineSnapshot: iterationSnapshot.toJSON(),
|
|
145
212
|
matcher, logFileWriter,
|
|
146
213
|
};
|
|
147
|
-
// ---
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const filtered = filterExcluded(cachedTargets, excludeTargets);
|
|
152
|
-
resolveResult = filtered.length > 0
|
|
153
|
-
? { target: filtered[0], concurrency_override: cachedConcurrencyOverride, targetCount: cachedTargets.length, mappingReason: "failover_retry" }
|
|
154
|
-
: null;
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
resolveResult = resolveMapping(db, clientModel, { now: new Date(), excludeTargets });
|
|
158
|
-
if (resolveResult?.allTargets) {
|
|
159
|
-
cachedTargets = resolveResult.allTargets;
|
|
160
|
-
cachedConcurrencyOverride = resolveResult.concurrency_override;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
request.log.debug({ logId, model: clientModel, apiType: ctx.apiType, isStream, action: "resolve_mapping", resolved: !!resolveResult, cached: !!cachedTargets });
|
|
164
|
-
if (!resolveResult) {
|
|
165
|
-
if (excludeTargets.length > 0) {
|
|
166
|
-
return rejectAndReply(reply, rCtx, errors.upstreamConnectionFailed(), `All failover targets exhausted (${excludeTargets.length} attempted)`);
|
|
167
|
-
}
|
|
168
|
-
return rejectAndReply(reply, rCtx, errors.modelNotFound(clientModel), `No mapping found for model '${clientModel}'`);
|
|
214
|
+
// --- 选第一个非 excluded target ---
|
|
215
|
+
const filtered = filterExcluded(cachedTargets, excludeTargets);
|
|
216
|
+
if (filtered.length === 0) {
|
|
217
|
+
return rejectAndReply(reply, rCtx, errors.upstreamConnectionFailed(), `All failover targets exhausted (${excludeTargets.length} attempted)`);
|
|
169
218
|
}
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
// allowed_models 检查 — 仅首次迭代(已由 auth 中间件预解析为数组)
|
|
174
|
-
if (excludeTargets.length === 0) {
|
|
175
|
-
const allowedModels = request.routerKey?.allowed_models;
|
|
176
|
-
if (allowedModels && allowedModels.length > 0 && !allowedModels.includes(resolved.backend_model)) {
|
|
177
|
-
return rejectAndReply(reply, rCtx, errors.modelNotAllowed(resolved.backend_model), `Model '${resolved.backend_model}' not allowed`, resolved.provider_id);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
let provider = getProviderById(db, resolved.provider_id);
|
|
219
|
+
const resolved = filtered[0];
|
|
220
|
+
const isFailover = cachedTargets.length > 1;
|
|
221
|
+
const provider = getProviderById(db, resolved.provider_id);
|
|
181
222
|
if (!provider || !provider.is_active) {
|
|
182
223
|
return rejectAndReply(reply, rCtx, errors.providerUnavailable(), `Provider '${resolved.provider_id}' unavailable`, resolved.provider_id);
|
|
183
224
|
}
|
|
184
|
-
// 工具错误日志提取(仅首次迭代)
|
|
185
|
-
if (enhancementConfig.tool_error_logging_enabled && !toolErrorsLogged) {
|
|
186
|
-
toolErrorsLogged = true;
|
|
187
|
-
const failures = extractFailedToolResults(ctx.body);
|
|
188
|
-
if (failures.length > 0) {
|
|
189
|
-
request.log.info({ failures: failures.length, sessionId: ctx.metadata.get("session_id") }, "Tool error results detected");
|
|
190
|
-
pendingToolErrors = failures;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
// --- 溢出重定向 ---
|
|
194
|
-
let effectiveMappingReason = resolveResult.mappingReason;
|
|
195
|
-
const overflowResult = applyOverflowRedirect(resolved, db, currentBody);
|
|
196
|
-
if (overflowResult) {
|
|
197
|
-
const overflowProvider = getProviderById(db, overflowResult.provider_id);
|
|
198
|
-
if (overflowProvider && overflowProvider.is_active) {
|
|
199
|
-
resolved = { ...resolved, provider_id: overflowResult.provider_id, backend_model: overflowResult.backend_model };
|
|
200
|
-
provider = overflowProvider;
|
|
201
|
-
currentBody = { ...currentBody, model: overflowResult.backend_model };
|
|
202
|
-
effectiveMappingReason = "overflow_redirect";
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
225
|
// 当前迭代的工具错误刷新闭包(统一 6 处调用)
|
|
206
226
|
const flushCurrentErrors = () => flushToolErrors(provider.id, resolved.backend_model ?? clientModel, logId);
|
|
207
227
|
// --- 格式转换 + upstreamPath 决策 ---
|
|
@@ -210,10 +230,15 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
210
230
|
const effectiveApiType = resolvedPath.effectiveApiType;
|
|
211
231
|
const effectiveUpstreamPath = resolvedPath.effectiveUpstreamPath;
|
|
212
232
|
const needsTransform = resolvedPath.needsTransform;
|
|
233
|
+
// effectiveMappingReason: 首次迭代用 resolveResult.reason,溢出时覆盖
|
|
234
|
+
let effectiveMappingReason = isFailoverIteration ? "failover_retry" : resolveResult.mappingReason;
|
|
235
|
+
// 只有当前 target 是 overflow 扩展产生的才标记
|
|
236
|
+
const resolvedIdx = cachedTargets.findIndex(t => t.provider_id === resolved.provider_id && t.backend_model === resolved.backend_model);
|
|
237
|
+
if (overflowIndices.has(resolvedIdx))
|
|
238
|
+
effectiveMappingReason = "overflow_redirect";
|
|
213
239
|
// --- routing ---
|
|
214
240
|
currentBody = { ...currentBody, model: resolved.backend_model };
|
|
215
|
-
iterationSnapshot.add({ stage: "routing", client_model: clientModel, backend_model: resolved.backend_model, provider_id: resolved.provider_id, strategy:
|
|
216
|
-
iterationSnapshot.add({ stage: "overflow", triggered: overflowResult != null });
|
|
241
|
+
iterationSnapshot.add({ stage: "routing", client_model: clientModel, backend_model: resolved.backend_model, provider_id: resolved.provider_id, strategy: cachedTargets.length > 1 ? "failover" : "scheduled", mapping_reason: effectiveMappingReason });
|
|
217
242
|
// --- Plugin 调整 body 和 headers ---
|
|
218
243
|
const pluginResult = applyPluginAdjustments(pluginRegistry, currentBody, clientApiType, provider);
|
|
219
244
|
const injectedHeaders = pluginResult.headers;
|
|
@@ -21,11 +21,19 @@ export type StageRecord = {
|
|
|
21
21
|
} | {
|
|
22
22
|
stage: "provider_patch";
|
|
23
23
|
types: string[];
|
|
24
|
+
} | {
|
|
25
|
+
stage: "modality-redirect";
|
|
26
|
+
triggered: boolean;
|
|
27
|
+
original_model: string;
|
|
28
|
+
redirect_to: string;
|
|
29
|
+
redirect_provider: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
detected_modalities?: string[];
|
|
24
32
|
};
|
|
25
33
|
import type { MappingReason } from "../core/types.js";
|
|
26
34
|
export declare class PipelineSnapshot {
|
|
27
35
|
private readonly stages;
|
|
28
|
-
constructor(initial?: StageRecord[]);
|
|
36
|
+
constructor(initial?: readonly StageRecord[]);
|
|
29
37
|
add(record: StageRecord): void;
|
|
30
38
|
toJSON(): string;
|
|
31
39
|
getStages(): readonly StageRecord[];
|
|
@@ -134,7 +134,7 @@ export function collectTransportMetrics(db, apiType, result, isStream, lastSucce
|
|
|
134
134
|
metrics.cache_read_tokens_estimated = 1;
|
|
135
135
|
if (tracker) {
|
|
136
136
|
try {
|
|
137
|
-
tracker.updateCompletedMetrics(lastSuccessLogId, cachedTokens);
|
|
137
|
+
tracker.updateCompletedMetrics(lastSuccessLogId, cachedTokens, true);
|
|
138
138
|
}
|
|
139
139
|
catch (e) {
|
|
140
140
|
request.log.error({ err: e }, "tracker update failed");
|
|
@@ -149,7 +149,7 @@ export function collectTransportMetrics(db, apiType, result, isStream, lastSucce
|
|
|
149
149
|
metrics.cache_read_tokens_estimated = 1;
|
|
150
150
|
if (tracker) {
|
|
151
151
|
try {
|
|
152
|
-
tracker.updateCompletedMetrics(lastSuccessLogId, estimated);
|
|
152
|
+
tracker.updateCompletedMetrics(lastSuccessLogId, estimated, true);
|
|
153
153
|
}
|
|
154
154
|
catch (e) {
|
|
155
155
|
request.log.error({ err: e }, "tracker update failed");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MRL(Modality Redirect)预计算层
|
|
3
|
+
*
|
|
4
|
+
* 纯函数:检测请求体是否包含多模态内容(图片、音频等),若首 target 不支持
|
|
5
|
+
* 且配置了 multimodal_fallback,则将 fallback target prepend 到列表头部。
|
|
6
|
+
*/
|
|
7
|
+
import type Database from "better-sqlite3";
|
|
8
|
+
import type { Target } from "../../core/types.js";
|
|
9
|
+
import { PipelineSnapshot } from "../pipeline-snapshot.js";
|
|
10
|
+
/**
|
|
11
|
+
* 检测请求体包含的多模态类型,支持三种 API 格式:
|
|
12
|
+
* 1. OpenAI: messages[].content 为数组,检测 type="image_url"|"input_audio"
|
|
13
|
+
* 2. Anthropic: messages[].content[] 含 type="image"(包括嵌套在 tool_result.content[] 中)
|
|
14
|
+
* 3. Responses API: input[] 含 type="input_image"|"input_audio"(顶层或嵌套在 message content 中)
|
|
15
|
+
*
|
|
16
|
+
* 返回检测到的模态 Set(空 Set 表示无多模态内容)。
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectModalities(body: Record<string, unknown>): Set<string>;
|
|
19
|
+
/**
|
|
20
|
+
* MRL 层主函数。异常安全:任何内部错误均 catch 并返回原始 targets。
|
|
21
|
+
*/
|
|
22
|
+
export declare function computeModalityRedirectTargets(db: Database.Database, targets: Target[], clientModel: string, body: Record<string, unknown>, snapshot: PipelineSnapshot): Target[];
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { getProviderById } from "../../db/providers.js";
|
|
2
|
+
import { getMappingGroup } from "../../db/mappings.js";
|
|
3
|
+
import { parseModels } from "../../config/model-context.js";
|
|
4
|
+
// ---------- detectModalities ----------
|
|
5
|
+
/**
|
|
6
|
+
* 检测请求体包含的多模态类型,支持三种 API 格式:
|
|
7
|
+
* 1. OpenAI: messages[].content 为数组,检测 type="image_url"|"input_audio"
|
|
8
|
+
* 2. Anthropic: messages[].content[] 含 type="image"(包括嵌套在 tool_result.content[] 中)
|
|
9
|
+
* 3. Responses API: input[] 含 type="input_image"|"input_audio"(顶层或嵌套在 message content 中)
|
|
10
|
+
*
|
|
11
|
+
* 返回检测到的模态 Set(空 Set 表示无多模态内容)。
|
|
12
|
+
*/
|
|
13
|
+
export function detectModalities(body) {
|
|
14
|
+
const modalities = new Set();
|
|
15
|
+
const messages = body.messages;
|
|
16
|
+
if (Array.isArray(messages)) {
|
|
17
|
+
for (const msg of messages) {
|
|
18
|
+
if (msg == null || typeof msg !== "object")
|
|
19
|
+
continue;
|
|
20
|
+
const content = msg.content;
|
|
21
|
+
if (!Array.isArray(content))
|
|
22
|
+
continue;
|
|
23
|
+
for (const block of content) {
|
|
24
|
+
if (block == null || typeof block !== "object")
|
|
25
|
+
continue;
|
|
26
|
+
const rec = block;
|
|
27
|
+
const t = rec.type;
|
|
28
|
+
if (t === "image_url")
|
|
29
|
+
modalities.add("image");
|
|
30
|
+
if (t === "input_audio")
|
|
31
|
+
modalities.add("audio");
|
|
32
|
+
if (t === "image")
|
|
33
|
+
modalities.add("image");
|
|
34
|
+
// Anthropic tool_result 内嵌: type="tool_result" → content[] 可能含 image
|
|
35
|
+
if (t === "tool_result") {
|
|
36
|
+
const inner = rec.content;
|
|
37
|
+
if (Array.isArray(inner)) {
|
|
38
|
+
for (const ib of inner) {
|
|
39
|
+
if (ib == null || typeof ib !== "object")
|
|
40
|
+
continue;
|
|
41
|
+
if (ib.type === "image")
|
|
42
|
+
modalities.add("image");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Responses API: input[]
|
|
50
|
+
const input = body.input;
|
|
51
|
+
if (Array.isArray(input)) {
|
|
52
|
+
for (const item of input) {
|
|
53
|
+
if (item == null || typeof item !== "object")
|
|
54
|
+
continue;
|
|
55
|
+
const rec = item;
|
|
56
|
+
if (rec.type === "input_image")
|
|
57
|
+
modalities.add("image");
|
|
58
|
+
if (rec.type === "input_audio")
|
|
59
|
+
modalities.add("audio");
|
|
60
|
+
// 嵌套在 message 的 content 中
|
|
61
|
+
const content = rec.content;
|
|
62
|
+
if (Array.isArray(content)) {
|
|
63
|
+
for (const block of content) {
|
|
64
|
+
if (block == null || typeof block !== "object")
|
|
65
|
+
continue;
|
|
66
|
+
const bt = block.type;
|
|
67
|
+
if (bt === "input_image")
|
|
68
|
+
modalities.add("image");
|
|
69
|
+
if (bt === "input_audio")
|
|
70
|
+
modalities.add("audio");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return modalities;
|
|
76
|
+
}
|
|
77
|
+
// ---------- computeModalityRedirectTargets ----------
|
|
78
|
+
/** 判断模型 capabilities 是否包含指定 modality */
|
|
79
|
+
function supportsModality(capabilities, modality) {
|
|
80
|
+
return Array.isArray(capabilities) && capabilities.includes(modality);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* MRL 层主函数。异常安全:任何内部错误均 catch 并返回原始 targets。
|
|
84
|
+
*/
|
|
85
|
+
export function computeModalityRedirectTargets(db, targets, clientModel, body, snapshot) {
|
|
86
|
+
try {
|
|
87
|
+
// 空列表直接返回
|
|
88
|
+
if (targets.length === 0)
|
|
89
|
+
return targets;
|
|
90
|
+
// 检测多模态内容
|
|
91
|
+
const modalities = detectModalities(body);
|
|
92
|
+
// 无多模态内容 → no-op
|
|
93
|
+
if (modalities.size === 0) {
|
|
94
|
+
snapshot.add({
|
|
95
|
+
stage: "modality-redirect",
|
|
96
|
+
triggered: false,
|
|
97
|
+
original_model: targets[0].backend_model,
|
|
98
|
+
redirect_to: "",
|
|
99
|
+
redirect_provider: "",
|
|
100
|
+
reason: "no-multimodal-detected",
|
|
101
|
+
});
|
|
102
|
+
return targets;
|
|
103
|
+
}
|
|
104
|
+
// 检查首 target 的 provider 是否已支持所有检测到的模态
|
|
105
|
+
const firstTarget = targets[0];
|
|
106
|
+
const provider = getProviderById(db, firstTarget.provider_id);
|
|
107
|
+
if (provider) {
|
|
108
|
+
const entries = parseModels(provider.models);
|
|
109
|
+
const entry = entries.find(e => e.name === firstTarget.backend_model);
|
|
110
|
+
const firstTargetCapabilities = entry?.capabilities ?? [];
|
|
111
|
+
const allSupported = [...modalities].every(m => supportsModality(firstTargetCapabilities, m));
|
|
112
|
+
if (allSupported) {
|
|
113
|
+
snapshot.add({
|
|
114
|
+
stage: "modality-redirect",
|
|
115
|
+
triggered: false,
|
|
116
|
+
original_model: firstTarget.backend_model,
|
|
117
|
+
redirect_to: "",
|
|
118
|
+
redirect_provider: "",
|
|
119
|
+
reason: "first-target-supports-all-modalities",
|
|
120
|
+
});
|
|
121
|
+
return targets;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// 查找 multimodal_fallback 配置
|
|
125
|
+
const group = getMappingGroup(db, clientModel);
|
|
126
|
+
if (!group) {
|
|
127
|
+
snapshot.add({
|
|
128
|
+
stage: "modality-redirect",
|
|
129
|
+
triggered: false,
|
|
130
|
+
original_model: firstTarget.backend_model,
|
|
131
|
+
redirect_to: "",
|
|
132
|
+
redirect_provider: "",
|
|
133
|
+
reason: "no-mapping-group",
|
|
134
|
+
});
|
|
135
|
+
return targets;
|
|
136
|
+
}
|
|
137
|
+
let rule;
|
|
138
|
+
try {
|
|
139
|
+
rule = JSON.parse(group.rule);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
snapshot.add({
|
|
143
|
+
stage: "modality-redirect",
|
|
144
|
+
triggered: false,
|
|
145
|
+
original_model: firstTarget.backend_model,
|
|
146
|
+
redirect_to: "",
|
|
147
|
+
redirect_provider: "",
|
|
148
|
+
reason: "rule-parse-error",
|
|
149
|
+
});
|
|
150
|
+
return targets;
|
|
151
|
+
}
|
|
152
|
+
const fallback = rule.multimodal_fallback;
|
|
153
|
+
if (fallback == null || typeof fallback !== "object") {
|
|
154
|
+
snapshot.add({
|
|
155
|
+
stage: "modality-redirect",
|
|
156
|
+
triggered: false,
|
|
157
|
+
original_model: firstTarget.backend_model,
|
|
158
|
+
redirect_to: "",
|
|
159
|
+
redirect_provider: "",
|
|
160
|
+
reason: "no-multimodal-fallback-configured",
|
|
161
|
+
});
|
|
162
|
+
return targets;
|
|
163
|
+
}
|
|
164
|
+
const fb = fallback;
|
|
165
|
+
const fbProviderId = fb.provider_id;
|
|
166
|
+
const fbBackendModel = fb.backend_model;
|
|
167
|
+
if (typeof fbProviderId !== "string" || typeof fbBackendModel !== "string") {
|
|
168
|
+
snapshot.add({
|
|
169
|
+
stage: "modality-redirect",
|
|
170
|
+
triggered: false,
|
|
171
|
+
original_model: firstTarget.backend_model,
|
|
172
|
+
redirect_to: "",
|
|
173
|
+
redirect_provider: "",
|
|
174
|
+
reason: "invalid-fallback-config",
|
|
175
|
+
});
|
|
176
|
+
return targets;
|
|
177
|
+
}
|
|
178
|
+
// fallback provider 必须存在且 active
|
|
179
|
+
const fbProvider = getProviderById(db, fbProviderId);
|
|
180
|
+
if (!fbProvider || fbProvider.is_active !== 1) {
|
|
181
|
+
snapshot.add({
|
|
182
|
+
stage: "modality-redirect",
|
|
183
|
+
triggered: false,
|
|
184
|
+
original_model: firstTarget.backend_model,
|
|
185
|
+
redirect_to: fbBackendModel,
|
|
186
|
+
redirect_provider: fbProviderId,
|
|
187
|
+
reason: "fallback-provider-unavailable",
|
|
188
|
+
});
|
|
189
|
+
return targets;
|
|
190
|
+
}
|
|
191
|
+
// 检查 fallback model 是否覆盖所有首 target 缺失的模态
|
|
192
|
+
const firstTargetCapabilities = provider
|
|
193
|
+
? parseModels(provider.models).find(e => e.name === firstTarget.backend_model)?.capabilities ?? []
|
|
194
|
+
: [];
|
|
195
|
+
const missingModalities = [...modalities].filter(m => !supportsModality(firstTargetCapabilities, m));
|
|
196
|
+
const fbEntry = parseModels(fbProvider.models).find(e => e.name === fbBackendModel);
|
|
197
|
+
const fbCapabilities = fbEntry?.capabilities ?? [];
|
|
198
|
+
const fbMissing = missingModalities.filter(m => !supportsModality(fbCapabilities, m));
|
|
199
|
+
if (fbMissing.length > 0) {
|
|
200
|
+
snapshot.add({
|
|
201
|
+
stage: "modality-redirect",
|
|
202
|
+
triggered: false,
|
|
203
|
+
original_model: firstTarget.backend_model,
|
|
204
|
+
redirect_to: fbBackendModel,
|
|
205
|
+
redirect_provider: fbProviderId,
|
|
206
|
+
reason: "fallback-missing-modality",
|
|
207
|
+
detected_modalities: [...modalities],
|
|
208
|
+
});
|
|
209
|
+
return targets;
|
|
210
|
+
}
|
|
211
|
+
// prepend fallback target(如果与首 target 相同则跳过,避免重复消耗 failover 迭代)
|
|
212
|
+
if (fbProviderId === firstTarget.provider_id && fbBackendModel === firstTarget.backend_model) {
|
|
213
|
+
snapshot.add({
|
|
214
|
+
stage: "modality-redirect",
|
|
215
|
+
triggered: false,
|
|
216
|
+
original_model: firstTarget.backend_model,
|
|
217
|
+
redirect_to: fbBackendModel,
|
|
218
|
+
redirect_provider: fbProviderId,
|
|
219
|
+
reason: "fallback-same-as-first-target",
|
|
220
|
+
detected_modalities: [...modalities],
|
|
221
|
+
});
|
|
222
|
+
return targets;
|
|
223
|
+
}
|
|
224
|
+
const fbTarget = {
|
|
225
|
+
provider_id: fbProviderId,
|
|
226
|
+
backend_model: fbBackendModel,
|
|
227
|
+
};
|
|
228
|
+
snapshot.add({
|
|
229
|
+
stage: "modality-redirect",
|
|
230
|
+
triggered: true,
|
|
231
|
+
original_model: firstTarget.backend_model,
|
|
232
|
+
redirect_to: fbBackendModel,
|
|
233
|
+
redirect_provider: fbProviderId,
|
|
234
|
+
reason: "first-target-lacks-modality",
|
|
235
|
+
detected_modalities: [...modalities],
|
|
236
|
+
});
|
|
237
|
+
return [fbTarget, ...targets];
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// 异常安全:返回原始 targets,但记录诊断信息
|
|
241
|
+
console.error('computeModalityRedirectTargets: internal error, falling back to original targets', err);
|
|
242
|
+
snapshot.add({
|
|
243
|
+
stage: "modality-redirect",
|
|
244
|
+
triggered: false,
|
|
245
|
+
original_model: targets[0]?.backend_model ?? "",
|
|
246
|
+
redirect_to: "",
|
|
247
|
+
redirect_provider: "",
|
|
248
|
+
reason: "internal-error",
|
|
249
|
+
});
|
|
250
|
+
return targets;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -10,9 +10,20 @@ interface OverflowResult {
|
|
|
10
10
|
provider_id: string;
|
|
11
11
|
backend_model: string;
|
|
12
12
|
}
|
|
13
|
+
export interface OverflowExpansionResult {
|
|
14
|
+
targets: Target[];
|
|
15
|
+
/** expanded 中哪些 index 是 overflow 扩展产生的 */
|
|
16
|
+
overflowIndices: Set<number>;
|
|
17
|
+
}
|
|
13
18
|
/**
|
|
14
19
|
* 检查请求是否超出当前模型的上下文窗口,若超出且配置了溢出目标,则返回重定向信息。
|
|
15
20
|
* 返回 null 表示无需溢出。
|
|
16
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* 为 targets 列表中每个配置了溢出的 target 预计算 overflow 重定向目标。
|
|
24
|
+
* 返回值中溢出目标排在原 target 之前,供 failover 循环直接消费。
|
|
25
|
+
* 单个 target 的溢出计算失败不影响其他 target。
|
|
26
|
+
*/
|
|
27
|
+
export declare function expandOverflowTargets(targets: Target[], db: Database.Database, body: Record<string, unknown>): OverflowExpansionResult;
|
|
17
28
|
export declare function applyOverflowRedirect(target: Target, db: Database.Database, body: Record<string, unknown>): OverflowResult | null;
|
|
18
29
|
export {};
|
|
@@ -101,6 +101,30 @@ function getContextWindow(db, providerId, modelName) {
|
|
|
101
101
|
* 检查请求是否超出当前模型的上下文窗口,若超出且配置了溢出目标,则返回重定向信息。
|
|
102
102
|
* 返回 null 表示无需溢出。
|
|
103
103
|
*/
|
|
104
|
+
/**
|
|
105
|
+
* 为 targets 列表中每个配置了溢出的 target 预计算 overflow 重定向目标。
|
|
106
|
+
* 返回值中溢出目标排在原 target 之前,供 failover 循环直接消费。
|
|
107
|
+
* 单个 target 的溢出计算失败不影响其他 target。
|
|
108
|
+
*/
|
|
109
|
+
export function expandOverflowTargets(targets, db, body) {
|
|
110
|
+
const expanded = [];
|
|
111
|
+
const overflowIndices = new Set();
|
|
112
|
+
for (const target of targets) {
|
|
113
|
+
try {
|
|
114
|
+
const result = applyOverflowRedirect(target, db, body);
|
|
115
|
+
if (result) {
|
|
116
|
+
overflowIndices.add(expanded.length);
|
|
117
|
+
expanded.push({ provider_id: result.provider_id, backend_model: result.backend_model });
|
|
118
|
+
}
|
|
119
|
+
// eslint-disable-next-line taste/no-silent-catch -- 单target溢出失败不阻塞其余target
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.error('expandOverflowTargets: overflow computation failed for target', target.backend_model, err);
|
|
123
|
+
}
|
|
124
|
+
expanded.push(target);
|
|
125
|
+
}
|
|
126
|
+
return { targets: expanded, overflowIndices };
|
|
127
|
+
}
|
|
104
128
|
export function applyOverflowRedirect(target, db, body) {
|
|
105
129
|
if (!target.overflow_provider_id || !target.overflow_model)
|
|
106
130
|
return null;
|
|
@@ -24,7 +24,7 @@ export class PluginRegistry {
|
|
|
24
24
|
if (!existsSync(resolvedDir)) {
|
|
25
25
|
return loaded;
|
|
26
26
|
}
|
|
27
|
-
const files = readdirSync(resolvedDir).filter((f) => f.endsWith(".js") || f.endsWith(".mjs"));
|
|
27
|
+
const files = readdirSync(resolvedDir).filter((f) => f.endsWith(".js") || f.endsWith(".mjs") || f.endsWith(".cjs"));
|
|
28
28
|
for (const file of files) {
|
|
29
29
|
const filePath = join(resolvedDir, file);
|
|
30
30
|
try {
|
|
@@ -16,6 +16,9 @@ export class OpenAIToAnthropicTransform extends BaseSSETransform {
|
|
|
16
16
|
completedToolCallIndices = new Set();
|
|
17
17
|
finishReasonReceived = false;
|
|
18
18
|
processEvent(event) {
|
|
19
|
+
// OpenAI SSE 标准结束信号 [DONE] 不是 JSON,跳过解析
|
|
20
|
+
if (event.data === '[DONE]')
|
|
21
|
+
return;
|
|
19
22
|
let chunk;
|
|
20
23
|
try {
|
|
21
24
|
chunk = JSON.parse(event.data);
|
|
@@ -63,6 +63,9 @@ export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
|
+
// 上游响应过程中连接中断时,IncomingMessage 发射 'error' 事件。
|
|
67
|
+
// 无 listener 会导致 uncaught exception 使进程退出。
|
|
68
|
+
res.on("error", (error) => resolve({ kind: "throw", error }));
|
|
66
69
|
});
|
|
67
70
|
req.on("error", (error) => resolve({ kind: "throw", error }));
|
|
68
71
|
req.write(payload);
|
|
@@ -85,6 +88,9 @@ export function callGet(backend, apiKey, clientHeaders, upstreamPath, buildHeade
|
|
|
85
88
|
headers: filterHeaders(res.headers),
|
|
86
89
|
});
|
|
87
90
|
});
|
|
91
|
+
// 上游响应过程中连接中断时,IncomingMessage 发射 'error' 事件。
|
|
92
|
+
// 无 listener 会导致 uncaught exception 使进程退出。
|
|
93
|
+
res.on("error", (err) => reject(err));
|
|
88
94
|
});
|
|
89
95
|
req.on("error", (err) => reject(err));
|
|
90
96
|
req.end();
|
|
@@ -24,9 +24,15 @@ export class ProxyAgentFactory {
|
|
|
24
24
|
cached.agent.destroy();
|
|
25
25
|
this.cache.delete(provider.id);
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
try {
|
|
28
|
+
const agent = this.createAgent(provider.proxy_type, fullUrl);
|
|
29
|
+
this.cache.set(provider.id, { agent, proxyUrl: fullUrl });
|
|
30
|
+
return agent;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// proxy_url 格式无效时返回 undefined,由调用方回退到非代理的 keep-alive agent
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
30
36
|
}
|
|
31
37
|
invalidate(providerId) {
|
|
32
38
|
const cached = this.cache.get(providerId);
|
|
@@ -64,11 +70,17 @@ export class ProxyAgentFactory {
|
|
|
64
70
|
const username = provider.proxy_username;
|
|
65
71
|
const password = provider.proxy_password;
|
|
66
72
|
if (username) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
parsed.username = username;
|
|
76
|
+
if (password)
|
|
77
|
+
parsed.password = password;
|
|
78
|
+
url = parsed.toString();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// proxy_url 格式无效时返回原始 URL,由上游请求层处理连接错误
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
72
84
|
}
|
|
73
85
|
return url;
|
|
74
86
|
}
|