llm-simple-router 0.10.10 → 0.10.12
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/core/monitor/types.d.ts +2 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/migrations/046_fix_is_complete_for_successful_requests.sql +10 -0
- package/dist/db/stats.d.ts +2 -0
- package/dist/db/stats.js +16 -0
- package/dist/metrics/sse-metrics-transform.d.ts +2 -0
- package/dist/metrics/sse-metrics-transform.js +8 -0
- package/dist/proxy/format/registry.d.ts +2 -2
- package/dist/proxy/format/registry.js +9 -10
- package/dist/proxy/format/types.d.ts +2 -2
- package/dist/proxy/format/types.js +2 -2
- package/dist/proxy/handler/failover-loop.js +10 -9
- package/dist/proxy/orchestration/orchestrator.d.ts +3 -1
- package/dist/proxy/orchestration/orchestrator.js +1 -0
- package/dist/proxy/pipeline-snapshot.d.ts +2 -0
- package/dist/proxy/routing/mapping-resolver.js +8 -2
- package/dist/proxy/routing/usage-window-tracker.d.ts +3 -5
- package/dist/proxy/routing/usage-window-tracker.js +28 -43
- package/dist/proxy/transform/message-mapper.js +22 -2
- package/dist/proxy/transform/request-bridge-responses.js +178 -12
- package/dist/proxy/transform/request-transform-responses.js +96 -17
- package/dist/proxy/transform/request-transform.js +9 -1
- package/dist/proxy/transform/response-bridge-responses.d.ts +2 -2
- package/dist/proxy/transform/response-bridge-responses.js +8 -8
- package/dist/proxy/transform/response-transform-responses.d.ts +3 -2
- package/dist/proxy/transform/response-transform-responses.js +51 -10
- package/dist/proxy/transform/response-transform.d.ts +4 -4
- package/dist/proxy/transform/response-transform.js +32 -23
- package/dist/proxy/transform/stream-ant2oa.js +10 -2
- package/dist/proxy/transform/stream-ant2resp.d.ts +6 -0
- package/dist/proxy/transform/stream-ant2resp.js +43 -13
- package/dist/proxy/transform/stream-bridge-chat2resp.d.ts +3 -0
- package/dist/proxy/transform/stream-bridge-chat2resp.js +20 -5
- package/dist/proxy/transform/stream-bridge-resp2chat.d.ts +1 -0
- package/dist/proxy/transform/stream-bridge-resp2chat.js +24 -0
- package/dist/proxy/transform/stream-oa2ant.d.ts +1 -0
- package/dist/proxy/transform/stream-oa2ant.js +11 -3
- package/dist/proxy/transform/stream-resp2ant.d.ts +1 -0
- package/dist/proxy/transform/stream-resp2ant.js +10 -3
- package/dist/proxy/transform/thinking-mapper.js +11 -1
- package/dist/proxy/transform/tool-mapper.js +10 -7
- package/dist/proxy/transform/types.d.ts +11 -0
- package/dist/proxy/transform/usage-mapper.js +18 -10
- package/dist/proxy/transport/stream.js +3 -0
- package/dist/utils/mapping-reason-parser.d.ts +7 -0
- package/dist/utils/mapping-reason-parser.js +43 -0
- package/dist/utils/time-range.js +18 -23
- package/frontend-dist/assets/{CardContent-qcSyH2wC.js → CardContent-D8q9vc5O.js} +1 -1
- package/frontend-dist/assets/{CardTitle-BiP1PaEJ.js → CardTitle-yA_25649.js} +1 -1
- package/frontend-dist/assets/{Checkbox-DffzDfGN.js → Checkbox-DOkFBp83.js} +1 -1
- package/frontend-dist/assets/{CollapsibleContent-BLcA_5sz.js → CollapsibleContent-DzBUfYYw.js} +1 -1
- package/frontend-dist/assets/{CollapsibleTrigger-CFNX8ZWv.js → CollapsibleTrigger-CHYSp36W.js} +1 -1
- package/frontend-dist/assets/{Dashboard-DuRjJaT-.js → Dashboard-BvYHnpcJ.js} +1 -1
- package/frontend-dist/assets/{Input-D_nV5NLw.js → Input-CcyqZXkU.js} +1 -1
- package/frontend-dist/assets/{Label-DWvRM0Of.js → Label-CzP8C4iz.js} +1 -1
- package/frontend-dist/assets/{Login-CgiCloZ_.js → Login-CxzSo3yb.js} +1 -1
- package/frontend-dist/assets/{Logs-XcxKaNRX.js → Logs-Bxqdk7eE.js} +1 -1
- package/frontend-dist/assets/{MappingEntryEditor-DIoQxGfs.js → MappingEntryEditor-B1DXrpQE.js} +1 -1
- package/frontend-dist/assets/{ModelCard-Cm7JtPFg.js → ModelCard-CfnNmO62.js} +1 -1
- package/frontend-dist/assets/{ModelMappings-CvNdZ4UC.js → ModelMappings-BER9jpBp.js} +1 -1
- package/frontend-dist/assets/{Monitor-_pspFE5H.js → Monitor-DTRiQd_f.js} +1 -1
- package/frontend-dist/assets/{Providers-B__y99yf.js → Providers-CFDV-w7E.js} +1 -1
- package/frontend-dist/assets/{ProxyEnhancement-Bsp6wbOU.js → ProxyEnhancement-DsmirUED.js} +1 -1
- package/frontend-dist/assets/{QuickSetup-CLirza5Z.js → QuickSetup-Ch84omRO.js} +1 -1
- package/frontend-dist/assets/{RetryRules-D7TEvRsa.js → RetryRules-Dp5LarPp.js} +1 -1
- package/frontend-dist/assets/{RouterKeys-xsdh_HcW.js → RouterKeys-BAddL89X.js} +1 -1
- package/frontend-dist/assets/{RovingFocusItem-D2UUj3n_.js → RovingFocusItem-CRi6yQVX.js} +1 -1
- package/frontend-dist/assets/{Schedules-D7GDAZXY.js → Schedules-C_bDqD0b.js} +1 -1
- package/frontend-dist/assets/{Settings-BI_ABQ8W.js → Settings-Bcfiljtn.js} +1 -1
- package/frontend-dist/assets/{Setup-C8a8Q9z2.js → Setup-BHAW_Tm2.js} +1 -1
- package/frontend-dist/assets/{Switch-DhyEtE7A.js → Switch-OzhbhGwX.js} +1 -1
- package/frontend-dist/assets/{TooltipTrigger-BVNmzsHP.js → TooltipTrigger-DOgXsxyY.js} +1 -1
- package/frontend-dist/assets/{TransformRulesForm--5C_xErR.js → TransformRulesForm-IKRT5A3_.js} +1 -1
- package/frontend-dist/assets/{UnifiedRequestDialog-O6Ld5DZI.js → UnifiedRequestDialog-DYS5gkoE.js} +3 -3
- package/frontend-dist/assets/{VisuallyHiddenInput-CPQ662FS.js → VisuallyHiddenInput-C3oWOQXr.js} +1 -1
- package/frontend-dist/assets/{button-BWSfarSC.js → button-C1QSPeLI.js} +2 -2
- package/frontend-dist/assets/{copy-DOxEdrz9.js → copy-Cv_Emnxv.js} +1 -1
- package/frontend-dist/assets/{dialog--eSL5k5e.js → dialog-BBGI9zKO.js} +1 -1
- package/frontend-dist/assets/{index-B9huoJLE.js → index-247t8K8M.js} +2 -2
- package/frontend-dist/assets/requestDetail-3KCtYe1N.js +1 -0
- package/frontend-dist/assets/requestDetail-DZ55ph4h.js +1 -0
- package/frontend-dist/assets/{trash-2-CNeuAYsm.js → trash-2-DtOp0hat.js} +1 -1
- package/frontend-dist/assets/{useClipboard-CnSGwYPF.js → useClipboard-Bd3JzV3b.js} +1 -1
- package/frontend-dist/assets/{useLogRetention-Dul_BUBe.js → useLogRetention-BrbC5bfG.js} +1 -1
- package/frontend-dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend-dist/assets/requestDetail-DZltcrAt.js +0 -1
- package/frontend-dist/assets/requestDetail-NrvqHtpI.js +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MappingReason } from "../types.js";
|
|
1
2
|
/** Abstract SSE client interface (decouples from Node http ServerResponse). */
|
|
2
3
|
export interface SSEClient {
|
|
3
4
|
write(data: string): void;
|
|
@@ -35,6 +36,7 @@ export interface ActiveRequest {
|
|
|
35
36
|
streamContent?: StreamContentSnapshot;
|
|
36
37
|
clientIp?: string;
|
|
37
38
|
sessionId?: string;
|
|
39
|
+
mappingReason?: MappingReason;
|
|
38
40
|
clientRequest?: string;
|
|
39
41
|
upstreamRequest?: string;
|
|
40
42
|
completedAt?: number;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface ConcurrencyOverride {
|
|
|
21
21
|
queue_timeout_ms?: number;
|
|
22
22
|
max_queue_size?: number;
|
|
23
23
|
}
|
|
24
|
+
export type MappingReason = "direct_format" | "group_base_rule" | "group_schedule" | "fallback_provider" | "overflow_redirect" | "failover_retry";
|
|
24
25
|
export interface ResolveResult {
|
|
25
26
|
target: Target;
|
|
26
27
|
concurrency_override?: ConcurrencyOverride;
|
|
@@ -28,6 +29,8 @@ export interface ResolveResult {
|
|
|
28
29
|
targetCount: number;
|
|
29
30
|
/** 排除前的完整 target 列表,用于请求级缓存(BP-H2) */
|
|
30
31
|
allTargets?: Target[];
|
|
32
|
+
/** 映射解析原因,标识走了哪条解析路径 */
|
|
33
|
+
mappingReason: MappingReason;
|
|
31
34
|
}
|
|
32
35
|
export interface MetricsResult {
|
|
33
36
|
input_tokens: number | null;
|
package/dist/db/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey
|
|
|
12
12
|
export type { RouterKey } from "./router-keys.js";
|
|
13
13
|
export { getMetricsSummary, getMetricsTimeseries, insertMetrics, getClientTypeBreakdown } from "./metrics.js";
|
|
14
14
|
export type { MetricsSummaryRow, MetricsTimeseriesRow, MetricsPeriod, MetricsMetric, MetricsRow, MetricsInsert, ClientTypeBreakdown } from "./metrics.js";
|
|
15
|
-
export { getStats } from "./stats.js";
|
|
15
|
+
export { getStats, getLatestMetricTime } from "./stats.js";
|
|
16
16
|
export type { Stats } from "./stats.js";
|
|
17
17
|
export { getSetting, setSetting, isInitialized } from "./settings.js";
|
|
18
18
|
export { getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, } from "./settings.js";
|
package/dist/db/index.js
CHANGED
|
@@ -145,7 +145,7 @@ export { getActiveRetryRules, getAllRetryRules, getRetryRuleById, createRetryRul
|
|
|
145
145
|
export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogStreamContent, updateLogClientStatus, estimateLogTableSize, deleteOldestLogs, getLogCount, updateLogPipelineSnapshot, } from "./logs.js";
|
|
146
146
|
export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
|
|
147
147
|
export { getMetricsSummary, getMetricsTimeseries, insertMetrics, getClientTypeBreakdown } from "./metrics.js";
|
|
148
|
-
export { getStats } from "./stats.js";
|
|
148
|
+
export { getStats, getLatestMetricTime } from "./stats.js";
|
|
149
149
|
export { getSetting, setSetting, isInitialized } from "./settings.js";
|
|
150
150
|
export { getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, } from "./settings.js";
|
|
151
151
|
export { insertWindow, getLatestWindow, getWindowsInRange, getWindowUsage, } from "./usage-windows.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Fix historical request_metrics where is_complete=0 despite successful responses.
|
|
2
|
+
-- Root cause: StreamProxy.onEnd() collected metrics before SSE parser flush,
|
|
3
|
+
-- causing is_complete to always be 0 for providers using OpenAI SSE format.
|
|
4
|
+
-- Only fix records with clear success signals (status 200 + output tokens + duration).
|
|
5
|
+
UPDATE request_metrics
|
|
6
|
+
SET is_complete = 1
|
|
7
|
+
WHERE is_complete = 0
|
|
8
|
+
AND status_code = 200
|
|
9
|
+
AND output_tokens > 0
|
|
10
|
+
AND total_duration_ms > 0;
|
package/dist/db/stats.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
+
/** 获取指定条件下的最近一条 metric 的 created_at(用于窗口补齐定位,不限制 is_complete) */
|
|
3
|
+
export declare function getLatestMetricTime(db: Database.Database, providerId?: string, routerKeyId?: string): string | null;
|
|
2
4
|
export interface Stats {
|
|
3
5
|
totalRequests: number;
|
|
4
6
|
successRate: number;
|
package/dist/db/stats.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/** 获取指定条件下的最近一条 metric 的 created_at(用于窗口补齐定位,不限制 is_complete) */
|
|
2
|
+
export function getLatestMetricTime(db, providerId, routerKeyId) {
|
|
3
|
+
const conditions = [];
|
|
4
|
+
const params = [];
|
|
5
|
+
if (providerId) {
|
|
6
|
+
conditions.push("rm.provider_id = ?");
|
|
7
|
+
params.push(providerId);
|
|
8
|
+
}
|
|
9
|
+
if (routerKeyId) {
|
|
10
|
+
conditions.push("rm.router_key_id = ?");
|
|
11
|
+
params.push(routerKeyId);
|
|
12
|
+
}
|
|
13
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
14
|
+
const row = db.prepare(`SELECT rm.created_at FROM request_metrics rm ${where} ORDER BY rm.created_at DESC LIMIT 1`).get(...params);
|
|
15
|
+
return row?.created_at ?? null;
|
|
16
|
+
}
|
|
1
17
|
export function getStats(db, startTime, endTime, routerKeyId, providerId, backendModel) {
|
|
2
18
|
const conditions = [
|
|
3
19
|
"rm.is_complete = 1",
|
|
@@ -30,6 +30,8 @@ export declare class SSEMetricsTransform extends Transform {
|
|
|
30
30
|
constructor(apiType: "openai" | "openai-responses" | "anthropic", requestStartTime: number, options?: MetricsTransformOptions);
|
|
31
31
|
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
|
|
32
32
|
_flush(callback: TransformCallback): void;
|
|
33
|
+
/** Flush SSE parser 缓冲区并处理残余事件,确保 extractor 状态完整 */
|
|
34
|
+
flushParser(): void;
|
|
33
35
|
getExtractor(): MetricsExtractor;
|
|
34
36
|
/** 从 SSE 事件中提取内容文本,触发 onContentDelta 回调 */
|
|
35
37
|
private emitContentDelta;
|
|
@@ -54,6 +54,14 @@ export class SSEMetricsTransform extends Transform {
|
|
|
54
54
|
}
|
|
55
55
|
callback();
|
|
56
56
|
}
|
|
57
|
+
/** Flush SSE parser 缓冲区并处理残余事件,确保 extractor 状态完整 */
|
|
58
|
+
flushParser() {
|
|
59
|
+
const events = this.parser.flush();
|
|
60
|
+
for (const event of events) {
|
|
61
|
+
this.extractor.processEvent(event);
|
|
62
|
+
this.emitContentDelta(event);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
57
65
|
getExtractor() {
|
|
58
66
|
return this.extractor;
|
|
59
67
|
}
|
|
@@ -11,7 +11,7 @@ export declare class FormatRegistry {
|
|
|
11
11
|
body: Record<string, unknown>;
|
|
12
12
|
upstreamPath: string;
|
|
13
13
|
};
|
|
14
|
-
transformResponse(
|
|
15
|
-
transformError(
|
|
14
|
+
transformResponse(body: Record<string, unknown>, source: string, target: string): Record<string, unknown>;
|
|
15
|
+
transformError(body: Record<string, unknown>, source: string, target: string): string;
|
|
16
16
|
createStreamTransform(source: string, target: string, model: string): Transform | undefined;
|
|
17
17
|
}
|
|
@@ -21,26 +21,25 @@ export class FormatRegistry {
|
|
|
21
21
|
return { body, upstreamPath };
|
|
22
22
|
return { body: converter.transformRequest(body, model), upstreamPath };
|
|
23
23
|
}
|
|
24
|
-
transformResponse(
|
|
24
|
+
transformResponse(body, source, target) {
|
|
25
25
|
const converter = this.converters.get(`${source}→${target}`);
|
|
26
26
|
if (!converter)
|
|
27
|
-
return
|
|
28
|
-
return converter.transformResponse(
|
|
27
|
+
return body;
|
|
28
|
+
return converter.transformResponse(body);
|
|
29
29
|
}
|
|
30
|
-
transformError(
|
|
30
|
+
transformError(body, source, target) {
|
|
31
31
|
if (source === target)
|
|
32
|
-
return
|
|
32
|
+
return JSON.stringify(body);
|
|
33
33
|
try {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const code = parsed.error?.code ?? parsed.code;
|
|
34
|
+
const message = body.error?.message ?? body.message ?? JSON.stringify(body);
|
|
35
|
+
const code = body.error?.code ?? body.code;
|
|
37
36
|
const targetAdapter = this.adapters.get(target);
|
|
38
37
|
if (!targetAdapter)
|
|
39
|
-
return
|
|
38
|
+
return JSON.stringify(body);
|
|
40
39
|
return JSON.stringify(targetAdapter.formatError(message, code));
|
|
41
40
|
}
|
|
42
41
|
catch {
|
|
43
|
-
return
|
|
42
|
+
return JSON.stringify(body);
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
45
|
createStreamTransform(source, target, model) {
|
|
@@ -14,7 +14,7 @@ export interface FormatConverter {
|
|
|
14
14
|
readonly sourceType: string;
|
|
15
15
|
readonly targetType: string;
|
|
16
16
|
transformRequest(body: Record<string, unknown>, model: string): Record<string, unknown>;
|
|
17
|
-
transformResponse(
|
|
17
|
+
transformResponse(body: Record<string, unknown>): Record<string, unknown>;
|
|
18
18
|
createStreamTransform(model: string): Transform;
|
|
19
19
|
}
|
|
20
20
|
/** Factory: eliminates repetitive object literal structure across 6 converters. */
|
|
@@ -22,6 +22,6 @@ export declare function createConverter(deps: {
|
|
|
22
22
|
sourceType: string;
|
|
23
23
|
targetType: string;
|
|
24
24
|
requestTransform: (body: Record<string, unknown>) => Record<string, unknown>;
|
|
25
|
-
responseTransform: (
|
|
25
|
+
responseTransform: (body: Record<string, unknown>) => Record<string, unknown>;
|
|
26
26
|
streamTransformClass: new (model: string) => Transform;
|
|
27
27
|
}): FormatConverter;
|
|
@@ -6,8 +6,8 @@ export function createConverter(deps) {
|
|
|
6
6
|
transformRequest(body) {
|
|
7
7
|
return deps.requestTransform(body);
|
|
8
8
|
},
|
|
9
|
-
transformResponse(
|
|
10
|
-
return deps.responseTransform(
|
|
9
|
+
transformResponse(body) {
|
|
10
|
+
return deps.responseTransform(body);
|
|
11
11
|
},
|
|
12
12
|
createStreamTransform(model) {
|
|
13
13
|
return new deps.streamTransformClass(model);
|
|
@@ -150,7 +150,7 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
150
150
|
if (cachedTargets) {
|
|
151
151
|
const filtered = filterExcluded(cachedTargets, excludeTargets);
|
|
152
152
|
resolveResult = filtered.length > 0
|
|
153
|
-
? { target: filtered[0], concurrency_override: cachedConcurrencyOverride, targetCount: cachedTargets.length }
|
|
153
|
+
? { target: filtered[0], concurrency_override: cachedConcurrencyOverride, targetCount: cachedTargets.length, mappingReason: "failover_retry" }
|
|
154
154
|
: null;
|
|
155
155
|
}
|
|
156
156
|
else {
|
|
@@ -191,6 +191,7 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
// --- 溢出重定向 ---
|
|
194
|
+
let effectiveMappingReason = resolveResult.mappingReason;
|
|
194
195
|
const overflowResult = applyOverflowRedirect(resolved, db, currentBody);
|
|
195
196
|
if (overflowResult) {
|
|
196
197
|
const overflowProvider = getProviderById(db, overflowResult.provider_id);
|
|
@@ -198,6 +199,7 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
198
199
|
resolved = { ...resolved, provider_id: overflowResult.provider_id, backend_model: overflowResult.backend_model };
|
|
199
200
|
provider = overflowProvider;
|
|
200
201
|
currentBody = { ...currentBody, model: overflowResult.backend_model };
|
|
202
|
+
effectiveMappingReason = "overflow_redirect";
|
|
201
203
|
}
|
|
202
204
|
}
|
|
203
205
|
// 当前迭代的工具错误刷新闭包(统一 6 处调用)
|
|
@@ -210,7 +212,7 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
210
212
|
const needsTransform = resolvedPath.needsTransform;
|
|
211
213
|
// --- routing ---
|
|
212
214
|
currentBody = { ...currentBody, model: resolved.backend_model };
|
|
213
|
-
iterationSnapshot.add({ stage: "routing", client_model: clientModel, backend_model: resolved.backend_model, provider_id: resolved.provider_id, strategy: resolveResult.targetCount > 1 ? "failover" : "scheduled" });
|
|
215
|
+
iterationSnapshot.add({ stage: "routing", client_model: clientModel, backend_model: resolved.backend_model, provider_id: resolved.provider_id, strategy: resolveResult.targetCount > 1 ? "failover" : "scheduled", mapping_reason: effectiveMappingReason });
|
|
214
216
|
iterationSnapshot.add({ stage: "overflow", triggered: overflowResult != null });
|
|
215
217
|
// --- Plugin 调整 body 和 headers ---
|
|
216
218
|
const pluginResult = applyPluginAdjustments(pluginRegistry, currentBody, clientApiType, provider);
|
|
@@ -251,25 +253,24 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
251
253
|
try {
|
|
252
254
|
const parsed = JSON.parse(bodyStr);
|
|
253
255
|
if (parsed.type === "error" || parsed.error) {
|
|
254
|
-
return formatRegistry.transformError(
|
|
256
|
+
return formatRegistry.transformError(parsed, provider.api_type, ctx.apiType);
|
|
255
257
|
}
|
|
256
|
-
let transformed = formatRegistry.transformResponse(
|
|
258
|
+
let transformed = formatRegistry.transformResponse(parsed, provider.api_type, ctx.apiType);
|
|
257
259
|
if (pluginRegistry && !isStream) {
|
|
258
260
|
try {
|
|
259
|
-
const respObj = JSON.parse(transformed);
|
|
260
261
|
const respCtx = {
|
|
261
|
-
response:
|
|
262
|
+
response: transformed,
|
|
262
263
|
sourceApiType: provider.api_type,
|
|
263
264
|
targetApiType: clientApiType,
|
|
264
265
|
provider: { id: provider.id, name: provider.name, base_url: provider.base_url, api_type: provider.api_type },
|
|
265
266
|
};
|
|
266
267
|
pluginRegistry.applyBeforeResponse(respCtx);
|
|
267
268
|
pluginRegistry.applyAfterResponse(respCtx);
|
|
268
|
-
transformed =
|
|
269
|
+
transformed = respCtx.response;
|
|
269
270
|
}
|
|
270
271
|
catch { /* response hooks best-effort */ } // eslint-disable-line taste/no-silent-catch
|
|
271
272
|
}
|
|
272
|
-
return transformed;
|
|
273
|
+
return JSON.stringify(transformed);
|
|
273
274
|
}
|
|
274
275
|
catch (err) {
|
|
275
276
|
request.log.error({ err }, "responseTransform failed");
|
|
@@ -290,7 +291,7 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
|
|
|
290
291
|
const pipelineSnapshot = iterationSnapshot.toJSON();
|
|
291
292
|
// --- Execute through orchestrator ---
|
|
292
293
|
try {
|
|
293
|
-
const resilienceResult = await orchestrator.handle(request, reply, clientApiType, { resolved, provider, clientModel, isStream, trackerId: logId, sessionId: ctx.metadata.get("session_id"), clientRequest: clientReq, upstreamRequest: upstreamReqBase, concurrencyOverride }, { retryBaseDelayMs: config.RETRY_BASE_DELAY_MS, isFailover, ruleMatcher: matcher, transportFn });
|
|
294
|
+
const resilienceResult = await orchestrator.handle(request, reply, clientApiType, { resolved, provider, clientModel, isStream, trackerId: logId, sessionId: ctx.metadata.get("session_id"), clientRequest: clientReq, upstreamRequest: upstreamReqBase, concurrencyOverride, mappingReason: effectiveMappingReason }, { retryBaseDelayMs: config.RETRY_BASE_DELAY_MS, isFailover, ruleMatcher: matcher, transportFn });
|
|
294
295
|
// 日志记录
|
|
295
296
|
const lastLogId = logResilienceResult(db, {
|
|
296
297
|
apiType: clientApiType,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
2
|
import type { TransportResult } from "../types.js";
|
|
3
|
-
import type { Target, ConcurrencyOverride } from "../../core/types.js";
|
|
3
|
+
import type { Target, ConcurrencyOverride, MappingReason } from "../../core/types.js";
|
|
4
4
|
import type { ResilienceLayer, ResilienceResult } from "./resilience.js";
|
|
5
5
|
import type { RetryRuleMatcher } from "./retry-rules.js";
|
|
6
6
|
import type { SemaphoreScope } from "./scope.js";
|
|
@@ -30,6 +30,8 @@ export interface OrchestratorConfig {
|
|
|
30
30
|
upstreamRequest?: string;
|
|
31
31
|
/** Schedule 层的并发覆盖配置,覆盖 Provider 默认并发限制 */
|
|
32
32
|
concurrencyOverride?: ConcurrencyOverride;
|
|
33
|
+
/** 映射解析原因 */
|
|
34
|
+
mappingReason?: MappingReason;
|
|
33
35
|
}
|
|
34
36
|
export interface HandleContext {
|
|
35
37
|
streamTimeoutMs?: number;
|
|
@@ -12,6 +12,7 @@ export type StageRecord = {
|
|
|
12
12
|
backend_model: string;
|
|
13
13
|
provider_id: string;
|
|
14
14
|
strategy: string;
|
|
15
|
+
mapping_reason?: MappingReason;
|
|
15
16
|
} | {
|
|
16
17
|
stage: "overflow";
|
|
17
18
|
triggered: boolean;
|
|
@@ -21,6 +22,7 @@ export type StageRecord = {
|
|
|
21
22
|
stage: "provider_patch";
|
|
22
23
|
types: string[];
|
|
23
24
|
};
|
|
25
|
+
import type { MappingReason } from "../core/types.js";
|
|
24
26
|
export declare class PipelineSnapshot {
|
|
25
27
|
private readonly stages;
|
|
26
28
|
constructor(initial?: StageRecord[]);
|
|
@@ -118,7 +118,7 @@ export function resolveMapping(db, clientModel, context) {
|
|
|
118
118
|
if (provider) {
|
|
119
119
|
const modelEntries = parseModels(provider.models);
|
|
120
120
|
if (modelEntries.some(m => m.name === backendModel)) {
|
|
121
|
-
return { target: { backend_model: backendModel, provider_id: provider.id }, targetCount: 1 };
|
|
121
|
+
return { target: { backend_model: backendModel, provider_id: provider.id }, targetCount: 1, mappingReason: "direct_format" };
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
return null;
|
|
@@ -131,7 +131,7 @@ export function resolveMapping(db, clientModel, context) {
|
|
|
131
131
|
for (const p of providers) {
|
|
132
132
|
const modelEntries = parseModels(p.models);
|
|
133
133
|
if (modelEntries.some(m => m.name === clientModel)) {
|
|
134
|
-
return { target: { backend_model: clientModel, provider_id: p.id }, targetCount: 1 };
|
|
134
|
+
return { target: { backend_model: clientModel, provider_id: p.id }, targetCount: 1, mappingReason: "fallback_provider" };
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
return null;
|
|
@@ -158,10 +158,15 @@ export function resolveMapping(db, clientModel, context) {
|
|
|
158
158
|
// 5. 确定使用的 targets:schedule 优先,否则 base
|
|
159
159
|
let activeTargets = baseTargets;
|
|
160
160
|
let concurrencyOverride;
|
|
161
|
+
let mappingReason = "group_base_rule";
|
|
161
162
|
if (matchedSchedule) {
|
|
162
163
|
const scheduleTargets = parseScheduleTargets(matchedSchedule.mapping_rule);
|
|
164
|
+
// schedule 命中但 targets 解析失败时,activeTargets 仍为 base targets
|
|
165
|
+
// mappingReason 保持 group_base_rule(实际使用的 targets 来源)
|
|
166
|
+
// concurrencyOverride 来自 schedule(有意保留,schedule 控制并发上限)
|
|
163
167
|
if (scheduleTargets.length > 0) {
|
|
164
168
|
activeTargets = scheduleTargets;
|
|
169
|
+
mappingReason = "group_schedule";
|
|
165
170
|
}
|
|
166
171
|
concurrencyOverride = parseConcurrencyRule(matchedSchedule.concurrency_rule);
|
|
167
172
|
}
|
|
@@ -174,5 +179,6 @@ export function resolveMapping(db, clientModel, context) {
|
|
|
174
179
|
concurrency_override: concurrencyOverride,
|
|
175
180
|
targetCount: activeTargets.length,
|
|
176
181
|
allTargets: activeTargets,
|
|
182
|
+
mappingReason,
|
|
177
183
|
};
|
|
178
184
|
}
|
|
@@ -2,12 +2,10 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
export declare class UsageWindowTracker {
|
|
3
3
|
private db;
|
|
4
4
|
constructor(db: Database.Database);
|
|
5
|
-
/**
|
|
5
|
+
/** 请求成功后调用,按需创建新窗口(前向模式:窗口 = [now, now + 5h]) */
|
|
6
6
|
recordRequest(providerId: string, routerKeyId?: string): void;
|
|
7
|
-
/** 启动时按活跃 provider
|
|
7
|
+
/** 启动时按活跃 provider 补齐缺失的窗口(每个 provider 仅创建一个前向窗口) */
|
|
8
8
|
reconcileOnStartup(): void;
|
|
9
|
-
/** 为单个 provider
|
|
9
|
+
/** 为单个 provider 补齐窗口:无窗口时基于最新 log 创建前向窗口 */
|
|
10
10
|
private reconcileProvider;
|
|
11
|
-
/** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
|
|
12
|
-
private backfillProviderWindows;
|
|
13
11
|
}
|
|
@@ -4,71 +4,56 @@ import { getAllProviders } from "../../db/providers.js";
|
|
|
4
4
|
import { toSqliteDatetime, parseSqliteDatetime as parseDate } from "../../utils/datetime.js";
|
|
5
5
|
// eslint-disable-next-line no-magic-numbers
|
|
6
6
|
const WINDOW_DURATION_MS = 5 * 3600_000;
|
|
7
|
+
const MS_PER_MINUTE = 60000;
|
|
8
|
+
// 过期判断最小间隔(毫秒),同分钟内不重复创建窗口
|
|
9
|
+
const WINDOW_GRACE_PERIOD_MS = MS_PER_MINUTE;
|
|
7
10
|
export class UsageWindowTracker {
|
|
8
11
|
db;
|
|
9
12
|
constructor(db) {
|
|
10
13
|
this.db = db;
|
|
11
14
|
}
|
|
12
|
-
/**
|
|
15
|
+
/** 请求成功后调用,按需创建新窗口(前向模式:窗口 = [now, now + 5h]) */
|
|
13
16
|
recordRequest(providerId, routerKeyId) {
|
|
14
17
|
const now = new Date();
|
|
15
18
|
const latest = getLatestWindow(this.db, routerKeyId, providerId);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
19
|
+
// 无窗口 → 创建。有窗口但已过期(end_time <= now)且跨分钟 → 新窗口。
|
|
20
|
+
// 同分钟内的快速调用不重复创建,避免同一分钟内的多次请求产生多个窗口。
|
|
21
|
+
if (!latest) {
|
|
22
|
+
createForwardWindow(this.db, now, routerKeyId, providerId);
|
|
23
|
+
}
|
|
24
|
+
else if (parseDate(latest.end_time) <= now
|
|
25
|
+
&& now.getTime() - parseDate(latest.end_time).getTime() >= WINDOW_GRACE_PERIOD_MS) {
|
|
26
|
+
createForwardWindow(this.db, now, routerKeyId, providerId);
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
|
-
/** 启动时按活跃 provider
|
|
29
|
+
/** 启动时按活跃 provider 补齐缺失的窗口(每个 provider 仅创建一个前向窗口) */
|
|
28
30
|
reconcileOnStartup() {
|
|
29
31
|
const providers = getAllProviders(this.db).filter((p) => p.is_active);
|
|
30
32
|
for (const provider of providers) {
|
|
31
33
|
this.reconcileProvider(provider.id);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
|
-
/** 为单个 provider
|
|
36
|
+
/** 为单个 provider 补齐窗口:无窗口时基于最新 log 创建前向窗口 */
|
|
35
37
|
reconcileProvider(providerId) {
|
|
36
38
|
const latest = getLatestWindow(this.db, undefined, providerId);
|
|
37
|
-
|
|
38
|
-
if (!lastLog)
|
|
39
|
+
if (latest)
|
|
39
40
|
return;
|
|
40
|
-
if (!latest) {
|
|
41
|
-
const firstLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at ASC LIMIT 1").get(providerId);
|
|
42
|
-
if (!firstLog)
|
|
43
|
-
return;
|
|
44
|
-
const truncated = truncateToMinute(parseDate(firstLog.created_at));
|
|
45
|
-
this.backfillProviderWindows(providerId, truncated);
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
this.backfillProviderWindows(providerId, parseDate(latest.end_time));
|
|
49
|
-
}
|
|
50
|
-
/** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
|
|
51
|
-
backfillProviderWindows(providerId, baseTime) {
|
|
52
41
|
const lastLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at DESC LIMIT 1").get(providerId);
|
|
53
42
|
if (!lastLog)
|
|
54
43
|
return;
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
while (windowStart < lastLogTime) {
|
|
58
|
-
const windowEnd = new Date(windowStart.getTime() + WINDOW_DURATION_MS);
|
|
59
|
-
insertWindow(this.db, {
|
|
60
|
-
id: randomUUID(),
|
|
61
|
-
router_key_id: null,
|
|
62
|
-
provider_id: providerId,
|
|
63
|
-
start_time: toSqliteDatetime(windowStart),
|
|
64
|
-
end_time: toSqliteDatetime(windowEnd),
|
|
65
|
-
});
|
|
66
|
-
windowStart = windowEnd;
|
|
67
|
-
}
|
|
44
|
+
const anchor = parseDate(lastLog.created_at);
|
|
45
|
+
createForwardWindow(this.db, anchor, undefined, providerId);
|
|
68
46
|
}
|
|
69
47
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
48
|
+
/** 创建 5h 前向窗口:窗口 = [start, start + 5h],start 向下取整到分钟 */
|
|
49
|
+
function createForwardWindow(db, anchor, routerKeyId, providerId) {
|
|
50
|
+
const start = new Date(Math.floor(anchor.getTime() / MS_PER_MINUTE) * MS_PER_MINUTE);
|
|
51
|
+
const end = new Date(start.getTime() + WINDOW_DURATION_MS);
|
|
52
|
+
insertWindow(db, {
|
|
53
|
+
id: randomUUID(),
|
|
54
|
+
router_key_id: routerKeyId ?? null,
|
|
55
|
+
provider_id: providerId ?? null,
|
|
56
|
+
start_time: toSqliteDatetime(start),
|
|
57
|
+
end_time: toSqliteDatetime(end),
|
|
58
|
+
});
|
|
74
59
|
}
|
|
@@ -66,6 +66,8 @@ export function convertMessagesOA2Ant(messages) {
|
|
|
66
66
|
// tool_calls → tool_use blocks
|
|
67
67
|
if (msg.tool_calls) {
|
|
68
68
|
for (const tc of msg.tool_calls) {
|
|
69
|
+
if (!tc.function)
|
|
70
|
+
continue;
|
|
69
71
|
const input = parseToolArguments(tc.function.arguments);
|
|
70
72
|
blocks.push({ type: "tool_use", id: sanitizeToolUseId(tc.id), name: tc.function.name, input });
|
|
71
73
|
}
|
|
@@ -160,10 +162,28 @@ export function convertMessagesAnt2OA(system, messages) {
|
|
|
160
162
|
if (!content?.length)
|
|
161
163
|
continue;
|
|
162
164
|
const textParts = content.filter((b) => b.type === "text");
|
|
165
|
+
const imageParts = content.filter((b) => b.type === "image");
|
|
163
166
|
const toolResults = content.filter((b) => b.type === "tool_result");
|
|
164
|
-
|
|
167
|
+
// 仅有 text → 简单字符串 content
|
|
168
|
+
if (textParts.length > 0 && imageParts.length === 0) {
|
|
165
169
|
result.push({ role: "user", content: textParts.map(b => b.text).join("") });
|
|
166
170
|
}
|
|
171
|
+
else if (textParts.length > 0 || imageParts.length > 0) {
|
|
172
|
+
// 有 image 或混合 → content part 数组
|
|
173
|
+
const parts = [];
|
|
174
|
+
for (const tb of textParts) {
|
|
175
|
+
parts.push({ type: "text", text: tb.text });
|
|
176
|
+
}
|
|
177
|
+
for (const ib of imageParts) {
|
|
178
|
+
if (ib.source.type === "base64" && ib.source.data) {
|
|
179
|
+
parts.push({ type: "image_url", image_url: { url: `data:${ib.source.media_type};base64,${ib.source.data}` } });
|
|
180
|
+
}
|
|
181
|
+
else if (ib.source.type === "url" && ib.source.url) {
|
|
182
|
+
parts.push({ type: "image_url", image_url: { url: ib.source.url } });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
result.push({ role: "user", content: parts });
|
|
186
|
+
}
|
|
167
187
|
for (const tr of toolResults) {
|
|
168
188
|
let toolCallId = tr.tool_use_id;
|
|
169
189
|
// 空 tool_use_id → 按顺序配对到预生成的 UUID
|
|
@@ -198,7 +218,7 @@ export function convertMessagesAnt2OA(system, messages) {
|
|
|
198
218
|
oaiMsg.tool_calls = toolBlocks.map((b, i) => ({
|
|
199
219
|
id: b.id || (idMap ? idMap.get(i) || randomUUID() : randomUUID()),
|
|
200
220
|
type: "function",
|
|
201
|
-
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
|
|
221
|
+
function: { name: b.name ?? "unknown", arguments: JSON.stringify(b.input ?? {}) },
|
|
202
222
|
}));
|
|
203
223
|
}
|
|
204
224
|
if (oaiMsg.content || oaiMsg.tool_calls) {
|