llm-simple-router 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/recommended-retry-rules.json +1 -2
- package/dist/admin/proxy-enhancement.js +6 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/index.js +7 -0
- package/dist/metrics/sse-metrics-transform.d.ts +11 -0
- package/dist/metrics/sse-metrics-transform.js +47 -2
- package/dist/proxy/anthropic.d.ts +1 -0
- package/dist/proxy/anthropic.js +2 -2
- package/dist/proxy/enhancement/enhancement-handler.d.ts +2 -1
- package/dist/proxy/enhancement/enhancement-handler.js +2 -2
- package/dist/proxy/enhancement-config.d.ts +2 -0
- package/dist/proxy/enhancement-config.js +4 -0
- 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/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 +89 -2
- package/dist/proxy/stream-proxy.d.ts +2 -1
- package/dist/proxy/stream-proxy.js +9 -3
- package/dist/proxy/transport-fn.d.ts +1 -0
- package/dist/proxy/transport-fn.js +13 -1
- package/frontend-dist/assets/{CardContent-BtAcFNMy.js → CardContent-DG1NiXMU.js} +1 -1
- package/frontend-dist/assets/{CardTitle-Bmwf1S5Y.js → CardTitle-6JvqGNd9.js} +1 -1
- package/frontend-dist/assets/{CascadingModelSelect-CicfrqcY.js → CascadingModelSelect-BzDHmRLv.js} +1 -1
- package/frontend-dist/assets/{Checkbox-B1o39YuC.js → Checkbox-D4TrUHCb.js} +1 -1
- package/frontend-dist/assets/{CollapsibleTrigger-2jySTCeh.js → CollapsibleTrigger-ZJCGAkxi.js} +1 -1
- package/frontend-dist/assets/{Collection-ChUVejsh.js → Collection-CRTZGViV.js} +1 -1
- package/frontend-dist/assets/{Dashboard-DkJauxYu.js → Dashboard-C-B4p8HM.js} +1 -1
- package/frontend-dist/assets/{DialogTitle-D0erB-Fr.js → DialogTitle-CecSHUUq.js} +1 -1
- package/frontend-dist/assets/{Input-BDbKynVD.js → Input-DoCE9j9O.js} +1 -1
- package/frontend-dist/assets/{Label-CrHq5hrg.js → Label-6sa_UFaw.js} +1 -1
- package/frontend-dist/assets/{Login-D2YdqYnu.js → Login-BkCJQFjz.js} +1 -1
- package/frontend-dist/assets/{Logs-DgeOPIkd.js → Logs-HG_BuJe5.js} +1 -1
- package/frontend-dist/assets/{ModelMappings-De_UjiND.js → ModelMappings-CjI27TDc.js} +1 -1
- package/frontend-dist/assets/{Monitor-BgRMReMF.js → Monitor-CElP7aKi.js} +1 -1
- package/frontend-dist/assets/{PopoverTrigger-BVsxIE2L.js → PopoverTrigger-D0nT5UVQ.js} +1 -1
- package/frontend-dist/assets/{PopperContent-B23SzU9H.js → PopperContent-BbE-uPX0.js} +1 -1
- package/frontend-dist/assets/{Providers-DQypvsEg.js → Providers-DTqfl249.js} +1 -1
- package/frontend-dist/assets/ProxyEnhancement-DQixi_0_.js +5 -0
- package/frontend-dist/assets/{RetryRules-CSseSPoO.js → RetryRules-B5r18TFL.js} +1 -1
- package/frontend-dist/assets/{RouterKeys-ccwqoMCX.js → RouterKeys-BwdoieS_.js} +1 -1
- package/frontend-dist/assets/{RovingFocusItem-rwA4uA9N.js → RovingFocusItem-CpDEc1ox.js} +1 -1
- package/frontend-dist/assets/{Schedules-8YYNjLNo.js → Schedules-C0q4rt97.js} +1 -1
- package/frontend-dist/assets/{SelectValue-TvIOOalu.js → SelectValue-CKJWYmgi.js} +1 -1
- package/frontend-dist/assets/{Settings-D1WDm5lQ.js → Settings-DQN3_4Gx.js} +1 -1
- package/frontend-dist/assets/{Setup-Bw-RIF9G.js → Setup-CEk8SRJu.js} +1 -1
- package/frontend-dist/assets/{Switch-D9wFEsMF.js → Switch-ThwlPMEz.js} +1 -1
- package/frontend-dist/assets/{TableHeader-HOR173Xk.js → TableHeader-UwRhaVOA.js} +1 -1
- package/frontend-dist/assets/{TabsTrigger-BOsmgFYE.js → TabsTrigger-CNV5JhP6.js} +1 -1
- package/frontend-dist/assets/{Teleport-BGbwtNTD.js → Teleport-BHTgdtZR.js} +1 -1
- package/frontend-dist/assets/{TooltipTrigger-DPzNY2Sp.js → TooltipTrigger-DKa4gPvX.js} +1 -1
- package/frontend-dist/assets/{UnifiedRequestDialog-CevmD2P2.js → UnifiedRequestDialog-CBbZxE1N.js} +1 -1
- package/frontend-dist/assets/{VisuallyHidden-ChauvWtH.js → VisuallyHidden-r2E3heZY.js} +1 -1
- package/frontend-dist/assets/{VisuallyHiddenInput-BcDuL0V8.js → VisuallyHiddenInput-DSyTANlz.js} +1 -1
- package/frontend-dist/assets/{alert-dialog-DgtxmV7t.js → alert-dialog-CNAhVHUE.js} +1 -1
- package/frontend-dist/assets/arrow-down-CFVTVH7t.js +1 -0
- package/frontend-dist/assets/{badge-NUBqZBxu.js → badge-RFq5LS37.js} +1 -1
- package/frontend-dist/assets/{button-BLX8zWc1.js → button-D4qBQ0nA.js} +2 -2
- package/frontend-dist/assets/check-CkLQpfOO.js +1 -0
- package/frontend-dist/assets/{copy-BMWzukd1.js → copy-BxjNh73N.js} +1 -1
- package/frontend-dist/assets/{dialog-Dsvgfiw-.js → dialog-CLepZTFY.js} +1 -1
- package/frontend-dist/assets/{file-text-CqJ33eWr.js → file-text-DqxNey63.js} +1 -1
- package/frontend-dist/assets/{index-C7LWG0FU.js → index-C4H_2b0G.js} +1 -1
- package/frontend-dist/assets/{lib-DQotd1d8.js → lib-SdzBxIwM.js} +1 -1
- package/frontend-dist/assets/loader-circle-Cffc9Uf0.js +1 -0
- package/frontend-dist/assets/{useClipboard-BDAhyrgL.js → useClipboard-DBClUufY.js} +1 -1
- package/frontend-dist/assets/{useFocusGuards-CHAbXhQp.js → useFocusGuards-B99Wx8XA.js} +1 -1
- package/frontend-dist/assets/useFormControl-DuWttDd8.js +1 -0
- package/frontend-dist/assets/{useLogRetention-X-CkHhJ7.js → useLogRetention-CQ7Q54Tt.js} +1 -1
- package/frontend-dist/assets/useNonce-D1Mva4rM.js +1 -0
- package/frontend-dist/assets/x-BJjJvWU8.js +1 -0
- package/frontend-dist/index.html +19 -19
- package/package.json +1 -1
- package/frontend-dist/assets/ProxyEnhancement-Cijb2FID.js +0 -5
- package/frontend-dist/assets/arrow-down-BuK6B6yc.js +0 -1
- package/frontend-dist/assets/check-CsZv9cnK.js +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
|
@@ -6,6 +6,5 @@
|
|
|
6
6
|
{ "name": "ZAI 操作失败 (code 500)", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*\"code\"\\s*:\\s*\"500\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
7
7
|
{ "name": "ZAI 速率限制 (HTTP 200, code 1302)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1302\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
8
8
|
{ "name": "ZAI SSE 错误 (HTTP 200, code 500)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"500\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
9
|
-
{ "name": "ZAI SSE 错误 (HTTP 200, code 1234)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1234\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 }
|
|
10
|
-
{ "name": "ZAI 过载限流 (HTTP 200, code 1305)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1305\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 }
|
|
9
|
+
{ "name": "ZAI SSE 错误 (HTTP 200, code 1234)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1234\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 }
|
|
11
10
|
]
|
|
@@ -3,6 +3,8 @@ import { setSetting } from "../db/settings.js";
|
|
|
3
3
|
import { loadEnhancementConfig } from "../proxy/enhancement-config.js";
|
|
4
4
|
const UpdateProxyEnhancementSchema = Type.Object({
|
|
5
5
|
claude_code_enabled: Type.Boolean(),
|
|
6
|
+
tool_call_loop_enabled: Type.Boolean(),
|
|
7
|
+
stream_loop_enabled: Type.Boolean(),
|
|
6
8
|
});
|
|
7
9
|
const SessionParamsSchema = Type.Object({
|
|
8
10
|
keyId: Type.String(),
|
|
@@ -16,12 +18,16 @@ export const adminProxyEnhancementRoutes = (app, options, done) => {
|
|
|
16
18
|
const config = loadEnhancementConfig(db);
|
|
17
19
|
return reply.send({
|
|
18
20
|
claude_code_enabled: config.claude_code_enabled,
|
|
21
|
+
tool_call_loop_enabled: config.tool_call_loop_enabled,
|
|
22
|
+
stream_loop_enabled: config.stream_loop_enabled,
|
|
19
23
|
});
|
|
20
24
|
});
|
|
21
25
|
app.put("/admin/api/proxy-enhancement", { schema: { body: UpdateProxyEnhancementSchema } }, async (request, reply) => {
|
|
22
26
|
const body = request.body;
|
|
23
27
|
const config = {
|
|
24
28
|
claude_code_enabled: body.claude_code_enabled,
|
|
29
|
+
tool_call_loop_enabled: body.tool_call_loop_enabled,
|
|
30
|
+
stream_loop_enabled: body.stream_loop_enabled,
|
|
25
31
|
};
|
|
26
32
|
setSetting(db, "proxy_enhancement", JSON.stringify(config));
|
|
27
33
|
return reply.send({ success: true });
|
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,8 @@ 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";
|
|
26
28
|
import { scheduleLogCleanup } from "./db/log-cleaner.js";
|
|
27
29
|
import { scheduleDbSizeMonitor } from "./db/db-size-monitor.js";
|
|
28
30
|
import { startUpgradeChecker, stopUpgradeChecker } from "./admin/upgrade.js";
|
|
@@ -156,6 +158,8 @@ export async function buildApp(options) {
|
|
|
156
158
|
// 5h 用量窗口追踪器,启动时自动补齐缺失窗口
|
|
157
159
|
const usageWindowTracker = new UsageWindowTracker(db);
|
|
158
160
|
usageWindowTracker.reconcileOnStartup();
|
|
161
|
+
// Session tracker(工具调用循环检测用),始终创建但检测受 proxy_enhancement 配置控制
|
|
162
|
+
const sessionTracker = new SessionTracker(DEFAULT_LOOP_PREVENTION_CONFIG.sessionTracker);
|
|
159
163
|
// 从 DB 读取已有 provider 的并发配置,初始化信号量管理器和 tracker
|
|
160
164
|
const allProviders = getAllProviders(db);
|
|
161
165
|
for (const p of allProviders) {
|
|
@@ -182,6 +186,7 @@ export async function buildApp(options) {
|
|
|
182
186
|
semaphoreManager,
|
|
183
187
|
tracker,
|
|
184
188
|
usageWindowTracker,
|
|
189
|
+
sessionTracker,
|
|
185
190
|
});
|
|
186
191
|
app.register(anthropicProxy, {
|
|
187
192
|
db,
|
|
@@ -191,6 +196,7 @@ export async function buildApp(options) {
|
|
|
191
196
|
semaphoreManager,
|
|
192
197
|
tracker,
|
|
193
198
|
usageWindowTracker,
|
|
199
|
+
sessionTracker,
|
|
194
200
|
});
|
|
195
201
|
app.register(adminRoutes, { db, matcher, tracker, semaphoreManager });
|
|
196
202
|
// 前端静态文件服务(生产环境)
|
|
@@ -229,6 +235,7 @@ export async function buildApp(options) {
|
|
|
229
235
|
logCleanup.stop();
|
|
230
236
|
dbSizeMonitor.stop();
|
|
231
237
|
tracker.stopPushInterval();
|
|
238
|
+
sessionTracker.stop();
|
|
232
239
|
await app.close();
|
|
233
240
|
db.close();
|
|
234
241
|
},
|
|
@@ -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,8 +45,8 @@ 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
|
-
// flush 无条件推送最终状态,确保消费者能拿到完整指标
|
|
46
50
|
if (this.onMetrics && !this.flushed) {
|
|
47
51
|
this.flushed = true;
|
|
48
52
|
this.lastCallbackTime = Date.now();
|
|
@@ -53,6 +57,47 @@ export class SSEMetricsTransform extends Transform {
|
|
|
53
57
|
getExtractor() {
|
|
54
58
|
return this.extractor;
|
|
55
59
|
}
|
|
60
|
+
/** 从 SSE 事件中提取内容文本,触发 onContentDelta 回调 */
|
|
61
|
+
emitContentDelta(event) {
|
|
62
|
+
if (!this.onContentDelta || !event.data)
|
|
63
|
+
return;
|
|
64
|
+
const delta = this.extractContentDelta(event.data);
|
|
65
|
+
if (delta)
|
|
66
|
+
this.onContentDelta(delta);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 从 SSE data 字段中提取实际内容文本(thinking / text / tool JSON delta)。
|
|
70
|
+
* 忽略框架事件(message_start、ping 等),仅返回模型输出的内容。
|
|
71
|
+
*/
|
|
72
|
+
extractContentDelta(data) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(data);
|
|
75
|
+
if (this.apiType === "anthropic") {
|
|
76
|
+
if (parsed.type !== "content_block_delta" || typeof parsed.delta !== "object" || !parsed.delta)
|
|
77
|
+
return undefined;
|
|
78
|
+
const delta = parsed.delta;
|
|
79
|
+
if (delta.type === "thinking_delta" && typeof delta.thinking === "string")
|
|
80
|
+
return delta.thinking;
|
|
81
|
+
if (delta.type === "text_delta" && typeof delta.text === "string")
|
|
82
|
+
return delta.text;
|
|
83
|
+
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string")
|
|
84
|
+
return delta.partial_json;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const choices = parsed.choices;
|
|
88
|
+
if (!Array.isArray(choices) || choices.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const first = choices[0];
|
|
91
|
+
if (typeof first.delta !== "object" || !first.delta)
|
|
92
|
+
return undefined;
|
|
93
|
+
const delta = first.delta;
|
|
94
|
+
if (typeof delta.content === "string")
|
|
95
|
+
return delta.content;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { /* 非 JSON 数据行,跳过 */ }
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
56
101
|
/** 节流逻辑:首次或距上次回调超过 throttleMs 时触发 */
|
|
57
102
|
emitMetricsIfReady() {
|
|
58
103
|
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();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyRequest } from "fastify";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
|
+
import { type EnhancementConfig } from "../enhancement-config.js";
|
|
3
4
|
export interface InterceptResponse {
|
|
4
5
|
statusCode: number;
|
|
5
6
|
body: unknown;
|
|
@@ -18,6 +19,6 @@ export interface EnhancementResult {
|
|
|
18
19
|
* 在代理转发前应用代理增强逻辑(指令解析 + 会话记忆 + 模型替换 + 命令拦截)。
|
|
19
20
|
* 仅当 proxy_enhancement.claude_code_enabled 开启时生效。
|
|
20
21
|
*/
|
|
21
|
-
export declare function applyEnhancement(db: Database.Database, request: FastifyRequest, clientModel: string, sessionId?: string): EnhancementResult;
|
|
22
|
+
export declare function applyEnhancement(db: Database.Database, request: FastifyRequest, clientModel: string, sessionId?: string, enhancementConfig?: EnhancementConfig): EnhancementResult;
|
|
22
23
|
/** 生成注入到非流式响应中的模型信息标签 */
|
|
23
24
|
export declare function buildModelInfoTag(effectiveModel: string): string;
|
|
@@ -60,9 +60,9 @@ function buildDisplayModels(db, allowedModelsRaw) {
|
|
|
60
60
|
* 在代理转发前应用代理增强逻辑(指令解析 + 会话记忆 + 模型替换 + 命令拦截)。
|
|
61
61
|
* 仅当 proxy_enhancement.claude_code_enabled 开启时生效。
|
|
62
62
|
*/
|
|
63
|
-
export function applyEnhancement(db, request, clientModel, sessionId) {
|
|
63
|
+
export function applyEnhancement(db, request, clientModel, sessionId, enhancementConfig) {
|
|
64
64
|
const nullResult = { effectiveModel: clientModel, originalModel: null, interceptResponse: null };
|
|
65
|
-
const enhancement = loadEnhancementConfig(db);
|
|
65
|
+
const enhancement = enhancementConfig ?? loadEnhancementConfig(db);
|
|
66
66
|
if (!enhancement.claude_code_enabled) {
|
|
67
67
|
return nullResult;
|
|
68
68
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
export interface EnhancementConfig {
|
|
3
3
|
claude_code_enabled: boolean;
|
|
4
|
+
tool_call_loop_enabled: boolean;
|
|
5
|
+
stream_loop_enabled: boolean;
|
|
4
6
|
}
|
|
5
7
|
/** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
|
|
6
8
|
export declare function loadEnhancementConfig(db: Database.Database): EnhancementConfig;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getSetting } from "../db/settings.js";
|
|
2
2
|
const DEFAULT_CONFIG = {
|
|
3
3
|
claude_code_enabled: false,
|
|
4
|
+
tool_call_loop_enabled: false,
|
|
5
|
+
stream_loop_enabled: false,
|
|
4
6
|
};
|
|
5
7
|
/** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
|
|
6
8
|
export function loadEnhancementConfig(db) {
|
|
@@ -11,6 +13,8 @@ export function loadEnhancementConfig(db) {
|
|
|
11
13
|
const parsed = JSON.parse(raw);
|
|
12
14
|
return {
|
|
13
15
|
claude_code_enabled: parsed.claude_code_enabled ?? false,
|
|
16
|
+
tool_call_loop_enabled: parsed.tool_call_loop_enabled ?? false,
|
|
17
|
+
stream_loop_enabled: parsed.stream_loop_enabled ?? false,
|
|
14
18
|
};
|
|
15
19
|
}
|
|
16
20
|
catch {
|
|
@@ -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
|
+
};
|