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.
Files changed (102) hide show
  1. package/dist/config.d.ts +2 -0
  2. package/dist/config.js +5 -0
  3. package/dist/constants.d.ts +1 -0
  4. package/dist/constants.js +1 -0
  5. package/dist/index.js +14 -0
  6. package/dist/metrics/sse-metrics-transform.d.ts +11 -0
  7. package/dist/metrics/sse-metrics-transform.js +47 -1
  8. package/dist/proxy/anthropic.d.ts +1 -0
  9. package/dist/proxy/anthropic.js +2 -2
  10. package/dist/proxy/loop-prevention/detectors/detector.d.ts +10 -0
  11. package/dist/proxy/loop-prevention/detectors/detector.js +1 -0
  12. package/dist/proxy/loop-prevention/detectors/ngram-detector.d.ts +15 -0
  13. package/dist/proxy/loop-prevention/detectors/ngram-detector.js +65 -0
  14. package/dist/proxy/loop-prevention/session-tracker.d.ts +14 -0
  15. package/dist/proxy/loop-prevention/session-tracker.js +67 -0
  16. package/dist/proxy/loop-prevention/stream-loop-guard.d.ts +12 -0
  17. package/dist/proxy/loop-prevention/stream-loop-guard.js +28 -0
  18. package/dist/proxy/loop-prevention/tool-loop-guard.d.ts +13 -0
  19. package/dist/proxy/loop-prevention/tool-loop-guard.js +63 -0
  20. package/dist/proxy/loop-prevention/types.d.ts +38 -0
  21. package/dist/proxy/loop-prevention/types.js +18 -0
  22. package/dist/proxy/openai.d.ts +1 -0
  23. package/dist/proxy/openai.js +2 -2
  24. package/dist/proxy/patch/deepseek/index.d.ts +1 -1
  25. package/dist/proxy/patch/deepseek/index.js +3 -3
  26. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +3 -4
  27. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +23 -30
  28. package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +8 -4
  29. package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +57 -12
  30. package/dist/proxy/patch/index.d.ts +12 -3
  31. package/dist/proxy/patch/index.js +13 -4
  32. package/dist/proxy/patch/router-cleanup.d.ts +13 -0
  33. package/dist/proxy/patch/router-cleanup.js +87 -0
  34. package/dist/proxy/proxy-handler.d.ts +1 -0
  35. package/dist/proxy/proxy-handler.js +88 -2
  36. package/dist/proxy/stream-proxy.d.ts +2 -1
  37. package/dist/proxy/stream-proxy.js +11 -3
  38. package/dist/proxy/transport-fn.d.ts +2 -0
  39. package/dist/proxy/transport-fn.js +19 -1
  40. package/frontend-dist/assets/{CardContent-BtAcFNMy.js → CardContent-CpiBn1Oc.js} +1 -1
  41. package/frontend-dist/assets/{CardTitle-Bmwf1S5Y.js → CardTitle-dwtgd_nl.js} +1 -1
  42. package/frontend-dist/assets/{CascadingModelSelect-CicfrqcY.js → CascadingModelSelect-CxEXwaeM.js} +1 -1
  43. package/frontend-dist/assets/{Checkbox-B1o39YuC.js → Checkbox-D2U4I-pO.js} +1 -1
  44. package/frontend-dist/assets/{CollapsibleTrigger-2jySTCeh.js → CollapsibleTrigger-B2AdbZBh.js} +1 -1
  45. package/frontend-dist/assets/{Collection-ChUVejsh.js → Collection-BJZSFJsF.js} +1 -1
  46. package/frontend-dist/assets/{Dashboard-DkJauxYu.js → Dashboard-D3cDhJNh.js} +1 -1
  47. package/frontend-dist/assets/{DialogTitle-D0erB-Fr.js → DialogTitle-BTuQdRm1.js} +1 -1
  48. package/frontend-dist/assets/{Input-BDbKynVD.js → Input-BYULYPCe.js} +1 -1
  49. package/frontend-dist/assets/{Label-CrHq5hrg.js → Label-sImW5XUw.js} +1 -1
  50. package/frontend-dist/assets/{Login-D2YdqYnu.js → Login-B0kGGZFi.js} +1 -1
  51. package/frontend-dist/assets/{Logs-DgeOPIkd.js → Logs-BpAeeJRi.js} +1 -1
  52. package/frontend-dist/assets/{ModelMappings-De_UjiND.js → ModelMappings-CsHLYqQB.js} +1 -1
  53. package/frontend-dist/assets/{Monitor-BgRMReMF.js → Monitor-BmMFWFJg.js} +1 -1
  54. package/frontend-dist/assets/{PopoverTrigger-BVsxIE2L.js → PopoverTrigger-CIN3yOIw.js} +1 -1
  55. package/frontend-dist/assets/{PopperContent-B23SzU9H.js → PopperContent-BoOYHCag.js} +1 -1
  56. package/frontend-dist/assets/{Providers-DQypvsEg.js → Providers-DrepCc4A.js} +1 -1
  57. package/frontend-dist/assets/{ProxyEnhancement-Cijb2FID.js → ProxyEnhancement-dK_mOQ3m.js} +1 -1
  58. package/frontend-dist/assets/{RetryRules-CSseSPoO.js → RetryRules-Ce5HfNcc.js} +1 -1
  59. package/frontend-dist/assets/{RouterKeys-ccwqoMCX.js → RouterKeys-CeSGvjll.js} +1 -1
  60. package/frontend-dist/assets/{RovingFocusItem-rwA4uA9N.js → RovingFocusItem-gAYs0l8Z.js} +1 -1
  61. package/frontend-dist/assets/{Schedules-8YYNjLNo.js → Schedules-DphkPAWD.js} +1 -1
  62. package/frontend-dist/assets/{SelectValue-TvIOOalu.js → SelectValue-Cbbd2Xbm.js} +1 -1
  63. package/frontend-dist/assets/{Settings-D1WDm5lQ.js → Settings-DIP7VawX.js} +1 -1
  64. package/frontend-dist/assets/{Setup-Bw-RIF9G.js → Setup-_d_M-Qi6.js} +1 -1
  65. package/frontend-dist/assets/{Switch-D9wFEsMF.js → Switch-BmmYsqAx.js} +1 -1
  66. package/frontend-dist/assets/{TableHeader-HOR173Xk.js → TableHeader-C1mpCsyo.js} +1 -1
  67. package/frontend-dist/assets/{TabsTrigger-BOsmgFYE.js → TabsTrigger-kN1usMvC.js} +1 -1
  68. package/frontend-dist/assets/{Teleport-BGbwtNTD.js → Teleport-CRn-gy0B.js} +1 -1
  69. package/frontend-dist/assets/{TooltipTrigger-DPzNY2Sp.js → TooltipTrigger-rlKo7E3A.js} +1 -1
  70. package/frontend-dist/assets/UnifiedRequestDialog-C-Ui-fav.css +1 -0
  71. package/frontend-dist/assets/UnifiedRequestDialog-C7pEDa9D.js +4 -0
  72. package/frontend-dist/assets/{VisuallyHidden-ChauvWtH.js → VisuallyHidden-CQvCw9gB.js} +1 -1
  73. package/frontend-dist/assets/{VisuallyHiddenInput-BcDuL0V8.js → VisuallyHiddenInput-B-DnnaWN.js} +1 -1
  74. package/frontend-dist/assets/{alert-dialog-DgtxmV7t.js → alert-dialog-M3PhwD75.js} +1 -1
  75. package/frontend-dist/assets/arrow-down-BHq-drH-.js +1 -0
  76. package/frontend-dist/assets/{badge-NUBqZBxu.js → badge-7WIbGMsE.js} +1 -1
  77. package/frontend-dist/assets/{button-BLX8zWc1.js → button-q9xTxPJh.js} +2 -2
  78. package/frontend-dist/assets/check-CccuM1Sj.js +1 -0
  79. package/frontend-dist/assets/{copy-BMWzukd1.js → copy-C1a8OrYP.js} +1 -1
  80. package/frontend-dist/assets/{dialog-Dsvgfiw-.js → dialog-B6RnMgGx.js} +1 -1
  81. package/frontend-dist/assets/{file-text-CqJ33eWr.js → file-text-CsuQUjXR.js} +1 -1
  82. package/frontend-dist/assets/index-Ce7hFHTt.css +1 -0
  83. package/frontend-dist/assets/{index-C7LWG0FU.js → index-Dh7qL0Qt.js} +1 -1
  84. package/frontend-dist/assets/{lib-DQotd1d8.js → lib-D1G8Xa05.js} +1 -1
  85. package/frontend-dist/assets/loader-circle-CVhxR0Tt.js +1 -0
  86. package/frontend-dist/assets/{useClipboard-BDAhyrgL.js → useClipboard-CBONMfzU.js} +1 -1
  87. package/frontend-dist/assets/{useFocusGuards-CHAbXhQp.js → useFocusGuards-DwFX8o1a.js} +1 -1
  88. package/frontend-dist/assets/useFormControl-DC32gW1A.js +1 -0
  89. package/frontend-dist/assets/{useLogRetention-X-CkHhJ7.js → useLogRetention-C2IbjXjr.js} +1 -1
  90. package/frontend-dist/assets/useNonce-t7XaR4bX.js +1 -0
  91. package/frontend-dist/assets/x-Bq5KcbWI.js +1 -0
  92. package/frontend-dist/index.html +20 -20
  93. package/package.json +1 -1
  94. package/frontend-dist/assets/UnifiedRequestDialog-BjEigSaR.css +0 -1
  95. package/frontend-dist/assets/UnifiedRequestDialog-CevmD2P2.js +0 -3
  96. package/frontend-dist/assets/arrow-down-BuK6B6yc.js +0 -1
  97. package/frontend-dist/assets/check-CsZv9cnK.js +0 -1
  98. package/frontend-dist/assets/index-_Icfkt3I.css +0 -1
  99. package/frontend-dist/assets/loader-circle-CnEL8ILi.js +0 -1
  100. package/frontend-dist/assets/useFormControl-CCVkIi3o.js +0 -1
  101. package/frontend-dist/assets/useNonce-DyF1ycZV.js +0 -1
  102. package/frontend-dist/assets/x-DMAovOe-.js +0 -1
@@ -4,9 +4,8 @@
4
4
  *
5
5
  * 算法:
6
6
  * 1. 收集所有 assistant 消息中的 tool_use ID
7
- * 2. 移除 tool_use_id 不在集合中的 tool_result
8
- * 3. 移除清空后的空 user 消息
9
- * 4. 合并相邻的 user 消息(Anthropic API 不允许连续 user 消息)
10
- * 5. 合并相邻的 assistant 消息(同理)
7
+ * 2. tool_use_id 不在集合中的 tool_result 块转为 text(保留信息)
8
+ * 3. 合并相邻的 user 消息(Anthropic API 不允许连续 user 消息)
9
+ * 4. 合并相邻的 assistant 消息(同理)
11
10
  */
12
11
  export declare function patchOrphanToolResults(body: Record<string, unknown>): void;
@@ -4,10 +4,9 @@
4
4
  *
5
5
  * 算法:
6
6
  * 1. 收集所有 assistant 消息中的 tool_use ID
7
- * 2. 移除 tool_use_id 不在集合中的 tool_result
8
- * 3. 移除清空后的空 user 消息
9
- * 4. 合并相邻的 user 消息(Anthropic API 不允许连续 user 消息)
10
- * 5. 合并相邻的 assistant 消息(同理)
7
+ * 2. tool_use_id 不在集合中的 tool_result 块转为 text(保留信息)
8
+ * 3. 合并相邻的 user 消息(Anthropic API 不允许连续 user 消息)
9
+ * 4. 合并相邻的 assistant 消息(同理)
11
10
  */
12
11
  export function patchOrphanToolResults(body) {
13
12
  if (!body.messages)
@@ -26,41 +25,35 @@ export function patchOrphanToolResults(body) {
26
25
  }
27
26
  }
28
27
  }
29
- // Step 2: 移除孤儿 tool_result
30
- let removedAny = false;
28
+ // Step 2: 将孤儿 tool_result 块转为 text,而非丢弃
29
+ let convertedAny = false;
31
30
  for (const msg of messages) {
32
31
  if (msg.role !== "user" || !Array.isArray(msg.content))
33
32
  continue;
34
33
  const blocks = msg.content;
35
- const before = blocks.length;
36
- const filtered = blocks.filter(block => {
37
- if (block?.type === "tool_result" && typeof block.tool_use_id === "string") {
38
- return knownToolUseIds.has(block.tool_use_id);
34
+ const newBlocks = [];
35
+ let changed = false;
36
+ for (const block of blocks) {
37
+ if (block?.type === "tool_result" &&
38
+ typeof block.tool_use_id === "string" &&
39
+ !knownToolUseIds.has(block.tool_use_id)) {
40
+ newBlocks.push({ type: "text", text: JSON.stringify(block) });
41
+ changed = true;
42
+ }
43
+ else {
44
+ newBlocks.push(block);
39
45
  }
40
- return true;
41
- });
42
- if (filtered.length < before) {
43
- msg.content = filtered;
44
- removedAny = true;
45
- }
46
- }
47
- if (!removedAny)
48
- return;
49
- // Step 3: 移除清空后的空 user 消息(向后遍历避免索引错乱)
50
- for (let i = messages.length - 1; i >= 0; i--) {
51
- const msg = messages[i];
52
- if (msg.role !== "user")
53
- continue;
54
- if (Array.isArray(msg.content) && msg.content.length === 0) {
55
- messages.splice(i, 1);
56
46
  }
57
- else if (typeof msg.content === "string" && msg.content.trim() === "") {
58
- messages.splice(i, 1);
47
+ if (changed) {
48
+ msg.content = newBlocks;
49
+ convertedAny = true;
59
50
  }
60
51
  }
61
- // Step 4: 合并相邻的 user 消息
52
+ if (!convertedAny)
53
+ return;
54
+ // Step 3: 合并相邻的 user 消息(转换后可能产生连续 user 消息)
62
55
  mergeConsecutive(messages, "user");
63
- // Step 5: 合并相邻的 assistant 消息(删除空 user 消息后可能产生)
56
+ // Step 4: 合并相邻的 assistant 消息
64
57
  mergeConsecutive(messages, "assistant");
65
58
  }
66
59
  function mergeConsecutive(messages, role) {
@@ -1,6 +1,10 @@
1
1
  /**
2
- * DeepSeek thinking 协议实现不完整:开启 thinking 模式后部分轮次不返回 thinking block,
3
- * 但后续请求要求历史 assistant 消息必须携带 thinking block。
4
- * content 数组开头补一个空 thinking block 以绕过上游校验。
2
+ * 将非 DeepSeek 生成的消息中的 tool_use/tool_result 转为 text 块。
3
+ *
4
+ * 判断标准:assistant 消息含 tool_use 但(无 thinking 块或 thinking.signature 为空/缺失)。
5
+ *
6
+ * 背景:DeepSeek Anthropic API 开启 thinking 后要求含 tool_use 的 assistant 消息
7
+ * 必须携带 thinking 块。跨模型切换(GLM → DeepSeek)时历史消息缺 thinking 会导致 400。
8
+ * 转化为 text 规避格式校验,同时完整保留信息。
5
9
  */
6
- export declare function patchMissingThinkingBlocks(body: Record<string, unknown>): void;
10
+ export declare function patchNonDeepSeekToolMessages(body: Record<string, unknown>): void;
@@ -1,24 +1,69 @@
1
1
  /**
2
- * DeepSeek thinking 协议实现不完整:开启 thinking 模式后部分轮次不返回 thinking block,
3
- * 但后续请求要求历史 assistant 消息必须携带 thinking block。
4
- * content 数组开头补一个空 thinking block 以绕过上游校验。
2
+ * 将非 DeepSeek 生成的消息中的 tool_use/tool_result 转为 text 块。
3
+ *
4
+ * 判断标准:assistant 消息含 tool_use 但(无 thinking 块或 thinking.signature 为空/缺失)。
5
+ *
6
+ * 背景:DeepSeek Anthropic API 开启 thinking 后要求含 tool_use 的 assistant 消息
7
+ * 必须携带 thinking 块。跨模型切换(GLM → DeepSeek)时历史消息缺 thinking 会导致 400。
8
+ * 转化为 text 规避格式校验,同时完整保留信息。
5
9
  */
6
- export function patchMissingThinkingBlocks(body) {
10
+ export function patchNonDeepSeekToolMessages(body) {
7
11
  if (!body.messages)
8
12
  return;
9
13
  const messages = body.messages;
10
- // DeepSeek 可能在不传 thinking 参数时也启用 thinking 模式(从历史推断),
11
- // 所以只要历史中存在任何 thinking block,就视为 thinking 模式激活。
12
- const thinkingActive = !!body.thinking || messages.some((msg) => msg.role === "assistant" && Array.isArray(msg.content)
13
- && msg.content.some((b) => b && typeof b === "object" && b.type === "thinking"));
14
- if (!thinkingActive)
14
+ if (!Array.isArray(messages) || messages.length === 0)
15
15
  return;
16
+ // Step 1: 识别非 DeepSeek 的 assistant 消息,转换 tool_use → text
17
+ const convertedIds = new Set();
16
18
  for (const msg of messages) {
17
19
  if (msg.role !== "assistant" || !Array.isArray(msg.content))
18
20
  continue;
19
- const hasThinking = msg.content.some((b) => b && typeof b === "object" && b.type === "thinking");
20
- if (!hasThinking) {
21
- msg.content.unshift({ type: "thinking", thinking: "", signature: "" });
21
+ const blocks = msg.content;
22
+ const hasToolUse = blocks.some((b) => b && typeof b === "object" && b.type === "tool_use");
23
+ if (!hasToolUse)
24
+ continue;
25
+ const thinkingBlock = blocks.find((b) => b && typeof b === "object" && b.type === "thinking");
26
+ const hasValidSignature = thinkingBlock &&
27
+ typeof thinkingBlock.signature === "string" &&
28
+ thinkingBlock.signature !== "";
29
+ // 有合法 signature → DeepSeek 原生,不动
30
+ if (hasValidSignature)
31
+ continue;
32
+ // 非 DeepSeek:将 tool_use 块替换为 text
33
+ const newBlocks = [];
34
+ for (const block of blocks) {
35
+ if (block && typeof block === "object" && block.type === "tool_use") {
36
+ newBlocks.push({ type: "text", text: JSON.stringify(block) });
37
+ if (typeof block.id === "string") {
38
+ convertedIds.add(block.id);
39
+ }
40
+ }
41
+ else {
42
+ newBlocks.push(block);
43
+ }
44
+ }
45
+ msg.content = newBlocks;
46
+ }
47
+ if (convertedIds.size === 0)
48
+ return;
49
+ // Step 2: 转换对应 user 消息中的 tool_result → text
50
+ for (const msg of messages) {
51
+ if (msg.role !== "user" || !Array.isArray(msg.content))
52
+ continue;
53
+ const blocks = msg.content;
54
+ const newBlocks = [];
55
+ for (const block of blocks) {
56
+ if (block &&
57
+ typeof block === "object" &&
58
+ block.type === "tool_result" &&
59
+ typeof block.tool_use_id === "string" &&
60
+ convertedIds.has(block.tool_use_id)) {
61
+ newBlocks.push({ type: "text", text: JSON.stringify(block) });
62
+ }
63
+ else {
64
+ newBlocks.push(block);
65
+ }
22
66
  }
67
+ msg.content = newBlocks;
23
68
  }
24
69
  }
@@ -1,9 +1,18 @@
1
1
  interface ProviderInfo {
2
2
  base_url: string;
3
3
  }
4
+ export interface ProviderPatchMeta {
5
+ types: string[];
6
+ }
4
7
  /**
5
- * 根据 provider 信息分发到对应的补丁逻辑。
6
- * 每个补丁直接修改 body,不返回新对象。
8
+ * 通用消息补丁入口。
9
+ * 返回新的 body(必要时深拷贝),不修改原始 body。
10
+ * 执行顺序:
11
+ * 1. 清理 router 合成的 tool_use/tool_result(通用,所有 provider)
12
+ * 2. Provider-specific patches(如 DeepSeek thinking 校验)
7
13
  */
8
- export declare function applyProviderPatches(body: Record<string, unknown>, provider: ProviderInfo): void;
14
+ export declare function applyProviderPatches(body: Record<string, unknown>, provider: ProviderInfo): {
15
+ body: Record<string, unknown>;
16
+ meta: ProviderPatchMeta;
17
+ };
9
18
  export {};
@@ -1,12 +1,21 @@
1
1
  import { applyDeepSeekPatches } from "./deepseek/index.js";
2
+ import { patchRouterSyntheticToolCalls } from "./router-cleanup.js";
2
3
  /**
3
- * 根据 provider 信息分发到对应的补丁逻辑。
4
- * 每个补丁直接修改 body,不返回新对象。
4
+ * 通用消息补丁入口。
5
+ * 返回新的 body(必要时深拷贝),不修改原始 body。
6
+ * 执行顺序:
7
+ * 1. 清理 router 合成的 tool_use/tool_result(通用,所有 provider)
8
+ * 2. Provider-specific patches(如 DeepSeek thinking 校验)
5
9
  */
6
10
  export function applyProviderPatches(body, provider) {
7
- if (needsDeepSeekPatch(body, provider)) {
8
- applyDeepSeekPatches(body);
11
+ // router cleanup 始终执行,先克隆以避免修改原始 body
12
+ const cloned = JSON.parse(JSON.stringify(body));
13
+ patchRouterSyntheticToolCalls(cloned);
14
+ if (!needsDeepSeekPatch(body, provider)) {
15
+ return { body: cloned, meta: { types: [] } };
9
16
  }
17
+ applyDeepSeekPatches(cloned);
18
+ return { body: cloned, meta: { types: ["deepseek_tool_use_to_text"] } };
10
19
  }
11
20
  /** DeepSeek patch 触发条件:直连 DeepSeek,或经代理转发且模型名含 deepseek */
12
21
  function needsDeepSeekPatch(body, provider) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 从消息中移除 router 注入的合成 tool_use/tool_result。
3
+ *
4
+ * Router 通过 AskUserQuestion 注入的 tool_use(ID 以 toolu_router_ 开头)是给客户端
5
+ * UI 用的交互机制,不是任何 LLM 生成的内容,不应出现在发送给上游 provider 的上下文中。
6
+ *
7
+ * 处理步骤:
8
+ * 1. 移除 assistant 消息中的 router 合成 tool_use 块
9
+ * 2. 移除 user 消息中对应的 tool_result 块
10
+ * 3. 移除内容为空的消息
11
+ * 4. 合并连续的同角色消息
12
+ */
13
+ export declare function patchRouterSyntheticToolCalls(body: Record<string, unknown>): void;
@@ -0,0 +1,87 @@
1
+ import { TOOL_USE_ID_PREFIX } from "../enhancement/directive-parser.js";
2
+ /**
3
+ * 从消息中移除 router 注入的合成 tool_use/tool_result。
4
+ *
5
+ * Router 通过 AskUserQuestion 注入的 tool_use(ID 以 toolu_router_ 开头)是给客户端
6
+ * UI 用的交互机制,不是任何 LLM 生成的内容,不应出现在发送给上游 provider 的上下文中。
7
+ *
8
+ * 处理步骤:
9
+ * 1. 移除 assistant 消息中的 router 合成 tool_use 块
10
+ * 2. 移除 user 消息中对应的 tool_result 块
11
+ * 3. 移除内容为空的消息
12
+ * 4. 合并连续的同角色消息
13
+ */
14
+ export function patchRouterSyntheticToolCalls(body) {
15
+ if (!body.messages)
16
+ return;
17
+ const messages = body.messages;
18
+ if (!Array.isArray(messages) || messages.length === 0)
19
+ return;
20
+ // Step 1: 收集 router 合成的 tool_use ID,移除这些块
21
+ const removedIds = new Set();
22
+ for (const msg of messages) {
23
+ if (msg.role !== "assistant" || !Array.isArray(msg.content))
24
+ continue;
25
+ const blocks = msg.content;
26
+ const filtered = blocks.filter((block) => {
27
+ if (block?.type === "tool_use" &&
28
+ typeof block.id === "string" &&
29
+ block.id.startsWith(TOOL_USE_ID_PREFIX)) {
30
+ removedIds.add(block.id);
31
+ return false;
32
+ }
33
+ return true;
34
+ });
35
+ if (filtered.length !== blocks.length)
36
+ msg.content = filtered;
37
+ }
38
+ if (removedIds.size === 0)
39
+ return;
40
+ // Step 2: 移除对应的 tool_result
41
+ for (const msg of messages) {
42
+ if (msg.role !== "user" || !Array.isArray(msg.content))
43
+ continue;
44
+ const blocks = msg.content;
45
+ const filtered = blocks.filter((block) => !(block?.type === "tool_result" &&
46
+ typeof block.tool_use_id === "string" &&
47
+ removedIds.has(block.tool_use_id)));
48
+ if (filtered.length !== blocks.length)
49
+ msg.content = filtered;
50
+ }
51
+ // Step 3: 移除内容为空的消息
52
+ for (let i = messages.length - 1; i >= 0; i--) {
53
+ const content = messages[i].content;
54
+ const empty = content == null ||
55
+ content === "" ||
56
+ (Array.isArray(content) && content.length === 0);
57
+ if (empty)
58
+ messages.splice(i, 1);
59
+ }
60
+ // Step 4: 合并连续的同角色消息
61
+ mergeConsecutive(messages, "user");
62
+ mergeConsecutive(messages, "assistant");
63
+ }
64
+ function mergeConsecutive(messages, role) {
65
+ let i = 1;
66
+ while (i < messages.length) {
67
+ if (messages[i].role === role && messages[i - 1].role === role) {
68
+ const prev = messages[i - 1];
69
+ const curr = messages[i];
70
+ prev.content = [
71
+ ...normalizeToArray(prev.content),
72
+ ...normalizeToArray(curr.content),
73
+ ];
74
+ messages.splice(i, 1);
75
+ }
76
+ else {
77
+ i++;
78
+ }
79
+ }
80
+ }
81
+ function normalizeToArray(content) {
82
+ if (Array.isArray(content))
83
+ return content;
84
+ if (typeof content === "string")
85
+ return [{ type: "text", text: content }];
86
+ return [{ type: "text", text: String(content ?? "") }];
87
+ }
@@ -12,6 +12,7 @@ export interface RouteHandlerDeps {
12
12
  tracker?: RequestTracker;
13
13
  orchestrator: ProxyOrchestrator;
14
14
  usageWindowTracker?: import("./usage-window-tracker.js").UsageWindowTracker;
15
+ sessionTracker?: import("./loop-prevention/session-tracker.js").SessionTracker;
15
16
  }
16
17
  export declare function handleProxyRequest(request: FastifyRequest, reply: FastifyReply, apiType: "openai" | "anthropic", upstreamPath: string, errors: ProxyErrorFormatter, deps: RouteHandlerDeps, options?: {
17
18
  beforeSendProxy?: (body: Record<string, unknown>, isStream: boolean) => void;
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { HTTP_UNPROCESSABLE_ENTITY } from "../constants.js";
2
3
  import { getProviderById, insertRequestLog } from "../db/index.js";
3
4
  import { decrypt } from "../utils/crypto.js";
4
5
  import { getSetting } from "../db/settings.js";
@@ -10,12 +11,15 @@ import { buildUpstreamHeaders, buildUpstreamUrl } from "./proxy-core.js";
10
11
  import { ProviderSwitchNeeded } from "./types.js";
11
12
  import { updateLogStreamContent, updateLogClientStatus } from "../db/index.js";
12
13
  import { insertRejectedLog } from "./log-helpers.js";
14
+ import { ToolLoopGuard } from "./loop-prevention/tool-loop-guard.js";
15
+ import { TOOL_USE_ID_PREFIX, TOOL_USE_ID_PROVIDER_PREFIX } from "./enhancement/directive-parser.js";
13
16
  import { buildTransportFn } from "./transport-fn.js";
14
17
  import { applyOverflowRedirect } from "./overflow.js";
15
18
  import { applyProviderPatches } from "./patch/index.js";
16
19
  const HTTP_ERROR_THRESHOLD = 400;
17
20
  const MAX_LOG_FIELD_LENGTH = 80;
18
21
  const UPSTREAM_ERROR_STATUS = 502;
22
+ const TIER2_LOOP_THRESHOLD = 2;
19
23
  /** 从 TransportResult 中提取最终 HTTP status code */
20
24
  function getTransportStatusCode(result) {
21
25
  if (result.kind === "success" || result.kind === "error" || result.kind === "stream_error")
@@ -70,6 +74,44 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
70
74
  const clientModel = request.body.model || "unknown";
71
75
  const sessionId = request.headers["x-claude-code-session-id"];
72
76
  const { effectiveModel, originalModel, interceptResponse } = applyEnhancement(deps.db, request, clientModel, sessionId);
77
+ // --- 工具调用循环检测 ---
78
+ if (deps.sessionTracker && sessionId) {
79
+ const routerKeyId = request.routerKey?.id ?? null;
80
+ const sessionKey = routerKeyId ? `${routerKeyId}:${sessionId}` : sessionId;
81
+ const lastToolUse = extractLastToolUse(request.body);
82
+ if (lastToolUse) {
83
+ const toolGuard = new ToolLoopGuard(deps.sessionTracker, {
84
+ enabled: true,
85
+ minConsecutiveCount: 3,
86
+ detectorConfig: { n: 6, windowSize: 500, repeatThreshold: 5 },
87
+ });
88
+ const checkResult = toolGuard.check(sessionKey, lastToolUse);
89
+ if (checkResult.detected) {
90
+ const loopCount = deps.sessionTracker.getLoopCount(sessionKey);
91
+ if (loopCount === 1) {
92
+ // 层级 1:透明重试 — 注入中断提示词
93
+ toolGuard.injectLoopBreakPrompt(request.body, apiType, lastToolUse.toolName);
94
+ request.log.warn({ sessionId, toolName: lastToolUse.toolName, loopCount }, "Tool call loop detected, injecting break prompt");
95
+ }
96
+ else if (loopCount === TIER2_LOOP_THRESHOLD) {
97
+ // 层级 2:优雅中断
98
+ return reply.code(HTTP_UNPROCESSABLE_ENTITY).send({
99
+ error: {
100
+ type: "tool_call_loop_detected",
101
+ message: `检测到工具调用循环(连续重复调用 "${lastToolUse.toolName}")。请求已中断。`,
102
+ suggestion: "请回顾对话历史,停止重复调用工具,直接告知用户当前的进展和遇到的问题。",
103
+ },
104
+ });
105
+ }
106
+ else {
107
+ // 层级 3:直接断开
108
+ request.log.warn({ sessionId, toolName: lastToolUse.toolName, loopCount }, "Tool call loop detected, hard disconnecting");
109
+ reply.raw.destroy();
110
+ return reply;
111
+ }
112
+ }
113
+ }
114
+ }
73
115
  if (interceptResponse)
74
116
  return handleIntercept(deps.db, apiType, request, reply, interceptResponse, clientModel, sessionId);
75
117
  return executeFailoverLoop({
@@ -91,7 +133,7 @@ async function executeFailoverLoop(ctx) {
91
133
  rootLogId = logId;
92
134
  const isFailoverIteration = rootLogId !== logId;
93
135
  const routerKeyId = request.routerKey?.id ?? null;
94
- const body = request.body;
136
+ let body = request.body;
95
137
  const isStream = body.stream === true;
96
138
  const cliHdrs = request.headers;
97
139
  const rCtx = {
@@ -143,7 +185,8 @@ async function executeFailoverLoop(ctx) {
143
185
  body.model = overflowResult.backend_model;
144
186
  }
145
187
  }
146
- applyProviderPatches(body, provider);
188
+ const patchResult = applyProviderPatches(body, provider);
189
+ body = patchResult.body;
147
190
  const apiKey = decrypt(provider.api_key, getSetting(deps.db, "encryption_key"));
148
191
  options?.beforeSendProxy?.(body, isStream);
149
192
  const reqBodyStr = JSON.stringify(body);
@@ -242,3 +285,46 @@ async function executeFailoverLoop(ctx) {
242
285
  }
243
286
  }
244
287
  }
288
+ function extractLastToolUse(body) {
289
+ const messages = body.messages;
290
+ if (!messages)
291
+ return null;
292
+ for (let i = messages.length - 1; i >= 0; i--) {
293
+ const msg = messages[i];
294
+ if (msg.role === "assistant") {
295
+ const content = msg.content;
296
+ if (Array.isArray(content)) {
297
+ // 从后往前遍历,找到第一个非 router 合成的 tool_use
298
+ for (let j = content.length - 1; j >= 0; j--) {
299
+ const block = content[j];
300
+ if (block.type !== "tool_use")
301
+ continue;
302
+ // 跳过 router 生成的 synthetic tool_use(如 AskUserQuestion 的模型选择)
303
+ const id = block.id;
304
+ if (id && (id.startsWith(TOOL_USE_ID_PREFIX) || id.startsWith(TOOL_USE_ID_PROVIDER_PREFIX))) {
305
+ continue;
306
+ }
307
+ const inputStr = JSON.stringify(block.input ?? {});
308
+ return {
309
+ toolName: block.name,
310
+ toolUseId: typeof block.id === "string" ? block.id : undefined,
311
+ inputHash: simpleHash(inputStr),
312
+ inputText: inputStr,
313
+ timestamp: Date.now(),
314
+ };
315
+ }
316
+ }
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+ function simpleHash(s) {
322
+ const HASH_SHIFT = 5;
323
+ let hash = 0;
324
+ for (let i = 0; i < s.length; i++) {
325
+ const char = s.charCodeAt(i);
326
+ hash = ((hash << HASH_SHIFT) - hash) + char;
327
+ hash |= 0;
328
+ }
329
+ return String(hash);
330
+ }
@@ -1,7 +1,8 @@
1
1
  import type { FastifyReply } from "fastify";
2
2
  import type { RawHeaders, TransportResult } from "./types.js";
3
3
  import type { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
4
+ import type { StreamLoopGuard } from "./loop-prevention/stream-loop-guard.js";
4
5
  import { type BuildHeadersFn } from "./transport.js";
5
6
  export declare function callStream(backend: {
6
7
  base_url: string;
7
- }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, reply: FastifyReply, timeoutMs: number, upstreamPath: string, buildHeaders: BuildHeadersFn, metricsTransform?: SSEMetricsTransform, checkEarlyError?: (bufferedData: string) => boolean, compatResolve?: (result: TransportResult) => void): Promise<TransportResult>;
8
+ }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, reply: FastifyReply, timeoutMs: number, upstreamPath: string, buildHeaders: BuildHeadersFn, metricsTransform?: SSEMetricsTransform, checkEarlyError?: (bufferedData: string) => boolean, compatResolve?: (result: TransportResult) => void, loopGuard?: StreamLoopGuard): Promise<TransportResult>;
@@ -11,6 +11,7 @@ class StreamProxy {
11
11
  metricsTransform;
12
12
  checkEarlyError;
13
13
  timeoutMs;
14
+ loopGuard;
14
15
  state = "BUFFERING";
15
16
  resolved = false;
16
17
  resolveFn = null;
@@ -26,13 +27,14 @@ class StreamProxy {
26
27
  // 流式阶段 SSE error 扫描缓冲(跨 chunk 边界匹配)
27
28
  sseScanBuffer = "";
28
29
  static SSE_SCAN_MAX = 8192;
29
- constructor(statusCode, rawUpstreamHeaders, sentUpstreamHeaders, reply, metricsTransform, checkEarlyError, timeoutMs) {
30
+ constructor(statusCode, rawUpstreamHeaders, sentUpstreamHeaders, reply, metricsTransform, checkEarlyError, timeoutMs, loopGuard) {
30
31
  this.statusCode = statusCode;
31
32
  this.sentUpstreamHeaders = sentUpstreamHeaders;
32
33
  this.reply = reply;
33
34
  this.metricsTransform = metricsTransform;
34
35
  this.checkEarlyError = checkEarlyError;
35
36
  this.timeoutMs = timeoutMs;
37
+ this.loopGuard = loopGuard;
36
38
  this.sseHeaders = filterHeaders(rawUpstreamHeaders);
37
39
  this.sseHeaders["Content-Type"] = "text/event-stream";
38
40
  this.sseHeaders["Cache-Control"] = "no-cache";
@@ -211,6 +213,12 @@ class StreamProxy {
211
213
  }
212
214
  }
213
215
  this.pipeEntry.write(chunk);
216
+ // loopGuard 由 SSEMetricsTransform 的 onContentDelta 回调驱动,
217
+ // 此处仅检查是否已触发(触发后终止流)
218
+ if (this.loopGuard?.isTriggered()) {
219
+ this.terminal("stream_abort", { metrics: this.collectMetrics(false) });
220
+ return;
221
+ }
214
222
  }
215
223
  onEnd() {
216
224
  if (this.resolved)
@@ -259,7 +267,7 @@ class StreamProxy {
259
267
  }
260
268
  }
261
269
  // ---------- callStream ----------
262
- export function callStream(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, buildHeaders, metricsTransform, checkEarlyError, compatResolve) {
270
+ export function callStream(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, buildHeaders, metricsTransform, checkEarlyError, compatResolve, loopGuard) {
263
271
  return new Promise((resolve) => {
264
272
  const effectiveResolve = compatResolve ?? resolve;
265
273
  const url = new URL(buildUpstreamUrl(backend.base_url, upstreamPath));
@@ -283,7 +291,7 @@ export function callStream(backend, apiKey, body, clientHeaders, reply, timeoutM
283
291
  });
284
292
  return;
285
293
  }
286
- const proxy = new StreamProxy(statusCode, upstreamRes.headers, upstreamHeaders, reply, metricsTransform, checkEarlyError, timeoutMs);
294
+ const proxy = new StreamProxy(statusCode, upstreamRes.headers, upstreamHeaders, reply, metricsTransform, checkEarlyError, timeoutMs, loopGuard);
287
295
  proxy.bindResolve(effectiveResolve);
288
296
  proxy.registerCloseHandler();
289
297
  // 无 early error checker 时直接开始流式传输
@@ -1,9 +1,11 @@
1
1
  import type { FastifyReply, FastifyRequest } from "fastify";
2
2
  import { getProviderById } from "../db/index.js";
3
+ import type { LoopPreventionConfig } from "./loop-prevention/types.js";
3
4
  import type { RawHeaders, TransportResult } from "./types.js";
4
5
  import type { Target } from "./strategy/types.js";
5
6
  import type { RequestTracker } from "../monitor/request-tracker.js";
6
7
  import type { RetryRuleMatcher } from "./retry-rules.js";
8
+ export declare function setLoopPreventionConfig(config: LoopPreventionConfig): void;
7
9
  export interface TransportFnParams {
8
10
  provider: NonNullable<ReturnType<typeof getProviderById>>;
9
11
  apiKey: string;
@@ -2,6 +2,9 @@ import { callNonStream, callStream } from "./transport.js";
2
2
  import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
3
3
  import { MetricsExtractor } from "../metrics/metrics-extractor.js";
4
4
  import { buildUpstreamHeaders } from "./proxy-core.js";
5
+ import { StreamLoopGuard } from "./loop-prevention/stream-loop-guard.js";
6
+ import { NGramLoopDetector } from "./loop-prevention/detectors/ngram-detector.js";
7
+ import { DEFAULT_LOOP_PREVENTION_CONFIG } from "./loop-prevention/types.js";
5
8
  import { UPSTREAM_SUCCESS } from "./types.js";
6
9
  import { buildModelInfoTag } from "./enhancement/enhancement-handler.js";
7
10
  import { DEFAULT_MAX_RAW as STREAM_CONTENT_MAX_RAW, DEFAULT_MAX_TEXT as STREAM_CONTENT_MAX_TEXT } from "../monitor/stream-content-accumulator.js";
@@ -26,17 +29,32 @@ function toStreamMetrics(m) {
26
29
  toolUseTokens: m.tool_use_tokens,
27
30
  };
28
31
  }
32
+ let _loopConfig;
33
+ export function setLoopPreventionConfig(config) {
34
+ _loopConfig = config;
35
+ }
36
+ function getLoopPreventionConfig() {
37
+ return _loopConfig ?? DEFAULT_LOOP_PREVENTION_CONFIG;
38
+ }
29
39
  export function buildTransportFn(p) {
30
40
  const buildHeaders = (cliHdrs, key, bytes) => buildUpstreamHeaders(cliHdrs, key, bytes, p.apiType);
31
41
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
32
42
  return async (_target) => {
33
43
  if (p.isStream) {
44
+ const loopPreventionConfig = getLoopPreventionConfig();
45
+ let streamLoopGuard;
46
+ if (loopPreventionConfig.enabled && loopPreventionConfig.stream.enabled) {
47
+ streamLoopGuard = new StreamLoopGuard(loopPreventionConfig.stream, new NGramLoopDetector(loopPreventionConfig.stream.detectorConfig), (reason) => {
48
+ p.request.log.warn({ logId: p.logId, reason }, "Stream loop detected, aborting");
49
+ });
50
+ }
34
51
  const metricsTransform = new SSEMetricsTransform(p.apiType, p.startTime, {
35
52
  onMetrics: (m) => { p.tracker?.update(p.logId, { streamMetrics: toStreamMetrics(m) }); },
36
53
  onChunk: (rawLine) => { p.tracker?.appendStreamChunk(p.logId, rawLine, p.apiType, STREAM_CONTENT_MAX_RAW, STREAM_CONTENT_MAX_TEXT); },
54
+ onContentDelta: streamLoopGuard ? (text) => streamLoopGuard.feed(text) : undefined,
37
55
  });
38
56
  const checkEarlyError = p.matcher ? (data) => p.matcher.test(UPSTREAM_SUCCESS, data) : undefined;
39
- const streamResult = await callStream(p.provider, p.apiKey, p.body, p.cliHdrs, p.reply, p.streamTimeoutMs, p.upstreamPath, buildHeaders, metricsTransform, checkEarlyError);
57
+ const streamResult = await callStream(p.provider, p.apiKey, p.body, p.cliHdrs, p.reply, p.streamTimeoutMs, p.upstreamPath, buildHeaders, metricsTransform, checkEarlyError, undefined, streamLoopGuard);
40
58
  const m = (streamResult.kind === "stream_success" || streamResult.kind === "stream_abort")
41
59
  ? streamResult.metrics : undefined;
42
60
  if (m)
@@ -1 +1 @@
1
- import{G as e,It as t,J as n,Pt as r,lt as i,ot as a,r as o}from"./button-BLX8zWc1.js";var s=[`data-size`],c=n({__name:`Card`,props:{class:{type:[Boolean,null,String,Object,Array]},size:{default:`default`}},setup(n){let c=n;return(l,u)=>(a(),e(`div`,{"data-slot":`card`,"data-size":n.size,class:t(r(o)(`ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-lg py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col`,c.class))},[i(l.$slots,`default`)],10,s))}}),l=n({__name:`CardContent`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(n){let s=n;return(n,c)=>(a(),e(`div`,{"data-slot":`card-content`,class:t(r(o)(`px-4 group-data-[size=sm]/card:px-3`,s.class))},[i(n.$slots,`default`)],2))}});export{c as n,l as t};
1
+ import{G as e,It as t,J as n,Pt as r,lt as i,ot as a,r as o}from"./button-q9xTxPJh.js";var s=[`data-size`],c=n({__name:`Card`,props:{class:{type:[Boolean,null,String,Object,Array]},size:{default:`default`}},setup(n){let c=n;return(l,u)=>(a(),e(`div`,{"data-slot":`card`,"data-size":n.size,class:t(r(o)(`ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-lg py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col`,c.class))},[i(l.$slots,`default`)],10,s))}}),l=n({__name:`CardContent`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(n){let s=n;return(n,c)=>(a(),e(`div`,{"data-slot":`card-content`,class:t(r(o)(`px-4 group-data-[size=sm]/card:px-3`,s.class))},[i(n.$slots,`default`)],2))}});export{c as n,l as t};
@@ -1 +1 @@
1
- import{G as e,It as t,J as n,Pt as r,lt as i,ot as a,r as o}from"./button-BLX8zWc1.js";var s=n({__name:`CardHeader`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(n){let s=n;return(n,c)=>(a(),e(`div`,{"data-slot":`card-header`,class:t(r(o)(`gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]`,s.class))},[i(n.$slots,`default`)],2))}}),c=n({__name:`CardTitle`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(n){let s=n;return(n,c)=>(a(),e(`div`,{"data-slot":`card-title`,class:t(r(o)(`text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading`,s.class))},[i(n.$slots,`default`)],2))}});export{s as n,c as t};
1
+ import{G as e,It as t,J as n,Pt as r,lt as i,ot as a,r as o}from"./button-q9xTxPJh.js";var s=n({__name:`CardHeader`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(n){let s=n;return(n,c)=>(a(),e(`div`,{"data-slot":`card-header`,class:t(r(o)(`gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]`,s.class))},[i(n.$slots,`default`)],2))}}),c=n({__name:`CardTitle`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(n){let s=n;return(n,c)=>(a(),e(`div`,{"data-slot":`card-title`,class:t(r(o)(`text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading`,s.class))},[i(n.$slots,`default`)],2))}});export{s as n,c as t};
@@ -1 +1 @@
1
- import{Dt as e,G as t,H as n,It as r,J as i,Pt as a,U as o,V as s,W as c,ct as l,ot as u,q as d,yt as f,z as p,zt as m}from"./button-BLX8zWc1.js";import{b as h}from"./Teleport-BGbwtNTD.js";import{a as g}from"./PopperContent-B23SzU9H.js";import{n as _,r as v,t as y}from"./PopoverTrigger-BVsxIE2L.js";var b=h(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),x=[`onMouseenter`],S={class:`truncate max-w-40`},C=[`onMouseenter`],w=[`onClick`],T={class:`truncate`},E={key:0,class:`shrink-0 text-xs text-muted-foreground`},D={key:0,class:`px-2 py-1.5 text-sm text-muted-foreground`},O=i({__name:`CascadingSelect`,props:{groups:{},modelValue:{},placeholder:{default:`请选择...`}},emits:[`update:modelValue`],setup(i,{emit:h}){let O=i,k=h,A=e(!1),j=e(null),M=s(()=>{if(!O.modelValue)return``;let e=O.groups.find(e=>e.key===O.modelValue.groupKey);if(!e)return``;let t=e.options.find(e=>e.value===O.modelValue.value);return t?`${e.label} / ${t.label}`:``});function N(e,t){k(`update:modelValue`,{groupKey:e,value:t}),A.value=!1}function P(e){A.value=e,e||(j.value=null)}return(e,s)=>(u(),o(a(v),{open:A.value,"onUpdate:open":P},{default:f(()=>[d(a(y),{"as-child":``},{default:f(()=>[n(`div`,{class:r([`flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer hover:bg-accent hover:text-accent-foreground`,{"ring-2 ring-ring ring-offset-2":A.value}])},[n(`span`,{class:r([`truncate`,i.modelValue?`text-foreground`:`text-muted-foreground`])},m(M.value||i.placeholder),3),d(a(g),{class:`h-4 w-4 shrink-0 opacity-50`})],2)]),_:1}),d(a(_),{align:`start`,"side-offset":4,class:`z-[200] w-auto min-w-56 overflow-visible p-1`},{default:f(()=>[(u(!0),t(p,null,l(i.groups,e=>(u(),t(`div`,{key:e.key,class:r([`relative flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground`,{"bg-accent text-accent-foreground z-10":j.value===e.key}]),onMouseenter:t=>j.value=e.key},[n(`span`,S,m(e.label),1),d(a(b),{class:`ml-1 h-4 w-4 shrink-0 opacity-50`}),j.value===e.key&&e.options.length>0?(u(),t(`div`,{key:0,class:`absolute left-full top-0 ml-0.5 min-w-48 rounded-md border bg-popover p-1 text-popover-foreground shadow-md`,onMouseenter:t=>j.value=e.key},[(u(!0),t(p,null,l(e.options,a=>(u(),t(`div`,{key:a.value,class:r([`flex cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground`,{"bg-accent text-accent-foreground":i.modelValue?.groupKey===e.key&&i.modelValue?.value===a.value}]),onClick:t=>N(e.key,a.value)},[n(`span`,T,m(a.label),1),a.tag?(u(),t(`span`,E,m(a.tag),1)):c(``,!0)],10,w))),128))],40,C)):c(``,!0)],42,x))),128)),i.groups.length===0?(u(),t(`div`,D,` 暂无选项 `)):c(``,!0)]),_:1})]),_:1},8,[`open`]))}}),k=i({__name:`CascadingModelSelect`,props:{providers:{},modelValue:{},placeholder:{default:`选择供应商 / 模型`}},emits:[`update:modelValue`],setup(e,{emit:t}){let n=e,r=t;function i(e){return e>=1e6?`${e/1e6}M`:`${e/1e3}K`}let a=s(()=>n.providers.map(e=>({key:e.provider.id,label:e.provider.name,options:e.models.map(e=>({value:e.name,label:e.name,tag:i(e.contextWindow)}))}))),c=s(()=>n.modelValue?{groupKey:n.modelValue.provider_id,value:n.modelValue.model}:void 0);function l(e){r(`update:modelValue`,{provider_id:e.groupKey,model:e.value})}return(t,n)=>(u(),o(O,{groups:a.value,"model-value":c.value,placeholder:e.placeholder,"onUpdate:modelValue":l},null,8,[`groups`,`model-value`,`placeholder`]))}});export{k as t};
1
+ import{Dt as e,G as t,H as n,It as r,J as i,Pt as a,U as o,V as s,W as c,ct as l,ot as u,q as d,yt as f,z as p,zt as m}from"./button-q9xTxPJh.js";import{b as h}from"./Teleport-CRn-gy0B.js";import{a as g}from"./PopperContent-BoOYHCag.js";import{n as _,r as v,t as y}from"./PopoverTrigger-CIN3yOIw.js";var b=h(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),x=[`onMouseenter`],S={class:`truncate max-w-40`},C=[`onMouseenter`],w=[`onClick`],T={class:`truncate`},E={key:0,class:`shrink-0 text-xs text-muted-foreground`},D={key:0,class:`px-2 py-1.5 text-sm text-muted-foreground`},O=i({__name:`CascadingSelect`,props:{groups:{},modelValue:{},placeholder:{default:`请选择...`}},emits:[`update:modelValue`],setup(i,{emit:h}){let O=i,k=h,A=e(!1),j=e(null),M=s(()=>{if(!O.modelValue)return``;let e=O.groups.find(e=>e.key===O.modelValue.groupKey);if(!e)return``;let t=e.options.find(e=>e.value===O.modelValue.value);return t?`${e.label} / ${t.label}`:``});function N(e,t){k(`update:modelValue`,{groupKey:e,value:t}),A.value=!1}function P(e){A.value=e,e||(j.value=null)}return(e,s)=>(u(),o(a(v),{open:A.value,"onUpdate:open":P},{default:f(()=>[d(a(y),{"as-child":``},{default:f(()=>[n(`div`,{class:r([`flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer hover:bg-accent hover:text-accent-foreground`,{"ring-2 ring-ring ring-offset-2":A.value}])},[n(`span`,{class:r([`truncate`,i.modelValue?`text-foreground`:`text-muted-foreground`])},m(M.value||i.placeholder),3),d(a(g),{class:`h-4 w-4 shrink-0 opacity-50`})],2)]),_:1}),d(a(_),{align:`start`,"side-offset":4,class:`z-[200] w-auto min-w-56 overflow-visible p-1`},{default:f(()=>[(u(!0),t(p,null,l(i.groups,e=>(u(),t(`div`,{key:e.key,class:r([`relative flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground`,{"bg-accent text-accent-foreground z-10":j.value===e.key}]),onMouseenter:t=>j.value=e.key},[n(`span`,S,m(e.label),1),d(a(b),{class:`ml-1 h-4 w-4 shrink-0 opacity-50`}),j.value===e.key&&e.options.length>0?(u(),t(`div`,{key:0,class:`absolute left-full top-0 ml-0.5 min-w-48 rounded-md border bg-popover p-1 text-popover-foreground shadow-md`,onMouseenter:t=>j.value=e.key},[(u(!0),t(p,null,l(e.options,a=>(u(),t(`div`,{key:a.value,class:r([`flex cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground`,{"bg-accent text-accent-foreground":i.modelValue?.groupKey===e.key&&i.modelValue?.value===a.value}]),onClick:t=>N(e.key,a.value)},[n(`span`,T,m(a.label),1),a.tag?(u(),t(`span`,E,m(a.tag),1)):c(``,!0)],10,w))),128))],40,C)):c(``,!0)],42,x))),128)),i.groups.length===0?(u(),t(`div`,D,` 暂无选项 `)):c(``,!0)]),_:1})]),_:1},8,[`open`]))}}),k=i({__name:`CascadingModelSelect`,props:{providers:{},modelValue:{},placeholder:{default:`选择供应商 / 模型`}},emits:[`update:modelValue`],setup(e,{emit:t}){let n=e,r=t;function i(e){return e>=1e6?`${e/1e6}M`:`${e/1e3}K`}let a=s(()=>n.providers.map(e=>({key:e.provider.id,label:e.provider.name,options:e.models.map(e=>({value:e.name,label:e.name,tag:i(e.contextWindow)}))}))),c=s(()=>n.modelValue?{groupKey:n.modelValue.provider_id,value:n.modelValue.model}:void 0);function l(e){r(`update:modelValue`,{provider_id:e.groupKey,model:e.value})}return(t,n)=>(u(),o(O,{groups:a.value,"model-value":c.value,placeholder:e.placeholder,"onUpdate:modelValue":l},null,8,[`groups`,`model-value`,`placeholder`]))}});export{k as t};