@mseep/anything-analyzer 3.6.50

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 (172) hide show
  1. package/.codeartsdoer/.codebaseignore +0 -0
  2. package/.codeartsdoer/AGENTS.md +12 -0
  3. package/.github/workflows/build.yml +146 -0
  4. package/README.en.md +264 -0
  5. package/README.md +276 -0
  6. package/RELEASE_NOTES.md +16 -0
  7. package/USAGE.md +490 -0
  8. package/color-preview-r3.html +414 -0
  9. package/color-preview.html +414 -0
  10. package/dev-app-update.yml +3 -0
  11. package/electron-builder.yml +36 -0
  12. package/electron.vite.config.ts +40 -0
  13. package/package.json +53 -0
  14. package/report-2026-04-13-copilot-claude-sonnet-4.6.md +955 -0
  15. package/resources/doloffer-logo.png +0 -0
  16. package/resources/entitlements.mac.plist +12 -0
  17. package/resources/icon.ico +0 -0
  18. package/resources/icon.png +0 -0
  19. package/src/main/ai/ai-analyzer.ts +517 -0
  20. package/src/main/ai/crypto-script-extractor.ts +206 -0
  21. package/src/main/ai/data-assembler.ts +205 -0
  22. package/src/main/ai/llm-router.ts +1120 -0
  23. package/src/main/ai/prompt-builder.ts +349 -0
  24. package/src/main/ai/scene-detector.ts +302 -0
  25. package/src/main/capture/capture-engine.ts +130 -0
  26. package/src/main/capture/interaction-recorder.ts +171 -0
  27. package/src/main/capture/js-injector.ts +57 -0
  28. package/src/main/capture/replay-engine.ts +256 -0
  29. package/src/main/capture/storage-collector.ts +76 -0
  30. package/src/main/cdp/cdp-manager.ts +233 -0
  31. package/src/main/db/database.ts +41 -0
  32. package/src/main/db/migrations.ts +235 -0
  33. package/src/main/db/repositories.ts +574 -0
  34. package/src/main/fingerprint/http-spoofing.ts +48 -0
  35. package/src/main/fingerprint/presets.ts +173 -0
  36. package/src/main/fingerprint/profile-generator.ts +115 -0
  37. package/src/main/fingerprint/profile-store.ts +52 -0
  38. package/src/main/index.ts +260 -0
  39. package/src/main/ipc.ts +856 -0
  40. package/src/main/logger.ts +42 -0
  41. package/src/main/mcp/mcp-config.ts +66 -0
  42. package/src/main/mcp/mcp-manager.ts +155 -0
  43. package/src/main/mcp/mcp-server.ts +1038 -0
  44. package/src/main/prompt-templates.ts +170 -0
  45. package/src/main/proxy/ca-manager.ts +204 -0
  46. package/src/main/proxy/cert-download-page.ts +171 -0
  47. package/src/main/proxy/cert-installer.ts +242 -0
  48. package/src/main/proxy/mitm-proxy-config.ts +37 -0
  49. package/src/main/proxy/mitm-proxy-server.ts +1085 -0
  50. package/src/main/proxy/system-proxy.ts +248 -0
  51. package/src/main/session/session-manager.ts +724 -0
  52. package/src/main/tab-manager.ts +582 -0
  53. package/src/main/updater.ts +111 -0
  54. package/src/main/window.ts +235 -0
  55. package/src/preload/hook-script.ts +270 -0
  56. package/src/preload/index.ts +211 -0
  57. package/src/preload/interaction-hook.ts +286 -0
  58. package/src/preload/stealth-script.ts +302 -0
  59. package/src/preload/target-preload.ts +15 -0
  60. package/src/renderer/App.tsx +656 -0
  61. package/src/renderer/components/AiLogDetail.tsx +173 -0
  62. package/src/renderer/components/AiLogList.tsx +101 -0
  63. package/src/renderer/components/AiLogView.module.css +364 -0
  64. package/src/renderer/components/AiLogView.tsx +86 -0
  65. package/src/renderer/components/AnalyzeBar.module.css +79 -0
  66. package/src/renderer/components/AnalyzeBar.tsx +104 -0
  67. package/src/renderer/components/BrowserPanel.module.css +67 -0
  68. package/src/renderer/components/BrowserPanel.tsx +90 -0
  69. package/src/renderer/components/ControlBar.module.css +47 -0
  70. package/src/renderer/components/ControlBar.tsx +205 -0
  71. package/src/renderer/components/HookLog.tsx +132 -0
  72. package/src/renderer/components/InteractionLog.tsx +183 -0
  73. package/src/renderer/components/MCPServerModal.tsx +427 -0
  74. package/src/renderer/components/PromptTemplateModal.tsx +254 -0
  75. package/src/renderer/components/ReportView.module.css +413 -0
  76. package/src/renderer/components/ReportView.tsx +429 -0
  77. package/src/renderer/components/RequestDetail.module.css +191 -0
  78. package/src/renderer/components/RequestDetail.tsx +202 -0
  79. package/src/renderer/components/RequestLog.module.css +69 -0
  80. package/src/renderer/components/RequestLog.tsx +208 -0
  81. package/src/renderer/components/SessionList.module.css +245 -0
  82. package/src/renderer/components/SessionList.tsx +247 -0
  83. package/src/renderer/components/SettingsModal.tsx +100 -0
  84. package/src/renderer/components/StatusBar.module.css +44 -0
  85. package/src/renderer/components/StatusBar.tsx +102 -0
  86. package/src/renderer/components/StorageView.module.css +41 -0
  87. package/src/renderer/components/StorageView.tsx +178 -0
  88. package/src/renderer/components/TabBar.module.css +88 -0
  89. package/src/renderer/components/TabBar.tsx +70 -0
  90. package/src/renderer/components/Titlebar.module.css +254 -0
  91. package/src/renderer/components/Titlebar.tsx +169 -0
  92. package/src/renderer/components/settings/FingerprintSection.tsx +198 -0
  93. package/src/renderer/components/settings/GeneralSection.tsx +164 -0
  94. package/src/renderer/components/settings/LLMSection.tsx +148 -0
  95. package/src/renderer/components/settings/MCPServerSection.tsx +136 -0
  96. package/src/renderer/components/settings/MitmProxySection.tsx +320 -0
  97. package/src/renderer/components/settings/ProxySection.tsx +110 -0
  98. package/src/renderer/css-modules.d.ts +4 -0
  99. package/src/renderer/hooks/useCapture.ts +383 -0
  100. package/src/renderer/hooks/useConfirm.tsx +91 -0
  101. package/src/renderer/hooks/useSession.ts +136 -0
  102. package/src/renderer/hooks/useTabs.ts +103 -0
  103. package/src/renderer/i18n/en.ts +167 -0
  104. package/src/renderer/i18n/index.ts +47 -0
  105. package/src/renderer/i18n/zh.ts +170 -0
  106. package/src/renderer/index.html +12 -0
  107. package/src/renderer/main.tsx +15 -0
  108. package/src/renderer/styles/global.css +144 -0
  109. package/src/renderer/styles/themes/ayu-dark.css +59 -0
  110. package/src/renderer/styles/themes/catppuccin.css +59 -0
  111. package/src/renderer/styles/themes/discord.css +59 -0
  112. package/src/renderer/styles/themes/dracula.css +59 -0
  113. package/src/renderer/styles/themes/github-dark.css +59 -0
  114. package/src/renderer/styles/themes/gruvbox.css +59 -0
  115. package/src/renderer/styles/themes/index.css +11 -0
  116. package/src/renderer/styles/themes/light.css +59 -0
  117. package/src/renderer/styles/themes/nord.css +59 -0
  118. package/src/renderer/styles/themes/one-dark.css +59 -0
  119. package/src/renderer/styles/themes/tokyo-night.css +59 -0
  120. package/src/renderer/styles/tokens.css +137 -0
  121. package/src/renderer/theme.ts +31 -0
  122. package/src/renderer/ui/Badge.module.css +38 -0
  123. package/src/renderer/ui/Badge.tsx +36 -0
  124. package/src/renderer/ui/Button.module.css +142 -0
  125. package/src/renderer/ui/Button.tsx +46 -0
  126. package/src/renderer/ui/Collapse.module.css +49 -0
  127. package/src/renderer/ui/Collapse.tsx +57 -0
  128. package/src/renderer/ui/CopyableBlock.module.css +56 -0
  129. package/src/renderer/ui/CopyableBlock.tsx +42 -0
  130. package/src/renderer/ui/Empty.module.css +19 -0
  131. package/src/renderer/ui/Empty.tsx +34 -0
  132. package/src/renderer/ui/Icons.tsx +346 -0
  133. package/src/renderer/ui/Input.module.css +103 -0
  134. package/src/renderer/ui/Input.tsx +94 -0
  135. package/src/renderer/ui/InputNumber.module.css +68 -0
  136. package/src/renderer/ui/InputNumber.tsx +104 -0
  137. package/src/renderer/ui/Modal.module.css +83 -0
  138. package/src/renderer/ui/Modal.tsx +67 -0
  139. package/src/renderer/ui/Popconfirm.module.css +73 -0
  140. package/src/renderer/ui/Popconfirm.tsx +74 -0
  141. package/src/renderer/ui/Progress.module.css +35 -0
  142. package/src/renderer/ui/Progress.tsx +30 -0
  143. package/src/renderer/ui/Select.module.css +91 -0
  144. package/src/renderer/ui/Select.tsx +100 -0
  145. package/src/renderer/ui/Spinner.module.css +44 -0
  146. package/src/renderer/ui/Spinner.tsx +27 -0
  147. package/src/renderer/ui/Switch.module.css +39 -0
  148. package/src/renderer/ui/Switch.tsx +43 -0
  149. package/src/renderer/ui/Tabs.module.css +76 -0
  150. package/src/renderer/ui/Tabs.tsx +53 -0
  151. package/src/renderer/ui/Tag.module.css +66 -0
  152. package/src/renderer/ui/Tag.tsx +47 -0
  153. package/src/renderer/ui/Timeline.module.css +42 -0
  154. package/src/renderer/ui/Timeline.tsx +29 -0
  155. package/src/renderer/ui/Toast.module.css +99 -0
  156. package/src/renderer/ui/Toast.tsx +90 -0
  157. package/src/renderer/ui/Tooltip.module.css +26 -0
  158. package/src/renderer/ui/Tooltip.tsx +23 -0
  159. package/src/renderer/ui/VirtualTable.module.css +230 -0
  160. package/src/renderer/ui/VirtualTable.tsx +416 -0
  161. package/src/renderer/ui/index.ts +55 -0
  162. package/src/shared/types.ts +695 -0
  163. package/tests/main/ai/crypto-script-extractor.test.ts +281 -0
  164. package/tests/main/ai/llm-router.test.ts +1537 -0
  165. package/tests/main/ai/prompt-builder.test.ts +178 -0
  166. package/tests/main/ai/scene-detector.test.ts +212 -0
  167. package/tests/main/db/migrations.test.ts +134 -0
  168. package/tests/main/release-workflow.test.ts +59 -0
  169. package/tsconfig.json +7 -0
  170. package/tsconfig.node.json +23 -0
  171. package/tsconfig.web.json +24 -0
  172. package/vitest.config.ts +13 -0
@@ -0,0 +1,1537 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { LLMRouter } from "../../../src/main/ai/llm-router";
3
+ import type { LLMProviderConfig } from "../../../src/shared/types";
4
+
5
+ // Helper: create a mock Response with SSE stream
6
+ function createSSEResponse(
7
+ events: Array<{ event?: string; data: string }>,
8
+ ): Response {
9
+ const lines =
10
+ events
11
+ .map((e) => {
12
+ const parts: string[] = [];
13
+ if (e.event) parts.push(`event: ${e.event}`);
14
+ parts.push(`data: ${e.data}`);
15
+ return parts.join("\n");
16
+ })
17
+ .join("\n\n") + "\n\n";
18
+
19
+ const encoder = new TextEncoder();
20
+ const stream = new ReadableStream({
21
+ start(controller) {
22
+ controller.enqueue(encoder.encode(lines));
23
+ controller.close();
24
+ },
25
+ });
26
+ return new Response(stream, {
27
+ status: 200,
28
+ headers: { "content-type": "text/event-stream" },
29
+ });
30
+ }
31
+
32
+ function createRawSSEResponse(body: string): Response {
33
+ const encoder = new TextEncoder();
34
+ const stream = new ReadableStream({
35
+ start(controller) {
36
+ controller.enqueue(encoder.encode(body));
37
+ controller.close();
38
+ },
39
+ });
40
+ return new Response(stream, {
41
+ status: 200,
42
+ headers: { "content-type": "text/event-stream" },
43
+ });
44
+ }
45
+
46
+ // Helper: create a mock JSON Response
47
+ function createJSONResponse(body: unknown): Response {
48
+ return new Response(JSON.stringify(body), {
49
+ status: 200,
50
+ headers: { "content-type": "application/json" },
51
+ });
52
+ }
53
+
54
+ const baseConfig: LLMProviderConfig = {
55
+ name: "openai",
56
+ baseUrl: "https://api.openai.com/v1",
57
+ apiKey: "sk-test",
58
+ model: "gpt-4o",
59
+ maxTokens: 4096,
60
+ };
61
+
62
+ describe("LLMRouter", () => {
63
+ let fetchSpy: ReturnType<typeof vi.fn>;
64
+
65
+ beforeEach(() => {
66
+ fetchSpy = vi.fn();
67
+ vi.stubGlobal("fetch", fetchSpy);
68
+ });
69
+
70
+ afterEach(() => {
71
+ vi.useRealTimers();
72
+ vi.restoreAllMocks();
73
+ });
74
+
75
+ describe("routing", () => {
76
+ it("should abort while waiting to retry rate-limited requests", async () => {
77
+ vi.useFakeTimers();
78
+ const controller = new AbortController();
79
+ fetchSpy.mockResolvedValueOnce(
80
+ new Response("rate limited", {
81
+ status: 429,
82
+ headers: { "retry-after": "60" },
83
+ }),
84
+ );
85
+
86
+ const router = new LLMRouter(baseConfig);
87
+ const request = router.complete(
88
+ [{ role: "user", content: "test" }],
89
+ undefined,
90
+ controller.signal,
91
+ );
92
+ await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
93
+
94
+ controller.abort();
95
+
96
+ await expect(request).rejects.toThrow("LLM 请求已取消");
97
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it("should connect abort signal to standard LLM requests", async () => {
101
+ const controller = new AbortController();
102
+ fetchSpy.mockImplementationOnce((_url, options) => {
103
+ return new Promise((_resolve, reject) => {
104
+ options.signal.addEventListener("abort", () => {
105
+ reject(new DOMException("Aborted", "AbortError"));
106
+ });
107
+ });
108
+ });
109
+
110
+ const router = new LLMRouter(baseConfig);
111
+ const request = router.complete([{ role: "user", content: "test" }], undefined, controller.signal);
112
+
113
+ const [, options] = fetchSpy.mock.calls[0];
114
+ controller.abort();
115
+ expect(options.signal.aborted).toBe(true);
116
+ await expect(request).rejects.toThrow("LLM 请求已取消");
117
+ });
118
+
119
+ it("should connect abort signal to tool-enabled LLM requests", async () => {
120
+ const controller = new AbortController();
121
+ fetchSpy.mockImplementationOnce((_url, options) => {
122
+ return new Promise((_resolve, reject) => {
123
+ options.signal.addEventListener("abort", () => {
124
+ reject(new DOMException("Aborted", "AbortError"));
125
+ });
126
+ });
127
+ });
128
+
129
+ const router = new LLMRouter(baseConfig);
130
+ const request = router.completeWithTools(
131
+ [{ role: "user", content: "test" }],
132
+ [],
133
+ async () => "unused",
134
+ undefined,
135
+ 1,
136
+ controller.signal,
137
+ );
138
+
139
+ const [, options] = fetchSpy.mock.calls[0];
140
+ controller.abort();
141
+ expect(options.signal.aborted).toBe(true);
142
+ await expect(request).rejects.toThrow("LLM 请求已取消");
143
+ });
144
+
145
+ it("should route minimax to Anthropic messages endpoint", async () => {
146
+ const config: LLMProviderConfig = {
147
+ name: "minimax",
148
+ baseUrl: "https://api.minimax.io/anthropic/v1",
149
+ apiKey: "test-minimax-key",
150
+ model: "MiniMax-M2.7",
151
+ maxTokens: 4096,
152
+ };
153
+ fetchSpy.mockResolvedValueOnce(
154
+ createJSONResponse({
155
+ content: [{ type: "text", text: "hello from MiniMax" }],
156
+ usage: { input_tokens: 10, output_tokens: 5 },
157
+ }),
158
+ );
159
+
160
+ const router = new LLMRouter(config);
161
+ await router.complete([{ role: "user", content: "test" }]);
162
+
163
+ const [url] = fetchSpy.mock.calls[0];
164
+ expect(url).toBe("https://api.minimax.io/anthropic/v1/messages");
165
+ });
166
+
167
+ it("should use x-api-key header for minimax", async () => {
168
+ const config: LLMProviderConfig = {
169
+ name: "minimax",
170
+ baseUrl: "https://api.minimax.io/anthropic/v1",
171
+ apiKey: "test-minimax-key",
172
+ model: "MiniMax-M2.7",
173
+ maxTokens: 4096,
174
+ };
175
+ fetchSpy.mockResolvedValueOnce(
176
+ createJSONResponse({
177
+ content: [{ type: "text", text: "hello" }],
178
+ usage: { input_tokens: 10, output_tokens: 5 },
179
+ }),
180
+ );
181
+
182
+ const router = new LLMRouter(config);
183
+ await router.complete([{ role: "user", content: "test" }]);
184
+
185
+ const [, options] = fetchSpy.mock.calls[0];
186
+ expect(options.headers["x-api-key"]).toBe("test-minimax-key");
187
+ expect(options.headers).not.toHaveProperty("Authorization");
188
+ });
189
+
190
+ it("should parse MiniMax response content and usage correctly", async () => {
191
+ const config: LLMProviderConfig = {
192
+ name: "minimax",
193
+ baseUrl: "https://api.minimax.io/anthropic/v1",
194
+ apiKey: "test-minimax-key",
195
+ model: "MiniMax-M2.7-highspeed",
196
+ maxTokens: 4096,
197
+ };
198
+ fetchSpy.mockResolvedValueOnce(
199
+ createJSONResponse({
200
+ content: [{ type: "text", text: "MiniMax response" }],
201
+ usage: { input_tokens: 20, output_tokens: 10 },
202
+ }),
203
+ );
204
+
205
+ const router = new LLMRouter(config);
206
+ const result = await router.complete([{ role: "user", content: "hello" }]);
207
+
208
+ expect(result.content).toBe("MiniMax response");
209
+ expect(result.promptTokens).toBe(20);
210
+ expect(result.completionTokens).toBe(10);
211
+ });
212
+
213
+ it("should reject Anthropic-compatible responses without text content", async () => {
214
+ const config: LLMProviderConfig = {
215
+ name: "minimax",
216
+ baseUrl: "https://api.minimax.io/anthropic/v1",
217
+ apiKey: "test-minimax-key",
218
+ model: "MiniMax-M2.7-highspeed",
219
+ maxTokens: 4096,
220
+ };
221
+ fetchSpy.mockResolvedValueOnce(
222
+ createJSONResponse({
223
+ content: [{ type: "tool_use", id: "call-1", name: "lookup", input: {} }],
224
+ }),
225
+ );
226
+
227
+ const router = new LLMRouter(config);
228
+
229
+ await expect(
230
+ router.complete([{ role: "user", content: "hello" }]),
231
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 text content 字段");
232
+ });
233
+
234
+ it("should reject Anthropic-compatible responses with non-string text content", async () => {
235
+ const config: LLMProviderConfig = {
236
+ name: "minimax",
237
+ baseUrl: "https://api.minimax.io/anthropic/v1",
238
+ apiKey: "test-minimax-key",
239
+ model: "MiniMax-M2.7-highspeed",
240
+ maxTokens: 4096,
241
+ };
242
+ fetchSpy.mockResolvedValueOnce(
243
+ createJSONResponse({
244
+ content: [{ type: "text", text: { value: "not a string" } }],
245
+ }),
246
+ );
247
+
248
+ const router = new LLMRouter(config);
249
+
250
+ await expect(
251
+ router.complete([{ role: "user", content: "hello" }]),
252
+ ).rejects.toThrow("LLM 响应格式异常: text content 必须是字符串");
253
+ });
254
+
255
+ it("should route to completions endpoint when apiType is undefined", async () => {
256
+ const config: LLMProviderConfig = { ...baseConfig };
257
+ fetchSpy.mockResolvedValueOnce(
258
+ createJSONResponse({
259
+ choices: [{ message: { content: "hello" } }],
260
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
261
+ }),
262
+ );
263
+
264
+ const router = new LLMRouter(config);
265
+ await router.complete([{ role: "user", content: "test" }]);
266
+
267
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
268
+ const [url] = fetchSpy.mock.calls[0];
269
+ expect(url).toBe("https://api.openai.com/v1/chat/completions");
270
+ });
271
+
272
+ it('should route to completions endpoint when apiType is "completions"', async () => {
273
+ const config: LLMProviderConfig = {
274
+ ...baseConfig,
275
+ apiType: "completions",
276
+ };
277
+ fetchSpy.mockResolvedValueOnce(
278
+ createJSONResponse({
279
+ choices: [{ message: { content: "hello" } }],
280
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
281
+ }),
282
+ );
283
+
284
+ const router = new LLMRouter(config);
285
+ await router.complete([{ role: "user", content: "test" }]);
286
+
287
+ const [url] = fetchSpy.mock.calls[0];
288
+ expect(url).toBe("https://api.openai.com/v1/chat/completions");
289
+ });
290
+
291
+ it("should reject malformed OpenAI completion responses", async () => {
292
+ fetchSpy.mockResolvedValueOnce(
293
+ createJSONResponse({
294
+ id: "chatcmpl-test",
295
+ object: "chat.completion",
296
+ }),
297
+ );
298
+
299
+ const router = new LLMRouter(baseConfig);
300
+
301
+ await expect(
302
+ router.complete([{ role: "user", content: "test" }]),
303
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 choices 字段");
304
+ });
305
+
306
+ it("should reject non-object OpenAI completion JSON with a clear format error", async () => {
307
+ fetchSpy.mockResolvedValueOnce(createJSONResponse(null));
308
+
309
+ const router = new LLMRouter(baseConfig);
310
+
311
+ await expect(
312
+ router.complete([{ role: "user", content: "test" }]),
313
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 choices 字段");
314
+ });
315
+
316
+ it("should reject OpenAI completion choices without message content", async () => {
317
+ fetchSpy.mockResolvedValueOnce(
318
+ createJSONResponse({
319
+ choices: [{ message: {} }],
320
+ }),
321
+ );
322
+
323
+ const router = new LLMRouter(baseConfig);
324
+
325
+ await expect(
326
+ router.complete([{ role: "user", content: "test" }]),
327
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 message.content 字段");
328
+ });
329
+
330
+ it('should route to responses endpoint when apiType is "responses"', async () => {
331
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
332
+ fetchSpy.mockResolvedValueOnce(
333
+ createJSONResponse({
334
+ output_text: "hello",
335
+ usage: { input_tokens: 10, output_tokens: 5 },
336
+ }),
337
+ );
338
+
339
+ const router = new LLMRouter(config);
340
+ await router.complete([{ role: "user", content: "test" }]);
341
+
342
+ const [url] = fetchSpy.mock.calls[0];
343
+ expect(url).toBe("https://api.openai.com/v1/responses");
344
+ });
345
+ });
346
+
347
+ describe("completeResponses - request body", () => {
348
+ it("should extract system message as instructions field", async () => {
349
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
350
+ fetchSpy.mockResolvedValueOnce(
351
+ createJSONResponse({
352
+ output_text: "result",
353
+ usage: { input_tokens: 10, output_tokens: 5 },
354
+ }),
355
+ );
356
+
357
+ const router = new LLMRouter(config);
358
+ await router.complete([
359
+ { role: "system", content: "You are a helpful assistant" },
360
+ { role: "user", content: "Hello" },
361
+ ]);
362
+
363
+ const [, options] = fetchSpy.mock.calls[0];
364
+ const body = JSON.parse(options.body);
365
+ expect(body.instructions).toBe("You are a helpful assistant");
366
+ expect(body.input).toEqual([{ role: "user", content: "Hello" }]);
367
+ expect(body.model).toBe("gpt-4o");
368
+ expect(body.max_output_tokens).toBe(4096);
369
+ expect(body).not.toHaveProperty("max_tokens");
370
+ expect(body).not.toHaveProperty("messages");
371
+ });
372
+
373
+ it("should omit instructions when no system message", async () => {
374
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
375
+ fetchSpy.mockResolvedValueOnce(
376
+ createJSONResponse({
377
+ output_text: "result",
378
+ usage: { input_tokens: 10, output_tokens: 5 },
379
+ }),
380
+ );
381
+
382
+ const router = new LLMRouter(config);
383
+ await router.complete([{ role: "user", content: "Hello" }]);
384
+
385
+ const [, options] = fetchSpy.mock.calls[0];
386
+ const body = JSON.parse(options.body);
387
+ expect(body).not.toHaveProperty("instructions");
388
+ expect(body.input).toEqual([{ role: "user", content: "Hello" }]);
389
+ });
390
+ });
391
+
392
+ describe("completeResponses - non-streaming", () => {
393
+ it("should parse output_text and usage from response", async () => {
394
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
395
+ fetchSpy.mockResolvedValueOnce(
396
+ createJSONResponse({
397
+ output_text: "# Report\nContent here",
398
+ usage: { input_tokens: 100, output_tokens: 200 },
399
+ }),
400
+ );
401
+
402
+ const router = new LLMRouter(config);
403
+ const result = await router.complete([{ role: "user", content: "test" }]);
404
+
405
+ expect(result.content).toBe("# Report\nContent here");
406
+ expect(result.promptTokens).toBe(100);
407
+ expect(result.completionTokens).toBe(200);
408
+ });
409
+
410
+ it("should parse message output when output_text is omitted", async () => {
411
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
412
+ fetchSpy.mockResolvedValueOnce(
413
+ createJSONResponse({
414
+ output: [
415
+ {
416
+ type: "message",
417
+ content: [
418
+ { type: "output_text", text: "# Report\n" },
419
+ { type: "output_text", text: "Content from output" },
420
+ ],
421
+ },
422
+ ],
423
+ usage: { input_tokens: 30, output_tokens: 40 },
424
+ }),
425
+ );
426
+
427
+ const router = new LLMRouter(config);
428
+ const result = await router.complete([{ role: "user", content: "test" }]);
429
+
430
+ expect(result.content).toBe("# Report\nContent from output");
431
+ expect(result.promptTokens).toBe(30);
432
+ expect(result.completionTokens).toBe(40);
433
+ });
434
+
435
+ it("should reject malformed Responses API results", async () => {
436
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
437
+ fetchSpy.mockResolvedValueOnce(
438
+ createJSONResponse({
439
+ id: "resp-test",
440
+ status: "completed",
441
+ }),
442
+ );
443
+
444
+ const router = new LLMRouter(config);
445
+
446
+ await expect(
447
+ router.complete([{ role: "user", content: "test" }]),
448
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 output 字段");
449
+ });
450
+
451
+ it("should reject Responses API results without output text", async () => {
452
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
453
+ fetchSpy.mockResolvedValueOnce(
454
+ createJSONResponse({
455
+ status: "completed",
456
+ output: [],
457
+ }),
458
+ );
459
+
460
+ const router = new LLMRouter(config);
461
+
462
+ await expect(
463
+ router.complete([{ role: "user", content: "test" }]),
464
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 output_text 字段");
465
+ });
466
+
467
+ it("should reject incomplete Responses API results", async () => {
468
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
469
+ fetchSpy.mockResolvedValueOnce(
470
+ createJSONResponse({
471
+ status: "incomplete",
472
+ incomplete_details: { reason: "max_output_tokens" },
473
+ output_text: "partial",
474
+ }),
475
+ );
476
+
477
+ const router = new LLMRouter(config);
478
+
479
+ await expect(
480
+ router.complete([{ role: "user", content: "test" }]),
481
+ ).rejects.toThrow("Responses API incomplete: max_output_tokens");
482
+ });
483
+
484
+ it("should reject failed Responses API results", async () => {
485
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
486
+ fetchSpy.mockResolvedValueOnce(
487
+ createJSONResponse({
488
+ status: "failed",
489
+ }),
490
+ );
491
+
492
+ const router = new LLMRouter(config);
493
+
494
+ await expect(
495
+ router.complete([{ role: "user", content: "test" }]),
496
+ ).rejects.toThrow("Responses API failed: unknown");
497
+ });
498
+ });
499
+
500
+ describe("completeWithTools - Responses API", () => {
501
+ it("should reject failed Responses API tool rounds explicitly", async () => {
502
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
503
+ fetchSpy.mockResolvedValueOnce(
504
+ createJSONResponse({
505
+ status: "failed",
506
+ error: { message: "tool planning failed" },
507
+ }),
508
+ );
509
+
510
+ const router = new LLMRouter(config);
511
+
512
+ await expect(
513
+ router.completeWithTools(
514
+ [{ role: "user", content: "test" }],
515
+ [],
516
+ async () => "unused",
517
+ ),
518
+ ).rejects.toThrow("Responses API failed: tool planning failed");
519
+ });
520
+
521
+ it("should reject incomplete Responses API tool rounds explicitly", async () => {
522
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
523
+ fetchSpy.mockResolvedValueOnce(
524
+ createJSONResponse({
525
+ status: "incomplete",
526
+ incomplete_details: { reason: "max_output_tokens" },
527
+ }),
528
+ );
529
+
530
+ const router = new LLMRouter(config);
531
+
532
+ await expect(
533
+ router.completeWithTools(
534
+ [{ role: "user", content: "test" }],
535
+ [],
536
+ async () => "unused",
537
+ ),
538
+ ).rejects.toThrow("Responses API incomplete: max_output_tokens");
539
+ });
540
+
541
+ it("should reject Responses API function calls without call id", async () => {
542
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
543
+ fetchSpy.mockResolvedValueOnce(
544
+ createJSONResponse({
545
+ output: [{ type: "function_call", name: "lookup", arguments: "{}" }],
546
+ }),
547
+ );
548
+
549
+ const router = new LLMRouter(config);
550
+
551
+ await expect(
552
+ router.completeWithTools(
553
+ [{ role: "user", content: "test" }],
554
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
555
+ async () => "unused",
556
+ ),
557
+ ).rejects.toThrow("function_call missing call_id");
558
+ });
559
+
560
+ it("should reject Responses API function calls with non-string names", async () => {
561
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
562
+ fetchSpy.mockResolvedValueOnce(
563
+ createJSONResponse({
564
+ output: [
565
+ {
566
+ type: "function_call",
567
+ id: "fc-1",
568
+ call_id: "call-1",
569
+ name: 123,
570
+ arguments: "{}",
571
+ },
572
+ ],
573
+ }),
574
+ );
575
+
576
+ const router = new LLMRouter(config);
577
+
578
+ await expect(
579
+ router.completeWithTools(
580
+ [{ role: "user", content: "test" }],
581
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
582
+ async () => "unused",
583
+ ),
584
+ ).rejects.toThrow("function_call missing name");
585
+ });
586
+
587
+ it("should use Responses API call_id when returning function outputs", async () => {
588
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
589
+ fetchSpy.mockResolvedValueOnce(
590
+ createJSONResponse({
591
+ output: [
592
+ {
593
+ type: "function_call",
594
+ id: "fc-1",
595
+ call_id: "call-1",
596
+ name: "lookup",
597
+ arguments: "{}",
598
+ },
599
+ ],
600
+ }),
601
+ ).mockResolvedValueOnce(
602
+ createJSONResponse({
603
+ output: [
604
+ {
605
+ type: "message",
606
+ content: [{ type: "output_text", text: "done" }],
607
+ },
608
+ ],
609
+ }),
610
+ );
611
+
612
+ const router = new LLMRouter(config);
613
+ await router.completeWithTools(
614
+ [{ role: "user", content: "test" }],
615
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
616
+ async () => "tool result",
617
+ );
618
+
619
+ const [, secondOptions] = fetchSpy.mock.calls[1];
620
+ const secondBody = JSON.parse(secondOptions.body);
621
+ expect(secondBody.input).toContainEqual({
622
+ type: "function_call_output",
623
+ call_id: "call-1",
624
+ output: "tool result",
625
+ });
626
+ });
627
+
628
+ it("should reject Responses API function calls with non-string arguments", async () => {
629
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
630
+ fetchSpy.mockResolvedValueOnce(
631
+ createJSONResponse({
632
+ output: [
633
+ {
634
+ type: "function_call",
635
+ id: "fc-1",
636
+ call_id: "call-1",
637
+ name: "lookup",
638
+ arguments: { query: "test" },
639
+ },
640
+ ],
641
+ }),
642
+ );
643
+
644
+ const router = new LLMRouter(config);
645
+
646
+ await expect(
647
+ router.completeWithTools(
648
+ [{ role: "user", content: "test" }],
649
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
650
+ async () => "unused",
651
+ ),
652
+ ).rejects.toThrow("function_call arguments must be a string");
653
+ });
654
+
655
+ it("should reject Responses API function calls with malformed JSON arguments", async () => {
656
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
657
+ fetchSpy.mockResolvedValueOnce(
658
+ createJSONResponse({
659
+ output: [
660
+ {
661
+ type: "function_call",
662
+ id: "fc-1",
663
+ call_id: "call-1",
664
+ name: "lookup",
665
+ arguments: "{",
666
+ },
667
+ ],
668
+ }),
669
+ );
670
+
671
+ const router = new LLMRouter(config);
672
+
673
+ await expect(
674
+ router.completeWithTools(
675
+ [{ role: "user", content: "test" }],
676
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
677
+ async () => "unused",
678
+ ),
679
+ ).rejects.toThrow("function_call arguments must be a valid JSON object");
680
+ });
681
+
682
+ it("should reject Responses API function calls with empty arguments", async () => {
683
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
684
+ fetchSpy.mockResolvedValueOnce(
685
+ createJSONResponse({
686
+ output: [
687
+ {
688
+ type: "function_call",
689
+ id: "fc-1",
690
+ call_id: "call-1",
691
+ name: "lookup",
692
+ arguments: "",
693
+ },
694
+ ],
695
+ }),
696
+ );
697
+
698
+ const router = new LLMRouter(config);
699
+
700
+ await expect(
701
+ router.completeWithTools(
702
+ [{ role: "user", content: "test" }],
703
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
704
+ async () => "unused",
705
+ ),
706
+ ).rejects.toThrow("function_call arguments must be a valid JSON object");
707
+ });
708
+
709
+ it("should validate Responses API function calls before streaming tool status", async () => {
710
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
711
+ const chunks: string[] = [];
712
+ fetchSpy.mockResolvedValueOnce(
713
+ createJSONResponse({
714
+ output: [
715
+ {
716
+ type: "function_call",
717
+ id: "fc-1",
718
+ call_id: "call-1",
719
+ arguments: "{}",
720
+ },
721
+ ],
722
+ }),
723
+ );
724
+
725
+ const router = new LLMRouter(config);
726
+
727
+ await expect(
728
+ router.completeWithTools(
729
+ [{ role: "user", content: "test" }],
730
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
731
+ async () => "unused",
732
+ (chunk) => chunks.push(chunk),
733
+ ),
734
+ ).rejects.toThrow("function_call missing name");
735
+ expect(chunks).toEqual([]);
736
+ });
737
+ });
738
+
739
+ describe("completeWithTools - OpenAI", () => {
740
+ it("should reject OpenAI tool rounds without assistant messages", async () => {
741
+ fetchSpy.mockResolvedValueOnce(
742
+ createJSONResponse({
743
+ choices: [{ finish_reason: "tool_calls" }],
744
+ }),
745
+ );
746
+
747
+ const router = new LLMRouter(baseConfig);
748
+
749
+ await expect(
750
+ router.completeWithTools(
751
+ [{ role: "user", content: "test" }],
752
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
753
+ async () => "unused",
754
+ ),
755
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 message 字段");
756
+ });
757
+
758
+ it("should reject OpenAI tool calls when tool_calls is not an array", async () => {
759
+ fetchSpy.mockResolvedValueOnce(
760
+ createJSONResponse({
761
+ choices: [
762
+ {
763
+ message: {
764
+ content: null,
765
+ tool_calls: { id: "call-1" },
766
+ },
767
+ },
768
+ ],
769
+ }),
770
+ );
771
+
772
+ const router = new LLMRouter(baseConfig);
773
+
774
+ await expect(
775
+ router.completeWithTools(
776
+ [{ role: "user", content: "test" }],
777
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
778
+ async () => "unused",
779
+ ),
780
+ ).rejects.toThrow("tool_calls must be an array");
781
+ });
782
+
783
+ it("should reject OpenAI tool calls without ids", async () => {
784
+ fetchSpy.mockResolvedValueOnce(
785
+ createJSONResponse({
786
+ choices: [
787
+ {
788
+ message: {
789
+ content: null,
790
+ tool_calls: [
791
+ {
792
+ type: "function",
793
+ function: { name: "lookup", arguments: "{}" },
794
+ },
795
+ ],
796
+ },
797
+ },
798
+ ],
799
+ }),
800
+ );
801
+
802
+ const router = new LLMRouter(baseConfig);
803
+
804
+ await expect(
805
+ router.completeWithTools(
806
+ [{ role: "user", content: "test" }],
807
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
808
+ async () => "unused",
809
+ ),
810
+ ).rejects.toThrow("tool_call missing id");
811
+ });
812
+
813
+ it("should reject OpenAI tool calls with non-string ids", async () => {
814
+ fetchSpy.mockResolvedValueOnce(
815
+ createJSONResponse({
816
+ choices: [
817
+ {
818
+ message: {
819
+ content: null,
820
+ tool_calls: [
821
+ {
822
+ id: 123,
823
+ type: "function",
824
+ function: { name: "lookup", arguments: "{}" },
825
+ },
826
+ ],
827
+ },
828
+ },
829
+ ],
830
+ }),
831
+ );
832
+
833
+ const router = new LLMRouter(baseConfig);
834
+
835
+ await expect(
836
+ router.completeWithTools(
837
+ [{ role: "user", content: "test" }],
838
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
839
+ async () => "unused",
840
+ ),
841
+ ).rejects.toThrow("tool_call missing id");
842
+ });
843
+
844
+ it("should reject OpenAI tool calls without function names", async () => {
845
+ fetchSpy.mockResolvedValueOnce(
846
+ createJSONResponse({
847
+ choices: [
848
+ {
849
+ message: {
850
+ content: null,
851
+ tool_calls: [
852
+ {
853
+ id: "call-1",
854
+ type: "function",
855
+ function: { arguments: "{}" },
856
+ },
857
+ ],
858
+ },
859
+ },
860
+ ],
861
+ }),
862
+ );
863
+
864
+ const router = new LLMRouter(baseConfig);
865
+
866
+ await expect(
867
+ router.completeWithTools(
868
+ [{ role: "user", content: "test" }],
869
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
870
+ async () => "unused",
871
+ ),
872
+ ).rejects.toThrow("tool_call missing name");
873
+ });
874
+
875
+ it("should validate OpenAI tool calls before streaming tool status", async () => {
876
+ fetchSpy.mockResolvedValueOnce(
877
+ createJSONResponse({
878
+ choices: [
879
+ {
880
+ message: {
881
+ content: null,
882
+ tool_calls: [
883
+ {
884
+ id: "call-1",
885
+ type: "function",
886
+ function: { arguments: "{}" },
887
+ },
888
+ ],
889
+ },
890
+ },
891
+ ],
892
+ }),
893
+ );
894
+
895
+ const router = new LLMRouter(baseConfig);
896
+
897
+ await expect(
898
+ router.completeWithTools(
899
+ [{ role: "user", content: "test" }],
900
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
901
+ async () => "unused",
902
+ () => undefined,
903
+ ),
904
+ ).rejects.toThrow("tool_call missing name");
905
+ });
906
+
907
+ it("should reject OpenAI tool calls with non-string arguments", async () => {
908
+ fetchSpy.mockResolvedValueOnce(
909
+ createJSONResponse({
910
+ choices: [
911
+ {
912
+ message: {
913
+ content: null,
914
+ tool_calls: [
915
+ {
916
+ id: "call-1",
917
+ type: "function",
918
+ function: { name: "lookup", arguments: { query: "test" } },
919
+ },
920
+ ],
921
+ },
922
+ },
923
+ ],
924
+ }),
925
+ );
926
+
927
+ const router = new LLMRouter(baseConfig);
928
+
929
+ await expect(
930
+ router.completeWithTools(
931
+ [{ role: "user", content: "test" }],
932
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
933
+ async () => "unused",
934
+ ),
935
+ ).rejects.toThrow("tool_call arguments must be a string");
936
+ });
937
+
938
+ it("should reject OpenAI tool calls with malformed JSON arguments", async () => {
939
+ fetchSpy.mockResolvedValueOnce(
940
+ createJSONResponse({
941
+ choices: [
942
+ {
943
+ message: {
944
+ content: null,
945
+ tool_calls: [
946
+ {
947
+ id: "call-1",
948
+ type: "function",
949
+ function: { name: "lookup", arguments: "{" },
950
+ },
951
+ ],
952
+ },
953
+ },
954
+ ],
955
+ }),
956
+ );
957
+
958
+ const router = new LLMRouter(baseConfig);
959
+
960
+ await expect(
961
+ router.completeWithTools(
962
+ [{ role: "user", content: "test" }],
963
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
964
+ async () => "unused",
965
+ ),
966
+ ).rejects.toThrow("tool_call arguments must be a valid JSON object");
967
+ });
968
+
969
+ it("should reject OpenAI tool calls with empty arguments", async () => {
970
+ fetchSpy.mockResolvedValueOnce(
971
+ createJSONResponse({
972
+ choices: [
973
+ {
974
+ message: {
975
+ content: null,
976
+ tool_calls: [
977
+ {
978
+ id: "call-1",
979
+ type: "function",
980
+ function: { name: "lookup", arguments: "" },
981
+ },
982
+ ],
983
+ },
984
+ },
985
+ ],
986
+ }),
987
+ );
988
+
989
+ const router = new LLMRouter(baseConfig);
990
+
991
+ await expect(
992
+ router.completeWithTools(
993
+ [{ role: "user", content: "test" }],
994
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
995
+ async () => "unused",
996
+ ),
997
+ ).rejects.toThrow("tool_call arguments must be a valid JSON object");
998
+ });
999
+ });
1000
+
1001
+ describe("completeWithTools - Anthropic", () => {
1002
+ it("should reject Anthropic tool uses without id", async () => {
1003
+ const config: LLMProviderConfig = {
1004
+ name: "minimax",
1005
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1006
+ apiKey: "test-minimax-key",
1007
+ model: "MiniMax-M2.7-highspeed",
1008
+ maxTokens: 4096,
1009
+ };
1010
+ fetchSpy.mockResolvedValueOnce(
1011
+ createJSONResponse({
1012
+ content: [{ type: "tool_use", name: "lookup", input: {} }],
1013
+ }),
1014
+ );
1015
+
1016
+ const router = new LLMRouter(config);
1017
+
1018
+ await expect(
1019
+ router.completeWithTools(
1020
+ [{ role: "user", content: "hello" }],
1021
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
1022
+ async () => "unused",
1023
+ ),
1024
+ ).rejects.toThrow("tool_use missing id");
1025
+ });
1026
+
1027
+ it("should reject Anthropic tool uses without name", async () => {
1028
+ const config: LLMProviderConfig = {
1029
+ name: "minimax",
1030
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1031
+ apiKey: "test-minimax-key",
1032
+ model: "MiniMax-M2.7-highspeed",
1033
+ maxTokens: 4096,
1034
+ };
1035
+ fetchSpy.mockResolvedValueOnce(
1036
+ createJSONResponse({
1037
+ content: [{ type: "tool_use", id: "call-1", input: {} }],
1038
+ }),
1039
+ );
1040
+
1041
+ const router = new LLMRouter(config);
1042
+
1043
+ await expect(
1044
+ router.completeWithTools(
1045
+ [{ role: "user", content: "hello" }],
1046
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
1047
+ async () => "unused",
1048
+ ),
1049
+ ).rejects.toThrow("tool_use missing name");
1050
+ });
1051
+
1052
+ it("should reject Anthropic tool uses with non-string ids", async () => {
1053
+ const config: LLMProviderConfig = {
1054
+ name: "minimax",
1055
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1056
+ apiKey: "test-minimax-key",
1057
+ model: "MiniMax-M2.7-highspeed",
1058
+ maxTokens: 4096,
1059
+ };
1060
+ fetchSpy.mockResolvedValueOnce(
1061
+ createJSONResponse({
1062
+ content: [{ type: "tool_use", id: 123, name: "lookup", input: {} }],
1063
+ }),
1064
+ );
1065
+
1066
+ const router = new LLMRouter(config);
1067
+
1068
+ await expect(
1069
+ router.completeWithTools(
1070
+ [{ role: "user", content: "hello" }],
1071
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
1072
+ async () => "unused",
1073
+ ),
1074
+ ).rejects.toThrow("tool_use missing id");
1075
+ });
1076
+
1077
+ it("should reject Anthropic tool uses with non-object input", async () => {
1078
+ const config: LLMProviderConfig = {
1079
+ name: "minimax",
1080
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1081
+ apiKey: "test-minimax-key",
1082
+ model: "MiniMax-M2.7-highspeed",
1083
+ maxTokens: 4096,
1084
+ };
1085
+ fetchSpy.mockResolvedValueOnce(
1086
+ createJSONResponse({
1087
+ content: [{ type: "tool_use", id: "call-1", name: "lookup", input: "query=test" }],
1088
+ }),
1089
+ );
1090
+
1091
+ const router = new LLMRouter(config);
1092
+
1093
+ await expect(
1094
+ router.completeWithTools(
1095
+ [{ role: "user", content: "hello" }],
1096
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
1097
+ async () => "unused",
1098
+ ),
1099
+ ).rejects.toThrow("tool_use input must be an object");
1100
+ });
1101
+
1102
+ it("should validate Anthropic tool uses before streaming tool status", async () => {
1103
+ const config: LLMProviderConfig = {
1104
+ name: "minimax",
1105
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1106
+ apiKey: "test-minimax-key",
1107
+ model: "MiniMax-M2.7-highspeed",
1108
+ maxTokens: 4096,
1109
+ };
1110
+ const chunks: string[] = [];
1111
+ fetchSpy.mockResolvedValueOnce(
1112
+ createJSONResponse({
1113
+ content: [{ type: "tool_use", id: "call-1", input: {} }],
1114
+ }),
1115
+ );
1116
+
1117
+ const router = new LLMRouter(config);
1118
+
1119
+ await expect(
1120
+ router.completeWithTools(
1121
+ [{ role: "user", content: "hello" }],
1122
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
1123
+ async () => "unused",
1124
+ (chunk) => chunks.push(chunk),
1125
+ ),
1126
+ ).rejects.toThrow("tool_use missing name");
1127
+ expect(chunks).toEqual([]);
1128
+ });
1129
+
1130
+ it("should reject final Anthropic tool loop responses without text content", async () => {
1131
+ const config: LLMProviderConfig = {
1132
+ name: "minimax",
1133
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1134
+ apiKey: "test-minimax-key",
1135
+ model: "MiniMax-M2.7-highspeed",
1136
+ maxTokens: 4096,
1137
+ };
1138
+ fetchSpy.mockResolvedValueOnce(
1139
+ createJSONResponse({
1140
+ content: [{ type: "tool_use", id: "call-1", name: "lookup", input: {} }],
1141
+ }),
1142
+ ).mockResolvedValueOnce(
1143
+ createJSONResponse({
1144
+ content: [{ type: "thinking", text: "internal" }],
1145
+ }),
1146
+ );
1147
+
1148
+ const router = new LLMRouter(config);
1149
+
1150
+ await expect(
1151
+ router.completeWithTools(
1152
+ [{ role: "user", content: "hello" }],
1153
+ [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
1154
+ async () => "tool result",
1155
+ ),
1156
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 text content 字段");
1157
+ });
1158
+ });
1159
+
1160
+ describe("completeResponses - streaming", () => {
1161
+ it("should parse SSE events with event: prefix and call onChunk", async () => {
1162
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1163
+ fetchSpy.mockResolvedValueOnce(
1164
+ createSSEResponse([
1165
+ { event: "response.output_text.delta", data: '{"delta":"Hello "}' },
1166
+ { event: "response.output_text.delta", data: '{"delta":"world"}' },
1167
+ {
1168
+ event: "response.completed",
1169
+ data: '{"response":{"usage":{"input_tokens":50,"output_tokens":30}}}',
1170
+ },
1171
+ ]),
1172
+ );
1173
+
1174
+ const router = new LLMRouter(config);
1175
+ const chunks: string[] = [];
1176
+ const result = await router.complete(
1177
+ [{ role: "user", content: "test" }],
1178
+ (chunk) => chunks.push(chunk),
1179
+ );
1180
+
1181
+ expect(chunks).toEqual(["Hello ", "world"]);
1182
+ expect(result.content).toBe("Hello world");
1183
+ expect(result.promptTokens).toBe(50);
1184
+ expect(result.completionTokens).toBe(30);
1185
+ });
1186
+
1187
+ it("should set stream: true in request body when onChunk provided", async () => {
1188
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1189
+ fetchSpy.mockResolvedValueOnce(
1190
+ createSSEResponse([
1191
+ { event: "response.output_text.delta", data: '{"delta":"Hi"}' },
1192
+ {
1193
+ event: "response.completed",
1194
+ data: '{"response":{"usage":{"input_tokens":1,"output_tokens":1}}}',
1195
+ },
1196
+ ]),
1197
+ );
1198
+
1199
+ const router = new LLMRouter(config);
1200
+ await router.complete([{ role: "user", content: "test" }], () => {});
1201
+
1202
+ const [, options] = fetchSpy.mock.calls[0];
1203
+ const body = JSON.parse(options.body);
1204
+ expect(body.stream).toBe(true);
1205
+ });
1206
+
1207
+ it("should parse the final SSE line when the stream has no trailing newline", async () => {
1208
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1209
+ fetchSpy.mockResolvedValueOnce(
1210
+ createRawSSEResponse(
1211
+ [
1212
+ 'event: response.output_text.delta',
1213
+ 'data: {"delta":"final chunk"}',
1214
+ ].join("\n"),
1215
+ ),
1216
+ );
1217
+
1218
+ const router = new LLMRouter(config);
1219
+ const chunks: string[] = [];
1220
+ const result = await router.complete(
1221
+ [{ role: "user", content: "test" }],
1222
+ (chunk) => chunks.push(chunk),
1223
+ );
1224
+
1225
+ expect(chunks).toEqual(["final chunk"]);
1226
+ expect(result.content).toBe("final chunk");
1227
+ });
1228
+
1229
+ it("should reject when Responses API stream emits a failure event", async () => {
1230
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1231
+ fetchSpy.mockResolvedValueOnce(
1232
+ createSSEResponse([
1233
+ {
1234
+ event: "response.failed",
1235
+ data: '{"error":{"message":"rate limit exceeded"}}',
1236
+ },
1237
+ ]),
1238
+ );
1239
+
1240
+ const router = new LLMRouter(config);
1241
+
1242
+ await expect(
1243
+ router.complete([{ role: "user", content: "test" }], () => {}),
1244
+ ).rejects.toThrow("Responses API stream error: rate limit exceeded");
1245
+ });
1246
+
1247
+ it("should read nested Responses API stream failure messages", async () => {
1248
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1249
+ fetchSpy.mockResolvedValueOnce(
1250
+ createSSEResponse([
1251
+ {
1252
+ event: "response.failed",
1253
+ data: '{"response":{"error":{"message":"model overloaded"}}}',
1254
+ },
1255
+ ]),
1256
+ );
1257
+
1258
+ const router = new LLMRouter(config);
1259
+
1260
+ await expect(
1261
+ router.complete([{ role: "user", content: "test" }], () => {}),
1262
+ ).rejects.toThrow("Responses API stream error: model overloaded");
1263
+ });
1264
+
1265
+ it("should reject when Responses API stream emits an incomplete event", async () => {
1266
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1267
+ fetchSpy.mockResolvedValueOnce(
1268
+ createSSEResponse([
1269
+ {
1270
+ event: "response.incomplete",
1271
+ data: '{"response":{"incomplete_details":{"reason":"max_output_tokens"}}}',
1272
+ },
1273
+ ]),
1274
+ );
1275
+
1276
+ const router = new LLMRouter(config);
1277
+
1278
+ await expect(
1279
+ router.complete([{ role: "user", content: "test" }], () => {}),
1280
+ ).rejects.toThrow("Responses API incomplete: max_output_tokens");
1281
+ });
1282
+
1283
+ it("should reject completed Responses API streams without output text", async () => {
1284
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1285
+ fetchSpy.mockResolvedValueOnce(
1286
+ createSSEResponse([
1287
+ {
1288
+ event: "response.completed",
1289
+ data: '{"response":{"usage":{"input_tokens":1,"output_tokens":0}}}',
1290
+ },
1291
+ ]),
1292
+ );
1293
+
1294
+ const router = new LLMRouter(config);
1295
+
1296
+ await expect(
1297
+ router.complete([{ role: "user", content: "test" }], () => {}),
1298
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 output_text 字段");
1299
+ });
1300
+
1301
+ it("should reject malformed Responses API stream JSON", async () => {
1302
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1303
+ fetchSpy.mockResolvedValueOnce(
1304
+ createRawSSEResponse(
1305
+ [
1306
+ "event: response.output_text.delta",
1307
+ 'data: {"delta":"broken"',
1308
+ ].join("\n"),
1309
+ ),
1310
+ );
1311
+
1312
+ const router = new LLMRouter(config);
1313
+
1314
+ await expect(
1315
+ router.complete([{ role: "user", content: "test" }], () => {}),
1316
+ ).rejects.toThrow("Responses API stream error: malformed JSON payload");
1317
+ });
1318
+
1319
+ it("should reject non-string Responses API stream deltas", async () => {
1320
+ const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
1321
+ fetchSpy.mockResolvedValueOnce(
1322
+ createSSEResponse([
1323
+ {
1324
+ event: "response.output_text.delta",
1325
+ data: '{"delta":{"text":"not a string"}}',
1326
+ },
1327
+ ]),
1328
+ );
1329
+
1330
+ const router = new LLMRouter(config);
1331
+
1332
+ await expect(
1333
+ router.complete([{ role: "user", content: "test" }], () => {}),
1334
+ ).rejects.toThrow("Responses API stream error: delta must be a string");
1335
+ });
1336
+ });
1337
+
1338
+ describe("completeOpenAI - streaming", () => {
1339
+ it("should parse the final SSE line when the stream has no trailing newline", async () => {
1340
+ fetchSpy.mockResolvedValueOnce(
1341
+ createRawSSEResponse(
1342
+ [
1343
+ 'data: {"choices":[{"delta":{"content":"final chat chunk"}}]}',
1344
+ ].join("\n"),
1345
+ ),
1346
+ );
1347
+
1348
+ const router = new LLMRouter(baseConfig);
1349
+ const chunks: string[] = [];
1350
+ const result = await router.complete(
1351
+ [{ role: "user", content: "test" }],
1352
+ (chunk) => chunks.push(chunk),
1353
+ );
1354
+
1355
+ expect(chunks).toEqual(["final chat chunk"]);
1356
+ expect(result.content).toBe("final chat chunk");
1357
+ });
1358
+
1359
+ it("should reject when OpenAI chat stream emits an error payload", async () => {
1360
+ fetchSpy.mockResolvedValueOnce(
1361
+ createSSEResponse([
1362
+ {
1363
+ data: '{"error":{"message":"quota exceeded"}}',
1364
+ },
1365
+ ]),
1366
+ );
1367
+
1368
+ const router = new LLMRouter(baseConfig);
1369
+
1370
+ await expect(
1371
+ router.complete([{ role: "user", content: "test" }], () => {}),
1372
+ ).rejects.toThrow("OpenAI stream error: quota exceeded");
1373
+ });
1374
+
1375
+ it("should reject completed OpenAI chat streams without content", async () => {
1376
+ fetchSpy.mockResolvedValueOnce(
1377
+ createSSEResponse([
1378
+ {
1379
+ data: '{"choices":[{"delta":{}}],"usage":{"prompt_tokens":1,"completion_tokens":0}}',
1380
+ },
1381
+ { data: "[DONE]" },
1382
+ ]),
1383
+ );
1384
+
1385
+ const router = new LLMRouter(baseConfig);
1386
+
1387
+ await expect(
1388
+ router.complete([{ role: "user", content: "test" }], () => {}),
1389
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 message.content 字段");
1390
+ });
1391
+
1392
+ it("should reject malformed OpenAI chat stream JSON", async () => {
1393
+ fetchSpy.mockResolvedValueOnce(
1394
+ createRawSSEResponse('data: {"choices":[{"delta":{"content":"broken"}}'),
1395
+ );
1396
+
1397
+ const router = new LLMRouter(baseConfig);
1398
+
1399
+ await expect(
1400
+ router.complete([{ role: "user", content: "test" }], () => {}),
1401
+ ).rejects.toThrow("OpenAI stream error: malformed JSON payload");
1402
+ });
1403
+
1404
+ it("should reject non-string OpenAI chat stream deltas", async () => {
1405
+ fetchSpy.mockResolvedValueOnce(
1406
+ createSSEResponse([
1407
+ {
1408
+ data: '{"choices":[{"delta":{"content":{"text":"not a string"}}}]}',
1409
+ },
1410
+ ]),
1411
+ );
1412
+
1413
+ const router = new LLMRouter(baseConfig);
1414
+
1415
+ await expect(
1416
+ router.complete([{ role: "user", content: "test" }], () => {}),
1417
+ ).rejects.toThrow("OpenAI stream error: delta.content must be a string");
1418
+ });
1419
+ });
1420
+
1421
+ describe("completeAnthropic - streaming", () => {
1422
+ it("should reject when Anthropic stream emits an error payload", async () => {
1423
+ const config: LLMProviderConfig = {
1424
+ name: "minimax",
1425
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1426
+ apiKey: "test-minimax-key",
1427
+ model: "MiniMax-M2.7",
1428
+ maxTokens: 4096,
1429
+ };
1430
+ fetchSpy.mockResolvedValueOnce(
1431
+ createSSEResponse([
1432
+ {
1433
+ data: '{"type":"error","error":{"message":"overloaded"}}',
1434
+ },
1435
+ ]),
1436
+ );
1437
+
1438
+ const router = new LLMRouter(config);
1439
+
1440
+ await expect(
1441
+ router.complete([{ role: "user", content: "test" }], () => {}),
1442
+ ).rejects.toThrow("Anthropic stream error: overloaded");
1443
+ });
1444
+
1445
+ it("should parse the final SSE line when the stream has no trailing newline", async () => {
1446
+ const config: LLMProviderConfig = {
1447
+ name: "minimax",
1448
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1449
+ apiKey: "test-minimax-key",
1450
+ model: "MiniMax-M2.7",
1451
+ maxTokens: 4096,
1452
+ };
1453
+ fetchSpy.mockResolvedValueOnce(
1454
+ createRawSSEResponse(
1455
+ [
1456
+ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"final anthropic chunk"}}',
1457
+ ].join("\n"),
1458
+ ),
1459
+ );
1460
+
1461
+ const router = new LLMRouter(config);
1462
+ const chunks: string[] = [];
1463
+ const result = await router.complete(
1464
+ [{ role: "user", content: "test" }],
1465
+ (chunk) => chunks.push(chunk),
1466
+ );
1467
+
1468
+ expect(chunks).toEqual(["final anthropic chunk"]);
1469
+ expect(result.content).toBe("final anthropic chunk");
1470
+ });
1471
+
1472
+ it("should reject completed Anthropic streams without text content", async () => {
1473
+ const config: LLMProviderConfig = {
1474
+ name: "minimax",
1475
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1476
+ apiKey: "test-minimax-key",
1477
+ model: "MiniMax-M2.7",
1478
+ maxTokens: 4096,
1479
+ };
1480
+ fetchSpy.mockResolvedValueOnce(
1481
+ createSSEResponse([
1482
+ {
1483
+ data: '{"type":"message_delta","usage":{"output_tokens":0}}',
1484
+ },
1485
+ ]),
1486
+ );
1487
+
1488
+ const router = new LLMRouter(config);
1489
+
1490
+ await expect(
1491
+ router.complete([{ role: "user", content: "test" }], () => {}),
1492
+ ).rejects.toThrow("LLM 响应格式异常: 缺少 text content 字段");
1493
+ });
1494
+
1495
+ it("should reject malformed Anthropic stream JSON", async () => {
1496
+ const config: LLMProviderConfig = {
1497
+ name: "minimax",
1498
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1499
+ apiKey: "test-minimax-key",
1500
+ model: "MiniMax-M2.7",
1501
+ maxTokens: 4096,
1502
+ };
1503
+ fetchSpy.mockResolvedValueOnce(
1504
+ createRawSSEResponse('data: {"type":"content_block_delta","delta":{"text":"broken"}'),
1505
+ );
1506
+
1507
+ const router = new LLMRouter(config);
1508
+
1509
+ await expect(
1510
+ router.complete([{ role: "user", content: "test" }], () => {}),
1511
+ ).rejects.toThrow("Anthropic stream error: malformed JSON payload");
1512
+ });
1513
+
1514
+ it("should reject non-string Anthropic stream deltas", async () => {
1515
+ const config: LLMProviderConfig = {
1516
+ name: "minimax",
1517
+ baseUrl: "https://api.minimax.io/anthropic/v1",
1518
+ apiKey: "test-minimax-key",
1519
+ model: "MiniMax-M2.7",
1520
+ maxTokens: 4096,
1521
+ };
1522
+ fetchSpy.mockResolvedValueOnce(
1523
+ createSSEResponse([
1524
+ {
1525
+ data: '{"type":"content_block_delta","delta":{"text":{"value":"not a string"}}}',
1526
+ },
1527
+ ]),
1528
+ );
1529
+
1530
+ const router = new LLMRouter(config);
1531
+
1532
+ await expect(
1533
+ router.complete([{ role: "user", content: "test" }], () => {}),
1534
+ ).rejects.toThrow("Anthropic stream error: delta.text must be a string");
1535
+ });
1536
+ });
1537
+ });