@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.
- package/.codeartsdoer/.codebaseignore +0 -0
- package/.codeartsdoer/AGENTS.md +12 -0
- package/.github/workflows/build.yml +146 -0
- package/README.en.md +264 -0
- package/README.md +276 -0
- package/RELEASE_NOTES.md +16 -0
- package/USAGE.md +490 -0
- package/color-preview-r3.html +414 -0
- package/color-preview.html +414 -0
- package/dev-app-update.yml +3 -0
- package/electron-builder.yml +36 -0
- package/electron.vite.config.ts +40 -0
- package/package.json +53 -0
- package/report-2026-04-13-copilot-claude-sonnet-4.6.md +955 -0
- package/resources/doloffer-logo.png +0 -0
- package/resources/entitlements.mac.plist +12 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/src/main/ai/ai-analyzer.ts +517 -0
- package/src/main/ai/crypto-script-extractor.ts +206 -0
- package/src/main/ai/data-assembler.ts +205 -0
- package/src/main/ai/llm-router.ts +1120 -0
- package/src/main/ai/prompt-builder.ts +349 -0
- package/src/main/ai/scene-detector.ts +302 -0
- package/src/main/capture/capture-engine.ts +130 -0
- package/src/main/capture/interaction-recorder.ts +171 -0
- package/src/main/capture/js-injector.ts +57 -0
- package/src/main/capture/replay-engine.ts +256 -0
- package/src/main/capture/storage-collector.ts +76 -0
- package/src/main/cdp/cdp-manager.ts +233 -0
- package/src/main/db/database.ts +41 -0
- package/src/main/db/migrations.ts +235 -0
- package/src/main/db/repositories.ts +574 -0
- package/src/main/fingerprint/http-spoofing.ts +48 -0
- package/src/main/fingerprint/presets.ts +173 -0
- package/src/main/fingerprint/profile-generator.ts +115 -0
- package/src/main/fingerprint/profile-store.ts +52 -0
- package/src/main/index.ts +260 -0
- package/src/main/ipc.ts +856 -0
- package/src/main/logger.ts +42 -0
- package/src/main/mcp/mcp-config.ts +66 -0
- package/src/main/mcp/mcp-manager.ts +155 -0
- package/src/main/mcp/mcp-server.ts +1038 -0
- package/src/main/prompt-templates.ts +170 -0
- package/src/main/proxy/ca-manager.ts +204 -0
- package/src/main/proxy/cert-download-page.ts +171 -0
- package/src/main/proxy/cert-installer.ts +242 -0
- package/src/main/proxy/mitm-proxy-config.ts +37 -0
- package/src/main/proxy/mitm-proxy-server.ts +1085 -0
- package/src/main/proxy/system-proxy.ts +248 -0
- package/src/main/session/session-manager.ts +724 -0
- package/src/main/tab-manager.ts +582 -0
- package/src/main/updater.ts +111 -0
- package/src/main/window.ts +235 -0
- package/src/preload/hook-script.ts +270 -0
- package/src/preload/index.ts +211 -0
- package/src/preload/interaction-hook.ts +286 -0
- package/src/preload/stealth-script.ts +302 -0
- package/src/preload/target-preload.ts +15 -0
- package/src/renderer/App.tsx +656 -0
- package/src/renderer/components/AiLogDetail.tsx +173 -0
- package/src/renderer/components/AiLogList.tsx +101 -0
- package/src/renderer/components/AiLogView.module.css +364 -0
- package/src/renderer/components/AiLogView.tsx +86 -0
- package/src/renderer/components/AnalyzeBar.module.css +79 -0
- package/src/renderer/components/AnalyzeBar.tsx +104 -0
- package/src/renderer/components/BrowserPanel.module.css +67 -0
- package/src/renderer/components/BrowserPanel.tsx +90 -0
- package/src/renderer/components/ControlBar.module.css +47 -0
- package/src/renderer/components/ControlBar.tsx +205 -0
- package/src/renderer/components/HookLog.tsx +132 -0
- package/src/renderer/components/InteractionLog.tsx +183 -0
- package/src/renderer/components/MCPServerModal.tsx +427 -0
- package/src/renderer/components/PromptTemplateModal.tsx +254 -0
- package/src/renderer/components/ReportView.module.css +413 -0
- package/src/renderer/components/ReportView.tsx +429 -0
- package/src/renderer/components/RequestDetail.module.css +191 -0
- package/src/renderer/components/RequestDetail.tsx +202 -0
- package/src/renderer/components/RequestLog.module.css +69 -0
- package/src/renderer/components/RequestLog.tsx +208 -0
- package/src/renderer/components/SessionList.module.css +245 -0
- package/src/renderer/components/SessionList.tsx +247 -0
- package/src/renderer/components/SettingsModal.tsx +100 -0
- package/src/renderer/components/StatusBar.module.css +44 -0
- package/src/renderer/components/StatusBar.tsx +102 -0
- package/src/renderer/components/StorageView.module.css +41 -0
- package/src/renderer/components/StorageView.tsx +178 -0
- package/src/renderer/components/TabBar.module.css +88 -0
- package/src/renderer/components/TabBar.tsx +70 -0
- package/src/renderer/components/Titlebar.module.css +254 -0
- package/src/renderer/components/Titlebar.tsx +169 -0
- package/src/renderer/components/settings/FingerprintSection.tsx +198 -0
- package/src/renderer/components/settings/GeneralSection.tsx +164 -0
- package/src/renderer/components/settings/LLMSection.tsx +148 -0
- package/src/renderer/components/settings/MCPServerSection.tsx +136 -0
- package/src/renderer/components/settings/MitmProxySection.tsx +320 -0
- package/src/renderer/components/settings/ProxySection.tsx +110 -0
- package/src/renderer/css-modules.d.ts +4 -0
- package/src/renderer/hooks/useCapture.ts +383 -0
- package/src/renderer/hooks/useConfirm.tsx +91 -0
- package/src/renderer/hooks/useSession.ts +136 -0
- package/src/renderer/hooks/useTabs.ts +103 -0
- package/src/renderer/i18n/en.ts +167 -0
- package/src/renderer/i18n/index.ts +47 -0
- package/src/renderer/i18n/zh.ts +170 -0
- package/src/renderer/index.html +12 -0
- package/src/renderer/main.tsx +15 -0
- package/src/renderer/styles/global.css +144 -0
- package/src/renderer/styles/themes/ayu-dark.css +59 -0
- package/src/renderer/styles/themes/catppuccin.css +59 -0
- package/src/renderer/styles/themes/discord.css +59 -0
- package/src/renderer/styles/themes/dracula.css +59 -0
- package/src/renderer/styles/themes/github-dark.css +59 -0
- package/src/renderer/styles/themes/gruvbox.css +59 -0
- package/src/renderer/styles/themes/index.css +11 -0
- package/src/renderer/styles/themes/light.css +59 -0
- package/src/renderer/styles/themes/nord.css +59 -0
- package/src/renderer/styles/themes/one-dark.css +59 -0
- package/src/renderer/styles/themes/tokyo-night.css +59 -0
- package/src/renderer/styles/tokens.css +137 -0
- package/src/renderer/theme.ts +31 -0
- package/src/renderer/ui/Badge.module.css +38 -0
- package/src/renderer/ui/Badge.tsx +36 -0
- package/src/renderer/ui/Button.module.css +142 -0
- package/src/renderer/ui/Button.tsx +46 -0
- package/src/renderer/ui/Collapse.module.css +49 -0
- package/src/renderer/ui/Collapse.tsx +57 -0
- package/src/renderer/ui/CopyableBlock.module.css +56 -0
- package/src/renderer/ui/CopyableBlock.tsx +42 -0
- package/src/renderer/ui/Empty.module.css +19 -0
- package/src/renderer/ui/Empty.tsx +34 -0
- package/src/renderer/ui/Icons.tsx +346 -0
- package/src/renderer/ui/Input.module.css +103 -0
- package/src/renderer/ui/Input.tsx +94 -0
- package/src/renderer/ui/InputNumber.module.css +68 -0
- package/src/renderer/ui/InputNumber.tsx +104 -0
- package/src/renderer/ui/Modal.module.css +83 -0
- package/src/renderer/ui/Modal.tsx +67 -0
- package/src/renderer/ui/Popconfirm.module.css +73 -0
- package/src/renderer/ui/Popconfirm.tsx +74 -0
- package/src/renderer/ui/Progress.module.css +35 -0
- package/src/renderer/ui/Progress.tsx +30 -0
- package/src/renderer/ui/Select.module.css +91 -0
- package/src/renderer/ui/Select.tsx +100 -0
- package/src/renderer/ui/Spinner.module.css +44 -0
- package/src/renderer/ui/Spinner.tsx +27 -0
- package/src/renderer/ui/Switch.module.css +39 -0
- package/src/renderer/ui/Switch.tsx +43 -0
- package/src/renderer/ui/Tabs.module.css +76 -0
- package/src/renderer/ui/Tabs.tsx +53 -0
- package/src/renderer/ui/Tag.module.css +66 -0
- package/src/renderer/ui/Tag.tsx +47 -0
- package/src/renderer/ui/Timeline.module.css +42 -0
- package/src/renderer/ui/Timeline.tsx +29 -0
- package/src/renderer/ui/Toast.module.css +99 -0
- package/src/renderer/ui/Toast.tsx +90 -0
- package/src/renderer/ui/Tooltip.module.css +26 -0
- package/src/renderer/ui/Tooltip.tsx +23 -0
- package/src/renderer/ui/VirtualTable.module.css +230 -0
- package/src/renderer/ui/VirtualTable.tsx +416 -0
- package/src/renderer/ui/index.ts +55 -0
- package/src/shared/types.ts +695 -0
- package/tests/main/ai/crypto-script-extractor.test.ts +281 -0
- package/tests/main/ai/llm-router.test.ts +1537 -0
- package/tests/main/ai/prompt-builder.test.ts +178 -0
- package/tests/main/ai/scene-detector.test.ts +212 -0
- package/tests/main/db/migrations.test.ts +134 -0
- package/tests/main/release-workflow.test.ts +59 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +23 -0
- package/tsconfig.web.json +24 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
import type { LLMProviderConfig, AiRequestLogData } from "@shared/types";
|
|
2
|
+
import type { MCPToolInfo } from "../mcp/mcp-manager";
|
|
3
|
+
|
|
4
|
+
interface LLMResponse {
|
|
5
|
+
content: string;
|
|
6
|
+
promptTokens: number;
|
|
7
|
+
completionTokens: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ChatMessage {
|
|
11
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
12
|
+
content: string;
|
|
13
|
+
// OpenAI tool call fields
|
|
14
|
+
tool_calls?: ToolCall[];
|
|
15
|
+
tool_call_id?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ToolCall {
|
|
20
|
+
id: string;
|
|
21
|
+
type: "function";
|
|
22
|
+
function: { name: string; arguments: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ResponsesIncompleteDetails {
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Anthropic content block types
|
|
30
|
+
interface AnthropicTextBlock {
|
|
31
|
+
type: "text";
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface AnthropicToolUseBlock {
|
|
36
|
+
type: "tool_use";
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
input: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type AnthropicContentBlock = AnthropicTextBlock | AnthropicToolUseBlock;
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT = 600000; // 10 minutes — LLM relay servers can be slow; user can cancel manually
|
|
45
|
+
|
|
46
|
+
interface ResponsesOutputItem {
|
|
47
|
+
type: string;
|
|
48
|
+
content?: Array<{ type: string; text?: unknown }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractResponsesOutputText(output: ResponsesOutputItem[]): string {
|
|
52
|
+
let content = "";
|
|
53
|
+
for (const item of output) {
|
|
54
|
+
if (item.type === "message" && Array.isArray(item.content)) {
|
|
55
|
+
content += item.content
|
|
56
|
+
.filter((c) => c.type === "output_text" && typeof c.text === "string")
|
|
57
|
+
.map((c) => c.text as string)
|
|
58
|
+
.join("");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readResponsesOutputText(data: {
|
|
65
|
+
output_text?: unknown;
|
|
66
|
+
output?: ResponsesOutputItem[];
|
|
67
|
+
}): string {
|
|
68
|
+
const content =
|
|
69
|
+
typeof data.output_text === "string" && data.output_text.length > 0
|
|
70
|
+
? data.output_text
|
|
71
|
+
: Array.isArray(data.output)
|
|
72
|
+
? extractResponsesOutputText(data.output)
|
|
73
|
+
: "";
|
|
74
|
+
|
|
75
|
+
if (content.length === 0) {
|
|
76
|
+
throw new Error(`LLM 响应格式异常: 缺少 output_text 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return content;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function requireLLMContent(content: string, fieldName: string): string {
|
|
83
|
+
if (content.length === 0) {
|
|
84
|
+
throw new Error(`LLM 响应格式异常: 缺少 ${fieldName} 字段`);
|
|
85
|
+
}
|
|
86
|
+
return content;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readToolArguments(argumentsJson: string, fieldName: string): Record<string, unknown> {
|
|
94
|
+
try {
|
|
95
|
+
const args = JSON.parse(argumentsJson);
|
|
96
|
+
if (isRecord(args)) return args;
|
|
97
|
+
} catch {
|
|
98
|
+
// Fall through to the common protocol error below.
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`${fieldName} arguments must be a valid JSON object`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseStreamJson<T>(data: string, providerName: string): T {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(data) as T;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`${providerName} stream error: malformed JSON payload`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readStreamTextDelta(
|
|
112
|
+
value: unknown,
|
|
113
|
+
providerName: string,
|
|
114
|
+
fieldName: string,
|
|
115
|
+
required = false,
|
|
116
|
+
): string {
|
|
117
|
+
if (typeof value === "string") return value;
|
|
118
|
+
if (!required && value == null) return "";
|
|
119
|
+
throw new Error(`${providerName} stream error: ${fieldName} must be a string`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readAnthropicTextContent(data: {
|
|
123
|
+
content: Array<{ type: string; text?: unknown }>;
|
|
124
|
+
}): string {
|
|
125
|
+
const textBlocks = data.content.filter((block) => block.type === "text");
|
|
126
|
+
if (textBlocks.some((block) => typeof block.text !== "string")) {
|
|
127
|
+
throw new Error(`LLM 响应格式异常: text content 必须是字符串 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const content = textBlocks.map((block) => block.text as string).join("");
|
|
131
|
+
if (content.length === 0) {
|
|
132
|
+
throw new Error(`LLM 响应格式异常: 缺少 text content 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return content;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sanitize string content in LLM request body to remove control characters
|
|
140
|
+
* that may break JSON parsing in intermediate proxies.
|
|
141
|
+
*/
|
|
142
|
+
function sanitizeForJson(obj: unknown): unknown {
|
|
143
|
+
if (typeof obj === 'string') {
|
|
144
|
+
// Remove ASCII control chars (except \n \r \t) and Unicode replacement char
|
|
145
|
+
return obj.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\uFFFD]/g, '');
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(obj)) return obj.map(sanitizeForJson);
|
|
148
|
+
if (obj !== null && typeof obj === 'object') {
|
|
149
|
+
const result: Record<string, unknown> = {};
|
|
150
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
151
|
+
result[key] = sanitizeForJson(value);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
return obj;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Mask sensitive values in HTTP headers before logging.
|
|
160
|
+
* "Bearer sk-1234567890abcdef" → "Bearer sk-****cdef"
|
|
161
|
+
*/
|
|
162
|
+
function maskSensitiveHeaders(headers: Record<string, string>): Record<string, string> {
|
|
163
|
+
const masked = { ...headers };
|
|
164
|
+
for (const key of Object.keys(masked)) {
|
|
165
|
+
const lower = key.toLowerCase();
|
|
166
|
+
if (lower === 'authorization' || lower === 'x-api-key' || lower === 'api-key') {
|
|
167
|
+
masked[key] = masked[key].replace(/(\w{2,4})\w{4,}(\w{4})/, '$1****$2');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return masked;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function delayWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
174
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
175
|
+
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const cleanup = (): void => signal?.removeEventListener("abort", abort);
|
|
178
|
+
const timeout = setTimeout(() => {
|
|
179
|
+
cleanup();
|
|
180
|
+
resolve();
|
|
181
|
+
}, ms);
|
|
182
|
+
const abort = (): void => {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
cleanup();
|
|
185
|
+
reject(signal?.reason);
|
|
186
|
+
};
|
|
187
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* LLMRouter — Unified interface for calling different LLM providers.
|
|
193
|
+
* Supports OpenAI, Anthropic, and OpenAI-compatible APIs.
|
|
194
|
+
*/
|
|
195
|
+
export class LLMRouter {
|
|
196
|
+
constructor(
|
|
197
|
+
private config: LLMProviderConfig,
|
|
198
|
+
private onRequestComplete?: (log: AiRequestLogData) => void,
|
|
199
|
+
) {}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Safely parse JSON from a fetch Response.
|
|
203
|
+
* Throws a clear error if the body is not valid JSON (e.g. HTML error pages)
|
|
204
|
+
* or if the API returned a structured error (Anthropic { type: "error" }).
|
|
205
|
+
*/
|
|
206
|
+
private async safeParseJson<T>(response: Response): Promise<T> {
|
|
207
|
+
const text = await response.text();
|
|
208
|
+
let data: unknown;
|
|
209
|
+
try {
|
|
210
|
+
data = JSON.parse(text);
|
|
211
|
+
} catch {
|
|
212
|
+
// Likely HTML or plain text — show a truncated preview
|
|
213
|
+
const preview = text.slice(0, 200).replace(/\n/g, ' ');
|
|
214
|
+
throw new Error(`LLM 返回了非 JSON 响应 (${response.status}): ${preview}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Anthropic error format: { type: "error", error: { type, message } }
|
|
218
|
+
const obj = data !== null && typeof data === "object"
|
|
219
|
+
? data as Record<string, unknown>
|
|
220
|
+
: {};
|
|
221
|
+
if (obj.type === 'error' && typeof obj.error === 'object' && obj.error !== null) {
|
|
222
|
+
const err = obj.error as Record<string, unknown>;
|
|
223
|
+
throw new Error(`LLM API 错误: ${err.type ?? 'unknown'} — ${err.message ?? JSON.stringify(err)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Responses API failed payloads need endpoint-specific handling.
|
|
227
|
+
if (obj.status === "failed") {
|
|
228
|
+
return data as T;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// OpenAI error format: { error: { message, type, code } }
|
|
232
|
+
if (typeof obj.error === 'object' && obj.error !== null && !obj.type) {
|
|
233
|
+
const err = obj.error as Record<string, unknown>;
|
|
234
|
+
throw new Error(`LLM API 错误: ${err.message ?? JSON.stringify(err)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return obj as T;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async complete(
|
|
241
|
+
messages: ChatMessage[],
|
|
242
|
+
onChunk?: (chunk: string) => void,
|
|
243
|
+
signal?: AbortSignal,
|
|
244
|
+
): Promise<LLMResponse> {
|
|
245
|
+
if (this.config.name === "anthropic" || this.config.name === "minimax") {
|
|
246
|
+
return this.completeAnthropic(messages, onChunk, signal);
|
|
247
|
+
}
|
|
248
|
+
if (this.config.apiType === "responses") {
|
|
249
|
+
return this.completeResponses(messages, onChunk, signal);
|
|
250
|
+
}
|
|
251
|
+
return this.completeOpenAI(messages, onChunk, signal);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Agentic loop: LLM ↔ tool calls via MCP.
|
|
256
|
+
* Uses non-streaming for tool-call rounds, streams only the final text response.
|
|
257
|
+
*/
|
|
258
|
+
async completeWithTools(
|
|
259
|
+
messages: ChatMessage[],
|
|
260
|
+
tools: MCPToolInfo[],
|
|
261
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
262
|
+
onChunk?: (chunk: string) => void,
|
|
263
|
+
maxRounds = 10,
|
|
264
|
+
signal?: AbortSignal,
|
|
265
|
+
): Promise<LLMResponse> {
|
|
266
|
+
if (this.config.name === "anthropic" || this.config.name === "minimax") {
|
|
267
|
+
return this.agenticLoopAnthropic(messages, tools, callTool, onChunk, maxRounds, signal);
|
|
268
|
+
}
|
|
269
|
+
if (this.config.apiType === "responses") {
|
|
270
|
+
return this.agenticLoopResponses(messages, tools, callTool, onChunk, maxRounds, signal);
|
|
271
|
+
}
|
|
272
|
+
return this.agenticLoopOpenAI(messages, tools, callTool, onChunk, maxRounds, signal);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- Agentic Loop: OpenAI / Custom ----
|
|
276
|
+
|
|
277
|
+
private async agenticLoopOpenAI(
|
|
278
|
+
messages: ChatMessage[],
|
|
279
|
+
tools: MCPToolInfo[],
|
|
280
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
281
|
+
onChunk?: (chunk: string) => void,
|
|
282
|
+
maxRounds = 10,
|
|
283
|
+
signal?: AbortSignal,
|
|
284
|
+
): Promise<LLMResponse> {
|
|
285
|
+
const openaiTools = tools.map((t) => ({
|
|
286
|
+
type: "function" as const,
|
|
287
|
+
function: {
|
|
288
|
+
name: t.name,
|
|
289
|
+
description: t.description,
|
|
290
|
+
parameters: t.inputSchema,
|
|
291
|
+
},
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
const history = [...messages];
|
|
295
|
+
let totalPromptTokens = 0;
|
|
296
|
+
let totalCompletionTokens = 0;
|
|
297
|
+
|
|
298
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
299
|
+
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
300
|
+
const body = {
|
|
301
|
+
model: this.config.model,
|
|
302
|
+
messages: history.map((m) => {
|
|
303
|
+
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
|
304
|
+
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
|
305
|
+
if (m.tool_call_id) msg.tool_call_id = m.tool_call_id;
|
|
306
|
+
if (m.name) msg.name = m.name;
|
|
307
|
+
return msg;
|
|
308
|
+
}),
|
|
309
|
+
max_tokens: this.config.maxTokens,
|
|
310
|
+
tools: openaiTools,
|
|
311
|
+
stream: false,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
signal?.throwIfAborted();
|
|
315
|
+
const response = await this.fetchWithRetry(url, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: {
|
|
318
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
319
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
320
|
+
},
|
|
321
|
+
body: JSON.stringify(sanitizeForJson(body)),
|
|
322
|
+
}, 1, false, signal);
|
|
323
|
+
|
|
324
|
+
const data = await this.safeParseJson<{
|
|
325
|
+
choices: Array<{
|
|
326
|
+
message: {
|
|
327
|
+
content: string | null;
|
|
328
|
+
tool_calls?: ToolCall[];
|
|
329
|
+
role: string;
|
|
330
|
+
};
|
|
331
|
+
finish_reason: string;
|
|
332
|
+
}>;
|
|
333
|
+
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
334
|
+
}>(response);
|
|
335
|
+
|
|
336
|
+
if (!Array.isArray(data.choices) || data.choices.length === 0) {
|
|
337
|
+
throw new Error(`LLM 响应格式异常: 缺少 choices 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
totalPromptTokens += data.usage?.prompt_tokens || 0;
|
|
341
|
+
totalCompletionTokens += data.usage?.completion_tokens || 0;
|
|
342
|
+
|
|
343
|
+
const choice = data.choices[0];
|
|
344
|
+
if (!choice) throw new Error("No response from LLM");
|
|
345
|
+
|
|
346
|
+
const assistantMsg = choice.message;
|
|
347
|
+
if (!isRecord(assistantMsg)) {
|
|
348
|
+
throw new Error(`LLM 响应格式异常: 缺少 message 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
349
|
+
}
|
|
350
|
+
if (assistantMsg.tool_calls !== undefined && !Array.isArray(assistantMsg.tool_calls)) {
|
|
351
|
+
throw new Error("tool_calls must be an array");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Has tool calls → execute and continue loop
|
|
355
|
+
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
|
|
356
|
+
for (const tc of assistantMsg.tool_calls) {
|
|
357
|
+
if (typeof tc.id !== "string" || tc.id.length === 0) throw new Error("tool_call missing id");
|
|
358
|
+
if (typeof tc.function?.name !== "string" || tc.function.name.length === 0) throw new Error("tool_call missing name");
|
|
359
|
+
if (typeof tc.function?.arguments !== "string") throw new Error("tool_call arguments must be a string");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
history.push({
|
|
363
|
+
role: "assistant",
|
|
364
|
+
content: assistantMsg.content || "",
|
|
365
|
+
tool_calls: assistantMsg.tool_calls,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// 通知前端正在调用工具
|
|
369
|
+
if (onChunk) {
|
|
370
|
+
const toolNames = assistantMsg.tool_calls.map((tc) => tc.function.name).join(", ");
|
|
371
|
+
onChunk(`\n\n> 🔧 调用工具: ${toolNames}\n\n`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const tc of assistantMsg.tool_calls) {
|
|
375
|
+
const args = readToolArguments(tc.function.arguments, "tool_call");
|
|
376
|
+
let result: string;
|
|
377
|
+
try {
|
|
378
|
+
result = await callTool(tc.function.name, args);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
381
|
+
}
|
|
382
|
+
history.push({
|
|
383
|
+
role: "tool",
|
|
384
|
+
content: result,
|
|
385
|
+
tool_call_id: tc.id,
|
|
386
|
+
name: tc.function.name,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// No tool calls → this is the final answer
|
|
393
|
+
if (typeof assistantMsg.content !== "string") {
|
|
394
|
+
throw new Error(`LLM 响应格式异常: 缺少 message.content 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
395
|
+
}
|
|
396
|
+
const content = assistantMsg.content;
|
|
397
|
+
if (onChunk && content) onChunk(content);
|
|
398
|
+
return {
|
|
399
|
+
content,
|
|
400
|
+
promptTokens: totalPromptTokens,
|
|
401
|
+
completionTokens: totalCompletionTokens,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Max rounds exceeded — do final call without tools to force text response
|
|
406
|
+
return this.complete(history, onChunk, signal);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---- Agentic Loop: Anthropic ----
|
|
410
|
+
|
|
411
|
+
private async agenticLoopAnthropic(
|
|
412
|
+
messages: ChatMessage[],
|
|
413
|
+
tools: MCPToolInfo[],
|
|
414
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
415
|
+
onChunk?: (chunk: string) => void,
|
|
416
|
+
maxRounds = 10,
|
|
417
|
+
signal?: AbortSignal,
|
|
418
|
+
): Promise<LLMResponse> {
|
|
419
|
+
const anthropicTools = tools.map((t) => ({
|
|
420
|
+
name: t.name,
|
|
421
|
+
description: t.description,
|
|
422
|
+
input_schema: t.inputSchema,
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
426
|
+
// Anthropic message format: role is "user" | "assistant", content can be array
|
|
427
|
+
const history: Array<{ role: string; content: string | AnthropicContentBlock[] | Array<{ type: string; tool_use_id?: string; content?: string }> }> = messages
|
|
428
|
+
.filter((m) => m.role !== "system")
|
|
429
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
430
|
+
|
|
431
|
+
let totalPromptTokens = 0;
|
|
432
|
+
let totalCompletionTokens = 0;
|
|
433
|
+
|
|
434
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
435
|
+
const url = `${this.config.baseUrl.replace(/\/$/, "")}/messages`;
|
|
436
|
+
const body: Record<string, unknown> = {
|
|
437
|
+
model: this.config.model,
|
|
438
|
+
max_tokens: this.config.maxTokens,
|
|
439
|
+
messages: history,
|
|
440
|
+
tools: anthropicTools,
|
|
441
|
+
stream: false,
|
|
442
|
+
};
|
|
443
|
+
if (systemMsg) body.system = systemMsg.content;
|
|
444
|
+
|
|
445
|
+
signal?.throwIfAborted();
|
|
446
|
+
const response = await this.fetchWithRetry(url, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: {
|
|
449
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
450
|
+
"x-api-key": this.config.apiKey,
|
|
451
|
+
"anthropic-version": "2023-06-01",
|
|
452
|
+
},
|
|
453
|
+
body: JSON.stringify(sanitizeForJson(body)),
|
|
454
|
+
}, 1, false, signal);
|
|
455
|
+
|
|
456
|
+
const data = await this.safeParseJson<{
|
|
457
|
+
content: AnthropicContentBlock[];
|
|
458
|
+
stop_reason: string;
|
|
459
|
+
usage?: { input_tokens: number; output_tokens: number };
|
|
460
|
+
}>(response);
|
|
461
|
+
|
|
462
|
+
totalPromptTokens += data.usage?.input_tokens || 0;
|
|
463
|
+
totalCompletionTokens += data.usage?.output_tokens || 0;
|
|
464
|
+
|
|
465
|
+
if (!Array.isArray(data.content)) {
|
|
466
|
+
throw new Error(`LLM 响应格式异常: 缺少 content 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const toolUseBlocks = data.content.filter(
|
|
470
|
+
(b): b is AnthropicToolUseBlock => b.type === "tool_use",
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (toolUseBlocks.length > 0) {
|
|
474
|
+
for (const block of toolUseBlocks) {
|
|
475
|
+
if (typeof block.id !== "string" || block.id.length === 0) throw new Error("tool_use missing id");
|
|
476
|
+
if (typeof block.name !== "string" || block.name.length === 0) throw new Error("tool_use missing name");
|
|
477
|
+
if (!isRecord(block.input)) throw new Error("tool_use input must be an object");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Push assistant message with content blocks
|
|
481
|
+
history.push({ role: "assistant", content: data.content });
|
|
482
|
+
|
|
483
|
+
if (onChunk) {
|
|
484
|
+
const toolNames = toolUseBlocks.map((b) => b.name).join(", ");
|
|
485
|
+
onChunk(`\n\n> 🔧 调用工具: ${toolNames}\n\n`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Execute tools and push results
|
|
489
|
+
const toolResults: Array<{ type: "tool_result"; tool_use_id: string; content: string }> = [];
|
|
490
|
+
for (const block of toolUseBlocks) {
|
|
491
|
+
let result: string;
|
|
492
|
+
try {
|
|
493
|
+
result = await callTool(block.name, block.input);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
496
|
+
}
|
|
497
|
+
toolResults.push({
|
|
498
|
+
type: "tool_result",
|
|
499
|
+
tool_use_id: block.id,
|
|
500
|
+
content: result,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
history.push({ role: "user", content: toolResults });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// No tool use → extract text content as final answer
|
|
508
|
+
const textContent = readAnthropicTextContent(data);
|
|
509
|
+
if (onChunk && textContent) onChunk(textContent);
|
|
510
|
+
return {
|
|
511
|
+
content: textContent,
|
|
512
|
+
promptTokens: totalPromptTokens,
|
|
513
|
+
completionTokens: totalCompletionTokens,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Max rounds exceeded — final call without tools
|
|
518
|
+
return this.complete(messages, onChunk, signal);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- Agentic Loop: OpenAI Responses API ----
|
|
522
|
+
|
|
523
|
+
private async agenticLoopResponses(
|
|
524
|
+
messages: ChatMessage[],
|
|
525
|
+
tools: MCPToolInfo[],
|
|
526
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
527
|
+
onChunk?: (chunk: string) => void,
|
|
528
|
+
maxRounds = 10,
|
|
529
|
+
signal?: AbortSignal,
|
|
530
|
+
): Promise<LLMResponse> {
|
|
531
|
+
const responsesTools = tools.map((t) => ({
|
|
532
|
+
type: "function" as const,
|
|
533
|
+
name: t.name,
|
|
534
|
+
description: t.description,
|
|
535
|
+
parameters: t.inputSchema,
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
539
|
+
const input: Array<Record<string, unknown>> = messages
|
|
540
|
+
.filter((m) => m.role !== "system")
|
|
541
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
542
|
+
|
|
543
|
+
let totalPromptTokens = 0;
|
|
544
|
+
let totalCompletionTokens = 0;
|
|
545
|
+
|
|
546
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
547
|
+
const url = `${this.config.baseUrl.replace(/\/$/, "")}/responses`;
|
|
548
|
+
const body: Record<string, unknown> = {
|
|
549
|
+
model: this.config.model,
|
|
550
|
+
input,
|
|
551
|
+
max_output_tokens: this.config.maxTokens,
|
|
552
|
+
tools: responsesTools,
|
|
553
|
+
stream: false,
|
|
554
|
+
};
|
|
555
|
+
if (systemMsg) body.instructions = systemMsg.content;
|
|
556
|
+
|
|
557
|
+
signal?.throwIfAborted();
|
|
558
|
+
const response = await this.fetchWithRetry(url, {
|
|
559
|
+
method: "POST",
|
|
560
|
+
headers: {
|
|
561
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
562
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
563
|
+
},
|
|
564
|
+
body: JSON.stringify(sanitizeForJson(body)),
|
|
565
|
+
}, 1, false, signal);
|
|
566
|
+
|
|
567
|
+
const data = await this.safeParseJson<{
|
|
568
|
+
status?: string;
|
|
569
|
+
incomplete_details?: ResponsesIncompleteDetails;
|
|
570
|
+
error?: { message?: string };
|
|
571
|
+
output: Array<ResponsesOutputItem & {
|
|
572
|
+
id?: string;
|
|
573
|
+
call_id?: string;
|
|
574
|
+
name?: string;
|
|
575
|
+
arguments?: string;
|
|
576
|
+
}>;
|
|
577
|
+
output_text?: string;
|
|
578
|
+
usage?: { input_tokens: number; output_tokens: number };
|
|
579
|
+
}>(response);
|
|
580
|
+
|
|
581
|
+
totalPromptTokens += data.usage?.input_tokens || 0;
|
|
582
|
+
totalCompletionTokens += data.usage?.output_tokens || 0;
|
|
583
|
+
|
|
584
|
+
if (data.status === "incomplete") {
|
|
585
|
+
throw new Error(`Responses API incomplete: ${data.incomplete_details?.reason || "unknown"}`);
|
|
586
|
+
}
|
|
587
|
+
if (data.status === "failed") {
|
|
588
|
+
throw new Error(`Responses API failed: ${data.error?.message || "unknown"}`);
|
|
589
|
+
}
|
|
590
|
+
if (!Array.isArray(data.output)) {
|
|
591
|
+
throw new Error(`LLM 响应格式异常: 缺少 output 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const functionCalls = data.output.filter((item) => item.type === "function_call");
|
|
595
|
+
|
|
596
|
+
if (functionCalls.length > 0) {
|
|
597
|
+
for (const fc of functionCalls) {
|
|
598
|
+
if (typeof fc.call_id !== "string" || fc.call_id.length === 0) throw new Error("function_call missing call_id");
|
|
599
|
+
if (typeof fc.name !== "string" || fc.name.length === 0) throw new Error("function_call missing name");
|
|
600
|
+
if (typeof fc.arguments !== "string") throw new Error("function_call arguments must be a string");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const item of data.output) {
|
|
604
|
+
input.push(item as Record<string, unknown>);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (onChunk) {
|
|
608
|
+
const toolNames = functionCalls.map((fc) => fc.name).join(", ");
|
|
609
|
+
onChunk(`\n\n> 🔧 调用工具: ${toolNames}\n\n`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
for (const fc of functionCalls) {
|
|
613
|
+
let result: string;
|
|
614
|
+
const args = readToolArguments(fc.arguments, "function_call");
|
|
615
|
+
try {
|
|
616
|
+
result = await callTool(fc.name, args);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
619
|
+
}
|
|
620
|
+
input.push({
|
|
621
|
+
type: "function_call_output",
|
|
622
|
+
call_id: fc.call_id,
|
|
623
|
+
output: result,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// No function calls → extract text
|
|
630
|
+
const content = readResponsesOutputText(data);
|
|
631
|
+
|
|
632
|
+
if (onChunk && content) onChunk(content);
|
|
633
|
+
return {
|
|
634
|
+
content,
|
|
635
|
+
promptTokens: totalPromptTokens,
|
|
636
|
+
completionTokens: totalCompletionTokens,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Max rounds exceeded — do final call without tools
|
|
641
|
+
return this.completeResponses(messages, onChunk, signal);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private async completeOpenAI(
|
|
645
|
+
messages: ChatMessage[],
|
|
646
|
+
onChunk?: (chunk: string) => void,
|
|
647
|
+
signal?: AbortSignal,
|
|
648
|
+
): Promise<LLMResponse> {
|
|
649
|
+
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
650
|
+
const stream = !!onChunk;
|
|
651
|
+
const body = {
|
|
652
|
+
model: this.config.model,
|
|
653
|
+
messages,
|
|
654
|
+
max_tokens: this.config.maxTokens,
|
|
655
|
+
stream,
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const response = await this.fetchWithRetry(url, {
|
|
659
|
+
method: "POST",
|
|
660
|
+
headers: {
|
|
661
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
662
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
663
|
+
},
|
|
664
|
+
body: JSON.stringify(sanitizeForJson(body)),
|
|
665
|
+
}, 1, stream, signal);
|
|
666
|
+
|
|
667
|
+
if (stream) return this.parseOpenAIStream(response, onChunk!);
|
|
668
|
+
|
|
669
|
+
const data = await this.safeParseJson<{
|
|
670
|
+
choices: Array<{ message: { content: string } }>;
|
|
671
|
+
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
672
|
+
}>(response);
|
|
673
|
+
if (!Array.isArray(data.choices) || data.choices.length === 0) {
|
|
674
|
+
throw new Error(`LLM 响应格式异常: 缺少 choices 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
675
|
+
}
|
|
676
|
+
const content = data.choices[0]?.message?.content;
|
|
677
|
+
if (typeof content !== "string") {
|
|
678
|
+
throw new Error(`LLM 响应格式异常: 缺少 message.content 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
content,
|
|
682
|
+
promptTokens: data.usage?.prompt_tokens || 0,
|
|
683
|
+
completionTokens: data.usage?.completion_tokens || 0,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private async completeResponses(
|
|
688
|
+
messages: ChatMessage[],
|
|
689
|
+
onChunk?: (chunk: string) => void,
|
|
690
|
+
signal?: AbortSignal,
|
|
691
|
+
): Promise<LLMResponse> {
|
|
692
|
+
const url = `${this.config.baseUrl.replace(/\/$/, "")}/responses`;
|
|
693
|
+
const stream = !!onChunk;
|
|
694
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
695
|
+
const inputMessages = messages
|
|
696
|
+
.filter((m) => m.role !== "system")
|
|
697
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
698
|
+
const body: Record<string, unknown> = {
|
|
699
|
+
model: this.config.model,
|
|
700
|
+
input: inputMessages,
|
|
701
|
+
max_output_tokens: this.config.maxTokens,
|
|
702
|
+
stream,
|
|
703
|
+
};
|
|
704
|
+
if (systemMsg) body.instructions = systemMsg.content;
|
|
705
|
+
|
|
706
|
+
const response = await this.fetchWithRetry(url, {
|
|
707
|
+
method: "POST",
|
|
708
|
+
headers: {
|
|
709
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
710
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
711
|
+
},
|
|
712
|
+
body: JSON.stringify(sanitizeForJson(body)),
|
|
713
|
+
}, 1, stream, signal);
|
|
714
|
+
|
|
715
|
+
if (stream) return this.parseResponsesStream(response, onChunk!);
|
|
716
|
+
|
|
717
|
+
const data = await this.safeParseJson<{
|
|
718
|
+
status?: string;
|
|
719
|
+
incomplete_details?: ResponsesIncompleteDetails;
|
|
720
|
+
error?: { message?: string };
|
|
721
|
+
output_text?: string;
|
|
722
|
+
output?: ResponsesOutputItem[];
|
|
723
|
+
usage?: { input_tokens: number; output_tokens: number };
|
|
724
|
+
}>(response);
|
|
725
|
+
if (data.status === "incomplete") {
|
|
726
|
+
throw new Error(`Responses API incomplete: ${data.incomplete_details?.reason || "unknown"}`);
|
|
727
|
+
}
|
|
728
|
+
if (data.status === "failed") {
|
|
729
|
+
throw new Error(`Responses API failed: ${data.error?.message || "unknown"}`);
|
|
730
|
+
}
|
|
731
|
+
if (typeof data.output_text !== "string" && !Array.isArray(data.output)) {
|
|
732
|
+
throw new Error(`LLM 响应格式异常: 缺少 output 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
733
|
+
}
|
|
734
|
+
const content = readResponsesOutputText(data);
|
|
735
|
+
return {
|
|
736
|
+
content,
|
|
737
|
+
promptTokens: data.usage?.input_tokens || 0,
|
|
738
|
+
completionTokens: data.usage?.output_tokens || 0,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private async completeAnthropic(
|
|
743
|
+
messages: ChatMessage[],
|
|
744
|
+
onChunk?: (chunk: string) => void,
|
|
745
|
+
signal?: AbortSignal,
|
|
746
|
+
): Promise<LLMResponse> {
|
|
747
|
+
const url = `${this.config.baseUrl.replace(/\/$/, "")}/messages`;
|
|
748
|
+
const stream = !!onChunk;
|
|
749
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
750
|
+
const userMessages = messages
|
|
751
|
+
.filter((m) => m.role !== "system")
|
|
752
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
753
|
+
const body: Record<string, unknown> = {
|
|
754
|
+
model: this.config.model,
|
|
755
|
+
max_tokens: this.config.maxTokens,
|
|
756
|
+
messages: userMessages,
|
|
757
|
+
stream,
|
|
758
|
+
};
|
|
759
|
+
if (systemMsg) body.system = systemMsg.content;
|
|
760
|
+
|
|
761
|
+
const response = await this.fetchWithRetry(url, {
|
|
762
|
+
method: "POST",
|
|
763
|
+
headers: {
|
|
764
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
765
|
+
"x-api-key": this.config.apiKey,
|
|
766
|
+
"anthropic-version": "2023-06-01",
|
|
767
|
+
},
|
|
768
|
+
body: JSON.stringify(sanitizeForJson(body)),
|
|
769
|
+
}, 1, stream, signal);
|
|
770
|
+
|
|
771
|
+
if (stream) return this.parseAnthropicStream(response, onChunk!);
|
|
772
|
+
|
|
773
|
+
const data = await this.safeParseJson<{
|
|
774
|
+
content: Array<{ type: string; text: string }>;
|
|
775
|
+
usage?: { input_tokens: number; output_tokens: number };
|
|
776
|
+
}>(response);
|
|
777
|
+
if (!Array.isArray(data.content)) {
|
|
778
|
+
throw new Error(`LLM 响应格式异常: 缺少 content 字段 — ${JSON.stringify(data).slice(0, 200)}`);
|
|
779
|
+
}
|
|
780
|
+
const content = readAnthropicTextContent(data);
|
|
781
|
+
return {
|
|
782
|
+
content,
|
|
783
|
+
promptTokens: data.usage?.input_tokens || 0,
|
|
784
|
+
completionTokens: data.usage?.output_tokens || 0,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private async parseOpenAIStream(
|
|
789
|
+
response: Response,
|
|
790
|
+
onChunk: (chunk: string) => void,
|
|
791
|
+
): Promise<LLMResponse> {
|
|
792
|
+
let fullContent = "",
|
|
793
|
+
promptTokens = 0,
|
|
794
|
+
completionTokens = 0;
|
|
795
|
+
const reader = response.body?.getReader();
|
|
796
|
+
if (!reader) throw new Error("No response body");
|
|
797
|
+
const decoder = new TextDecoder();
|
|
798
|
+
let buffer = "";
|
|
799
|
+
const processLine = (line: string): void => {
|
|
800
|
+
const trimmed = line.trim();
|
|
801
|
+
if (!trimmed || !trimmed.startsWith("data: ")) return;
|
|
802
|
+
const data = trimmed.slice(6);
|
|
803
|
+
if (data === "[DONE]") return;
|
|
804
|
+
const parsed = parseStreamJson<any>(data, "OpenAI");
|
|
805
|
+
if (parsed.error) {
|
|
806
|
+
const errorMsg = parsed.error.message || "Unknown stream error";
|
|
807
|
+
throw new Error(`OpenAI stream error: ${errorMsg}`);
|
|
808
|
+
}
|
|
809
|
+
const chunk = readStreamTextDelta(parsed.choices?.[0]?.delta?.content, "OpenAI", "delta.content");
|
|
810
|
+
if (chunk) {
|
|
811
|
+
fullContent += chunk;
|
|
812
|
+
onChunk(chunk);
|
|
813
|
+
}
|
|
814
|
+
if (parsed.usage) {
|
|
815
|
+
promptTokens = parsed.usage.prompt_tokens;
|
|
816
|
+
completionTokens = parsed.usage.completion_tokens;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
while (true) {
|
|
821
|
+
const { done, value } = await reader.read();
|
|
822
|
+
if (done) break;
|
|
823
|
+
buffer += decoder.decode(value, { stream: true });
|
|
824
|
+
const lines = buffer.split("\n");
|
|
825
|
+
buffer = lines.pop() || "";
|
|
826
|
+
for (const line of lines) processLine(line);
|
|
827
|
+
}
|
|
828
|
+
if (buffer) processLine(buffer);
|
|
829
|
+
return { content: requireLLMContent(fullContent, "message.content"), promptTokens, completionTokens };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private async parseResponsesStream(
|
|
833
|
+
response: Response,
|
|
834
|
+
onChunk: (chunk: string) => void,
|
|
835
|
+
): Promise<LLMResponse> {
|
|
836
|
+
let fullContent = "",
|
|
837
|
+
promptTokens = 0,
|
|
838
|
+
completionTokens = 0;
|
|
839
|
+
const reader = response.body?.getReader();
|
|
840
|
+
if (!reader) throw new Error("No response body");
|
|
841
|
+
const decoder = new TextDecoder();
|
|
842
|
+
let buffer = "";
|
|
843
|
+
let currentEvent = "";
|
|
844
|
+
const processLine = (line: string): void => {
|
|
845
|
+
const trimmed = line.trim();
|
|
846
|
+
if (!trimmed) {
|
|
847
|
+
currentEvent = "";
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (trimmed.startsWith("event: ")) {
|
|
851
|
+
currentEvent = trimmed.slice(7);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!trimmed.startsWith("data: ")) return;
|
|
855
|
+
const parsed = parseStreamJson<any>(trimmed.slice(6), "Responses API");
|
|
856
|
+
if (currentEvent === "response.output_text.delta") {
|
|
857
|
+
const delta = readStreamTextDelta(parsed.delta, "Responses API", "delta", true);
|
|
858
|
+
if (delta) {
|
|
859
|
+
fullContent += delta;
|
|
860
|
+
onChunk(delta);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (currentEvent === "response.completed" && parsed.response?.usage) {
|
|
864
|
+
promptTokens = parsed.response.usage.input_tokens || 0;
|
|
865
|
+
completionTokens = parsed.response.usage.output_tokens || 0;
|
|
866
|
+
}
|
|
867
|
+
if (currentEvent === "response.incomplete") {
|
|
868
|
+
const reason = parsed.response?.incomplete_details?.reason || "unknown";
|
|
869
|
+
throw new Error(`Responses API incomplete: ${reason}`);
|
|
870
|
+
}
|
|
871
|
+
if (currentEvent === "error" || currentEvent === "response.failed") {
|
|
872
|
+
const errorMsg =
|
|
873
|
+
parsed.message ||
|
|
874
|
+
parsed.error?.message ||
|
|
875
|
+
parsed.response?.error?.message ||
|
|
876
|
+
"Unknown stream error";
|
|
877
|
+
throw new Error(`Responses API stream error: ${errorMsg}`);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
while (true) {
|
|
882
|
+
const { done, value } = await reader.read();
|
|
883
|
+
if (done) break;
|
|
884
|
+
buffer += decoder.decode(value, { stream: true });
|
|
885
|
+
const lines = buffer.split("\n");
|
|
886
|
+
buffer = lines.pop() || "";
|
|
887
|
+
for (const line of lines) processLine(line);
|
|
888
|
+
}
|
|
889
|
+
if (buffer) processLine(buffer);
|
|
890
|
+
return { content: requireLLMContent(fullContent, "output_text"), promptTokens, completionTokens };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private async parseAnthropicStream(
|
|
894
|
+
response: Response,
|
|
895
|
+
onChunk: (chunk: string) => void,
|
|
896
|
+
): Promise<LLMResponse> {
|
|
897
|
+
let fullContent = "",
|
|
898
|
+
promptTokens = 0,
|
|
899
|
+
completionTokens = 0;
|
|
900
|
+
const reader = response.body?.getReader();
|
|
901
|
+
if (!reader) throw new Error("No response body");
|
|
902
|
+
const decoder = new TextDecoder();
|
|
903
|
+
let buffer = "";
|
|
904
|
+
const processLine = (line: string): void => {
|
|
905
|
+
const trimmed = line.trim();
|
|
906
|
+
if (!trimmed || !trimmed.startsWith("data: ")) return;
|
|
907
|
+
const parsed = parseStreamJson<any>(trimmed.slice(6), "Anthropic");
|
|
908
|
+
if (parsed.type === "error") {
|
|
909
|
+
const errorMsg = parsed.error?.message || "Unknown stream error";
|
|
910
|
+
throw new Error(`Anthropic stream error: ${errorMsg}`);
|
|
911
|
+
}
|
|
912
|
+
if (parsed.type === "content_block_delta") {
|
|
913
|
+
const delta = readStreamTextDelta(parsed.delta?.text, "Anthropic", "delta.text");
|
|
914
|
+
if (delta) {
|
|
915
|
+
fullContent += delta;
|
|
916
|
+
onChunk(delta);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (parsed.type === "message_start" && parsed.message?.usage)
|
|
920
|
+
promptTokens = parsed.message.usage.input_tokens;
|
|
921
|
+
if (parsed.type === "message_delta" && parsed.usage)
|
|
922
|
+
completionTokens = parsed.usage.output_tokens || 0;
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
while (true) {
|
|
926
|
+
const { done, value } = await reader.read();
|
|
927
|
+
if (done) break;
|
|
928
|
+
buffer += decoder.decode(value, { stream: true });
|
|
929
|
+
const lines = buffer.split("\n");
|
|
930
|
+
buffer = lines.pop() || "";
|
|
931
|
+
for (const line of lines) processLine(line);
|
|
932
|
+
}
|
|
933
|
+
if (buffer) processLine(buffer);
|
|
934
|
+
return { content: requireLLMContent(fullContent, "text content"), promptTokens, completionTokens };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private async fetchWithRetry(
|
|
938
|
+
url: string,
|
|
939
|
+
options: RequestInit,
|
|
940
|
+
retries = 1,
|
|
941
|
+
isStreaming = false,
|
|
942
|
+
signal?: AbortSignal,
|
|
943
|
+
): Promise<Response> {
|
|
944
|
+
const controller = new AbortController();
|
|
945
|
+
let abortedBySignal = false;
|
|
946
|
+
const abortFromSignal = (): void => {
|
|
947
|
+
abortedBySignal = true;
|
|
948
|
+
controller.abort(signal?.reason);
|
|
949
|
+
};
|
|
950
|
+
if (signal?.aborted) abortFromSignal();
|
|
951
|
+
signal?.addEventListener("abort", abortFromSignal, { once: true });
|
|
952
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
953
|
+
const startTime = Date.now();
|
|
954
|
+
|
|
955
|
+
// Extract headers for logging
|
|
956
|
+
const rawHeaders: Record<string, string> = {};
|
|
957
|
+
if (options.headers) {
|
|
958
|
+
const h = options.headers as Record<string, string>;
|
|
959
|
+
for (const [k, v] of Object.entries(h)) rawHeaders[k] = v;
|
|
960
|
+
}
|
|
961
|
+
const maskedHeaders = maskSensitiveHeaders(rawHeaders);
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
const response = await fetch(url, {
|
|
965
|
+
...options,
|
|
966
|
+
signal: controller.signal,
|
|
967
|
+
});
|
|
968
|
+
clearTimeout(timeout);
|
|
969
|
+
|
|
970
|
+
if (response.status === 429 && retries > 0) {
|
|
971
|
+
const retryAfter = parseInt(
|
|
972
|
+
response.headers.get("retry-after") || "5",
|
|
973
|
+
10,
|
|
974
|
+
);
|
|
975
|
+
await delayWithAbort(retryAfter * 1000, signal);
|
|
976
|
+
return this.fetchWithRetry(url, options, retries - 1, isStreaming, signal);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (!response.ok) {
|
|
980
|
+
const errorBody = await response.text().catch(() => "");
|
|
981
|
+
const host = (() => { try { return new URL(url).host; } catch { return url; } })();
|
|
982
|
+
const durationMs = Date.now() - startTime;
|
|
983
|
+
|
|
984
|
+
// Log failed request
|
|
985
|
+
this.onRequestComplete?.({
|
|
986
|
+
request_url: url,
|
|
987
|
+
request_method: (options.method ?? 'POST').toUpperCase(),
|
|
988
|
+
request_headers: JSON.stringify(maskedHeaders),
|
|
989
|
+
request_body: typeof options.body === 'string' ? options.body : '',
|
|
990
|
+
status_code: response.status,
|
|
991
|
+
response_headers: JSON.stringify(Object.fromEntries(response.headers.entries())),
|
|
992
|
+
response_body: errorBody.slice(0, 10000),
|
|
993
|
+
duration_ms: durationMs,
|
|
994
|
+
error: `${response.status} ${errorBody.slice(0, 200)}`,
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
throw new Error(`LLM 请求失败 (${host}): ${response.status} ${errorBody.slice(0, 200)}`);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Success path
|
|
1001
|
+
const durationMs = Date.now() - startTime;
|
|
1002
|
+
const responseHeadersObj = Object.fromEntries(response.headers.entries());
|
|
1003
|
+
|
|
1004
|
+
if (isStreaming) {
|
|
1005
|
+
// Streaming: cannot read body, mark as [streaming]
|
|
1006
|
+
this.onRequestComplete?.({
|
|
1007
|
+
request_url: url,
|
|
1008
|
+
request_method: (options.method ?? 'POST').toUpperCase(),
|
|
1009
|
+
request_headers: JSON.stringify(maskedHeaders),
|
|
1010
|
+
request_body: typeof options.body === 'string' ? options.body : '',
|
|
1011
|
+
status_code: response.status,
|
|
1012
|
+
response_headers: JSON.stringify(responseHeadersObj),
|
|
1013
|
+
response_body: '[streaming]',
|
|
1014
|
+
duration_ms: durationMs,
|
|
1015
|
+
error: null,
|
|
1016
|
+
});
|
|
1017
|
+
return response;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Non-streaming: read body, log, then reconstruct Response
|
|
1021
|
+
const responseText = await response.text();
|
|
1022
|
+
this.onRequestComplete?.({
|
|
1023
|
+
request_url: url,
|
|
1024
|
+
request_method: (options.method ?? 'POST').toUpperCase(),
|
|
1025
|
+
request_headers: JSON.stringify(maskedHeaders),
|
|
1026
|
+
request_body: typeof options.body === 'string' ? options.body : '',
|
|
1027
|
+
status_code: response.status,
|
|
1028
|
+
response_headers: JSON.stringify(responseHeadersObj),
|
|
1029
|
+
response_body: responseText.slice(0, 100000),
|
|
1030
|
+
duration_ms: durationMs,
|
|
1031
|
+
error: null,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
return new Response(responseText, {
|
|
1035
|
+
status: response.status,
|
|
1036
|
+
statusText: response.statusText,
|
|
1037
|
+
headers: response.headers,
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
clearTimeout(timeout);
|
|
1042
|
+
const durationMs = Date.now() - startTime;
|
|
1043
|
+
|
|
1044
|
+
if (err instanceof Error && err.message.startsWith('LLM 请求失败')) {
|
|
1045
|
+
throw err; // Already logged above
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Network-level error — log it
|
|
1049
|
+
const diagMsg = abortedBySignal
|
|
1050
|
+
? "LLM 请求已取消"
|
|
1051
|
+
: this.diagnoseNetworkError(err as Error, url);
|
|
1052
|
+
this.onRequestComplete?.({
|
|
1053
|
+
request_url: url,
|
|
1054
|
+
request_method: (options.method ?? 'POST').toUpperCase(),
|
|
1055
|
+
request_headers: JSON.stringify(maskedHeaders),
|
|
1056
|
+
request_body: typeof options.body === 'string' ? options.body : '',
|
|
1057
|
+
status_code: null,
|
|
1058
|
+
response_headers: null,
|
|
1059
|
+
response_body: null,
|
|
1060
|
+
duration_ms: durationMs,
|
|
1061
|
+
error: diagMsg,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
throw new Error(diagMsg);
|
|
1065
|
+
} finally {
|
|
1066
|
+
signal?.removeEventListener("abort", abortFromSignal);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* 将底层网络错误转换为用户可理解的诊断信息。
|
|
1072
|
+
*/
|
|
1073
|
+
private diagnoseNetworkError(err: Error, url: string): string {
|
|
1074
|
+
// Node.js 18+ wraps the real error in err.cause — extract it for better diagnosis
|
|
1075
|
+
const cause = (err as any).cause;
|
|
1076
|
+
const msg = [err.message, cause?.message, cause?.code].filter(Boolean).join(' | ');
|
|
1077
|
+
const host = (() => {
|
|
1078
|
+
try { return new URL(url).host; } catch { return url; }
|
|
1079
|
+
})();
|
|
1080
|
+
|
|
1081
|
+
// AbortController timeout
|
|
1082
|
+
if (err.name === "AbortError" || msg.includes("aborted")) {
|
|
1083
|
+
return `连接超时:${host} 在 ${DEFAULT_TIMEOUT / 1000} 秒内未响应。请检查 API 地址是否正确,以及网络是否可达。`;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// DNS resolution failure
|
|
1087
|
+
if (msg.includes("ENOTFOUND") || msg.includes("getaddrinfo")) {
|
|
1088
|
+
return `DNS 解析失败:无法解析 ${host}。请检查 API 地址拼写是否正确。`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Connection refused (local service not running)
|
|
1092
|
+
if (msg.includes("ECONNREFUSED")) {
|
|
1093
|
+
return `连接被拒绝:${host} 未在监听。如果使用本地中转服务,请确认该服务已启动。`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Connection reset
|
|
1097
|
+
if (msg.includes("ECONNRESET") || msg.includes("socket hang up")) {
|
|
1098
|
+
return `连接被重置:${host} 中断了连接。可能是代理服务器不稳定或 API 服务限流。`;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// SSL/TLS errors
|
|
1102
|
+
if (msg.includes("UNABLE_TO_VERIFY") || msg.includes("CERT_") || msg.includes("certificate") || msg.includes("SSL")) {
|
|
1103
|
+
return `SSL 证书错误:无法与 ${host} 建立安全连接。如果使用自签证书的中转服务,需配置 NODE_TLS_REJECT_UNAUTHORIZED=0 环境变量(不推荐用于生产环境)。`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Network unreachable
|
|
1107
|
+
if (msg.includes("ENETUNREACH") || msg.includes("EHOSTUNREACH")) {
|
|
1108
|
+
return `网络不可达:无法连接到 ${host}。请检查网络连接。`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Generic "fetch failed" — the most common opaque error
|
|
1112
|
+
if (msg.includes("fetch failed")) {
|
|
1113
|
+
const causeDetail = cause ? ` (${cause.code || cause.message || cause})` : '';
|
|
1114
|
+
return `网络请求失败:无法连接到 ${host}${causeDetail}。常见原因:1) API 地址配置错误 2) 网络无法访问该地址(如需科学上网) 3) 本地中转服务未启动。`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Fallback: preserve original message
|
|
1118
|
+
return `LLM 请求失败 (${host}): ${msg}`;
|
|
1119
|
+
}
|
|
1120
|
+
}
|