llm-simple-router 0.6.3 → 0.6.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/dist/config.d.ts +2 -0
- package/dist/config.js +5 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/index.js +14 -0
- package/dist/metrics/sse-metrics-transform.d.ts +11 -0
- package/dist/metrics/sse-metrics-transform.js +47 -1
- package/dist/proxy/anthropic.d.ts +1 -0
- package/dist/proxy/anthropic.js +2 -2
- package/dist/proxy/loop-prevention/detectors/detector.d.ts +10 -0
- package/dist/proxy/loop-prevention/detectors/detector.js +1 -0
- package/dist/proxy/loop-prevention/detectors/ngram-detector.d.ts +15 -0
- package/dist/proxy/loop-prevention/detectors/ngram-detector.js +65 -0
- package/dist/proxy/loop-prevention/session-tracker.d.ts +14 -0
- package/dist/proxy/loop-prevention/session-tracker.js +67 -0
- package/dist/proxy/loop-prevention/stream-loop-guard.d.ts +12 -0
- package/dist/proxy/loop-prevention/stream-loop-guard.js +28 -0
- package/dist/proxy/loop-prevention/tool-loop-guard.d.ts +13 -0
- package/dist/proxy/loop-prevention/tool-loop-guard.js +63 -0
- package/dist/proxy/loop-prevention/types.d.ts +38 -0
- package/dist/proxy/loop-prevention/types.js +18 -0
- package/dist/proxy/openai.d.ts +1 -0
- package/dist/proxy/openai.js +2 -2
- package/dist/proxy/patch/deepseek/index.d.ts +1 -1
- package/dist/proxy/patch/deepseek/index.js +3 -3
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +3 -4
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +23 -30
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +8 -4
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +57 -12
- package/dist/proxy/patch/index.d.ts +12 -3
- package/dist/proxy/patch/index.js +13 -4
- package/dist/proxy/patch/router-cleanup.d.ts +13 -0
- package/dist/proxy/patch/router-cleanup.js +87 -0
- package/dist/proxy/proxy-handler.d.ts +1 -0
- package/dist/proxy/proxy-handler.js +88 -2
- package/dist/proxy/stream-proxy.d.ts +2 -1
- package/dist/proxy/stream-proxy.js +11 -3
- package/dist/proxy/transport-fn.d.ts +2 -0
- package/dist/proxy/transport-fn.js +19 -1
- package/frontend-dist/assets/{CardContent-BtAcFNMy.js → CardContent-CpiBn1Oc.js} +1 -1
- package/frontend-dist/assets/{CardTitle-Bmwf1S5Y.js → CardTitle-dwtgd_nl.js} +1 -1
- package/frontend-dist/assets/{CascadingModelSelect-CicfrqcY.js → CascadingModelSelect-CxEXwaeM.js} +1 -1
- package/frontend-dist/assets/{Checkbox-B1o39YuC.js → Checkbox-D2U4I-pO.js} +1 -1
- package/frontend-dist/assets/{CollapsibleTrigger-2jySTCeh.js → CollapsibleTrigger-B2AdbZBh.js} +1 -1
- package/frontend-dist/assets/{Collection-ChUVejsh.js → Collection-BJZSFJsF.js} +1 -1
- package/frontend-dist/assets/{Dashboard-DkJauxYu.js → Dashboard-D3cDhJNh.js} +1 -1
- package/frontend-dist/assets/{DialogTitle-D0erB-Fr.js → DialogTitle-BTuQdRm1.js} +1 -1
- package/frontend-dist/assets/{Input-BDbKynVD.js → Input-BYULYPCe.js} +1 -1
- package/frontend-dist/assets/{Label-CrHq5hrg.js → Label-sImW5XUw.js} +1 -1
- package/frontend-dist/assets/{Login-D2YdqYnu.js → Login-B0kGGZFi.js} +1 -1
- package/frontend-dist/assets/{Logs-DgeOPIkd.js → Logs-BpAeeJRi.js} +1 -1
- package/frontend-dist/assets/{ModelMappings-De_UjiND.js → ModelMappings-CsHLYqQB.js} +1 -1
- package/frontend-dist/assets/{Monitor-BgRMReMF.js → Monitor-BmMFWFJg.js} +1 -1
- package/frontend-dist/assets/{PopoverTrigger-BVsxIE2L.js → PopoverTrigger-CIN3yOIw.js} +1 -1
- package/frontend-dist/assets/{PopperContent-B23SzU9H.js → PopperContent-BoOYHCag.js} +1 -1
- package/frontend-dist/assets/{Providers-DQypvsEg.js → Providers-DrepCc4A.js} +1 -1
- package/frontend-dist/assets/{ProxyEnhancement-Cijb2FID.js → ProxyEnhancement-dK_mOQ3m.js} +1 -1
- package/frontend-dist/assets/{RetryRules-CSseSPoO.js → RetryRules-Ce5HfNcc.js} +1 -1
- package/frontend-dist/assets/{RouterKeys-ccwqoMCX.js → RouterKeys-CeSGvjll.js} +1 -1
- package/frontend-dist/assets/{RovingFocusItem-rwA4uA9N.js → RovingFocusItem-gAYs0l8Z.js} +1 -1
- package/frontend-dist/assets/{Schedules-8YYNjLNo.js → Schedules-DphkPAWD.js} +1 -1
- package/frontend-dist/assets/{SelectValue-TvIOOalu.js → SelectValue-Cbbd2Xbm.js} +1 -1
- package/frontend-dist/assets/{Settings-D1WDm5lQ.js → Settings-DIP7VawX.js} +1 -1
- package/frontend-dist/assets/{Setup-Bw-RIF9G.js → Setup-_d_M-Qi6.js} +1 -1
- package/frontend-dist/assets/{Switch-D9wFEsMF.js → Switch-BmmYsqAx.js} +1 -1
- package/frontend-dist/assets/{TableHeader-HOR173Xk.js → TableHeader-C1mpCsyo.js} +1 -1
- package/frontend-dist/assets/{TabsTrigger-BOsmgFYE.js → TabsTrigger-kN1usMvC.js} +1 -1
- package/frontend-dist/assets/{Teleport-BGbwtNTD.js → Teleport-CRn-gy0B.js} +1 -1
- package/frontend-dist/assets/{TooltipTrigger-DPzNY2Sp.js → TooltipTrigger-rlKo7E3A.js} +1 -1
- package/frontend-dist/assets/UnifiedRequestDialog-C-Ui-fav.css +1 -0
- package/frontend-dist/assets/UnifiedRequestDialog-C7pEDa9D.js +4 -0
- package/frontend-dist/assets/{VisuallyHidden-ChauvWtH.js → VisuallyHidden-CQvCw9gB.js} +1 -1
- package/frontend-dist/assets/{VisuallyHiddenInput-BcDuL0V8.js → VisuallyHiddenInput-B-DnnaWN.js} +1 -1
- package/frontend-dist/assets/{alert-dialog-DgtxmV7t.js → alert-dialog-M3PhwD75.js} +1 -1
- package/frontend-dist/assets/arrow-down-BHq-drH-.js +1 -0
- package/frontend-dist/assets/{badge-NUBqZBxu.js → badge-7WIbGMsE.js} +1 -1
- package/frontend-dist/assets/{button-BLX8zWc1.js → button-q9xTxPJh.js} +2 -2
- package/frontend-dist/assets/check-CccuM1Sj.js +1 -0
- package/frontend-dist/assets/{copy-BMWzukd1.js → copy-C1a8OrYP.js} +1 -1
- package/frontend-dist/assets/{dialog-Dsvgfiw-.js → dialog-B6RnMgGx.js} +1 -1
- package/frontend-dist/assets/{file-text-CqJ33eWr.js → file-text-CsuQUjXR.js} +1 -1
- package/frontend-dist/assets/index-Ce7hFHTt.css +1 -0
- package/frontend-dist/assets/{index-C7LWG0FU.js → index-Dh7qL0Qt.js} +1 -1
- package/frontend-dist/assets/{lib-DQotd1d8.js → lib-D1G8Xa05.js} +1 -1
- package/frontend-dist/assets/loader-circle-CVhxR0Tt.js +1 -0
- package/frontend-dist/assets/{useClipboard-BDAhyrgL.js → useClipboard-CBONMfzU.js} +1 -1
- package/frontend-dist/assets/{useFocusGuards-CHAbXhQp.js → useFocusGuards-DwFX8o1a.js} +1 -1
- package/frontend-dist/assets/useFormControl-DC32gW1A.js +1 -0
- package/frontend-dist/assets/{useLogRetention-X-CkHhJ7.js → useLogRetention-C2IbjXjr.js} +1 -1
- package/frontend-dist/assets/useNonce-t7XaR4bX.js +1 -0
- package/frontend-dist/assets/x-Bq5KcbWI.js +1 -0
- package/frontend-dist/index.html +20 -20
- package/package.json +1 -1
- package/frontend-dist/assets/UnifiedRequestDialog-BjEigSaR.css +0 -1
- package/frontend-dist/assets/UnifiedRequestDialog-CevmD2P2.js +0 -3
- package/frontend-dist/assets/arrow-down-BuK6B6yc.js +0 -1
- package/frontend-dist/assets/check-CsZv9cnK.js +0 -1
- package/frontend-dist/assets/index-_Icfkt3I.css +0 -1
- package/frontend-dist/assets/loader-circle-CnEL8ILi.js +0 -1
- package/frontend-dist/assets/useFormControl-CCVkIi3o.js +0 -1
- package/frontend-dist/assets/useNonce-DyF1ycZV.js +0 -1
- package/frontend-dist/assets/x-DMAovOe-.js +0 -1
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LoopPreventionConfig } from "./proxy/loop-prevention/types.js";
|
|
1
2
|
export interface Config {
|
|
2
3
|
PORT: number;
|
|
3
4
|
DB_PATH: string;
|
|
@@ -5,6 +6,7 @@ export interface Config {
|
|
|
5
6
|
TZ: string;
|
|
6
7
|
STREAM_TIMEOUT_MS: number;
|
|
7
8
|
RETRY_BASE_DELAY_MS: number;
|
|
9
|
+
LOOP_PREVENTION: LoopPreventionConfig;
|
|
8
10
|
}
|
|
9
11
|
export declare function resetConfig(): void;
|
|
10
12
|
export declare function getBaseConfig(): Config;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { DEFAULT_LOOP_PREVENTION_CONFIG } from "./proxy/loop-prevention/types.js";
|
|
3
4
|
let cachedConfig = null;
|
|
4
5
|
function getDefaultDbPath() {
|
|
5
6
|
if (process.env.DB_PATH)
|
|
@@ -19,6 +20,10 @@ export function getBaseConfig() {
|
|
|
19
20
|
TZ: process.env.TZ || "Asia/Shanghai",
|
|
20
21
|
STREAM_TIMEOUT_MS: parseInt(process.env.STREAM_TIMEOUT_MS || "3000000", 10),
|
|
21
22
|
RETRY_BASE_DELAY_MS: parseInt(process.env.RETRY_BASE_DELAY_MS || "1000", 10),
|
|
23
|
+
LOOP_PREVENTION: {
|
|
24
|
+
...DEFAULT_LOOP_PREVENTION_CONFIG,
|
|
25
|
+
...(process.env.LOOP_PREVENTION ? JSON.parse(process.env.LOOP_PREVENTION) : {}),
|
|
26
|
+
},
|
|
22
27
|
};
|
|
23
28
|
return cachedConfig;
|
|
24
29
|
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const HTTP_NOT_FOUND = 404;
|
|
|
5
5
|
export declare const HTTP_CONFLICT = 409;
|
|
6
6
|
export declare const HTTP_INTERNAL_ERROR = 500;
|
|
7
7
|
export declare const HTTP_BAD_GATEWAY = 502;
|
|
8
|
+
export declare const HTTP_UNPROCESSABLE_ENTITY = 422;
|
|
8
9
|
export declare const HTTP_SERVICE_UNAVAILABLE = 503;
|
|
9
10
|
export declare const PROXY_API_TYPES: Record<string, string>;
|
|
10
11
|
export declare function getProxyApiType(url: string): string | null;
|
package/dist/constants.js
CHANGED
|
@@ -6,6 +6,7 @@ export const HTTP_NOT_FOUND = 404;
|
|
|
6
6
|
export const HTTP_CONFLICT = 409;
|
|
7
7
|
export const HTTP_INTERNAL_ERROR = 500;
|
|
8
8
|
export const HTTP_BAD_GATEWAY = 502;
|
|
9
|
+
export const HTTP_UNPROCESSABLE_ENTITY = 422;
|
|
9
10
|
export const HTTP_SERVICE_UNAVAILABLE = 503;
|
|
10
11
|
// api_type 路由映射:proxy path → api type,用于全局 hook/errorHandler 中识别代理请求
|
|
11
12
|
export const PROXY_API_TYPES = {
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,9 @@ import { ProviderSemaphoreManager } from "./proxy/semaphore.js";
|
|
|
23
23
|
import { RequestTracker } from "./monitor/request-tracker.js";
|
|
24
24
|
import { modelState } from "./proxy/model-state.js";
|
|
25
25
|
import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
|
|
26
|
+
import { SessionTracker } from "./proxy/loop-prevention/session-tracker.js";
|
|
27
|
+
import { DEFAULT_LOOP_PREVENTION_CONFIG } from "./proxy/loop-prevention/types.js";
|
|
28
|
+
import { setLoopPreventionConfig } from "./proxy/transport-fn.js";
|
|
26
29
|
import { scheduleLogCleanup } from "./db/log-cleaner.js";
|
|
27
30
|
import { scheduleDbSizeMonitor } from "./db/db-size-monitor.js";
|
|
28
31
|
import { startUpgradeChecker, stopUpgradeChecker } from "./admin/upgrade.js";
|
|
@@ -156,6 +159,14 @@ export async function buildApp(options) {
|
|
|
156
159
|
// 5h 用量窗口追踪器,启动时自动补齐缺失窗口
|
|
157
160
|
const usageWindowTracker = new UsageWindowTracker(db);
|
|
158
161
|
usageWindowTracker.reconcileOnStartup();
|
|
162
|
+
const loopConfig = config.LOOP_PREVENTION ?? DEFAULT_LOOP_PREVENTION_CONFIG;
|
|
163
|
+
const sessionTracker = new SessionTracker(loopConfig.sessionTracker);
|
|
164
|
+
// buildApp() 默认启用循环预防。
|
|
165
|
+
// 用户可通过环境变量 LOOP_PREVENTION='{"enabled":false}' 关闭。
|
|
166
|
+
// 直接注册插件的测试不使用 buildApp(),不受此影响。
|
|
167
|
+
setLoopPreventionConfig(process.env.LOOP_PREVENTION
|
|
168
|
+
? loopConfig
|
|
169
|
+
: { ...loopConfig, enabled: true });
|
|
159
170
|
// 从 DB 读取已有 provider 的并发配置,初始化信号量管理器和 tracker
|
|
160
171
|
const allProviders = getAllProviders(db);
|
|
161
172
|
for (const p of allProviders) {
|
|
@@ -182,6 +193,7 @@ export async function buildApp(options) {
|
|
|
182
193
|
semaphoreManager,
|
|
183
194
|
tracker,
|
|
184
195
|
usageWindowTracker,
|
|
196
|
+
sessionTracker,
|
|
185
197
|
});
|
|
186
198
|
app.register(anthropicProxy, {
|
|
187
199
|
db,
|
|
@@ -191,6 +203,7 @@ export async function buildApp(options) {
|
|
|
191
203
|
semaphoreManager,
|
|
192
204
|
tracker,
|
|
193
205
|
usageWindowTracker,
|
|
206
|
+
sessionTracker,
|
|
194
207
|
});
|
|
195
208
|
app.register(adminRoutes, { db, matcher, tracker, semaphoreManager });
|
|
196
209
|
// 前端静态文件服务(生产环境)
|
|
@@ -229,6 +242,7 @@ export async function buildApp(options) {
|
|
|
229
242
|
logCleanup.stop();
|
|
230
243
|
dbSizeMonitor.stop();
|
|
231
244
|
tracker.stopPushInterval();
|
|
245
|
+
sessionTracker.stop();
|
|
232
246
|
await app.close();
|
|
233
247
|
db.close();
|
|
234
248
|
},
|
|
@@ -6,6 +6,8 @@ export interface MetricsTransformOptions {
|
|
|
6
6
|
onMetrics?: (metrics: MetricsResult) => void;
|
|
7
7
|
/** 每收到一个 SSE data 行时触发,传入原始文本行 */
|
|
8
8
|
onChunk?: (rawLine: string) => void;
|
|
9
|
+
/** 每次提取到内容文本(thinking / text / tool JSON delta)时触发,用于流式循环检测 */
|
|
10
|
+
onContentDelta?: (text: string) => void;
|
|
9
11
|
/** 回调节流间隔(毫秒),默认 5000 */
|
|
10
12
|
throttleMs?: number;
|
|
11
13
|
}
|
|
@@ -18,8 +20,10 @@ export interface MetricsTransformOptions {
|
|
|
18
20
|
export declare class SSEMetricsTransform extends Transform {
|
|
19
21
|
private parser;
|
|
20
22
|
private extractor;
|
|
23
|
+
private readonly apiType;
|
|
21
24
|
private onMetrics?;
|
|
22
25
|
private onChunk?;
|
|
26
|
+
private onContentDelta?;
|
|
23
27
|
private throttleMs;
|
|
24
28
|
private lastCallbackTime;
|
|
25
29
|
private flushed;
|
|
@@ -27,6 +31,13 @@ export declare class SSEMetricsTransform extends Transform {
|
|
|
27
31
|
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
|
|
28
32
|
_flush(callback: TransformCallback): void;
|
|
29
33
|
getExtractor(): MetricsExtractor;
|
|
34
|
+
/** 提取 SSE 事件中的内容文本,触发 onContentDelta 回调 */
|
|
35
|
+
private emitContentDelta;
|
|
36
|
+
/**
|
|
37
|
+
* 从 SSE data 字段中提取实际内容文本(thinking / text / tool JSON delta)。
|
|
38
|
+
* 忽略框架事件(message_start、ping 等),仅返回模型输出的内容。
|
|
39
|
+
*/
|
|
40
|
+
private extractContentDelta;
|
|
30
41
|
/** 节流逻辑:首次或距上次回调超过 throttleMs 时触发 */
|
|
31
42
|
private emitMetricsIfReady;
|
|
32
43
|
}
|
|
@@ -11,17 +11,21 @@ const DEFAULT_THROTTLE_MS = 5000;
|
|
|
11
11
|
export class SSEMetricsTransform extends Transform {
|
|
12
12
|
parser;
|
|
13
13
|
extractor;
|
|
14
|
+
apiType;
|
|
14
15
|
onMetrics;
|
|
15
16
|
onChunk;
|
|
17
|
+
onContentDelta;
|
|
16
18
|
throttleMs;
|
|
17
19
|
lastCallbackTime = 0;
|
|
18
20
|
flushed = false;
|
|
19
21
|
constructor(apiType, requestStartTime, options) {
|
|
20
22
|
super();
|
|
23
|
+
this.apiType = apiType;
|
|
21
24
|
this.parser = new SSEParser();
|
|
22
25
|
this.extractor = new MetricsExtractor(apiType, requestStartTime);
|
|
23
26
|
this.onMetrics = options?.onMetrics;
|
|
24
27
|
this.onChunk = options?.onChunk;
|
|
28
|
+
this.onContentDelta = options?.onContentDelta;
|
|
25
29
|
this.throttleMs = options?.throttleMs ?? DEFAULT_THROTTLE_MS;
|
|
26
30
|
}
|
|
27
31
|
_transform(chunk, _encoding, callback) {
|
|
@@ -29,7 +33,7 @@ export class SSEMetricsTransform extends Transform {
|
|
|
29
33
|
const events = this.parser.feed(text);
|
|
30
34
|
for (const event of events) {
|
|
31
35
|
this.extractor.processEvent(event);
|
|
32
|
-
|
|
36
|
+
this.emitContentDelta(event);
|
|
33
37
|
if (event.data != null && this.onChunk) {
|
|
34
38
|
this.onChunk(`data: ${event.data}`);
|
|
35
39
|
}
|
|
@@ -41,6 +45,7 @@ export class SSEMetricsTransform extends Transform {
|
|
|
41
45
|
const events = this.parser.flush();
|
|
42
46
|
for (const event of events) {
|
|
43
47
|
this.extractor.processEvent(event);
|
|
48
|
+
this.emitContentDelta(event);
|
|
44
49
|
}
|
|
45
50
|
// flush 无条件推送最终状态,确保消费者能拿到完整指标
|
|
46
51
|
if (this.onMetrics && !this.flushed) {
|
|
@@ -53,6 +58,47 @@ export class SSEMetricsTransform extends Transform {
|
|
|
53
58
|
getExtractor() {
|
|
54
59
|
return this.extractor;
|
|
55
60
|
}
|
|
61
|
+
/** 提取 SSE 事件中的内容文本,触发 onContentDelta 回调 */
|
|
62
|
+
emitContentDelta(event) {
|
|
63
|
+
if (!this.onContentDelta || !event.data)
|
|
64
|
+
return;
|
|
65
|
+
const delta = this.extractContentDelta(event.data);
|
|
66
|
+
if (delta)
|
|
67
|
+
this.onContentDelta(delta);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 从 SSE data 字段中提取实际内容文本(thinking / text / tool JSON delta)。
|
|
71
|
+
* 忽略框架事件(message_start、ping 等),仅返回模型输出的内容。
|
|
72
|
+
*/
|
|
73
|
+
extractContentDelta(data) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(data);
|
|
76
|
+
if (this.apiType === "anthropic") {
|
|
77
|
+
if (parsed.type !== "content_block_delta" || typeof parsed.delta !== "object" || !parsed.delta)
|
|
78
|
+
return undefined;
|
|
79
|
+
const delta = parsed.delta;
|
|
80
|
+
if (delta.type === "thinking_delta" && typeof delta.thinking === "string")
|
|
81
|
+
return delta.thinking;
|
|
82
|
+
if (delta.type === "text_delta" && typeof delta.text === "string")
|
|
83
|
+
return delta.text;
|
|
84
|
+
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string")
|
|
85
|
+
return delta.partial_json;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const choices = parsed.choices;
|
|
89
|
+
if (!Array.isArray(choices) || choices.length === 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
const first = choices[0];
|
|
92
|
+
if (typeof first.delta !== "object" || !first.delta)
|
|
93
|
+
return undefined;
|
|
94
|
+
const delta = first.delta;
|
|
95
|
+
if (typeof delta.content === "string")
|
|
96
|
+
return delta.content;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch { /* 非 JSON 数据行,跳过 */ }
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
56
102
|
/** 节流逻辑:首次或距上次回调超过 throttleMs 时触发 */
|
|
57
103
|
emitMetricsIfReady() {
|
|
58
104
|
if (!this.onMetrics)
|
|
@@ -12,5 +12,6 @@ export interface AnthropicProxyOptions {
|
|
|
12
12
|
semaphoreManager?: ProviderSemaphoreManager;
|
|
13
13
|
tracker?: RequestTracker;
|
|
14
14
|
usageWindowTracker?: UsageWindowTracker;
|
|
15
|
+
sessionTracker?: import("./loop-prevention/session-tracker.js").SessionTracker;
|
|
15
16
|
}
|
|
16
17
|
export declare const anthropicProxy: FastifyPluginCallback<AnthropicProxyOptions>;
|
package/dist/proxy/anthropic.js
CHANGED
|
@@ -18,7 +18,7 @@ const ANTHROPIC_ERROR_TYPE = {
|
|
|
18
18
|
};
|
|
19
19
|
const anthropicErrors = createErrorFormatter((kind, message) => ({ type: "error", error: { type: ANTHROPIC_ERROR_TYPE[kind], message } }));
|
|
20
20
|
const anthropicProxyRaw = (app, opts, done) => {
|
|
21
|
-
const { db, streamTimeoutMs, retryBaseDelayMs, matcher, semaphoreManager, tracker, usageWindowTracker } = opts;
|
|
21
|
+
const { db, streamTimeoutMs, retryBaseDelayMs, matcher, semaphoreManager, tracker, usageWindowTracker, sessionTracker } = opts;
|
|
22
22
|
const orchestrator = createOrchestrator(semaphoreManager, tracker);
|
|
23
23
|
app.post(MESSAGES_PATH, async (request, reply) => {
|
|
24
24
|
if (!orchestrator) {
|
|
@@ -34,7 +34,7 @@ const anthropicProxyRaw = (app, opts, done) => {
|
|
|
34
34
|
const e = anthropicErrors.providerUnavailable();
|
|
35
35
|
return reply.code(e.statusCode).send(e.body);
|
|
36
36
|
}
|
|
37
|
-
const deps = { db, streamTimeoutMs, retryBaseDelayMs, matcher, tracker, orchestrator, usageWindowTracker };
|
|
37
|
+
const deps = { db, streamTimeoutMs, retryBaseDelayMs, matcher, tracker, orchestrator, usageWindowTracker, sessionTracker };
|
|
38
38
|
return handleProxyRequest(request, reply, "anthropic", MESSAGES_PATH, anthropicErrors, deps);
|
|
39
39
|
});
|
|
40
40
|
done();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LoopDetector, LoopDetectorStatus } from "./detector.js";
|
|
2
|
+
import type { NGramDetectorConfig } from "../types.js";
|
|
3
|
+
export declare class NGramLoopDetector implements LoopDetector {
|
|
4
|
+
private readonly config;
|
|
5
|
+
private window;
|
|
6
|
+
private ngramCounts;
|
|
7
|
+
private detected;
|
|
8
|
+
private maxPeakCount;
|
|
9
|
+
private peakNgram;
|
|
10
|
+
private totalCharsProcessed;
|
|
11
|
+
constructor(config: NGramDetectorConfig);
|
|
12
|
+
feed(text: string): boolean;
|
|
13
|
+
reset(): void;
|
|
14
|
+
getStatus(): LoopDetectorStatus;
|
|
15
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class NGramLoopDetector {
|
|
2
|
+
config;
|
|
3
|
+
window = [];
|
|
4
|
+
ngramCounts = new Map();
|
|
5
|
+
detected = false;
|
|
6
|
+
maxPeakCount = 0;
|
|
7
|
+
peakNgram = "";
|
|
8
|
+
totalCharsProcessed = 0;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
feed(text) {
|
|
13
|
+
if (this.detected)
|
|
14
|
+
return true;
|
|
15
|
+
for (const char of text) {
|
|
16
|
+
this.window.push(char);
|
|
17
|
+
this.totalCharsProcessed++;
|
|
18
|
+
if (this.window.length >= this.config.n) {
|
|
19
|
+
const ngram = this.window.slice(-this.config.n).join("");
|
|
20
|
+
const count = (this.ngramCounts.get(ngram) ?? 0) + 1;
|
|
21
|
+
this.ngramCounts.set(ngram, count);
|
|
22
|
+
if (count > this.maxPeakCount) {
|
|
23
|
+
this.maxPeakCount = count;
|
|
24
|
+
this.peakNgram = ngram;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (this.window.length > this.config.windowSize) {
|
|
28
|
+
const leaving = this.window.slice(0, this.config.n).join("");
|
|
29
|
+
this.window.shift();
|
|
30
|
+
if (this.window.length >= this.config.n) {
|
|
31
|
+
const c = this.ngramCounts.get(leaving);
|
|
32
|
+
if (c && c > 1)
|
|
33
|
+
this.ngramCounts.set(leaving, c - 1);
|
|
34
|
+
else
|
|
35
|
+
this.ngramCounts.delete(leaving);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (this.maxPeakCount >= this.config.repeatThreshold) {
|
|
40
|
+
this.detected = true;
|
|
41
|
+
}
|
|
42
|
+
return this.detected;
|
|
43
|
+
}
|
|
44
|
+
reset() {
|
|
45
|
+
this.window = [];
|
|
46
|
+
this.ngramCounts.clear();
|
|
47
|
+
this.detected = false;
|
|
48
|
+
this.maxPeakCount = 0;
|
|
49
|
+
this.peakNgram = "";
|
|
50
|
+
this.totalCharsProcessed = 0;
|
|
51
|
+
}
|
|
52
|
+
getStatus() {
|
|
53
|
+
return {
|
|
54
|
+
detected: this.detected,
|
|
55
|
+
reason: this.detected ? `NGram '${this.peakNgram}' repeated ${this.maxPeakCount} times` : undefined,
|
|
56
|
+
details: {
|
|
57
|
+
peakNgram: this.peakNgram,
|
|
58
|
+
peakCount: this.maxPeakCount,
|
|
59
|
+
threshold: this.config.repeatThreshold,
|
|
60
|
+
totalChars: this.totalCharsProcessed,
|
|
61
|
+
windowSize: this.window.length,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ToolCallRecord, SessionTrackerConfig } from "./types.js";
|
|
2
|
+
export declare class SessionTracker {
|
|
3
|
+
private readonly config;
|
|
4
|
+
private sessions;
|
|
5
|
+
private cleanupTimer;
|
|
6
|
+
constructor(config: SessionTrackerConfig);
|
|
7
|
+
stop(): void;
|
|
8
|
+
recordAndGetHistory(sessionKey: string, record: ToolCallRecord): ToolCallRecord[];
|
|
9
|
+
incrementLoopCount(sessionKey: string): number;
|
|
10
|
+
resetLoopCount(sessionKey: string): void;
|
|
11
|
+
getLoopCount(sessionKey: string): number;
|
|
12
|
+
private cleanup;
|
|
13
|
+
getActiveSessionCount(): number;
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export class SessionTracker {
|
|
2
|
+
config;
|
|
3
|
+
sessions = new Map();
|
|
4
|
+
cleanupTimer = null;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
if (config.cleanupIntervalMs > 0) {
|
|
8
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), config.cleanupIntervalMs);
|
|
9
|
+
this.cleanupTimer.unref();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
stop() {
|
|
13
|
+
if (this.cleanupTimer) {
|
|
14
|
+
clearInterval(this.cleanupTimer);
|
|
15
|
+
this.cleanupTimer = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
recordAndGetHistory(sessionKey, record) {
|
|
19
|
+
let session = this.sessions.get(sessionKey);
|
|
20
|
+
if (!session) {
|
|
21
|
+
session = { lastAccessTime: Date.now(), toolCalls: [], loopDetectedCount: 0 };
|
|
22
|
+
this.sessions.set(sessionKey, session);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// If TTL expired, clear tool call history (session stays in active map)
|
|
26
|
+
if (Date.now() - session.lastAccessTime > this.config.sessionTtlMs) {
|
|
27
|
+
session.toolCalls = [];
|
|
28
|
+
}
|
|
29
|
+
session.lastAccessTime = Date.now();
|
|
30
|
+
}
|
|
31
|
+
// 同一 tool_use 不重复记录(模型切换、重试等场景会复用历史)
|
|
32
|
+
if (record.toolUseId && session.toolCalls.some(r => r.toolUseId === record.toolUseId)) {
|
|
33
|
+
return session.toolCalls;
|
|
34
|
+
}
|
|
35
|
+
session.toolCalls.push(record);
|
|
36
|
+
if (session.toolCalls.length > this.config.maxToolCallRecords) {
|
|
37
|
+
session.toolCalls = session.toolCalls.slice(-this.config.maxToolCallRecords);
|
|
38
|
+
}
|
|
39
|
+
return session.toolCalls;
|
|
40
|
+
}
|
|
41
|
+
incrementLoopCount(sessionKey) {
|
|
42
|
+
const session = this.sessions.get(sessionKey);
|
|
43
|
+
if (!session)
|
|
44
|
+
return 0;
|
|
45
|
+
session.loopDetectedCount++;
|
|
46
|
+
return session.loopDetectedCount;
|
|
47
|
+
}
|
|
48
|
+
resetLoopCount(sessionKey) {
|
|
49
|
+
const session = this.sessions.get(sessionKey);
|
|
50
|
+
if (session)
|
|
51
|
+
session.loopDetectedCount = 0;
|
|
52
|
+
}
|
|
53
|
+
getLoopCount(sessionKey) {
|
|
54
|
+
return this.sessions.get(sessionKey)?.loopDetectedCount ?? 0;
|
|
55
|
+
}
|
|
56
|
+
cleanup() {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [key, session] of this.sessions) {
|
|
59
|
+
if (now - session.lastAccessTime > this.config.sessionTtlMs) {
|
|
60
|
+
this.sessions.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
getActiveSessionCount() {
|
|
65
|
+
return this.sessions.size;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LoopDetector } from "./detectors/detector.js";
|
|
2
|
+
import type { StreamLoopGuardConfig } from "./types.js";
|
|
3
|
+
export declare class StreamLoopGuard {
|
|
4
|
+
private readonly config;
|
|
5
|
+
private readonly detector;
|
|
6
|
+
private readonly onLoopDetected;
|
|
7
|
+
private triggered;
|
|
8
|
+
constructor(config: StreamLoopGuardConfig, detector: LoopDetector, onLoopDetected: (reason: string) => void);
|
|
9
|
+
feed(text: string): void;
|
|
10
|
+
isTriggered(): boolean;
|
|
11
|
+
reset(): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class StreamLoopGuard {
|
|
2
|
+
config;
|
|
3
|
+
detector;
|
|
4
|
+
onLoopDetected;
|
|
5
|
+
triggered = false;
|
|
6
|
+
constructor(config, detector, onLoopDetected) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.detector = detector;
|
|
9
|
+
this.onLoopDetected = onLoopDetected;
|
|
10
|
+
}
|
|
11
|
+
feed(text) {
|
|
12
|
+
if (this.triggered)
|
|
13
|
+
return;
|
|
14
|
+
if (!this.config.enabled)
|
|
15
|
+
return;
|
|
16
|
+
if (this.detector.feed(text)) {
|
|
17
|
+
this.triggered = true;
|
|
18
|
+
this.onLoopDetected(this.detector.getStatus().reason ?? "stream_content_loop");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
isTriggered() {
|
|
22
|
+
return this.triggered;
|
|
23
|
+
}
|
|
24
|
+
reset() {
|
|
25
|
+
this.triggered = false;
|
|
26
|
+
this.detector.reset();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ToolCallRecord, ToolLoopGuardConfig, LoopCheckResult } from "./types.js";
|
|
2
|
+
import { SessionTracker } from "./session-tracker.js";
|
|
3
|
+
export declare class ToolLoopGuard {
|
|
4
|
+
private readonly tracker;
|
|
5
|
+
private readonly config;
|
|
6
|
+
constructor(tracker: SessionTracker, config: ToolLoopGuardConfig);
|
|
7
|
+
/**
|
|
8
|
+
* 检查本次工具调用是否构成循环。
|
|
9
|
+
* 如果 sessionKey 不可用(无 sessionId),返回 detected: false。
|
|
10
|
+
*/
|
|
11
|
+
check(sessionKey: string | null, toolCall: ToolCallRecord | null): LoopCheckResult;
|
|
12
|
+
injectLoopBreakPrompt(body: Record<string, unknown>, apiType: "openai" | "anthropic", toolName: string): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/proxy/loop-prevention/tool-loop-guard.ts
|
|
2
|
+
import { NGramLoopDetector } from "./detectors/ngram-detector.js";
|
|
3
|
+
export class ToolLoopGuard {
|
|
4
|
+
tracker;
|
|
5
|
+
config;
|
|
6
|
+
constructor(tracker, config) {
|
|
7
|
+
this.tracker = tracker;
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 检查本次工具调用是否构成循环。
|
|
12
|
+
* 如果 sessionKey 不可用(无 sessionId),返回 detected: false。
|
|
13
|
+
*/
|
|
14
|
+
check(sessionKey, toolCall) {
|
|
15
|
+
if (!sessionKey || !toolCall)
|
|
16
|
+
return { detected: false };
|
|
17
|
+
if (!this.config.enabled)
|
|
18
|
+
return { detected: false };
|
|
19
|
+
const history = this.tracker.recordAndGetHistory(sessionKey, toolCall);
|
|
20
|
+
// 第一层:筛选同名的 tool_name 记录
|
|
21
|
+
// 未达阈值时不重置 loopCount,保留跨请求升级到层级 2/3 的可能性
|
|
22
|
+
const sameNameRecords = history.filter(r => r.toolName === toolCall.toolName);
|
|
23
|
+
if (sameNameRecords.length < this.config.minConsecutiveCount) {
|
|
24
|
+
return { detected: false };
|
|
25
|
+
}
|
|
26
|
+
// 第二层:N-gram 检测 input 参数文本
|
|
27
|
+
const detector = new NGramLoopDetector(this.config.detectorConfig);
|
|
28
|
+
for (const record of sameNameRecords) {
|
|
29
|
+
detector.feed(record.inputText);
|
|
30
|
+
}
|
|
31
|
+
if (detector.getStatus().detected) {
|
|
32
|
+
this.tracker.incrementLoopCount(sessionKey);
|
|
33
|
+
return { detected: true, reason: "tool_call_loop", history: sameNameRecords };
|
|
34
|
+
}
|
|
35
|
+
this.tracker.resetLoopCount(sessionKey);
|
|
36
|
+
return { detected: false };
|
|
37
|
+
}
|
|
38
|
+
injectLoopBreakPrompt(body, apiType, toolName) {
|
|
39
|
+
const prompt = `你正在重复调用同一个工具 "${toolName}"。` +
|
|
40
|
+
`这很可能陷入了一个循环。请仔细回顾对话历史,` +
|
|
41
|
+
`分析之前调用该工具的结果,停止重复调用,` +
|
|
42
|
+
`改用其他方式完成任务或直接告知用户你遇到的问题。`;
|
|
43
|
+
if (apiType === "anthropic") {
|
|
44
|
+
const system = body.system;
|
|
45
|
+
if (Array.isArray(system)) {
|
|
46
|
+
system.push({ type: "text", text: prompt });
|
|
47
|
+
}
|
|
48
|
+
else if (typeof system === "string") {
|
|
49
|
+
body.system = [{ type: "text", text: system }, { type: "text", text: prompt }];
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
body.system = [{ type: "text", text: prompt }];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// OpenAI: 在 messages 开头插入 system message
|
|
57
|
+
const messages = body.messages;
|
|
58
|
+
if (messages) {
|
|
59
|
+
messages.unshift({ role: "system", content: prompt });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface NGramDetectorConfig {
|
|
2
|
+
n: number;
|
|
3
|
+
windowSize: number;
|
|
4
|
+
repeatThreshold: number;
|
|
5
|
+
}
|
|
6
|
+
export interface StreamLoopGuardConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
detectorConfig: NGramDetectorConfig;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolLoopGuardConfig {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
minConsecutiveCount: number;
|
|
13
|
+
detectorConfig: NGramDetectorConfig;
|
|
14
|
+
}
|
|
15
|
+
export interface SessionTrackerConfig {
|
|
16
|
+
sessionTtlMs: number;
|
|
17
|
+
maxToolCallRecords: number;
|
|
18
|
+
cleanupIntervalMs: number;
|
|
19
|
+
}
|
|
20
|
+
export interface LoopPreventionConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
stream: StreamLoopGuardConfig;
|
|
23
|
+
toolCall: ToolLoopGuardConfig;
|
|
24
|
+
sessionTracker: SessionTrackerConfig;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_LOOP_PREVENTION_CONFIG: LoopPreventionConfig;
|
|
27
|
+
export interface ToolCallRecord {
|
|
28
|
+
toolName: string;
|
|
29
|
+
toolUseId?: string;
|
|
30
|
+
inputHash: string;
|
|
31
|
+
inputText: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
}
|
|
34
|
+
export interface LoopCheckResult {
|
|
35
|
+
detected: boolean;
|
|
36
|
+
reason?: "tool_call_loop" | "stream_content_loop";
|
|
37
|
+
history?: ToolCallRecord[];
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers -- DEFAULT 配置值本身就是语义化命名 */
|
|
2
|
+
export const DEFAULT_LOOP_PREVENTION_CONFIG = {
|
|
3
|
+
enabled: false,
|
|
4
|
+
stream: {
|
|
5
|
+
enabled: true,
|
|
6
|
+
detectorConfig: { n: 6, windowSize: 1000, repeatThreshold: 10 },
|
|
7
|
+
},
|
|
8
|
+
toolCall: {
|
|
9
|
+
enabled: true,
|
|
10
|
+
minConsecutiveCount: 3,
|
|
11
|
+
detectorConfig: { n: 6, windowSize: 500, repeatThreshold: 5 },
|
|
12
|
+
},
|
|
13
|
+
sessionTracker: {
|
|
14
|
+
sessionTtlMs: 30 * 60 * 1000,
|
|
15
|
+
maxToolCallRecords: 50,
|
|
16
|
+
cleanupIntervalMs: 5 * 60 * 1000,
|
|
17
|
+
},
|
|
18
|
+
};
|
package/dist/proxy/openai.d.ts
CHANGED
|
@@ -12,5 +12,6 @@ export interface OpenaiProxyOptions {
|
|
|
12
12
|
semaphoreManager?: ProviderSemaphoreManager;
|
|
13
13
|
tracker?: RequestTracker;
|
|
14
14
|
usageWindowTracker?: UsageWindowTracker;
|
|
15
|
+
sessionTracker?: import("./loop-prevention/session-tracker.js").SessionTracker;
|
|
15
16
|
}
|
|
16
17
|
export declare const openaiProxy: FastifyPluginCallback<OpenaiProxyOptions>;
|
package/dist/proxy/openai.js
CHANGED
|
@@ -24,7 +24,7 @@ function sendError(reply, e) {
|
|
|
24
24
|
return reply.code(e.statusCode).send(e.body);
|
|
25
25
|
}
|
|
26
26
|
const openaiProxyRaw = (app, opts, done) => {
|
|
27
|
-
const { db, streamTimeoutMs, retryBaseDelayMs, matcher, semaphoreManager, tracker, usageWindowTracker } = opts;
|
|
27
|
+
const { db, streamTimeoutMs, retryBaseDelayMs, matcher, semaphoreManager, tracker, usageWindowTracker, sessionTracker } = opts;
|
|
28
28
|
const orchestrator = createOrchestrator(semaphoreManager, tracker);
|
|
29
29
|
app.post(CHAT_COMPLETIONS_PATH, async (request, reply) => {
|
|
30
30
|
if (!orchestrator) {
|
|
@@ -39,7 +39,7 @@ const openaiProxyRaw = (app, opts, done) => {
|
|
|
39
39
|
});
|
|
40
40
|
return sendError(reply, openaiErrors.providerUnavailable());
|
|
41
41
|
}
|
|
42
|
-
const deps = { db, streamTimeoutMs, retryBaseDelayMs, matcher, tracker, orchestrator, usageWindowTracker };
|
|
42
|
+
const deps = { db, streamTimeoutMs, retryBaseDelayMs, matcher, tracker, orchestrator, usageWindowTracker, sessionTracker };
|
|
43
43
|
return handleProxyRequest(request, reply, "openai", CHAT_COMPLETIONS_PATH, openaiErrors, deps, {
|
|
44
44
|
beforeSendProxy: (body, isStream) => {
|
|
45
45
|
if (isStream && !body.stream_options) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { patchNonDeepSeekToolMessages } from "./patch-thinking-blocks.js";
|
|
2
2
|
import { patchOrphanToolResults } from "./patch-orphan-tool-results.js";
|
|
3
3
|
/**
|
|
4
4
|
* 按序执行所有 DeepSeek 特定补丁。
|
|
5
|
-
*
|
|
5
|
+
* 非 DeepSeek tool 消息补丁先执行(转换 tool_use/tool_result),
|
|
6
6
|
* tool_result 配对修复后执行。
|
|
7
7
|
*/
|
|
8
8
|
export function applyDeepSeekPatches(body) {
|
|
9
|
-
|
|
9
|
+
patchNonDeepSeekToolMessages(body);
|
|
10
10
|
patchOrphanToolResults(body);
|
|
11
11
|
}
|