Skip to content

Commit 84621b6

Browse files
author
MouseWW
committed
fix(llm): allow cancelling retry waits
1 parent c1e9bb8 commit 84621b6

4 files changed

Lines changed: 104 additions & 37 deletions

File tree

RELEASE_NOTES.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
# Anything Analyzer v3.6.21
1+
# Anything Analyzer v3.6.22
22

33
## 修复
44

5-
- **LLM 分析取消立即生效**修复分析任务取消后底层 LLM 请求仍继续执行的问题
6-
- 普通补全和工具调用补全现在都会把 `AbortSignal` 传递到底层 `fetch`
7-
- 取消请求时统一返回 `LLM 请求已取消`,避免被误判为网络故障
5+
- **LLM 限流重试可立即取消**修复 LLM 返回 429 后等待 `retry-after` 期间取消不生效的问题
6+
- 重试等待现在会响应 `AbortSignal`,取消分析时不会继续卡在限流等待中
7+
- Responses API 非流式 `status: "failed"` 现在返回明确的 `Responses API failed: ...` 错误
88

99
## 下载
1010

1111
| 平台 | 文件 |
1212
|------|------|
13-
| Windows | Anything-Analyzer-Setup-3.6.21.exe |
14-
| macOS (Apple Silicon) | Anything-Analyzer-3.6.21-arm64.dmg |
15-
| macOS (Intel) | Anything-Analyzer-3.6.21-x64.dmg |
16-
| Linux | Anything-Analyzer-3.6.21.AppImage |
13+
| Windows | Anything-Analyzer-Setup-3.6.22.exe |
14+
| macOS (Apple Silicon) | Anything-Analyzer-3.6.22-arm64.dmg |
15+
| macOS (Intel) | Anything-Analyzer-3.6.22-x64.dmg |
16+
| Linux | Anything-Analyzer-3.6.22.AppImage |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anything-analyzer",
3-
"version": "3.6.21",
3+
"version": "3.6.22",
44
"description": "Universal web protocol analyzer with embedded browser and AI-powered analysis",
55
"packageManager": "pnpm@10.24.0",
66
"main": "./out/main/index.js",

src/main/ai/llm-router.ts

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,38 @@ function sanitizeForJson(obj: unknown): unknown {
6767
* Mask sensitive values in HTTP headers before logging.
6868
* "Bearer sk-1234567890abcdef" → "Bearer sk-****cdef"
6969
*/
70-
function maskSensitiveHeaders(headers: Record<string, string>): Record<string, string> {
71-
const masked = { ...headers };
72-
for (const key of Object.keys(masked)) {
73-
const lower = key.toLowerCase();
70+
function maskSensitiveHeaders(headers: Record<string, string>): Record<string, string> {
71+
const masked = { ...headers };
72+
for (const key of Object.keys(masked)) {
73+
const lower = key.toLowerCase();
7474
if (lower === 'authorization' || lower === 'x-api-key' || lower === 'api-key') {
7575
masked[key] = masked[key].replace(/(\w{2,4})\w{4,}(\w{4})/, '$1****$2');
7676
}
7777
}
78-
return masked;
79-
}
80-
81-
/**
82-
* LLMRouter — Unified interface for calling different LLM providers.
83-
* Supports OpenAI, Anthropic, and OpenAI-compatible APIs.
78+
return masked;
79+
}
80+
81+
function delayWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
82+
if (signal?.aborted) return Promise.reject(signal.reason);
83+
84+
return new Promise((resolve, reject) => {
85+
const cleanup = (): void => signal?.removeEventListener("abort", abort);
86+
const timeout = setTimeout(() => {
87+
cleanup();
88+
resolve();
89+
}, ms);
90+
const abort = (): void => {
91+
clearTimeout(timeout);
92+
cleanup();
93+
reject(signal?.reason);
94+
};
95+
signal?.addEventListener("abort", abort, { once: true });
96+
});
97+
}
98+
99+
/**
100+
* LLMRouter — Unified interface for calling different LLM providers.
101+
* Supports OpenAI, Anthropic, and OpenAI-compatible APIs.
84102
*/
85103
export class LLMRouter {
86104
constructor(
@@ -111,10 +129,15 @@ export class LLMRouter {
111129
throw new Error(`LLM API 错误: ${err.type ?? 'unknown'}${err.message ?? JSON.stringify(err)}`);
112130
}
113131

114-
// OpenAI error format: { error: { message, type, code } }
115-
if (typeof obj.error === 'object' && obj.error !== null && !obj.type) {
116-
const err = obj.error as Record<string, unknown>;
117-
throw new Error(`LLM API 错误: ${err.message ?? JSON.stringify(err)}`);
132+
// Responses API failed payloads need endpoint-specific handling.
133+
if (obj.status === "failed") {
134+
return data as T;
135+
}
136+
137+
// OpenAI error format: { error: { message, type, code } }
138+
if (typeof obj.error === 'object' && obj.error !== null && !obj.type) {
139+
const err = obj.error as Record<string, unknown>;
140+
throw new Error(`LLM API 错误: ${err.message ?? JSON.stringify(err)}`);
118141
}
119142

120143
return data as T;
@@ -573,18 +596,22 @@ export class LLMRouter {
573596

574597
if (stream) return this.parseResponsesStream(response, onChunk!);
575598

576-
const data = await this.safeParseJson<{
599+
const data = await this.safeParseJson<{
577600
status?: string;
578601
incomplete_details?: ResponsesIncompleteDetails;
579-
output_text?: string;
580-
usage?: { input_tokens: number; output_tokens: number };
581-
}>(response);
602+
error?: { message?: string };
603+
output_text?: string;
604+
usage?: { input_tokens: number; output_tokens: number };
605+
}>(response);
582606
if (data.status === "incomplete") {
583607
throw new Error(`Responses API incomplete: ${data.incomplete_details?.reason || "unknown"}`);
584608
}
585-
return {
586-
content: data.output_text || "",
587-
promptTokens: data.usage?.input_tokens || 0,
609+
if (data.status === "failed") {
610+
throw new Error(`Responses API failed: ${data.error?.message || "unknown"}`);
611+
}
612+
return {
613+
content: data.output_text || "",
614+
promptTokens: data.usage?.input_tokens || 0,
588615
completionTokens: data.usage?.output_tokens || 0,
589616
};
590617
}
@@ -833,12 +860,12 @@ export class LLMRouter {
833860
});
834861
clearTimeout(timeout);
835862

836-
if (response.status === 429 && retries > 0) {
863+
if (response.status === 429 && retries > 0) {
837864
const retryAfter = parseInt(
838865
response.headers.get("retry-after") || "5",
839866
10,
840867
);
841-
await new Promise((r) => setTimeout(r, retryAfter * 1000));
868+
await delayWithAbort(retryAfter * 1000, signal);
842869
return this.fetchWithRetry(url, options, retries - 1, isStreaming, signal);
843870
}
844871

tests/main/ai/llm-router.test.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,36 @@ describe("LLMRouter", () => {
6767
vi.stubGlobal("fetch", fetchSpy);
6868
});
6969

70-
afterEach(() => {
71-
vi.restoreAllMocks();
72-
});
73-
70+
afterEach(() => {
71+
vi.useRealTimers();
72+
vi.restoreAllMocks();
73+
});
74+
7475
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+
75100
it("should connect abort signal to standard LLM requests", async () => {
76101
const controller = new AbortController();
77102
fetchSpy.mockImplementationOnce((_url, options) => {
@@ -317,7 +342,22 @@ describe("LLMRouter", () => {
317342
router.complete([{ role: "user", content: "test" }]),
318343
).rejects.toThrow("Responses API incomplete: max_output_tokens");
319344
});
320-
});
345+
346+
it("should reject failed Responses API results", async () => {
347+
const config: LLMProviderConfig = { ...baseConfig, apiType: "responses" };
348+
fetchSpy.mockResolvedValueOnce(
349+
createJSONResponse({
350+
status: "failed",
351+
}),
352+
);
353+
354+
const router = new LLMRouter(config);
355+
356+
await expect(
357+
router.complete([{ role: "user", content: "test" }]),
358+
).rejects.toThrow("Responses API failed: unknown");
359+
});
360+
});
321361

322362
describe("completeResponses - streaming", () => {
323363
it("should parse SSE events with event: prefix and call onChunk", async () => {

0 commit comments

Comments
 (0)