From bd4ac5d3d5ab93fd864ce5e663ee5d9525ea7b02 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 15:10:46 +0800 Subject: [PATCH 01/23] =?UTF-8?q?fix(proxy):=20=E8=AF=86=E5=88=AB=20200+HT?= =?UTF-8?q?ML=20=E5=81=87200=E5=B9=B6=E8=A7=A6=E5=8F=91=E6=95=85=E9=9A=9C?= =?UTF-8?q?=E8=BD=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- biome.json | 2 +- src/app/v1/_lib/proxy/forwarder.ts | 141 ++++++-- .../utils/upstream-error-detection.test.ts | 25 ++ src/lib/utils/upstream-error-detection.ts | 26 ++ .../unit/lib/provider-endpoints/probe.test.ts | 26 ++ .../proxy-forwarder-fake-200-html.test.ts | 301 ++++++++++++++++++ 6 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts diff --git a/biome.json b/biome.json index 87362d2ac..4e430dd39 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index c33082343..31878c7e4 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -25,6 +25,7 @@ import { import { getGlobalAgentPool, getProxyAgentForProvider } from "@/lib/proxy-agent"; import { SessionManager } from "@/lib/session-manager"; import { CONTEXT_1M_BETA_HEADER, shouldApplyContext1m } from "@/lib/special-attributes"; +import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; import { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout, @@ -84,6 +85,62 @@ const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商( type CacheTtlOption = CacheTtlPreference | null | undefined; +// 非流式响应体检查的上限(字节):避免上游在 2xx 场景返回超大内容导致内存占用失控。 +// 说明: +// - 该检查仅用于“空响应/假 200”启发式判定,不用于业务逻辑解析; +// - 超过上限时,仍认为“非空”,但会跳过 JSON 内容结构检查(避免截断导致误判)。 +const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 1024 * 1024; // 1 MiB + +async function readResponseTextUpTo( + response: Response, + maxBytes: number +): Promise<{ text: string; truncated: boolean }> { + const reader = response.body?.getReader(); + if (!reader) { + return { text: "", truncated: false }; + } + + const decoder = new TextDecoder(); + const chunks: string[] = []; + let bytesRead = 0; + let truncated = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + + const remaining = maxBytes - bytesRead; + if (remaining <= 0) { + truncated = true; + break; + } + + if (value.byteLength > remaining) { + chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true })); + bytesRead += remaining; + truncated = true; + break; + } + + chunks.push(decoder.decode(value, { stream: true })); + bytesRead += value.byteLength; + } + + const flushed = decoder.decode(); + if (flushed) chunks.push(flushed); + + if (truncated) { + try { + await reader.cancel(); + } catch { + // ignore + } + } + + return { text: chunks.join(""), truncated }; +} + function resolveCacheTtlPreference( keyPref: CacheTtlOption, providerPref: CacheTtlOption @@ -619,7 +676,11 @@ export class ProxyForwarder { // ========== 空响应检测(仅非流式)========== const contentType = response.headers.get("content-type") || ""; - const isSSE = contentType.includes("text/event-stream"); + const normalizedContentType = contentType.toLowerCase(); + const isSSE = normalizedContentType.includes("text/event-stream"); + const isHtml = + normalizedContentType.includes("text/html") || + normalizedContentType.includes("application/xhtml+xml"); // ========== 流式响应:延迟成功判定(避免“假 200”)========== // 背景:上游可能返回 HTTP 200,但 SSE 内容为错误 JSON(如 {"error": "..."})。 @@ -655,29 +716,62 @@ export class ProxyForwarder { return response; } - if (!isSSE) { - // 非流式响应:检测空响应 - const contentLength = response.headers.get("content-length"); + // 非流式响应:检测空响应 + const contentLength = response.headers.get("content-length"); - // 检测 Content-Length: 0 的情况 - if (contentLength === "0") { - throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); + // 检测 Content-Length: 0 的情况 + if (contentLength === "0") { + throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); + } + + // 200 + text/html(或 xhtml)通常是上游网关/WAF/Cloudflare 的错误页,但被包装成了 HTTP 200。 + // 这种“假 200”会导致: + // - 熔断/故障转移统计被误记为成功; + // - session 智能绑定被更新到不可用 provider(影响后续重试)。 + // 因此这里在进入成功分支前做一次强信号检测:仅当 body 看起来是完整 HTML 文档时才视为错误。 + let inspectedText: string | undefined; + let inspectedTruncated = false; + if (isHtml || !contentLength) { + const clonedResponse = response.clone(); + const inspected = await readResponseTextUpTo( + clonedResponse, + NON_STREAM_BODY_INSPECTION_MAX_BYTES + ); + inspectedText = inspected.text; + inspectedTruncated = inspected.truncated; + } + + if (inspectedText !== undefined) { + const detected = detectUpstreamErrorFromSseOrJsonText(inspectedText); + if (detected.isError && detected.code === "FAKE_200_HTML_BODY") { + throw new ProxyError(detected.code, 502, { + body: detected.detail ?? "", + providerId: currentProvider.id, + providerName: currentProvider.name, + }); } + } - // 对于没有 Content-Length 的情况,需要 clone 并检查响应体 - // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的 - if (!contentLength) { - const clonedResponse = response.clone(); - const responseText = await clonedResponse.text(); - - if (!responseText || responseText.trim() === "") { - throw new EmptyResponseError( - currentProvider.id, - currentProvider.name, - "empty_body" - ); - } + // 对于没有 Content-Length 的情况,需要 clone 并检查响应体 + // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的 + if (!contentLength) { + const responseText = inspectedText ?? ""; + + if (!responseText || responseText.trim() === "") { + throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); + } + if (inspectedTruncated) { + logger.debug( + "ProxyForwarder: Response body too large for non-stream content check, skipping JSON parse", + { + providerId: currentProvider.id, + providerName: currentProvider.name, + contentType, + maxBytes: NON_STREAM_BODY_INSPECTION_MAX_BYTES, + } + ); + } else { // 尝试解析 JSON 并检查是否有输出内容 try { const responseJson = JSON.parse(responseText) as Record; @@ -722,7 +816,12 @@ export class ProxyForwarder { // 注意:不抛出错误,因为某些请求(如 count_tokens)可能合法地返回 0 output tokens } } - } catch (_parseError) { + } catch (_parseOrContentError) { + // EmptyResponseError 会触发重试/故障转移,不能在这里被当作 JSON 解析错误吞掉。 + if (isEmptyResponseError(_parseOrContentError)) { + throw _parseOrContentError; + } + // JSON 解析失败但响应体不为空,不视为空响应错误 logger.debug("ProxyForwarder: Non-JSON response body, skipping content check", { providerId: currentProvider.id, diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts index d1facd969..88b5b7516 100644 --- a/src/lib/utils/upstream-error-detection.test.ts +++ b/src/lib/utils/upstream-error-detection.test.ts @@ -16,6 +16,31 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { }); }); + test("明显的 HTML 文档视为错误(覆盖 200+text/html 的“假 200”)", () => { + const html = [ + "", + '', + "New API", + "Something went wrong", + "", + ].join("\n"); + const res = detectUpstreamErrorFromSseOrJsonText(html); + expect(res).toEqual({ + isError: true, + code: "FAKE_200_HTML_BODY", + detail: expect.any(String), + }); + }); + + test("纯 JSON:content 内包含 文本不应误判为 HTML 错误", () => { + const body = JSON.stringify({ + type: "message", + content: [{ type: "text", text: "not an error" }], + }); + const res = detectUpstreamErrorFromSseOrJsonText(body); + expect(res.isError).toBe(false); + }); + test("纯 JSON:error 字段非空视为错误", () => { const res = detectUpstreamErrorFromSseOrJsonText('{"error":"当前无可用凭证"}'); expect(res.isError).toBe(true); diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index 066f1bc8f..56734b971 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -18,6 +18,7 @@ import { parseSSEData } from "@/lib/utils/sse"; * * 设计目标(偏保守) * - 仅基于结构化字段做启发式判断:`error` 与 `message`; + * - 对明显的 HTML 文档(doctype/html 标签)做强信号判定,覆盖部分网关/WAF/Cloudflare 返回的“假 200”; * - 不扫描模型生成的正文内容(例如 content/choices),避免把用户/模型自然语言里的 "error" 误判为上游错误; * - message 关键字检测仅对“小体积 JSON”启用,降低误判与性能开销。 * - 返回的 `code` 是语言无关的错误码(便于写入 DB/监控/告警); @@ -53,6 +54,7 @@ const DEFAULT_MESSAGE_KEYWORD = /error/i; const FAKE_200_CODES = { EMPTY_BODY: "FAKE_200_EMPTY_BODY", + HTML_BODY: "FAKE_200_HTML_BODY", JSON_ERROR_NON_EMPTY: "FAKE_200_JSON_ERROR_NON_EMPTY", JSON_ERROR_MESSAGE_NON_EMPTY: "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY", JSON_MESSAGE_KEYWORD_MATCH: "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH", @@ -63,6 +65,16 @@ const FAKE_200_CODES = { const MAY_HAVE_JSON_ERROR_KEY = /"error"\s*:/; const MAY_HAVE_JSON_MESSAGE_KEY = /"message"\s*:/; +const HTML_DOC_SNIFF_MAX_CHARS = 1024; +const HTML_DOCTYPE_RE = /^]/i; +const HTML_HTML_TAG_RE = /]/i; + +function isLikelyHtmlDocument(trimmedText: string): boolean { + if (!trimmedText.startsWith("<")) return false; + const head = trimmedText.slice(0, HTML_DOC_SNIFF_MAX_CHARS); + return HTML_DOCTYPE_RE.test(head) || HTML_HTML_TAG_RE.test(head); +} + function isPlainRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -194,6 +206,20 @@ export function detectUpstreamErrorFromSseOrJsonText( return { isError: true, code: FAKE_200_CODES.EMPTY_BODY }; } + // 情况 0:明显的 HTML 文档(通常是网关/WAF/Cloudflare 返回的错误页) + // + // 说明: + // - 此处不依赖 Content-Type:部分上游会缺失/错误设置该字段; + // - 仅匹配 doctype/html 标签等“强信号”,避免把普通 `<...>` 文本误判为 HTML 页面。 + if (isLikelyHtmlDocument(trimmed)) { + return { + isError: true, + code: FAKE_200_CODES.HTML_BODY, + // 避免对超大 HTML 做无谓处理:仅截取前段用于脱敏/截断与排查 + detail: truncateForDetail(trimmed.slice(0, 4096)), + }; + } + // 情况 1:纯 JSON(对象) // 上游可能 Content-Type 设置为 SSE,但实际上返回 JSON;此处只处理对象格式({...}), // 不处理数组([...])以避免误判(数组场景的语义差异较大,后续若确认需要再扩展)。 diff --git a/tests/unit/lib/provider-endpoints/probe.test.ts b/tests/unit/lib/provider-endpoints/probe.test.ts index c77b04845..d44372197 100644 --- a/tests/unit/lib/provider-endpoints/probe.test.ts +++ b/tests/unit/lib/provider-endpoints/probe.test.ts @@ -51,6 +51,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { @@ -91,6 +93,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { @@ -134,6 +138,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); vi.stubGlobal( @@ -172,6 +178,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); vi.stubGlobal( @@ -208,6 +216,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); const fetchMock = vi.fn(async () => { @@ -253,6 +263,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: recordFailureMock, + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); vi.stubGlobal( @@ -299,6 +311,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: recordFailureMock, + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); vi.stubGlobal( @@ -369,6 +383,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: recordFailureMock, + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); vi.stubGlobal( @@ -409,6 +425,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: recordFailureMock, + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); vi.stubGlobal( @@ -445,6 +463,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); // Mock net.createConnection to simulate successful TCP connection @@ -498,6 +518,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); const mockSocket = { @@ -543,6 +565,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); @@ -573,6 +597,8 @@ describe("provider-endpoints: probe", () => { })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ recordEndpointFailure: vi.fn(async () => {}), + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), })); const mockSocket = { diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts new file mode 100644 index 000000000..96fb31c4b --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + pickRandomProviderWithExclusion: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn(async () => {}), + getCircuitState: vi.fn(() => "closed"), + getProviderHealthInfo: vi.fn(async () => ({ + health: { failureCount: 0 }, + config: { failureThreshold: 3 }, + })), + updateMessageRequestDetails: vi.fn(async () => {}), + isHttp2Enabled: vi.fn(async () => false), + getPreferredProviderEndpoints: vi.fn(async () => []), + getEndpointFilterStats: vi.fn(async () => null), + recordEndpointSuccess: vi.fn(async () => {}), + recordEndpointFailure: vi.fn(async () => {}), + isVendorTypeCircuitOpen: vi.fn(async () => false), + recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}), + // ErrorCategory.PROVIDER_ERROR + categorizeErrorAsync: vi.fn(async () => 0), + }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: mocks.isHttp2Enabled, + }; +}); + +vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({ + getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints, + getEndpointFilterStats: mocks.getEndpointFilterStats, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointSuccess: mocks.recordEndpointSuccess, + recordEndpointFailure: mocks.recordEndpointFailure, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + getCircuitState: mocks.getCircuitState, + getProviderHealthInfo: mocks.getProviderHealthInfo, + recordFailure: mocks.recordFailure, + recordSuccess: mocks.recordSuccess, +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen, + recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout, +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestDetails: mocks.updateMessageRequestDetails, +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + ProxyProviderResolver: { + pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + categorizeErrorAsync: mocks.categorizeErrorAsync, + }; +}); + +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "p1", + url: "https://provider.example.com", + key: "k", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: 1, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30_000, + streamingIdleTimeoutMs: 10_000, + requestTimeoutNonStreamingMs: 1_000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createSession(): ProxySession { + const headers = new Headers(); + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://example.com/v1/messages"), + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "claude-test", + log: "(test)", + message: { + model: "claude-test", + messages: [{ role: "user", content: "hi" }], + }, + }, + userAgent: null, + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: () => false, + }); + + return session as ProxySession; +} + +describe("ProxyForwarder - fake 200 HTML body", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("200 + text/html 的 HTML 页面应视为失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const htmlBody = [ + "", + "New API", + "blocked", + ].join("\n"); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(htmlBody, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "content-length": String(htmlBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_HTML_BODY" }) + ); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + + test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const missingContentJson = JSON.stringify({ type: "message", content: [] }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(missingContentJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + // 故意不提供 content-length:覆盖 forwarder 的 clone + JSON 内容结构检查分支 + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ reason: "missing_content" }) + ); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); +}); From d3f9427661299afcafdb6f6b9a68b1c86486c31b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 15:22:54 +0800 Subject: [PATCH 02/23] =?UTF-8?q?fix(utils):=20=E6=94=B6=E7=B4=A7=20HTML?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3=E8=AF=86=E5=88=AB=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/upstream-error-detection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index 56734b971..3e7805e93 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -67,7 +67,7 @@ const MAY_HAVE_JSON_MESSAGE_KEY = /"message"\s*:/; const HTML_DOC_SNIFF_MAX_CHARS = 1024; const HTML_DOCTYPE_RE = /^]/i; -const HTML_HTML_TAG_RE = /]/i; +const HTML_HTML_TAG_RE = /^]/i; function isLikelyHtmlDocument(trimmedText: string): boolean { if (!trimmedText.startsWith("<")) return false; From 62047337df61cd4c493c1b6d545b09b9e8f2730e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 15:57:32 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix(proxy):=20=E9=9D=9E=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=81=87200=E8=A1=A5=E9=BD=90=E5=BC=BA=E4=BF=A1=E5=8F=B7=20JSO?= =?UTF-8?q?N=20error=20=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/forwarder.ts | 27 +++++++++- .../proxy-forwarder-fake-200-html.test.ts | 51 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 31878c7e4..6e266aa2c 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -91,6 +91,14 @@ type CacheTtlOption = CacheTtlPreference | null | undefined; // - 超过上限时,仍认为“非空”,但会跳过 JSON 内容结构检查(避免截断导致误判)。 const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 1024 * 1024; // 1 MiB +/** + * 读取响应体文本,但最多读取 `maxBytes` 字节(用于非流式 2xx 的“空响应/假 200”嗅探)。 + * + * 注意: + * - 该函数只用于启发式检测,不用于业务逻辑解析; + * - 超过上限时会 `cancel()` reader,避免继续占用资源; + * - 调用方应使用 `response.clone()`,避免消费掉原始响应体,影响后续透传/解析。 + */ async function readResponseTextUpTo( response: Response, maxBytes: number @@ -742,8 +750,23 @@ export class ProxyForwarder { } if (inspectedText !== undefined) { - const detected = detectUpstreamErrorFromSseOrJsonText(inspectedText); - if (detected.isError && detected.code === "FAKE_200_HTML_BODY") { + // 对非流式 2xx 响应:只启用“强信号”判定(HTML 文档 / 顶层 error 非空 / 空 body)。 + // `message` 关键字匹配属于弱信号,误判风险更高;该规则主要用于 SSE 结束后的补充检测。 + const detected = detectUpstreamErrorFromSseOrJsonText(inspectedText, { + maxJsonCharsForMessageCheck: 0, + }); + + if (detected.isError && detected.code === "FAKE_200_EMPTY_BODY") { + throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); + } + + const isStrongFake200 = + detected.isError && + (detected.code === "FAKE_200_HTML_BODY" || + detected.code === "FAKE_200_JSON_ERROR_NON_EMPTY" || + detected.code === "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY"); + + if (isStrongFake200) { throw new ProxyError(detected.code, 502, { body: detected.detail ?? "", providerId: currentProvider.id, diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index 96fb31c4b..ea56bbe5c 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -249,6 +249,57 @@ describe("ProxyForwarder - fake 200 HTML body", () => { expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); + test("200 + text/html 但 body 是 JSON error 也应视为失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const jsonErrorBody = JSON.stringify({ error: "upstream blocked" }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(jsonErrorBody, { + status: 200, + headers: { + // 故意使用 text/html:模拟部分上游 Content-Type 错配但 body 仍为错误 JSON 的情况 + "content-type": "text/html; charset=utf-8", + "content-length": String(jsonErrorBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) + ); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => { const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); From edb3dac070d5dd203662719ebe44ae4ea0b183a5 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 16:12:46 +0800 Subject: [PATCH 04/23] =?UTF-8?q?fix(utils):=20=E5=81=87200=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E5=85=BC=E5=AE=B9=20BOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/upstream-error-detection.test.ts | 18 ++++++++++++++++++ src/lib/utils/upstream-error-detection.ts | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts index 88b5b7516..6ff642fba 100644 --- a/src/lib/utils/upstream-error-detection.test.ts +++ b/src/lib/utils/upstream-error-detection.test.ts @@ -32,6 +32,24 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { }); }); + test("带 BOM 的 HTML 文档也应视为错误", () => { + const htmlWithBom = "\uFEFF \n\nblocked"; + const res = detectUpstreamErrorFromSseOrJsonText(htmlWithBom); + expect(res.isError).toBe(true); + if (res.isError) { + expect(res.code).toBe("FAKE_200_HTML_BODY"); + } + }); + + test("带 BOM 的 JSON error 也应正常识别", () => { + const jsonWithBom = '\uFEFF \n{"error":"当前无可用凭证"}'; + const res = detectUpstreamErrorFromSseOrJsonText(jsonWithBom); + expect(res.isError).toBe(true); + if (res.isError) { + expect(res.code).toBe("FAKE_200_JSON_ERROR_NON_EMPTY"); + } + }); + test("纯 JSON:content 内包含 文本不应误判为 HTML 错误", () => { const body = JSON.stringify({ type: "message", diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index 3e7805e93..c97ba8ffa 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -69,6 +69,16 @@ const HTML_DOC_SNIFF_MAX_CHARS = 1024; const HTML_DOCTYPE_RE = /^]/i; const HTML_HTML_TAG_RE = /^]/i; +/** + * 判断文本是否“很像”一个完整的 HTML 文档(强信号)。 + * + * 规则(偏保守): + * - 仅当文本以 `<` 开头时才继续; + * - 仅在前 1024 字符内检测 `` 或以 `` 开头; + * - 不做 HTML 解析/清洗,避免误判与额外开销。 + * + * 说明:调用方应先对文本做 `trim()`,并在需要时移除 BOM(`\uFEFF`)。 + */ function isLikelyHtmlDocument(trimmedText: string): boolean { if (!trimmedText.startsWith("<")) return false; const head = trimmedText.slice(0, HTML_DOC_SNIFF_MAX_CHARS); @@ -201,11 +211,17 @@ export function detectUpstreamErrorFromSseOrJsonText( messageKeyword: options.messageKeyword ?? DEFAULT_MESSAGE_KEYWORD, }; - const trimmed = text.trim(); + let trimmed = text.trim(); if (!trimmed) { return { isError: true, code: FAKE_200_CODES.EMPTY_BODY }; } + // 某些上游会带 UTF-8 BOM(\uFEFF),会导致 startsWith("{") / startsWith("<") 等快速判断失效。 + // 这里仅剥离首字符 BOM,并再做一次 trimStart,避免误判。 + if (trimmed.charCodeAt(0) === 0xfeff) { + trimmed = trimmed.slice(1).trimStart(); + } + // 情况 0:明显的 HTML 文档(通常是网关/WAF/Cloudflare 返回的错误页) // // 说明: From de8c498d098de7878b5be7f51063c8161f80863e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 16:34:19 +0800 Subject: [PATCH 05/23] =?UTF-8?q?perf(proxy):=20=E9=99=8D=E4=BD=8E?= =?UTF-8?q?=E9=9D=9E=E6=B5=81=E5=BC=8F=E5=97=85=E6=8E=A2=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/forwarder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 6e266aa2c..5dd6f5957 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -89,7 +89,7 @@ type CacheTtlOption = CacheTtlPreference | null | undefined; // 说明: // - 该检查仅用于“空响应/假 200”启发式判定,不用于业务逻辑解析; // - 超过上限时,仍认为“非空”,但会跳过 JSON 内容结构检查(避免截断导致误判)。 -const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 1024 * 1024; // 1 MiB +const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 32 * 1024; // 32 KiB /** * 读取响应体文本,但最多读取 `maxBytes` 字节(用于非流式 2xx 的“空响应/假 200”嗅探)。 From a8f6b44c3bddff5c92f9e022d34ab4818b28764f Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 16:57:37 +0800 Subject: [PATCH 06/23] =?UTF-8?q?fix(proxy):=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E9=9A=90=E8=97=8F=20FAKE=5F200=5F*=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/errors.ts | 6 ++ src/app/v1/_lib/proxy/forwarder.ts | 59 +++++++++++-------- .../proxy-forwarder-fake-200-html.test.ts | 11 ++++ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 13d0ad9d0..38df1c9ac 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -447,6 +447,12 @@ export class ProxyError extends Error { * - getClientSafeMessage(): 不包含供应商名称,用于返回给客户端 */ getClientSafeMessage(): string { + // 注意:一些内部检测/统计用的“错误码”(例如 FAKE_200_*)不适合直接暴露给客户端。 + // 这里做最小映射:仅在 502 且 message 为 FAKE_200_* 时返回统一文案。 + if (this.statusCode === 502 && this.message.startsWith("FAKE_200_")) { + return "Upstream service returned an invalid response"; + } + return this.message; } } diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 5dd6f5957..d09a9ea1d 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -113,34 +113,45 @@ async function readResponseTextUpTo( let bytesRead = 0; let truncated = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value || value.byteLength === 0) continue; - - const remaining = maxBytes - bytesRead; - if (remaining <= 0) { - truncated = true; - break; - } + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + + const remaining = maxBytes - bytesRead; + // 注意:remaining<=0 发生在“已经读到下一块 chunk”之后。 + // 对启发式嗅探而言,直接标记 truncated 并退出即可(等价于丢弃超出上限的后续字节), + // 避免对超出部分做无谓的解码开销。 + if (remaining <= 0) { + truncated = true; + break; + } - if (value.byteLength > remaining) { - chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true })); - bytesRead += remaining; - truncated = true; - break; - } + if (value.byteLength > remaining) { + chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true })); + bytesRead += remaining; + truncated = true; + break; + } - chunks.push(decoder.decode(value, { stream: true })); - bytesRead += value.byteLength; - } + chunks.push(decoder.decode(value, { stream: true })); + bytesRead += value.byteLength; + } - const flushed = decoder.decode(); - if (flushed) chunks.push(flushed); + const flushed = decoder.decode(); + if (flushed) chunks.push(flushed); + } finally { + if (truncated) { + try { + await reader.cancel(); + } catch { + // ignore + } + } - if (truncated) { try { - await reader.cancel(); + reader.releaseLock(); } catch { // ignore } @@ -740,6 +751,8 @@ export class ProxyForwarder { let inspectedText: string | undefined; let inspectedTruncated = false; if (isHtml || !contentLength) { + // 注意:Response.clone() 会 tee 底层 ReadableStream,可能带来一定的瞬时内存开销; + // 这里通过“最多读取 32 KiB”并在截断时 cancel 克隆分支来控制开销。 const clonedResponse = response.clone(); const inspected = await readResponseTextUpTo( clonedResponse, diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index ea56bbe5c..8e99ee258 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -83,6 +83,7 @@ vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { }); import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; import type { Provider } from "@/types/provider"; @@ -245,6 +246,11 @@ describe("ProxyForwarder - fake 200 HTML body", () => { 1, expect.objectContaining({ message: "FAKE_200_HTML_BODY" }) ); + const failure1 = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure1).toBeInstanceOf(ProxyError); + expect((failure1 as ProxyError).getClientSafeMessage()).toBe( + "Upstream service returned an invalid response" + ); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); @@ -296,6 +302,11 @@ describe("ProxyForwarder - fake 200 HTML body", () => { 1, expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) ); + const failure2 = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure2).toBeInstanceOf(ProxyError); + expect((failure2 as ProxyError).getClientSafeMessage()).toBe( + "Upstream service returned an invalid response" + ); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); From 380981ae7ab1177b6753ae7523626923aea3f69c Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 19:09:12 +0800 Subject: [PATCH 07/23] =?UTF-8?q?fix(logs):=20=E8=A1=A5=E9=BD=90=20endpoin?= =?UTF-8?q?t=5Fpool=5Fexhausted/404=20=E9=94=99=E5=9B=A0=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - endpoint_pool_exhausted 写入 attemptNumber,避免被 initial_selection/session_reuse 去重吞掉\n- 决策链/技术时间线补齐 resource_not_found 的失败态与说明\n- 更新 provider-chain i18n 文案并新增单测覆盖 --- messages/en/provider-chain.json | 4 ++ messages/ja/provider-chain.json | 4 ++ messages/ru/provider-chain.json | 4 ++ messages/zh-CN/provider-chain.json | 4 ++ messages/zh-TW/provider-chain.json | 4 ++ .../components/LogicTraceTab.tsx | 2 + .../_components/provider-chain-popover.tsx | 17 +++++- src/app/v1/_lib/proxy/forwarder.ts | 3 + .../utils/provider-chain-formatter.test.ts | 55 +++++++++++++++++++ src/lib/utils/provider-chain-formatter.ts | 45 +++++++++++++++ .../proxy-forwarder-endpoint-audit.test.ts | 53 ++++++++++++++++++ 11 files changed, 193 insertions(+), 2 deletions(-) diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index a347e8dc8..b52d93bdb 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "Request Chain:", "systemError": "System Error", + "resourceNotFound": "Resource Not Found (404)", "concurrentLimit": "Concurrent Limit", "http2Fallback": "HTTP/2 Fallback", "clientError": "Client Error", @@ -45,6 +46,7 @@ "retry_success": "Retry Success", "retry_failed": "Retry Failed", "system_error": "System Error", + "resource_not_found": "Resource Not Found (404)", "client_error_non_retryable": "Client Error", "concurrent_limit_failed": "Concurrent Limit", "http2_fallback": "HTTP/2 Fallback", @@ -120,6 +122,7 @@ "candidateInfo": " • {name}: weight={weight} cost={cost} probability={probability}%", "selected": "✓ Selected: {provider}", "requestFailed": "Request Failed (Attempt {attempt})", + "resourceNotFoundFailed": "Resource Not Found (404) (Attempt {attempt})", "attemptNumber": "Attempt {number}", "firstAttempt": "First Attempt", "nthAttempt": "Attempt {attempt}", @@ -150,6 +153,7 @@ "meaning": "Meaning", "notCountedInCircuit": "This error is not counted in provider circuit breaker", "systemErrorNote": "Note: This error is not counted in provider circuit breaker", + "resourceNotFoundNote": "Note: This error is not counted in the circuit breaker and will trigger failover after retries are exhausted.", "reselection": "Reselecting Provider", "reselect": "Reselecting Provider", "excluded": "Excluded: {providers}", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 37adb84f9..c033f4fb6 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "リクエストチェーン:", "systemError": "システムエラー", + "resourceNotFound": "リソースが見つかりません(404)", "concurrentLimit": "同時実行制限", "http2Fallback": "HTTP/2 フォールバック", "clientError": "クライアントエラー", @@ -45,6 +46,7 @@ "retry_success": "リトライ成功", "retry_failed": "リトライ失敗", "system_error": "システムエラー", + "resource_not_found": "リソースが見つかりません(404)", "client_error_non_retryable": "クライアントエラー", "concurrent_limit_failed": "同時実行制限", "http2_fallback": "HTTP/2 フォールバック", @@ -120,6 +122,7 @@ "candidateInfo": " • {name}: 重み={weight} コスト={cost} 確率={probability}%", "selected": "✓ 選択: {provider}", "requestFailed": "リクエスト失敗(試行{attempt})", + "resourceNotFoundFailed": "リソースが見つかりません(404)(試行{attempt})", "attemptNumber": "試行 {number}", "firstAttempt": "初回試行", "nthAttempt": "試行{attempt}", @@ -150,6 +153,7 @@ "meaning": "意味", "notCountedInCircuit": "このエラーはプロバイダーサーキットブレーカーにカウントされません", "systemErrorNote": "注記:このエラーはプロバイダーサーキットブレーカーにカウントされません", + "resourceNotFoundNote": "注記:このエラーはサーキットブレーカーにカウントされず、リトライ枯渇後にフェイルオーバーします。", "reselection": "プロバイダー再選択", "reselect": "プロバイダー再選択", "excluded": "除外済み: {providers}", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index e37650b04..837dc6228 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "Цепочка запросов:", "systemError": "Системная ошибка", + "resourceNotFound": "Ресурс не найден (404)", "concurrentLimit": "Лимит параллельных запросов", "http2Fallback": "Откат HTTP/2", "clientError": "Ошибка клиента", @@ -45,6 +46,7 @@ "retry_success": "Повтор успешен", "retry_failed": "Повтор не удался", "system_error": "Системная ошибка", + "resource_not_found": "Ресурс не найден (404)", "client_error_non_retryable": "Ошибка клиента", "concurrent_limit_failed": "Лимит параллельных запросов", "http2_fallback": "Откат HTTP/2", @@ -120,6 +122,7 @@ "candidateInfo": " • {name}: вес={weight} стоимость={cost} вероятность={probability}%", "selected": "✓ Выбрано: {provider}", "requestFailed": "Запрос не выполнен (Попытка {attempt})", + "resourceNotFoundFailed": "Ресурс не найден (404) (Попытка {attempt})", "attemptNumber": "Попытка {number}", "firstAttempt": "Первая попытка", "nthAttempt": "Попытка {attempt}", @@ -150,6 +153,7 @@ "meaning": "Значение", "notCountedInCircuit": "Эта ошибка не учитывается в автомате защиты провайдера", "systemErrorNote": "Примечание: Эта ошибка не учитывается в автомате защиты провайдера", + "resourceNotFoundNote": "Примечание: Эта ошибка не учитывается в автомате защиты; после исчерпания повторов произойдёт переключение.", "reselection": "Повторный выбор провайдера", "reselect": "Повторный выбор провайдера", "excluded": "Исключено: {providers}", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index fe75d85a1..e7f22584d 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "请求链路:", "systemError": "系统错误", + "resourceNotFound": "资源不存在(404)", "concurrentLimit": "并发限制", "http2Fallback": "HTTP/2 回退", "clientError": "客户端错误", @@ -45,6 +46,7 @@ "retry_success": "重试成功", "retry_failed": "重试失败", "system_error": "系统错误", + "resource_not_found": "资源不存在(404)", "client_error_non_retryable": "客户端错误", "concurrent_limit_failed": "并发限制", "http2_fallback": "HTTP/2 回退", @@ -120,6 +122,7 @@ "candidateInfo": " • {name}: 权重={weight} 成本={cost} 概率={probability}%", "selected": "✓ 选择: {provider}", "requestFailed": "请求失败(第 {attempt} 次尝试)", + "resourceNotFoundFailed": "资源不存在(404,第 {attempt} 次尝试)", "attemptNumber": "第 {number} 次", "firstAttempt": "首次尝试", "nthAttempt": "第 {attempt} 次尝试", @@ -150,6 +153,7 @@ "meaning": "含义", "notCountedInCircuit": "此错误不计入供应商熔断器", "systemErrorNote": "说明:此错误不计入供应商熔断器", + "resourceNotFoundNote": "说明:该错误不计入熔断器;重试耗尽后将触发故障转移。", "reselection": "重新选择供应商", "reselect": "重新选择供应商", "excluded": "已排除: {providers}", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 04aa28488..ccfce1bde 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "請求鏈路:", "systemError": "系統錯誤", + "resourceNotFound": "資源不存在(404)", "concurrentLimit": "並發限制", "http2Fallback": "HTTP/2 回退", "clientError": "客戶端錯誤", @@ -45,6 +46,7 @@ "retry_success": "重試成功", "retry_failed": "重試失敗", "system_error": "系統錯誤", + "resource_not_found": "資源不存在(404)", "client_error_non_retryable": "客戶端錯誤", "concurrent_limit_failed": "並發限制", "http2_fallback": "HTTP/2 回退", @@ -120,6 +122,7 @@ "candidateInfo": " • {name}: 權重={weight} 成本={cost} 概率={probability}%", "selected": "✓ 選擇: {provider}", "requestFailed": "請求失敗(第 {attempt} 次嘗試)", + "resourceNotFoundFailed": "資源不存在(404,第 {attempt} 次嘗試)", "attemptNumber": "第 {number} 次", "firstAttempt": "首次嘗試", "nthAttempt": "第 {attempt} 次嘗試", @@ -150,6 +153,7 @@ "meaning": "含義", "notCountedInCircuit": "此錯誤不計入供應商熔斷器", "systemErrorNote": "說明:此錯誤不計入供應商熔斷器", + "resourceNotFoundNote": "說明:該錯誤不計入熔斷器;重試耗盡後將觸發故障轉移。", "reselection": "重新選擇供應商", "reselect": "重新選擇供應商", "excluded": "已排除: {providers}", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index d732f846b..3cbc0d025 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -39,7 +39,9 @@ function getRequestStatus(item: ProviderChainItem): StepStatus { if ( item.reason === "retry_failed" || item.reason === "system_error" || + item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || + item.reason === "endpoint_pool_exhausted" || item.reason === "concurrent_limit_failed" ) { return "failure"; diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 7a2c99a76..1b0abff7b 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -33,7 +33,15 @@ interface ProviderChainPopoverProps { */ function isActualRequest(item: ProviderChainItem): boolean { if (item.reason === "concurrent_limit_failed") return true; - if (item.reason === "retry_failed" || item.reason === "system_error") return true; + if ( + item.reason === "retry_failed" || + item.reason === "system_error" || + item.reason === "resource_not_found" || + item.reason === "client_error_non_retryable" || + item.reason === "endpoint_pool_exhausted" + ) { + return true; + } if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { return true; } @@ -68,7 +76,12 @@ function getItemStatus(item: ProviderChainItem): { bgColor: "bg-emerald-50 dark:bg-emerald-950/30", }; } - if (item.reason === "retry_failed" || item.reason === "system_error") { + if ( + item.reason === "retry_failed" || + item.reason === "system_error" || + item.reason === "resource_not_found" || + item.reason === "endpoint_pool_exhausted" + ) { return { icon: XCircle, color: "text-rose-600", diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index d09a9ea1d..6105078f7 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -599,6 +599,9 @@ export class ProxyForwarder { session.addProviderToChain(currentProvider, { reason: "endpoint_pool_exhausted", strictBlockCause: strictBlockCause as ProviderChainItem["strictBlockCause"], + // 为避免被 initial_selection/session_reuse 去重吞掉,这里需要写入 attemptNumber。 + // 同时也能让“决策链/技术时间线”把它当作一次实际尝试(虽然请求未发出)。 + attemptNumber: 1, ...(filterStats ? { endpointFilterStats: filterStats } : {}), errorMessage: endpointSelectionError?.message, }); diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index 8caf5ed97..bf83d55aa 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -271,6 +271,61 @@ describe("endpoint_pool_exhausted", () => { }); }); +// ============================================================================= +// resource_not_found reason tests +// ============================================================================= + +describe("resource_not_found", () => { + const baseNotFoundItem: ProviderChainItem = { + id: 1, + name: "provider-a", + reason: "resource_not_found", + attemptNumber: 1, + statusCode: 404, + errorMessage: "Not Found", + timestamp: 1000, + errorDetails: { + provider: { + id: 1, + name: "provider-a", + statusCode: 404, + statusText: "Not Found", + }, + }, + }; + + describe("formatProviderSummary", () => { + test("renders resource_not_found item as failure in summary", () => { + const chain: ProviderChainItem[] = [baseNotFoundItem]; + const result = formatProviderSummary(chain, mockT); + + expect(result).toContain("provider-a"); + expect(result).toContain("✗"); + }); + }); + + describe("formatProviderDescription", () => { + test("shows resource not found label in request chain", () => { + const chain: ProviderChainItem[] = [baseNotFoundItem]; + const result = formatProviderDescription(chain, mockT); + + expect(result).toContain("provider-a"); + expect(result).toContain("description.resourceNotFound"); + }); + }); + + describe("formatProviderTimeline", () => { + test("renders resource_not_found with status code and note", () => { + const chain: ProviderChainItem[] = [baseNotFoundItem]; + const { timeline } = formatProviderTimeline(chain, mockT); + + expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]"); + expect(timeline).toContain("timeline.statusCode [code=404]"); + expect(timeline).toContain("timeline.resourceNotFoundNote"); + }); + }); +}); + // ============================================================================= // Unknown reason graceful degradation // ============================================================================= diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 5369bf1b0..9d85e4203 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -63,6 +63,7 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " if ( item.reason === "retry_failed" || item.reason === "system_error" || + item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" ) { @@ -91,6 +92,7 @@ function isActualRequest(item: ProviderChainItem): boolean { if ( item.reason === "retry_failed" || item.reason === "system_error" || + item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" ) { @@ -311,6 +313,8 @@ export function formatProviderDescription( desc += ` ${t("description.http2Fallback")}`; } else if (item.reason === "client_error_non_retryable") { desc += ` ${t("description.clientError")}`; + } else if (item.reason === "resource_not_found") { + desc += ` ${t("description.resourceNotFound")}`; } else if (item.reason === "endpoint_pool_exhausted") { desc += ` ${t("description.endpointPoolExhausted")}`; } @@ -436,6 +440,47 @@ export function formatProviderTimeline( continue; } + // === 资源不存在(上游 404) === + if (item.reason === "resource_not_found") { + const attempt = actualAttemptNumber ?? item.attemptNumber ?? 0; + timeline += `${t("timeline.resourceNotFoundFailed", { attempt })}\n\n`; + + if (item.errorDetails?.provider) { + const p = item.errorDetails.provider; + timeline += `${t("timeline.provider", { provider: p.name })}\n`; + timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${t("timeline.error", { error: p.statusText })}\n`; + + // 计算请求耗时 + if (i > 0 && item.timestamp && chain[i - 1]?.timestamp) { + const duration = item.timestamp - (chain[i - 1]?.timestamp || 0); + timeline += `${t("timeline.requestDuration", { duration })}\n`; + } + + // 错误详情(格式化 JSON) + if (p.upstreamParsed) { + timeline += `\n${t("timeline.errorDetails")}:\n`; + timeline += JSON.stringify(p.upstreamParsed, null, 2); + } else if (p.upstreamBody) { + timeline += `\n${t("timeline.errorDetails")}:\n${p.upstreamBody}`; + } + } else { + timeline += `${t("timeline.provider", { provider: item.name })}\n`; + if (item.statusCode) { + timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + } + timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") }); + } + + // 请求详情(用于问题排查) + if (item.errorDetails?.request) { + timeline += formatRequestDetails(item.errorDetails.request, t); + } + + timeline += `\n${t("timeline.resourceNotFoundNote")}`; + continue; + } + // === 供应商错误(请求失败) === if (item.reason === "retry_failed") { timeline += `${t("timeline.requestFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`; diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts index 6891fff23..0a3e23e53 100644 --- a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts @@ -562,6 +562,59 @@ describe("ProxyForwarder - endpoint audit", () => { expect(exhaustedItem!.errorMessage).toBeUndefined(); }); + test("endpoint_pool_exhausted should not be deduped away when initial_selection already recorded", async () => { + const requestPath = "/v1/messages"; + const session = createSession(new URL(`https://example.com${requestPath}`)); + const provider = createProvider({ + providerType: "claude", + providerVendorId: 123, + url: "https://provider.example.com/v1/messages", + }); + session.setProvider(provider); + + // Simulate ProviderSelector already recorded initial_selection for the same provider + session.addProviderToChain(provider, { reason: "initial_selection" }); + + mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]); + mocks.getEndpointFilterStats.mockResolvedValueOnce({ + total: 0, + enabled: 0, + circuitOpen: 0, + available: 0, + }); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + + await expect(ProxyForwarder.send(session)).rejects.toThrow(); + + expect(doForward).not.toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect(chain.some((item) => item.reason === "initial_selection")).toBe(true); + + const exhaustedItems = chain.filter((item) => item.reason === "endpoint_pool_exhausted"); + expect(exhaustedItems).toHaveLength(1); + + expect(exhaustedItems[0]).toEqual( + expect.objectContaining({ + id: provider.id, + name: provider.name, + reason: "endpoint_pool_exhausted", + strictBlockCause: "no_endpoint_candidates", + attemptNumber: 1, + endpointFilterStats: { + total: 0, + enabled: 0, + circuitOpen: 0, + available: 0, + }, + }) + ); + }); + test("endpoint pool exhausted (selector_error) should record endpoint_pool_exhausted with selectorError in decisionContext", async () => { const requestPath = "/v1/responses"; const session = createSession(new URL(`https://example.com${requestPath}`)); From 005fad362d3289742dc16d37a4882e6d1669db84 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 19:31:14 +0800 Subject: [PATCH 08/23] =?UTF-8?q?fix(proxy):=20=E9=9D=9E=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=20JSON=20=E5=81=87200=E6=A3=80=E6=B5=8B=E8=A6=86=E7=9B=96=20Co?= =?UTF-8?q?ntent-Length?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对 application/json 且 Content-Length<=32KiB 的 2xx 响应也做强信号嗅探\n- 补齐 200+JSON error(带 Content-Length)触发故障转移的回归测试 --- src/app/v1/_lib/proxy/forwarder.ts | 18 +++++- .../proxy-forwarder-fake-200-html.test.ts | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 6105078f7..c3a60fa99 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -703,6 +703,9 @@ export class ProxyForwarder { const isHtml = normalizedContentType.includes("text/html") || normalizedContentType.includes("application/xhtml+xml"); + const isJson = + normalizedContentType.includes("application/json") || + normalizedContentType.includes("+json"); // ========== 流式响应:延迟成功判定(避免“假 200”)========== // 背景:上游可能返回 HTTP 200,但 SSE 内容为错误 JSON(如 {"error": "..."})。 @@ -739,10 +742,14 @@ export class ProxyForwarder { } // 非流式响应:检测空响应 - const contentLength = response.headers.get("content-length"); + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader?.trim() || undefined; + const contentLengthBytes = contentLength ? Number.parseInt(contentLength, 10) : null; + const hasValidContentLength = + contentLengthBytes !== null && Number.isFinite(contentLengthBytes) && contentLengthBytes >= 0; // 检测 Content-Length: 0 的情况 - if (contentLength === "0") { + if (contentLengthBytes === 0) { throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); } @@ -753,7 +760,12 @@ export class ProxyForwarder { // 因此这里在进入成功分支前做一次强信号检测:仅当 body 看起来是完整 HTML 文档时才视为错误。 let inspectedText: string | undefined; let inspectedTruncated = false; - if (isHtml || !contentLength) { + const shouldInspectJson = + isJson && + hasValidContentLength && + contentLengthBytes <= NON_STREAM_BODY_INSPECTION_MAX_BYTES; + const shouldInspectBody = isHtml || !hasValidContentLength || shouldInspectJson; + if (shouldInspectBody) { // 注意:Response.clone() 会 tee 底层 ReadableStream,可能带来一定的瞬时内存开销; // 这里通过“最多读取 32 KiB”并在截断时 cancel 克隆分支来控制开销。 const clonedResponse = response.clone(); diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index 8e99ee258..ef5325e61 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -311,6 +311,61 @@ describe("ProxyForwarder - fake 200 HTML body", () => { expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); + test("200 + application/json 且有 Content-Length 的 JSON error 也应视为失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const jsonErrorBody = JSON.stringify({ error: "upstream blocked" }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(jsonErrorBody, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(jsonErrorBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) + ); + const failure3 = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure3).toBeInstanceOf(ProxyError); + expect((failure3 as ProxyError).getClientSafeMessage()).toBe( + "Upstream service returned an invalid response" + ); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => { const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); From 3a9df872210c2f4f49459bc1124abc2ab2b31a22 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Feb 2026 11:31:49 +0000 Subject: [PATCH 09/23] chore: format code (fix-issue-749-fake-200-html-detection-005fad3) --- src/app/v1/_lib/proxy/forwarder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index c3a60fa99..69b571b3a 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -746,7 +746,9 @@ export class ProxyForwarder { const contentLength = contentLengthHeader?.trim() || undefined; const contentLengthBytes = contentLength ? Number.parseInt(contentLength, 10) : null; const hasValidContentLength = - contentLengthBytes !== null && Number.isFinite(contentLengthBytes) && contentLengthBytes >= 0; + contentLengthBytes !== null && + Number.isFinite(contentLengthBytes) && + contentLengthBytes >= 0; // 检测 Content-Length: 0 的情况 if (contentLengthBytes === 0) { From 218c2b09f2143eb62f722d1ce6901df38108ddc2 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 19:42:26 +0800 Subject: [PATCH 10/23] =?UTF-8?q?fix(i18n):=20=E4=BF=AE=E6=AD=A3=20ru=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E6=B1=A0=E8=80=97=E5=B0=BD=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正俄语中 endpoint 的复数属格拼写(конечных точек)\n- 不影响 key,仅更新展示文案 --- messages/ru/provider-chain.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index 837dc6228..bfbfa03fd 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -39,7 +39,7 @@ "concurrentLimit": "Лимит параллельных запросов", "http2Fallback": "Откат HTTP/2", "clientError": "Ошибка клиента", - "endpointPoolExhausted": "Пул конечная точкаов исчерпан" + "endpointPoolExhausted": "Пул конечных точек исчерпан" }, "reasons": { "request_success": "Успешно", @@ -52,7 +52,7 @@ "http2_fallback": "Откат HTTP/2", "session_reuse": "Повторное использование сессии", "initial_selection": "Первоначальный выбор", - "endpoint_pool_exhausted": "Пул конечная точкаов исчерпан" + "endpoint_pool_exhausted": "Пул конечных точек исчерпан" }, "filterReasons": { "rate_limited": "Ограничение скорости", @@ -194,13 +194,13 @@ "ruleDescription": "Описание: {description}", "ruleHasOverride": "Переопределения: response={response}, statusCode={statusCode}", "clientErrorNote": "Эта ошибка вызвана вводом клиента, не повторяется и не учитывается в автомате защиты.", - "endpointPoolExhausted": "Пул конечная точкаов исчерпан (все конечная точкаы недоступны)", - "endpointStats": "Статистика фильтрации конечная точкаов", - "endpointStatsTotal": "Всего конечная точкаов: {count}", - "endpointStatsEnabled": "Включено конечная точкаов: {count}", + "endpointPoolExhausted": "Пул конечных точек исчерпан (все конечные точки недоступны)", + "endpointStats": "Статистика фильтрации конечных точек", + "endpointStatsTotal": "Всего конечных точек: {count}", + "endpointStatsEnabled": "Включено конечных точек: {count}", "endpointStatsCircuitOpen": "Эндпоинтов с открытым автоматом: {count}", - "endpointStatsAvailable": "Доступных конечная точкаов: {count}", - "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечная точкаов, провайдер пропущен без отката", - "strictBlockSelectorError": "Строгий режим: ошибка селектора конечная точкаов, провайдер пропущен без отката" + "endpointStatsAvailable": "Доступных конечных точек: {count}", + "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечных точек, провайдер пропущен без отката", + "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката" } } From 20c0d49e12e9a0f9f4c3ade3c868df96aa867e0a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 20:18:08 +0800 Subject: [PATCH 11/23] =?UTF-8?q?test(formatter):=20=E8=A1=A5=E9=BD=90=20r?= =?UTF-8?q?esource=5Fnot=5Ffound=20=E7=BB=84=E5=90=88=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 覆盖 resource_not_found + retry_success 多供应商链路\n- 覆盖缺少 errorDetails.provider 的降级渲染路径 --- .../utils/provider-chain-formatter.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index bf83d55aa..cce54ad5e 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -302,6 +302,25 @@ describe("resource_not_found", () => { expect(result).toContain("provider-a"); expect(result).toContain("✗"); }); + + test("renders resource_not_found alongside a successful retry in multi-provider chain", () => { + const chain: ProviderChainItem[] = [ + baseNotFoundItem, + { + id: 2, + name: "provider-b", + reason: "retry_success", + statusCode: 200, + timestamp: 2000, + attemptNumber: 1, + }, + ]; + const result = formatProviderSummary(chain, mockT); + + expect(result).toContain("provider-a"); + expect(result).toContain("provider-b"); + expect(result).toMatch(/provider-a\(.*\).*provider-b\(.*\)/); + }); }); describe("formatProviderDescription", () => { @@ -323,6 +342,30 @@ describe("resource_not_found", () => { expect(timeline).toContain("timeline.statusCode [code=404]"); expect(timeline).toContain("timeline.resourceNotFoundNote"); }); + + test("degrades gracefully when errorDetails.provider is missing", () => { + const chain: ProviderChainItem[] = [ + { + ...baseNotFoundItem, + errorDetails: { + request: { + method: "POST", + url: "https://example.com/v1/messages", + headers: "{}", + body: "{}", + bodyTruncated: false, + }, + }, + }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + + expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]"); + expect(timeline).toContain("timeline.provider [provider=provider-a]"); + expect(timeline).toContain("timeline.statusCode [code=404]"); + expect(timeline).toContain("timeline.error [error=Not Found]"); + expect(timeline).toContain("timeline.resourceNotFoundNote"); + }); }); }); From 0aebbf16c7ea9ed6e1f2c9a58a86495caa75edfe Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 21:51:27 +0800 Subject: [PATCH 12/23] =?UTF-8?q?fix(proxy):=20FAKE=5F200=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=8F=90=E7=A4=BA=E9=99=84=E5=B8=A6=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E7=89=87=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/errors.ts | 10 +++++++++- tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 38df1c9ac..db3838bb5 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -448,8 +448,16 @@ export class ProxyError extends Error { */ getClientSafeMessage(): string { // 注意:一些内部检测/统计用的“错误码”(例如 FAKE_200_*)不适合直接暴露给客户端。 - // 这里做最小映射:仅在 502 且 message 为 FAKE_200_* 时返回统一文案。 + // 这里做最小映射:仅在 502 且 message 为 FAKE_200_* 时返回统一文案,并附带安全的上游片段(若有)。 if (this.statusCode === 502 && this.message.startsWith("FAKE_200_")) { + const detail = this.upstreamError?.body?.trim(); + if (detail) { + // 注意:对 FAKE_200_* 路径,我们只会写入内部检测得到的脱敏/截断片段(详见 upstream-error-detection.ts)。 + // 这里做一次最小的 whitespace 归一化,避免多行内容污染客户端日志。 + const normalized = detail.replace(/\s+/g, " ").trim(); + return `Upstream service returned an invalid response: ${normalized}`; + } + return "Upstream service returned an invalid response"; } diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index ef5325e61..62a4f86ab 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -248,7 +248,7 @@ describe("ProxyForwarder - fake 200 HTML body", () => { ); const failure1 = mocks.recordFailure.mock.calls[0]?.[1]; expect(failure1).toBeInstanceOf(ProxyError); - expect((failure1 as ProxyError).getClientSafeMessage()).toBe( + expect((failure1 as ProxyError).getClientSafeMessage()).toContain( "Upstream service returned an invalid response" ); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); @@ -304,9 +304,10 @@ describe("ProxyForwarder - fake 200 HTML body", () => { ); const failure2 = mocks.recordFailure.mock.calls[0]?.[1]; expect(failure2).toBeInstanceOf(ProxyError); - expect((failure2 as ProxyError).getClientSafeMessage()).toBe( + expect((failure2 as ProxyError).getClientSafeMessage()).toContain( "Upstream service returned an invalid response" ); + expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); @@ -359,9 +360,10 @@ describe("ProxyForwarder - fake 200 HTML body", () => { ); const failure3 = mocks.recordFailure.mock.calls[0]?.[1]; expect(failure3).toBeInstanceOf(ProxyError); - expect((failure3 as ProxyError).getClientSafeMessage()).toBe( + expect((failure3 as ProxyError).getClientSafeMessage()).toContain( "Upstream service returned an invalid response" ); + expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); From 62fcf638f9ba068caa9fc748066dbbb118eb6407 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 23:40:07 +0800 Subject: [PATCH 13/23] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=20FAKE=5F200=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=8E=9F=E5=9B=A0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en/dashboard.json | 9 ++++ messages/ja/dashboard.json | 9 ++++ messages/ru/dashboard.json | 9 ++++ messages/zh-CN/dashboard.json | 9 ++++ messages/zh-TW/dashboard.json | 9 ++++ .../_components/error-details-dialog.test.tsx | 9 ++++ .../components/SummaryTab.tsx | 28 +++++++++++ .../provider-chain-popover.test.tsx | 9 ++++ .../_components/provider-chain-popover.tsx | 49 +++++++++++++++++-- src/app/v1/_lib/proxy/errors.ts | 28 +++++++++-- .../proxy-forwarder-fake-200-html.test.ts | 15 +++--- 11 files changed, 167 insertions(+), 16 deletions(-) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index f26229c75..3df4cd896 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -244,6 +244,15 @@ }, "errorMessage": "Error Message", "fake200ForwardedNotice": "Note: For streaming requests, this failure may be detected only after the stream ends; the response content may already have been forwarded to the client.", + "fake200DetectedReason": "Detected reason: {reason}", + "fake200Reasons": { + "emptyBody": "Empty response body", + "htmlBody": "HTML document returned (likely an error page)", + "jsonErrorNonEmpty": "JSON has a non-empty `error` field", + "jsonErrorMessageNonEmpty": "JSON has a non-empty `error.message`", + "jsonMessageKeywordMatch": "JSON `message` contains the word \"error\" (heuristic)", + "unknown": "Response body indicates an error" + }, "filteredProviders": "Filtered Providers", "providerChain": { "title": "Provider Decision Chain Timeline", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index fd6a42f10..8ba97c04d 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -244,6 +244,15 @@ }, "errorMessage": "エラーメッセージ", "fake200ForwardedNotice": "注意:ストリーミング要求では、失敗判定がストリーム終了後になる場合があります。応答内容は既にクライアントへ転送されている可能性があります。", + "fake200DetectedReason": "検出理由:{reason}", + "fake200Reasons": { + "emptyBody": "レスポンス本文が空です", + "htmlBody": "HTML ドキュメントが返されました (エラーページの可能性)", + "jsonErrorNonEmpty": "JSON の `error` フィールドが空ではありません", + "jsonErrorMessageNonEmpty": "JSON の `error.message` が空ではありません", + "jsonMessageKeywordMatch": "JSON の `message` に \"error\" が含まれています (ヒューリスティック)", + "unknown": "レスポンス本文がエラーを示しています" + }, "filteredProviders": "フィルタされたプロバイダー", "providerChain": { "title": "プロバイダー決定チェーンタイムライン", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 48337f945..b8e4ff9a4 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -244,6 +244,15 @@ }, "errorMessage": "Сообщение об ошибке", "fake200ForwardedNotice": "Примечание: для потоковых запросов эта ошибка может быть обнаружена только после завершения потока; содержимое ответа могло уже быть передано клиенту.", + "fake200DetectedReason": "Причина обнаружения: {reason}", + "fake200Reasons": { + "emptyBody": "Пустое тело ответа", + "htmlBody": "Получен HTML-документ (возможно, страница ошибки)", + "jsonErrorNonEmpty": "В JSON непустое поле `error`", + "jsonErrorMessageNonEmpty": "В JSON непустое `error.message`", + "jsonMessageKeywordMatch": "В JSON `message` содержит слово \"error\" (эвристика)", + "unknown": "Тело ответа указывает на ошибку" + }, "filteredProviders": "Отфильтрованные поставщики", "providerChain": { "title": "Хронология цепочки решений поставщика", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 823caee5a..3b364c18f 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -244,6 +244,15 @@ }, "errorMessage": "错误信息", "fake200ForwardedNotice": "提示:对于流式请求,该失败可能在流结束后才被识别;响应内容可能已原样透传给客户端。", + "fake200DetectedReason": "检测原因:{reason}", + "fake200Reasons": { + "emptyBody": "响应体为空", + "htmlBody": "返回了 HTML 文档(可能是错误页)", + "jsonErrorNonEmpty": "JSON 顶层 error 字段非空", + "jsonErrorMessageNonEmpty": "JSON 中 error.message 非空", + "jsonMessageKeywordMatch": "JSON message 字段包含 \"error\"(启发式)", + "unknown": "响应体内容指示错误" + }, "filteredProviders": "被过滤的供应商", "providerChain": { "title": "供应商决策链时间线", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ce0739f82..d2b5fc9a9 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -244,6 +244,15 @@ }, "errorMessage": "錯誤訊息", "fake200ForwardedNotice": "提示:對於串流請求,此失敗可能在串流結束後才被識別;回應內容可能已原樣透傳給用戶端。", + "fake200DetectedReason": "檢測原因:{reason}", + "fake200Reasons": { + "emptyBody": "回應本文為空", + "htmlBody": "回傳了 HTML 文件(可能是錯誤頁)", + "jsonErrorNonEmpty": "JSON 頂層 error 欄位非空", + "jsonErrorMessageNonEmpty": "JSON 中 error.message 非空", + "jsonMessageKeywordMatch": "JSON message 欄位包含 \"error\"(啟發式)", + "unknown": "回應本文內容顯示錯誤" + }, "filteredProviders": "被過濾的供應商", "providerChain": { "title": "供應商決策鏈時間軸", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 9cb749975..45cd78101 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -253,6 +253,15 @@ const messages = { }, errorMessage: "Error message", fake200ForwardedNotice: "Note: detected after stream end; payload may have been forwarded", + fake200DetectedReason: "Detected reason: {reason}", + fake200Reasons: { + emptyBody: "Empty response body", + htmlBody: "HTML document returned", + jsonErrorNonEmpty: "JSON has non-empty error field", + jsonErrorMessageNonEmpty: "JSON has non-empty error.message", + jsonMessageKeywordMatch: 'JSON message contains "error"', + unknown: "Response body indicates an error", + }, viewDetails: "View details", filteredProviders: "Filtered providers", providerChain: { diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 3052c02d8..68807ac4f 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -28,6 +28,25 @@ import { shouldHideOutputRate, } from "../types"; +// UI 仅用于“解释”内部的 FAKE_200_* 错误码,不参与判定逻辑。 +// 这些 code 代表:上游返回了 2xx(看起来成功),但响应体内容更像错误页/错误 JSON。 +function getFake200ReasonKey(code: string): string { + switch (code) { + case "FAKE_200_EMPTY_BODY": + return "fake200Reasons.emptyBody"; + case "FAKE_200_HTML_BODY": + return "fake200Reasons.htmlBody"; + case "FAKE_200_JSON_ERROR_NON_EMPTY": + return "fake200Reasons.jsonErrorNonEmpty"; + case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY": + return "fake200Reasons.jsonErrorMessageNonEmpty"; + case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH": + return "fake200Reasons.jsonMessageKeywordMatch"; + default: + return "fake200Reasons.unknown"; + } +} + export function SummaryTab({ statusCode, errorMessage, @@ -67,6 +86,10 @@ export function SummaryTab({ specialSettings && specialSettings.length > 0 ? JSON.stringify(specialSettings, null, 2) : null; const isFake200PostStreamFailure = typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_"); + const fake200Reason = + isFake200PostStreamFailure && typeof errorMessage === "string" + ? t(getFake200ReasonKey(errorMessage)) + : null; return (
@@ -426,6 +449,11 @@ export function SummaryTab({

{errorMessage.length > 200 ? `${errorMessage.slice(0, 200)}...` : errorMessage}

+ {isFake200PostStreamFailure && fake200Reason && ( +

+ {t("fake200DetectedReason", { reason: fake200Reason })} +

+ )} {/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端,因此需要提示用户避免误解。 */} {isFake200PostStreamFailure && (
diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx index c4f77d5df..faf2c6be6 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx @@ -86,6 +86,15 @@ const messages = { details: { clickStatusCode: "Click status code", fake200ForwardedNotice: "Note: payload may have been forwarded", + fake200DetectedReason: "Detected reason: {reason}", + fake200Reasons: { + emptyBody: "Empty response body", + htmlBody: "HTML document returned", + jsonErrorNonEmpty: "JSON has non-empty error field", + jsonErrorMessageNonEmpty: "JSON has non-empty error.message", + jsonMessageKeywordMatch: 'JSON message contains "error"', + unknown: "Response body indicates an error", + }, }, }, }, diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 1b0abff7b..47c3efeea 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -61,6 +61,25 @@ function parseGroupTags(groupTag?: string | null): string[] { return groups; } +// UI 仅用于“解释”内部的 FAKE_200_* 错误码,不参与判定逻辑。 +// 这些 code 代表:上游返回了 2xx(看起来成功),但响应体内容更像错误页/错误 JSON。 +function getFake200ReasonKey(code: string): string { + switch (code) { + case "FAKE_200_EMPTY_BODY": + return "logs.details.fake200Reasons.emptyBody"; + case "FAKE_200_HTML_BODY": + return "logs.details.fake200Reasons.htmlBody"; + case "FAKE_200_JSON_ERROR_NON_EMPTY": + return "logs.details.fake200Reasons.jsonErrorNonEmpty"; + case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY": + return "logs.details.fake200Reasons.jsonErrorMessageNonEmpty"; + case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH": + return "logs.details.fake200Reasons.jsonMessageKeywordMatch"; + default: + return "logs.details.fake200Reasons.unknown"; + } +} + /** * Get status icon and color for a provider chain item */ @@ -122,6 +141,9 @@ export function ProviderChainPopover({ const hasFake200PostStreamFailure = chain.some( (item) => typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_") ); + const fake200CodeForDisplay = chain.find( + (item) => typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_") + )?.errorMessage; // Calculate actual request count (excluding intermediate states) const requestCount = chain.filter(isActualRequest).length; @@ -174,7 +196,16 @@ export function ProviderChainPopover({ {hasFake200PostStreamFailure && (
)} @@ -468,9 +499,19 @@ export function ProviderChainPopover({ )}
{item.errorMessage && ( -

- {item.errorMessage} -

+ <> +

+ {item.errorMessage} +

+ {typeof item.errorMessage === "string" && + item.errorMessage.startsWith("FAKE_200_") && ( +

+ {t("logs.details.fake200DetectedReason", { + reason: t(getFake200ReasonKey(item.errorMessage)), + })} +

+ )} + )}
diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index db3838bb5..839fe8aea 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -448,17 +448,39 @@ export class ProxyError extends Error { */ getClientSafeMessage(): string { // 注意:一些内部检测/统计用的“错误码”(例如 FAKE_200_*)不适合直接暴露给客户端。 - // 这里做最小映射:仅在 502 且 message 为 FAKE_200_* 时返回统一文案,并附带安全的上游片段(若有)。 + // 这里做最小映射:仅在 502 且 message 为 FAKE_200_* 时返回“可读原因说明”,并附带安全的上游片段(若有)。 if (this.statusCode === 502 && this.message.startsWith("FAKE_200_")) { + // 说明:这些 code 都来自内部的“假 200”检测,代表:HTTP 状态码显示成功,但响应体内容更像错误页/错误 JSON。 + // 我们需要: + // 1) 给用户清晰的错误原因(避免只看到一个内部 code); + // 2) 不泄露内部错误码/供应商名称; + // 3) 在有 detail 时附带一小段“脱敏 + 截断”的上游片段,帮助排查。 + const reason = (() => { + switch (this.message) { + case "FAKE_200_EMPTY_BODY": + return "Upstream returned a successful HTTP status, but the response body was empty."; + case "FAKE_200_HTML_BODY": + return "Upstream returned a successful HTTP status, but the response body looks like an HTML document (likely an error page)."; + case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY": + return "Upstream returned a successful HTTP status, but the JSON body contains a non-empty `error.message`."; + case "FAKE_200_JSON_ERROR_NON_EMPTY": + return "Upstream returned a successful HTTP status, but the JSON body contains a non-empty `error` field."; + case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH": + return "Upstream returned a successful HTTP status, but the JSON `message` suggests an error (heuristic)."; + default: + return "Upstream returned a successful HTTP status, but the response body indicates an error."; + } + })(); + const detail = this.upstreamError?.body?.trim(); if (detail) { // 注意:对 FAKE_200_* 路径,我们只会写入内部检测得到的脱敏/截断片段(详见 upstream-error-detection.ts)。 // 这里做一次最小的 whitespace 归一化,避免多行内容污染客户端日志。 const normalized = detail.replace(/\s+/g, " ").trim(); - return `Upstream service returned an invalid response: ${normalized}`; + return `${reason} Upstream detail: ${normalized}`; } - return "Upstream service returned an invalid response"; + return reason; } return this.message; diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index 62a4f86ab..756464897 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -248,9 +248,8 @@ describe("ProxyForwarder - fake 200 HTML body", () => { ); const failure1 = mocks.recordFailure.mock.calls[0]?.[1]; expect(failure1).toBeInstanceOf(ProxyError); - expect((failure1 as ProxyError).getClientSafeMessage()).toContain( - "Upstream service returned an invalid response" - ); + expect((failure1 as ProxyError).getClientSafeMessage()).toContain("HTML document"); + expect((failure1 as ProxyError).getClientSafeMessage()).toContain("Upstream detail:"); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); @@ -304,9 +303,8 @@ describe("ProxyForwarder - fake 200 HTML body", () => { ); const failure2 = mocks.recordFailure.mock.calls[0]?.[1]; expect(failure2).toBeInstanceOf(ProxyError); - expect((failure2 as ProxyError).getClientSafeMessage()).toContain( - "Upstream service returned an invalid response" - ); + expect((failure2 as ProxyError).getClientSafeMessage()).toContain("JSON body"); + expect((failure2 as ProxyError).getClientSafeMessage()).toContain("`error`"); expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); @@ -360,9 +358,8 @@ describe("ProxyForwarder - fake 200 HTML body", () => { ); const failure3 = mocks.recordFailure.mock.calls[0]?.[1]; expect(failure3).toBeInstanceOf(ProxyError); - expect((failure3 as ProxyError).getClientSafeMessage()).toContain( - "Upstream service returned an invalid response" - ); + expect((failure3 as ProxyError).getClientSafeMessage()).toContain("JSON body"); + expect((failure3 as ProxyError).getClientSafeMessage()).toContain("`error`"); expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); From e9e2f04340b7d9097b8962003f47c36f89a2e7e7 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 00:47:07 +0800 Subject: [PATCH 14/23] =?UTF-8?q?fix(proxy):=20verboseProviderError=20?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0=E5=81=87200=E5=8E=9F=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fake-200/空响应:verboseProviderError 开启时在 error.details 返回详细报告与上游原文(不落库)\n- forwarder: 将检测到的原文片段挂到 ProxyError.upstreamError.rawBody\n- tests: 覆盖 verbose details 与 rawBody 透传 --- messages/en/settings/config.json | 2 +- messages/ja/settings/config.json | 2 +- messages/ru/settings/config.json | 2 +- messages/zh-CN/settings/config.json | 2 +- messages/zh-TW/settings/config.json | 2 +- src/app/v1/_lib/proxy/error-handler.ts | 48 +++++- src/app/v1/_lib/proxy/errors.ts | 20 ++- src/app/v1/_lib/proxy/forwarder.ts | 4 + ...ler-verbose-provider-error-details.test.ts | 143 ++++++++++++++++++ .../proxy-forwarder-fake-200-html.test.ts | 4 + 10 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index c7bd7ae2b..a9ba06418 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "e.g. Claude Code Hub", "siteTitleRequired": "Site title cannot be empty", "verboseProviderError": "Verbose Provider Error", - "verboseProviderErrorDesc": "When enabled, return detailed error messages when all providers are unavailable (including provider count, rate limit reasons, etc.); when disabled, only return a simple error code.", + "verboseProviderErrorDesc": "When enabled, return more detailed provider error information in some upstream failure scenarios (e.g. all providers unavailable, fake 200, or empty responses) and may include upstream response excerpts; when disabled, only return a simple error code.", "timezoneLabel": "System Timezone", "timezoneDescription": "Set the system timezone for unified backend time boundary calculations and frontend date/time display. Leave empty to use the TZ environment variable or default to UTC.", "timezoneAuto": "Auto (use TZ env variable)", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 7a9a6204e..b11dbe4c4 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "サイトタイトルは空にできません", "verboseProviderError": "詳細なプロバイダーエラー", - "verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。", + "verboseProviderErrorDesc": "有効にすると、一部の上流障害シナリオ(例:すべてのプロバイダーが利用不可、偽200、空のレスポンスなど)で、より詳細なエラー情報(上流レスポンスの抜粋を含む場合あり)を返します。無効の場合は簡潔なエラーコードのみを返します。", "timezoneLabel": "システムタイムゾーン", "timezoneDescription": "バックエンドの時間境界計算とフロントエンドの日付/時刻表示を統一するためのシステムタイムゾーンを設定します。空のままにすると環境変数 TZ またはデフォルトの UTC が使用されます。", "timezoneAuto": "自動 (環境変数 TZ を使用)", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index a65535f31..cbf8472d0 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "например: Claude Code Hub", "siteTitleRequired": "Название сайта не может быть пустым", "verboseProviderError": "Подробные ошибки провайдеров", - "verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки.", + "verboseProviderErrorDesc": "При включении возвращает более подробную информацию об ошибках провайдеров в некоторых сценариях (например, когда все провайдеры недоступны, «fake 200» или пустой ответ) и может включать фрагменты ответа апстрима; при отключении возвращает только простой код ошибки.", "timezoneLabel": "Системная Временная Зона", "timezoneDescription": "Установите системную временную зону для единых вычислений временных границ в бэкенде и отображения даты/времени в интерфейсе. Оставьте пустым для использования переменной окружения TZ или UTC по умолчанию.", "timezoneAuto": "Авто (использовать переменную окружения TZ)", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 91c876140..f08166b1c 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -33,7 +33,7 @@ "allowGlobalView": "允许查看全站使用量", "allowGlobalViewDesc": "关闭后,普通用户在仪表盘仅能查看自己密钥的使用统计。", "verboseProviderError": "详细供应商错误信息", - "verboseProviderErrorDesc": "开启后,当所有供应商不可用时返回详细错误信息(包含供应商数量、限流原因等);关闭后仅返回简洁错误码。", + "verboseProviderErrorDesc": "开启后,在部分上游异常场景(例如所有供应商不可用、检测到假200或空响应等)返回更详细错误信息(可能包含上游响应片段);关闭后仅返回简洁错误码。", "enableHttp2": "启用 HTTP/2", "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 4a3c7ee01..e4b200e89 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "站台標題不能為空", "verboseProviderError": "詳細供應商錯誤資訊", - "verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。", + "verboseProviderErrorDesc": "開啟後,在部分上游異常場景(例如所有供應商不可用、偵測到假200或空回應等)返回更詳細錯誤資訊(可能包含上游回應片段);關閉後僅返回簡潔錯誤碼。", "timezoneLabel": "系統時區", "timezoneDescription": "設定系統時區,用於統一後端時間邊界計算和前端日期/時間顯示。留空時使用環境變數 TZ 或預設 UTC。", "timezoneAuto": "自動 (使用環境變數 TZ)", diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index 977f7f92f..08fa5bb6f 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -1,3 +1,4 @@ +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; import { isClaudeErrorFormat, isGeminiErrorFormat, @@ -236,9 +237,54 @@ export class ProxyErrorHandler { overridden: false, }); + // verboseProviderError(系统设置)开启时:对“假 200/空响应”等上游异常返回更详细的报告,便于排查。 + // 注意: + // - 该逻辑放在 error override 之后:确保优先级更低,不覆盖用户自定义覆写。 + // - 仅回传在内存中短暂保留的原文(rawBody),不写入数据库/决策链,避免泄露与持久化污染。 + let details: Record | undefined; + let upstreamRequestId: string | undefined; + const shouldAttachVerboseDetails = + (error instanceof ProxyError && + error.statusCode === 502 && + error.message.startsWith("FAKE_200_")) || + isEmptyResponseError(error); + + if (shouldAttachVerboseDetails) { + const settings = await getCachedSystemSettings(); + if (settings.verboseProviderError) { + if (error instanceof ProxyError) { + upstreamRequestId = error.upstreamError?.requestId; + details = { + upstreamError: { + kind: "fake_200", + code: error.message, + clientSafeMessage: error.getClientSafeMessage(), + rawBody: error.upstreamError?.rawBody, + rawBodyTruncated: error.upstreamError?.rawBodyTruncated ?? false, + }, + }; + } else if (isEmptyResponseError(error)) { + details = { + upstreamError: { + kind: "empty_response", + reason: error.reason, + clientSafeMessage: error.getClientSafeMessage(), + rawBody: "", + rawBodyTruncated: false, + }, + }; + } + } + } + + const safeRequestId = + typeof upstreamRequestId === "string" && upstreamRequestId.trim() + ? upstreamRequestId.trim() + : undefined; + return await attachSessionIdToErrorResponse( session.sessionId, - ProxyResponses.buildError(statusCode, clientErrorMessage) + ProxyResponses.buildError(statusCode, clientErrorMessage, undefined, details, safeRequestId) ); } diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 839fe8aea..fc88d90bc 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -18,11 +18,29 @@ export class ProxyError extends Error { message: string, public readonly statusCode: number, public readonly upstreamError?: { - body: string; // 原始响应体(智能截断) + /** + * 上游响应体(智能截断)。 + * + * 注意:该字段会进入 getDetailedErrorMessage(),并被记录到数据库中, + * 因此不要在这里放入“大段原文”或未脱敏的敏感内容。 + */ + body: string; parsed?: unknown; // 解析后的 JSON(如果有) providerId?: number; providerName?: string; requestId?: string; // 上游请求 ID(用于覆写响应时注入) + + /** + * 上游响应体原文(通常为前缀片段)。 + * + * 设计目标: + * - 仅用于“本次错误响应”返回给客户端(受系统设置控制); + * - 不参与规则匹配与持久化(避免污染数据库/日志)。 + * + * 目前主要用于“假 200”检测:HTTP 状态码为 2xx,但 body 实际为错误页/错误 JSON。 + */ + rawBody?: string; + rawBodyTruncated?: boolean; } ) { super(message); diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 69b571b3a..781cfc921 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -801,6 +801,10 @@ export class ProxyForwarder { body: detected.detail ?? "", providerId: currentProvider.id, providerName: currentProvider.name, + // 注意:rawBody 仅用于“本次错误响应”向客户端提供更多排查信息(受系统设置控制), + // 不参与规则匹配/持久化,避免污染数据库或误触发覆写规则。 + rawBody: inspectedText, + rawBodyTruncated: inspectedTruncated, }); } } diff --git a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts new file mode 100644 index 000000000..59ff900ef --- /dev/null +++ b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + getCachedSystemSettings: vi.fn(async () => ({ verboseProviderError: false }) as any), + getErrorOverrideAsync: vi.fn(async () => undefined), + }; +}); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: mocks.getCachedSystemSettings, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getErrorOverrideAsync: mocks.getErrorOverrideAsync, + }; +}); + +import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; +import { EmptyResponseError, ProxyError } from "@/app/v1/_lib/proxy/errors"; + +function createSession(): any { + return { + sessionId: null, + messageContext: null, + startTime: Date.now(), + getProviderChain: () => [], + getCurrentModel: () => null, + getContext1mApplied: () => false, + provider: null, + }; +} + +describe("ProxyErrorHandler.handle - verboseProviderError details", () => { + beforeEach(() => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: false } as any); + mocks.getErrorOverrideAsync.mockResolvedValue(undefined); + }); + + test("verboseProviderError=false 时,不应附带 fake-200 raw body/details", async () => { + const session = createSession(); + const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, { + body: "sanitized", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: '{"error":"boom"}', + rawBodyTruncated: false, + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(502); + + const body = await res.json(); + expect(body.error.details).toBeUndefined(); + expect(body.request_id).toBeUndefined(); + }); + + test("verboseProviderError=true 时,fake-200 应返回详细报告与上游原文", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + + const session = createSession(); + const err = new ProxyError("FAKE_200_HTML_BODY", 502, { + body: "redacted snippet", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: "blocked", + rawBodyTruncated: false, + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(502); + + const body = await res.json(); + expect(body.request_id).toBe("req_123"); + expect(body.error.details).toEqual({ + upstreamError: { + kind: "fake_200", + code: "FAKE_200_HTML_BODY", + clientSafeMessage: expect.any(String), + rawBody: "blocked", + rawBodyTruncated: false, + }, + }); + }); + + test("verboseProviderError=true 时,空响应错误也应返回详细报告(rawBody 为空字符串)", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + + const session = createSession(); + const err = new EmptyResponseError(1, "p1", "empty_body"); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(502); + + const body = await res.json(); + expect(body.error.details).toEqual({ + upstreamError: { + kind: "empty_response", + reason: "empty_body", + clientSafeMessage: "Empty response: Response body is empty", + rawBody: "", + rawBodyTruncated: false, + }, + }); + }); + + test("有 error override 时,verbose details 不应覆盖覆写逻辑(优先级更低)", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + mocks.getErrorOverrideAsync.mockResolvedValue({ response: null, statusCode: 418 }); + + const session = createSession(); + const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, { + body: "sanitized", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: '{"error":"boom"}', + rawBodyTruncated: false, + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(418); + + const body = await res.json(); + expect(body.error.details).toBeUndefined(); + }); +}); diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index 756464897..2108ddc47 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -306,6 +306,8 @@ describe("ProxyForwarder - fake 200 HTML body", () => { expect((failure2 as ProxyError).getClientSafeMessage()).toContain("JSON body"); expect((failure2 as ProxyError).getClientSafeMessage()).toContain("`error`"); expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); + expect((failure2 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody); + expect((failure2 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); @@ -361,6 +363,8 @@ describe("ProxyForwarder - fake 200 HTML body", () => { expect((failure3 as ProxyError).getClientSafeMessage()).toContain("JSON body"); expect((failure3 as ProxyError).getClientSafeMessage()).toContain("`error`"); expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); + expect((failure3 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody); + expect((failure3 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false); expect(mocks.recordSuccess).toHaveBeenCalledWith(2); expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); From 1c5ef19cbd2995f456bb6e13a0799875c2cc3866 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 01:10:30 +0800 Subject: [PATCH 15/23] =?UTF-8?q?fix(proxy):=20=E5=BC=BA=E5=8C=96=20Conten?= =?UTF-8?q?t-Length=20=E6=A0=A1=E9=AA=8C=E4=B8=8E=E5=81=87200=E7=89=87?= =?UTF-8?q?=E6=AE=B5=E9=98=B2=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forwarder: 将非法 Content-Length 视为无效,避免漏检 HTML/空响应\n- errors: FAKE_200 客户端 detail 二次截断 + 轻量脱敏(防御性)\n- tests: 覆盖非法 Content-Length 漏检回归 --- src/app/v1/_lib/proxy/errors.ts | 18 +++++- src/app/v1/_lib/proxy/forwarder.ts | 21 ++++--- .../proxy-forwarder-fake-200-html.test.ts | 56 +++++++++++++++++++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index fc88d90bc..bfc3f420d 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -492,10 +492,22 @@ export class ProxyError extends Error { const detail = this.upstreamError?.body?.trim(); if (detail) { - // 注意:对 FAKE_200_* 路径,我们只会写入内部检测得到的脱敏/截断片段(详见 upstream-error-detection.ts)。 - // 这里做一次最小的 whitespace 归一化,避免多行内容污染客户端日志。 + // 注意:对 FAKE_200_* 路径,我们期望 upstreamError.body 来自内部检测得到的“脱敏 + 截断片段”(详见 upstream-error-detection.ts)。 + // + // 但为避免未来调用方误把“未脱敏的大段原文”塞进 upstreamError.body 导致泄露, + // 这里再做一次防御性处理: + // - whitespace 归一化(避免多行污染客户端日志) + // - 二次截断(上限 200 字符) + // - 轻量脱敏(避免明显的 token/key 泄露) const normalized = detail.replace(/\s+/g, " ").trim(); - return `${reason} Upstream detail: ${normalized}`; + const maxChars = 200; + const clipped = + normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized; + const safe = clipped + .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]") + .replace(/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/giu, "[REDACTED_KEY]") + .replace(/\bAIza[0-9A-Za-z_-]{16,}\b/g, "[REDACTED_KEY]"); + return `${reason} Upstream detail: ${safe}`; } return reason; diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 781cfc921..e2111e7b6 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -744,11 +744,18 @@ export class ProxyForwarder { // 非流式响应:检测空响应 const contentLengthHeader = response.headers.get("content-length"); const contentLength = contentLengthHeader?.trim() || undefined; - const contentLengthBytes = contentLength ? Number.parseInt(contentLength, 10) : null; - const hasValidContentLength = - contentLengthBytes !== null && - Number.isFinite(contentLengthBytes) && - contentLengthBytes >= 0; + const contentLengthBytes = (() => { + if (!contentLength) return null; + + // Content-Length 必须是纯数字;parseInt("12abc") 会返回 12,容易误判为合法值, + // 从而跳过 “!hasValidContentLength” 的检查分支。 + if (!/^\d+$/.test(contentLength)) return null; + + const num = Number(contentLength); + if (!Number.isSafeInteger(num) || num < 0) return null; + return num; + })(); + const hasValidContentLength = contentLengthBytes !== null; // 检测 Content-Length: 0 的情况 if (contentLengthBytes === 0) { @@ -809,9 +816,9 @@ export class ProxyForwarder { } } - // 对于没有 Content-Length 的情况,需要 clone 并检查响应体 + // 对于缺失或非法 Content-Length 的情况,需要 clone 并检查响应体 // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的 - if (!contentLength) { + if (!contentLength || !hasValidContentLength) { const responseText = inspectedText ?? ""; if (!responseText || responseText.trim() === "") { diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index 2108ddc47..adb19c60a 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -369,6 +369,62 @@ describe("ProxyForwarder - fake 200 HTML body", () => { expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); + test("200 + 非法 Content-Length 时应按缺失处理,避免漏检 HTML 假200", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const htmlErrorBody = "blocked"; + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(htmlErrorBody, { + status: 200, + headers: { + // 故意不提供 html/json 的 Content-Type,覆盖“仅靠 body 嗅探”的假200检测分支 + "content-type": "text/plain; charset=utf-8", + // 非法 Content-Length:parseInt("12abc") 会返回 12;修复后应视为非法并进入 body 检查分支 + "content-length": "12abc", + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_HTML_BODY" }) + ); + + const failure = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure).toBeInstanceOf(ProxyError); + expect((failure as ProxyError).upstreamError?.rawBody).toBe(htmlErrorBody); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => { const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); From fd3cd800a6348c272e8b048cfcb1bf3304815628 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 01:53:33 +0800 Subject: [PATCH 16/23] =?UTF-8?q?docs(proxy):=20=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E9=9D=9E=E6=B5=81=E5=BC=8F=E5=81=87200=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/forwarder.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index e2111e7b6..9ac5be497 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -769,6 +769,11 @@ export class ProxyForwarder { // 因此这里在进入成功分支前做一次强信号检测:仅当 body 看起来是完整 HTML 文档时才视为错误。 let inspectedText: string | undefined; let inspectedTruncated = false; + // 注意:这里不会对“大体积 JSON”做假 200 检测(例如 Content-Length > 32KiB)。 + // 原因: + // - 非流式路径需要 clone 并额外读取响应体,会带来额外的内存/延迟开销; + // - 大体积 JSON 更可能是正常响应(而不是网关/WAF 的短错误 JSON)。 + // 这意味着:极少数“超大 JSON 错误体 + HTTP 200”的上游异常可能会漏检。 const shouldInspectJson = isJson && hasValidContentLength && From 7cd494bb2716c2fb688dc09eb3b848141ffa15a5 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 02:03:43 +0800 Subject: [PATCH 17/23] =?UTF-8?q?docs(settings):=20=E8=A1=A5=E5=85=85=20ve?= =?UTF-8?q?rboseProviderError=20=E5=AE=89=E5=85=A8=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en/settings/config.json | 2 +- messages/ja/settings/config.json | 2 +- messages/ru/settings/config.json | 2 +- messages/zh-CN/settings/config.json | 2 +- messages/zh-TW/settings/config.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index a9ba06418..9214034a3 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "e.g. Claude Code Hub", "siteTitleRequired": "Site title cannot be empty", "verboseProviderError": "Verbose Provider Error", - "verboseProviderErrorDesc": "When enabled, return more detailed provider error information in some upstream failure scenarios (e.g. all providers unavailable, fake 200, or empty responses) and may include upstream response excerpts; when disabled, only return a simple error code.", + "verboseProviderErrorDesc": "When enabled, return more detailed provider error information in some upstream failure scenarios (e.g. all providers unavailable, fake 200, or empty responses). It may include upstream response excerpts in error responses (potentially sensitive); enable only in trusted environments. When disabled, only return a simple error code.", "timezoneLabel": "System Timezone", "timezoneDescription": "Set the system timezone for unified backend time boundary calculations and frontend date/time display. Leave empty to use the TZ environment variable or default to UTC.", "timezoneAuto": "Auto (use TZ env variable)", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index b11dbe4c4..124482e90 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "サイトタイトルは空にできません", "verboseProviderError": "詳細なプロバイダーエラー", - "verboseProviderErrorDesc": "有効にすると、一部の上流障害シナリオ(例:すべてのプロバイダーが利用不可、偽200、空のレスポンスなど)で、より詳細なエラー情報(上流レスポンスの抜粋を含む場合あり)を返します。無効の場合は簡潔なエラーコードのみを返します。", + "verboseProviderErrorDesc": "有効にすると、一部の上流障害シナリオ(例:すべてのプロバイダーが利用不可、偽200、空のレスポンスなど)で、より詳細なエラー情報を返します。エラー応答に上流レスポンスの抜粋(機密情報を含む可能性あり)が含まれる場合があるため、信頼できる環境でのみ有効化してください。無効の場合は簡潔なエラーコードのみを返します。", "timezoneLabel": "システムタイムゾーン", "timezoneDescription": "バックエンドの時間境界計算とフロントエンドの日付/時刻表示を統一するためのシステムタイムゾーンを設定します。空のままにすると環境変数 TZ またはデフォルトの UTC が使用されます。", "timezoneAuto": "自動 (環境変数 TZ を使用)", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index cbf8472d0..40df621e9 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "например: Claude Code Hub", "siteTitleRequired": "Название сайта не может быть пустым", "verboseProviderError": "Подробные ошибки провайдеров", - "verboseProviderErrorDesc": "При включении возвращает более подробную информацию об ошибках провайдеров в некоторых сценариях (например, когда все провайдеры недоступны, «fake 200» или пустой ответ) и может включать фрагменты ответа апстрима; при отключении возвращает только простой код ошибки.", + "verboseProviderErrorDesc": "При включении возвращает более подробную информацию об ошибках провайдеров в некоторых сценариях (например, когда все провайдеры недоступны, «fake 200» или пустой ответ). В ответе об ошибке могут присутствовать фрагменты ответа апстрима (потенциально содержащие чувствительные данные); включайте только в доверенной среде. При отключении возвращает только простой код ошибки.", "timezoneLabel": "Системная Временная Зона", "timezoneDescription": "Установите системную временную зону для единых вычислений временных границ в бэкенде и отображения даты/времени в интерфейсе. Оставьте пустым для использования переменной окружения TZ или UTC по умолчанию.", "timezoneAuto": "Авто (использовать переменную окружения TZ)", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index f08166b1c..e46a40830 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -33,7 +33,7 @@ "allowGlobalView": "允许查看全站使用量", "allowGlobalViewDesc": "关闭后,普通用户在仪表盘仅能查看自己密钥的使用统计。", "verboseProviderError": "详细供应商错误信息", - "verboseProviderErrorDesc": "开启后,在部分上游异常场景(例如所有供应商不可用、检测到假200或空响应等)返回更详细错误信息(可能包含上游响应片段);关闭后仅返回简洁错误码。", + "verboseProviderErrorDesc": "开启后,在部分上游异常场景(例如所有供应商不可用、检测到假200或空响应等)返回更详细错误信息(可能包含上游响应片段,且可能含敏感信息);仅建议在可信环境启用。关闭后仅返回简洁错误码。", "enableHttp2": "启用 HTTP/2", "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index e4b200e89..b3768101c 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "站台標題不能為空", "verboseProviderError": "詳細供應商錯誤資訊", - "verboseProviderErrorDesc": "開啟後,在部分上游異常場景(例如所有供應商不可用、偵測到假200或空回應等)返回更詳細錯誤資訊(可能包含上游回應片段);關閉後僅返回簡潔錯誤碼。", + "verboseProviderErrorDesc": "開啟後,在部分上游異常場景(例如所有供應商不可用、偵測到假200或空回應等)返回更詳細錯誤資訊(可能包含上游回應片段,且可能含敏感資訊);僅建議在可信環境啟用。關閉後僅返回簡潔錯誤碼。", "timezoneLabel": "系統時區", "timezoneDescription": "設定系統時區,用於統一後端時間邊界計算和前端日期/時間顯示。留空時使用環境變數 TZ 或預設 UTC。", "timezoneAuto": "自動 (使用環境變數 TZ)", From b56b79019452eefaa9d8e58bfa8e79796c1d009b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 10:29:03 +0800 Subject: [PATCH 18/23] =?UTF-8?q?fix(proxy):=20verboseProviderError=20rawB?= =?UTF-8?q?ody=20=E5=9F=BA=E7=A1=80=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/error-handler.ts | 10 +++++-- src/lib/utils/upstream-error-detection.ts | 2 +- ...ler-verbose-provider-error-details.test.ts | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index 08fa5bb6f..b8ef72569 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -7,6 +7,7 @@ import { } from "@/lib/error-override-validator"; import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; +import { sanitizeErrorTextForDetail } from "@/lib/utils/upstream-error-detection"; import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message"; import { attachSessionIdToErrorResponse } from "./error-session-id"; import { @@ -240,7 +241,8 @@ export class ProxyErrorHandler { // verboseProviderError(系统设置)开启时:对“假 200/空响应”等上游异常返回更详细的报告,便于排查。 // 注意: // - 该逻辑放在 error override 之后:确保优先级更低,不覆盖用户自定义覆写。 - // - 仅回传在内存中短暂保留的原文(rawBody),不写入数据库/决策链,避免泄露与持久化污染。 + // - rawBody 仅用于本次错误响应回传(受系统设置控制),不写入数据库/决策链; + // - 出于安全考虑,这里会对 rawBody 做基础脱敏(Bearer/key/JWT/email 等),避免上游错误页意外回显敏感信息。 let details: Record | undefined; let upstreamRequestId: string | undefined; const shouldAttachVerboseDetails = @@ -254,12 +256,16 @@ export class ProxyErrorHandler { if (settings.verboseProviderError) { if (error instanceof ProxyError) { upstreamRequestId = error.upstreamError?.requestId; + const rawBody = + typeof error.upstreamError?.rawBody === "string" && error.upstreamError.rawBody + ? sanitizeErrorTextForDetail(error.upstreamError.rawBody) + : error.upstreamError?.rawBody; details = { upstreamError: { kind: "fake_200", code: error.message, clientSafeMessage: error.getClientSafeMessage(), - rawBody: error.upstreamError?.rawBody, + rawBody, rawBodyTruncated: error.upstreamError?.rawBodyTruncated ?? false, }, }; diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index c97ba8ffa..9b5682226 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -104,7 +104,7 @@ function hasNonEmptyValue(value: unknown): boolean { return true; } -function sanitizeErrorTextForDetail(text: string): string { +export function sanitizeErrorTextForDetail(text: string): string { // 注意:这里的目的不是“完美脱敏”,而是尽量降低上游错误信息中意外夹带敏感内容的风险。 // 若后续发现更多敏感模式,可在不改变检测语义的前提下补充。 let sanitized = text; diff --git a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts index 59ff900ef..0b9b406de 100644 --- a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts +++ b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts @@ -99,6 +99,36 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { }); }); + test("verboseProviderError=true 时,rawBody 应做基础脱敏(避免泄露 token/key)", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + + const session = createSession(); + const err = new ProxyError("FAKE_200_HTML_BODY", 502, { + body: "redacted snippet", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: + 'Authorization: Bearer abc123 sk-1234567890abcdef1234567890 test@example.com', + rawBodyTruncated: false, + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(502); + + const body = await res.json(); + expect(body.request_id).toBe("req_123"); + expect(body.error.details.upstreamError.kind).toBe("fake_200"); + + const rawBody = body.error.details.upstreamError.rawBody as string; + expect(rawBody).toContain("Bearer [REDACTED]"); + expect(rawBody).toContain("[REDACTED_KEY]"); + expect(rawBody).toContain("[EMAIL]"); + expect(rawBody).not.toContain("Bearer abc123"); + expect(rawBody).not.toContain("sk-1234567890abcdef1234567890"); + expect(rawBody).not.toContain("test@example.com"); + }); + test("verboseProviderError=true 时,空响应错误也应返回详细报告(rawBody 为空字符串)", async () => { mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); From bb7649aaafb48738159a42425f73fb49ecf4afde Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Feb 2026 02:29:38 +0000 Subject: [PATCH 19/23] chore: format code (fix-issue-749-fake-200-html-detection-b56b790) --- .../proxy/error-handler-verbose-provider-error-details.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts index 0b9b406de..39c70bd0a 100644 --- a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts +++ b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts @@ -109,7 +109,7 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { providerName: "p1", requestId: "req_123", rawBody: - 'Authorization: Bearer abc123 sk-1234567890abcdef1234567890 test@example.com', + "Authorization: Bearer abc123 sk-1234567890abcdef1234567890 test@example.com", rawBodyTruncated: false, }); From 5794c6db1d46a5f7d3c9cf61e1b19f8d1409104e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 10:37:57 +0800 Subject: [PATCH 20/23] =?UTF-8?q?docs(settings):=20=E8=AF=B4=E6=98=8E=20ve?= =?UTF-8?q?rboseProviderError=20=E5=9F=BA=E7=A1=80=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en/settings/config.json | 2 +- messages/ja/settings/config.json | 2 +- messages/ru/settings/config.json | 2 +- messages/zh-CN/settings/config.json | 2 +- messages/zh-TW/settings/config.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 9214034a3..e25f5d9f4 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "e.g. Claude Code Hub", "siteTitleRequired": "Site title cannot be empty", "verboseProviderError": "Verbose Provider Error", - "verboseProviderErrorDesc": "When enabled, return more detailed provider error information in some upstream failure scenarios (e.g. all providers unavailable, fake 200, or empty responses). It may include upstream response excerpts in error responses (potentially sensitive); enable only in trusted environments. When disabled, only return a simple error code.", + "verboseProviderErrorDesc": "When enabled, return more detailed provider error information in some upstream failure scenarios (e.g. all providers unavailable, fake 200, or empty responses). It may include upstream response excerpts in error responses with basic redaction, but may still contain sensitive content; enable only in trusted environments. When disabled, only return a simple error code.", "timezoneLabel": "System Timezone", "timezoneDescription": "Set the system timezone for unified backend time boundary calculations and frontend date/time display. Leave empty to use the TZ environment variable or default to UTC.", "timezoneAuto": "Auto (use TZ env variable)", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 124482e90..e0fd98167 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "サイトタイトルは空にできません", "verboseProviderError": "詳細なプロバイダーエラー", - "verboseProviderErrorDesc": "有効にすると、一部の上流障害シナリオ(例:すべてのプロバイダーが利用不可、偽200、空のレスポンスなど)で、より詳細なエラー情報を返します。エラー応答に上流レスポンスの抜粋(機密情報を含む可能性あり)が含まれる場合があるため、信頼できる環境でのみ有効化してください。無効の場合は簡潔なエラーコードのみを返します。", + "verboseProviderErrorDesc": "有効にすると、一部の上流障害シナリオ(例:すべてのプロバイダーが利用不可、偽200、空のレスポンスなど)で、より詳細なエラー情報を返します。エラー応答に上流レスポンスの抜粋(基本的なマスキング済み、機密情報を含む可能性あり)が含まれる場合があるため、信頼できる環境でのみ有効化してください。無効の場合は簡潔なエラーコードのみを返します。", "timezoneLabel": "システムタイムゾーン", "timezoneDescription": "バックエンドの時間境界計算とフロントエンドの日付/時刻表示を統一するためのシステムタイムゾーンを設定します。空のままにすると環境変数 TZ またはデフォルトの UTC が使用されます。", "timezoneAuto": "自動 (環境変数 TZ を使用)", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 40df621e9..51699cabb 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "например: Claude Code Hub", "siteTitleRequired": "Название сайта не может быть пустым", "verboseProviderError": "Подробные ошибки провайдеров", - "verboseProviderErrorDesc": "При включении возвращает более подробную информацию об ошибках провайдеров в некоторых сценариях (например, когда все провайдеры недоступны, «fake 200» или пустой ответ). В ответе об ошибке могут присутствовать фрагменты ответа апстрима (потенциально содержащие чувствительные данные); включайте только в доверенной среде. При отключении возвращает только простой код ошибки.", + "verboseProviderErrorDesc": "При включении возвращает более подробную информацию об ошибках провайдеров в некоторых сценариях (например, когда все провайдеры недоступны, «fake 200» или пустой ответ). В ответе об ошибке могут присутствовать фрагменты ответа апстрима (с базовым редактированием, но потенциально содержащие чувствительные данные); включайте только в доверенной среде. При отключении возвращает только простой код ошибки.", "timezoneLabel": "Системная Временная Зона", "timezoneDescription": "Установите системную временную зону для единых вычислений временных границ в бэкенде и отображения даты/времени в интерфейсе. Оставьте пустым для использования переменной окружения TZ или UTC по умолчанию.", "timezoneAuto": "Авто (использовать переменную окружения TZ)", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index e46a40830..cea26f994 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -33,7 +33,7 @@ "allowGlobalView": "允许查看全站使用量", "allowGlobalViewDesc": "关闭后,普通用户在仪表盘仅能查看自己密钥的使用统计。", "verboseProviderError": "详细供应商错误信息", - "verboseProviderErrorDesc": "开启后,在部分上游异常场景(例如所有供应商不可用、检测到假200或空响应等)返回更详细错误信息(可能包含上游响应片段,且可能含敏感信息);仅建议在可信环境启用。关闭后仅返回简洁错误码。", + "verboseProviderErrorDesc": "开启后,在部分上游异常场景(例如所有供应商不可用、检测到假200或空响应等)返回更详细错误信息(可能包含上游响应片段,已做基础脱敏,但仍可能含敏感信息);仅建议在可信环境启用。关闭后仅返回简洁错误码。", "enableHttp2": "启用 HTTP/2", "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index b3768101c..537454696 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "站台標題不能為空", "verboseProviderError": "詳細供應商錯誤資訊", - "verboseProviderErrorDesc": "開啟後,在部分上游異常場景(例如所有供應商不可用、偵測到假200或空回應等)返回更詳細錯誤資訊(可能包含上游回應片段,且可能含敏感資訊);僅建議在可信環境啟用。關閉後僅返回簡潔錯誤碼。", + "verboseProviderErrorDesc": "開啟後,在部分上游異常場景(例如所有供應商不可用、偵測到假200或空回應等)返回更詳細錯誤資訊(可能包含上游回應片段,已做基礎脫敏,但仍可能含敏感資訊);僅建議在可信環境啟用。關閉後僅返回簡潔錯誤碼。", "timezoneLabel": "系統時區", "timezoneDescription": "設定系統時區,用於統一後端時間邊界計算和前端日期/時間顯示。留空時使用環境變數 TZ 或預設 UTC。", "timezoneAuto": "自動 (使用環境變數 TZ)", From 63523bc92078ac5681c8390ee71e35a7af4b7856 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 23:13:58 +0800 Subject: [PATCH 21/23] =?UTF-8?q?fix(proxy/logs):=20=E5=81=87200=20?= =?UTF-8?q?=E6=8E=A8=E6=96=AD=E7=8A=B6=E6=80=81=E7=A0=81=E5=B9=B6=E6=98=BE?= =?UTF-8?q?=E8=91=97=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en/dashboard.json | 3 + messages/en/provider-chain.json | 1 + messages/ja/dashboard.json | 3 + messages/ja/provider-chain.json | 1 + messages/ru/dashboard.json | 3 + messages/ru/provider-chain.json | 1 + messages/zh-CN/dashboard.json | 3 + messages/zh-CN/provider-chain.json | 1 + messages/zh-TW/dashboard.json | 3 + messages/zh-TW/provider-chain.json | 1 + .../components/LogicTraceTab.tsx | 4 +- .../provider-chain-popover.test.tsx | 22 +++ .../_components/provider-chain-popover.tsx | 44 ++++-- src/app/v1/_lib/proxy/error-handler.ts | 8 +- src/app/v1/_lib/proxy/errors.ts | 38 +++-- src/app/v1/_lib/proxy/forwarder.ts | 20 ++- src/app/v1/_lib/proxy/response-handler.ts | 32 ++++- src/app/v1/_lib/proxy/session.ts | 2 + .../utils/provider-chain-formatter.test.ts | 9 ++ src/lib/utils/provider-chain-formatter.ts | 22 ++- .../utils/upstream-error-detection.test.ts | 65 ++++++++- src/lib/utils/upstream-error-detection.ts | 132 ++++++++++++++++++ src/types/message.ts | 9 ++ ...ler-verbose-provider-error-details.test.ts | 25 +++- .../proxy-forwarder-fake-200-html.test.ts | 59 ++++++++ ...handler-endpoint-circuit-isolation.test.ts | 22 ++- 26 files changed, 483 insertions(+), 50 deletions(-) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 88e6d7b2f..6bd2beb95 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -253,6 +253,9 @@ "jsonMessageKeywordMatch": "JSON `message` contains the word \"error\" (heuristic)", "unknown": "Response body indicates an error" }, + "statusCodeInferredBadge": "Inferred", + "statusCodeInferredTooltip": "This status code is inferred from response body content (e.g., fake 200) and may differ from the upstream HTTP status.", + "statusCodeInferredSuffix": "(inferred)", "filteredProviders": "Filtered Providers", "providerChain": { "title": "Provider Decision Chain Timeline", diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 9c3080647..bf9cb81a7 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -136,6 +136,7 @@ "nthAttempt": "Attempt {attempt}", "provider": "Provider: {provider}", "statusCode": "Status Code: {code}", + "statusCodeInferred": "Status Code (inferred): {code}", "error": "Error: {error}", "requestDuration": "Request Duration: {duration}ms", "requestDurationSeconds": "Request Duration: {duration}s", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index c5c1e48cf..1d2729f76 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -253,6 +253,9 @@ "jsonMessageKeywordMatch": "JSON の `message` に \"error\" が含まれています (ヒューリスティック)", "unknown": "レスポンス本文がエラーを示しています" }, + "statusCodeInferredBadge": "推定", + "statusCodeInferredTooltip": "このステータスコードは応答本文の内容(例: fake 200)から推定されており、上流の HTTP ステータスと異なる場合があります。", + "statusCodeInferredSuffix": "(推定)", "filteredProviders": "フィルタされたプロバイダー", "providerChain": { "title": "プロバイダー決定チェーンタイムライン", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index f41566ad3..d8e55285b 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -136,6 +136,7 @@ "nthAttempt": "試行{attempt}", "provider": "プロバイダー: {provider}", "statusCode": "ステータスコード: {code}", + "statusCodeInferred": "ステータスコード(推定): {code}", "error": "エラー: {error}", "requestDuration": "リクエスト時間: {duration}ms", "requestDurationSeconds": "リクエスト時間: {duration}s", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 69be32873..867d3449a 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -253,6 +253,9 @@ "jsonMessageKeywordMatch": "В JSON `message` содержит слово \"error\" (эвристика)", "unknown": "Тело ответа указывает на ошибку" }, + "statusCodeInferredBadge": "Предположено", + "statusCodeInferredTooltip": "Этот код состояния выведен по содержимому тела ответа (например, fake 200) и может отличаться от HTTP-кода апстрима.", + "statusCodeInferredSuffix": "(предп.)", "filteredProviders": "Отфильтрованные поставщики", "providerChain": { "title": "Хронология цепочки решений поставщика", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index 0ced02a18..10019665b 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -136,6 +136,7 @@ "nthAttempt": "Попытка {attempt}", "provider": "Провайдер: {provider}", "statusCode": "Код состояния: {code}", + "statusCodeInferred": "Код состояния (выведено): {code}", "error": "Ошибка: {error}", "requestDuration": "Длительность запроса: {duration}мс", "requestDurationSeconds": "Длительность запроса: {duration}с", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 0d443f5f8..b28281175 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -253,6 +253,9 @@ "jsonMessageKeywordMatch": "JSON message 字段包含 \"error\"(启发式)", "unknown": "响应体内容指示错误" }, + "statusCodeInferredBadge": "推测", + "statusCodeInferredTooltip": "该状态码根据响应体内容推断(例如假200),可能与上游真实 HTTP 状态码不同。", + "statusCodeInferredSuffix": "(推测)", "filteredProviders": "被过滤的供应商", "providerChain": { "title": "供应商决策链时间线", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index b500b083f..dfe3daad7 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -136,6 +136,7 @@ "nthAttempt": "第 {attempt} 次尝试", "provider": "供应商: {provider}", "statusCode": "状态码: {code}", + "statusCodeInferred": "状态码(推测): {code}", "error": "错误: {error}", "requestDuration": "请求耗时: {duration}ms", "requestDurationSeconds": "请求耗时: {duration}s", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 51cfa354b..73d0c1753 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -253,6 +253,9 @@ "jsonMessageKeywordMatch": "JSON message 欄位包含 \"error\"(啟發式)", "unknown": "回應本文內容顯示錯誤" }, + "statusCodeInferredBadge": "推測", + "statusCodeInferredTooltip": "此狀態碼係根據回應內容推測(例如假200),可能與上游真實 HTTP 狀態碼不同。", + "statusCodeInferredSuffix": "(推測)", "filteredProviders": "被過濾的供應商", "providerChain": { "title": "供應商決策鏈時間軸", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index b1720e220..c9846479c 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -136,6 +136,7 @@ "nthAttempt": "第 {attempt} 次嘗試", "provider": "供應商: {provider}", "statusCode": "狀態碼: {code}", + "statusCodeInferred": "狀態碼(推測): {code}", "error": "錯誤: {error}", "requestDuration": "請求耗時: {duration}ms", "requestDurationSeconds": "請求耗時: {duration}s", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index cb431cba8..79a7e501c 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -466,10 +466,10 @@ export function LogicTraceTab({ subtitle={ isSessionReuse ? item.statusCode - ? `HTTP ${item.statusCode}` + ? `HTTP ${item.statusCode}${item.statusCodeInferred ? ` ${t("statusCodeInferredSuffix")}` : ""}` : item.name : item.statusCode - ? `HTTP ${item.statusCode}` + ? `HTTP ${item.statusCode}${item.statusCodeInferred ? ` ${t("statusCodeInferredSuffix")}` : ""}` : item.reason ? tChain(`reasons.${item.reason}`) : undefined diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx index faf2c6be6..c93284a78 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx @@ -87,6 +87,9 @@ const messages = { clickStatusCode: "Click status code", fake200ForwardedNotice: "Note: payload may have been forwarded", fake200DetectedReason: "Detected reason: {reason}", + statusCodeInferredBadge: "Inferred", + statusCodeInferredTooltip: "This status code is inferred from response body content.", + statusCodeInferredSuffix: "(inferred)", fake200Reasons: { emptyBody: "Empty response body", htmlBody: "HTML document returned", @@ -285,6 +288,25 @@ describe("provider-chain-popover layout", () => { expect(html).toContain("Note: payload may have been forwarded"); }); + test("renders inferred status code badge when statusCodeInferred=true", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Inferred"); + }); + test("requestCount<=1 branch keeps truncation container shrinkable", () => { const html = renderWithIntl( item.reason === "session_reuse" || item.selectionMethod === "session_reuse" ); const sessionReuseContext = sessionReuseItem?.decisionContext; + const singleRequestItem = chain.find(isActualRequest); return (
@@ -195,6 +190,30 @@ export function ProviderChainPopover({
{/* Provider name */}
{displayName}
+ {singleRequestItem?.statusCode && ( +
+ = 200 && singleRequestItem.statusCode < 300 + ? "border-emerald-500 text-emerald-600" + : "border-rose-500 text-rose-600" + )} + > + {singleRequestItem.statusCode} + + {singleRequestItem.statusCodeInferred && ( + + {t("logs.details.statusCodeInferredBadge")} + + )} +
+ )} {/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端。 */} {hasFake200PostStreamFailure && ( @@ -496,6 +515,15 @@ export function ProviderChainPopover({ {item.statusCode} )} + {item.statusCode && item.statusCodeInferred && ( + + {t("logs.details.statusCodeInferredBadge")} + + )} {item.reason && !item.statusCode && ( {tChain(`reasons.${item.reason}`)} diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index b8ef72569..4ec42b069 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -246,9 +246,7 @@ export class ProxyErrorHandler { let details: Record | undefined; let upstreamRequestId: string | undefined; const shouldAttachVerboseDetails = - (error instanceof ProxyError && - error.statusCode === 502 && - error.message.startsWith("FAKE_200_")) || + (error instanceof ProxyError && error.message.startsWith("FAKE_200_")) || isEmptyResponseError(error); if (shouldAttachVerboseDetails) { @@ -264,6 +262,10 @@ export class ProxyErrorHandler { upstreamError: { kind: "fake_200", code: error.message, + statusCode: error.statusCode, + statusCodeInferred: error.upstreamError?.statusCodeInferred ?? false, + statusCodeInferenceMatcherId: + error.upstreamError?.statusCodeInferenceMatcherId ?? null, clientSafeMessage: error.getClientSafeMessage(), rawBody, rawBodyTruncated: error.upstreamError?.rawBodyTruncated ?? false, diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index bfc3f420d..ed59b5659 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -41,6 +41,18 @@ export class ProxyError extends Error { */ rawBody?: string; rawBodyTruncated?: boolean; + + /** + * 标记该 ProxyError 的 statusCode 是否由“响应体内容”推断得出(而非上游真实 HTTP 状态码)。 + * + * 典型场景:上游返回 HTTP 200,但 body 为错误页/错误 JSON(假 200)。此时 CCH 会根据响应体内容推断更贴近语义的 4xx/5xx, + * 以便让故障转移/熔断/会话绑定逻辑与“真实上游错误状态码”保持一致。 + */ + statusCodeInferred?: boolean; + /** + * 命中的推断规则 id(仅用于内部调试/审计,不应用于用户展示文案)。 + */ + statusCodeInferenceMatcherId?: string; } ) { super(message); @@ -466,9 +478,9 @@ export class ProxyError extends Error { */ getClientSafeMessage(): string { // 注意:一些内部检测/统计用的“错误码”(例如 FAKE_200_*)不适合直接暴露给客户端。 - // 这里做最小映射:仅在 502 且 message 为 FAKE_200_* 时返回“可读原因说明”,并附带安全的上游片段(若有)。 - if (this.statusCode === 502 && this.message.startsWith("FAKE_200_")) { - // 说明:这些 code 都来自内部的“假 200”检测,代表:HTTP 状态码显示成功,但响应体内容更像错误页/错误 JSON。 + // 这里做最小映射:当 message 为 FAKE_200_* 时返回“可读原因说明”,并附带安全的上游片段(若有)。 + if (this.message.startsWith("FAKE_200_")) { + // 说明:这些 code 都来自内部的“假 200”检测,代表:上游返回 HTTP 200,但响应体内容更像错误页/错误 JSON。 // 我们需要: // 1) 给用户清晰的错误原因(避免只看到一个内部 code); // 2) 不泄露内部错误码/供应商名称; @@ -476,20 +488,24 @@ export class ProxyError extends Error { const reason = (() => { switch (this.message) { case "FAKE_200_EMPTY_BODY": - return "Upstream returned a successful HTTP status, but the response body was empty."; + return "Upstream returned HTTP 200, but the response body was empty."; case "FAKE_200_HTML_BODY": - return "Upstream returned a successful HTTP status, but the response body looks like an HTML document (likely an error page)."; + return "Upstream returned HTTP 200, but the response body looks like an HTML document (likely an error page)."; case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY": - return "Upstream returned a successful HTTP status, but the JSON body contains a non-empty `error.message`."; + return "Upstream returned HTTP 200, but the JSON body contains a non-empty `error.message`."; case "FAKE_200_JSON_ERROR_NON_EMPTY": - return "Upstream returned a successful HTTP status, but the JSON body contains a non-empty `error` field."; + return "Upstream returned HTTP 200, but the JSON body contains a non-empty `error` field."; case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH": - return "Upstream returned a successful HTTP status, but the JSON `message` suggests an error (heuristic)."; + return "Upstream returned HTTP 200, but the JSON `message` suggests an error (heuristic)."; default: - return "Upstream returned a successful HTTP status, but the response body indicates an error."; + return "Upstream returned HTTP 200, but the response body indicates an error."; } })(); + const inferredNote = this.upstreamError?.statusCodeInferred + ? ` Inferred HTTP status: ${this.statusCode}.` + : ""; + const detail = this.upstreamError?.body?.trim(); if (detail) { // 注意:对 FAKE_200_* 路径,我们期望 upstreamError.body 来自内部检测得到的“脱敏 + 截断片段”(详见 upstream-error-detection.ts)。 @@ -507,10 +523,10 @@ export class ProxyError extends Error { .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]") .replace(/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/giu, "[REDACTED_KEY]") .replace(/\bAIza[0-9A-Za-z_-]{16,}\b/g, "[REDACTED_KEY]"); - return `${reason} Upstream detail: ${safe}`; + return `${reason}${inferredNote} Upstream detail: ${safe}`; } - return reason; + return `${reason}${inferredNote}`; } return this.message; diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 814e6e14f..2842dd174 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -25,7 +25,10 @@ import { import { getGlobalAgentPool, getProxyAgentForProvider } from "@/lib/proxy-agent"; import { SessionManager } from "@/lib/session-manager"; import { CONTEXT_1M_BETA_HEADER, shouldApplyContext1m } from "@/lib/special-attributes"; -import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; +import { + detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText, +} from "@/lib/utils/upstream-error-detection"; import { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout, @@ -809,7 +812,10 @@ export class ProxyForwarder { detected.code === "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY"); if (isStrongFake200) { - throw new ProxyError(detected.code, 502, { + const inferredStatus = inferUpstreamErrorStatusCodeFromText(inspectedText); + const inferredStatusCode = inferredStatus?.statusCode; + + throw new ProxyError(detected.code, inferredStatusCode ?? 502, { body: detected.detail ?? "", providerId: currentProvider.id, providerName: currentProvider.name, @@ -817,6 +823,8 @@ export class ProxyForwarder { // 不参与规则匹配/持久化,避免污染数据库或误触发覆写规则。 rawBody: inspectedText, rawBodyTruncated: inspectedTruncated, + statusCodeInferred: inferredStatusCode !== undefined, + statusCodeInferenceMatcherId: inferredStatus?.matcherId, }); } } @@ -1132,6 +1140,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage, statusCode: lastError.statusCode, + statusCodeInferred: lastError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1264,6 +1273,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage, statusCode: lastError.statusCode, + statusCodeInferred: lastError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1328,6 +1338,7 @@ export class ProxyForwarder { providerId: currentProvider.id, providerName: currentProvider.name, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, error: errorMessage, attemptNumber: attemptCount, totalProvidersAttempted, @@ -1344,6 +1355,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage: errorMessage, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1466,6 +1478,7 @@ export class ProxyForwarder { providerId: currentProvider.id, providerName: currentProvider.name, statusCode: 404, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, error: errorMessage, attemptNumber: attemptCount, totalProvidersAttempted, @@ -1480,6 +1493,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage: errorMessage, statusCode: 404, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1622,6 +1636,7 @@ export class ProxyForwarder { providerId: currentProvider.id, providerName: currentProvider.name, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, error: errorMessage, attemptNumber: attemptCount, totalProvidersAttempted, @@ -1641,6 +1656,7 @@ export class ProxyForwarder { circuitFailureCount: health.failureCount + 1, // 包含本次失败 circuitFailureThreshold: config.failureThreshold, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index fa7f9bfcb..aa92f508e 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -11,7 +11,10 @@ import { SessionTracker } from "@/lib/session-tracker"; import { calculateRequestCost } from "@/lib/utils/cost-calculation"; import { hasValidPriceData } from "@/lib/utils/price-data"; import { parseSSEData } from "@/lib/utils/sse"; -import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; +import { + detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText, +} from "@/lib/utils/upstream-error-detection"; import { updateMessageRequestCost, updateMessageRequestDetails, @@ -91,7 +94,8 @@ type FinalizeDeferredStreamingResult = { * - 如果内容看起来是上游错误 JSON(假 200),则: * - 计入熔断器失败; * - 不更新 session 智能绑定(避免把会话粘到坏 provider); - * - 内部状态码改为 502(只影响统计与后续重试选择,不影响本次客户端响应)。 + * - 内部状态码改为“推断得到的 4xx/5xx”(未命中则回退 502), + * 仅影响统计与后续重试选择,不影响本次客户端响应。 * - 如果流正常结束且未命中错误判定,则按成功结算并更新绑定/熔断/endpoint 成功率。 * * @param streamEndedNormally - 必须是 reader 读到 done=true 的“自然结束”;超时/中断等异常结束由其它逻辑处理。 @@ -122,12 +126,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( : ({ isError: false } as const); // “内部结算用”的状态码(不会改变客户端实际 HTTP 状态码)。 - // - 假 200:映射为 502,确保内部统计/熔断/会话绑定把它当作失败。 + // - 假 200:优先映射为“推断得到的 4xx/5xx”(未命中则回退 502),确保内部统计/熔断/会话绑定把它当作失败。 // - 未自然结束:也应映射为失败(避免把中断/部分流误记为 200 completed)。 let effectiveStatusCode: number; let errorMessage: string | null; + let statusCodeInferred = false; + let statusCodeInferenceMatcherId: string | undefined; if (detected.isError) { - effectiveStatusCode = 502; + const inferred = inferUpstreamErrorStatusCodeFromText(allContent); + if (inferred) { + effectiveStatusCode = inferred.statusCode; + statusCodeInferred = true; + statusCodeInferenceMatcherId = inferred.matcherId; + } else { + effectiveStatusCode = 502; + } errorMessage = detected.code; } else if (!streamEndedNormally) { effectiveStatusCode = clientAborted ? 499 : 502; @@ -233,6 +246,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerName: meta.providerName, upstreamStatusCode: meta.upstreamStatusCode, effectiveStatusCode, + statusCodeInferred, + statusCodeInferenceMatcherId: statusCodeInferenceMatcherId ?? null, code: detected.code, detail: detected.detail ?? null, }); @@ -255,14 +270,16 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // the error is in the response content, not endpoint connectivity. // 记录到决策链(用于日志展示与 DB 持久化)。 - // 注意:这里用 effectiveStatusCode(502)而不是 upstreamStatusCode(200), - // 以便让内部链路明确显示这是一次失败(否则会被误读为成功)。 + // 注意:这里用 effectiveStatusCode(推断得到的 4xx/5xx,或回退 502) + // 而不是 upstreamStatusCode(200),以便让内部链路明确显示这是一次失败 + // (否则会被误读为成功)。 session.addProviderToChain(providerForChain, { endpointId: meta.endpointId, endpointUrl: meta.endpointUrl, reason: "retry_failed", attemptNumber: meta.attemptNumber, statusCode: effectiveStatusCode, + statusCodeInferred, errorMessage: detected.code, }); @@ -2539,7 +2556,8 @@ async function updateRequestCostFromUsage( * 统一的请求统计处理方法 * 用于消除 Gemini 透传、普通非流式、普通流式之间的重复统计逻辑 * - * @param statusCode - 内部结算状态码(可能与客户端实际收到的 HTTP 状态不同,例如“假 200”会被映射为 502) + * @param statusCode - 内部结算状态码(可能与客户端实际收到的 HTTP 状态不同,例如“假 200”会被推断并映射为更贴近语义的 4xx/5xx; + * 未命中推断规则时回退为 502) * @param errorMessage - 可选的内部错误原因(用于把假 200/解析失败等信息写入 DB 与监控) */ export async function finalizeRequestStats( diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 22cf12dca..9747aaf8e 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -474,6 +474,7 @@ export class ProxySession { endpointUrl?: string; // 修复:添加新字段 statusCode?: number; // 成功时的状态码 + statusCodeInferred?: boolean; // statusCode 是否为响应体推断 circuitFailureCount?: number; // 熔断失败计数 circuitFailureThreshold?: number; // 熔断阈值 errorDetails?: ProviderChainItem["errorDetails"]; // 结构化错误详情 @@ -502,6 +503,7 @@ export class ProxySession { errorMessage: metadata?.errorMessage, // 记录错误信息 // 修复:记录新字段 statusCode: metadata?.statusCode, + statusCodeInferred: metadata?.statusCodeInferred, circuitFailureCount: metadata?.circuitFailureCount, circuitFailureThreshold: metadata?.circuitFailureThreshold, errorDetails: metadata?.errorDetails, // 结构化错误详情 diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index 507229aed..d1f9f6950 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -447,6 +447,15 @@ describe("resource_not_found", () => { expect(timeline).toContain("timeline.resourceNotFoundNote"); }); + test("renders inferred status code label when statusCodeInferred=true", () => { + const chain: ProviderChainItem[] = [{ ...baseNotFoundItem, statusCodeInferred: true }]; + const { timeline } = formatProviderTimeline(chain, mockT); + + expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]"); + expect(timeline).toContain("timeline.statusCodeInferred [code=404]"); + expect(timeline).toContain("timeline.resourceNotFoundNote"); + }); + test("degrades gracefully when errorDetails.provider is missing", () => { const chain: ProviderChainItem[] = [ { diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index dd7bc8664..98e3188f5 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -129,6 +129,16 @@ function translateCircuitState(state: string | undefined, t: (key: string) => st } } +function formatTimelineStatusCode( + item: ProviderChainItem, + code: number, + t: (key: string, values?: Record) => string +): string { + return item.statusCodeInferred + ? t("timeline.statusCodeInferred", { code }) + : t("timeline.statusCode", { code }); +} + /** * 辅助函数:获取错误码含义 */ @@ -457,7 +467,7 @@ export function formatProviderTimeline( if (item.errorDetails?.provider) { const p = item.errorDetails.provider; timeline += `${t("timeline.provider", { provider: p.name })}\n`; - timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`; timeline += `${t("timeline.error", { error: p.statusText })}\n`; // 计算请求耗时 @@ -476,7 +486,7 @@ export function formatProviderTimeline( } else { timeline += `${t("timeline.provider", { provider: item.name })}\n`; if (item.statusCode) { - timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`; } timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") }); } @@ -498,7 +508,7 @@ export function formatProviderTimeline( if (item.errorDetails?.provider) { const p = item.errorDetails.provider; timeline += `${t("timeline.provider", { provider: p.name })}\n`; - timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`; timeline += `${t("timeline.error", { error: p.statusText })}\n`; // 计算请求耗时 @@ -545,7 +555,7 @@ export function formatProviderTimeline( // 降级:使用 errorMessage timeline += `${t("timeline.provider", { provider: item.name })}\n`; if (item.statusCode) { - timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`; } timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") }); @@ -633,12 +643,12 @@ export function formatProviderTimeline( if (item.errorDetails?.provider) { const p = item.errorDetails.provider; timeline += `${t("timeline.provider", { provider: p.name })}\n`; - timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`; timeline += `${t("timeline.error", { error: p.statusText })}\n`; } else { timeline += `${t("timeline.provider", { provider: item.name })}\n`; if (item.statusCode) { - timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`; } timeline += `${t("timeline.error", { error: item.errorMessage || t("timeline.unknown") })}\n`; } diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts index 6ff642fba..957ef374b 100644 --- a/src/lib/utils/upstream-error-detection.test.ts +++ b/src/lib/utils/upstream-error-detection.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; -import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; +import { + detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText, +} from "@/lib/utils/upstream-error-detection"; describe("detectUpstreamErrorFromSseOrJsonText", () => { test("空响应体视为错误", () => { @@ -254,3 +257,63 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { expect(res.isError).toBe(false); }); }); + +describe("inferUpstreamErrorStatusCodeFromText", () => { + test("空文本不推断状态码", () => { + expect(inferUpstreamErrorStatusCodeFromText("")).toBeNull(); + expect(inferUpstreamErrorStatusCodeFromText(" \n\t ")).toBeNull(); + }); + + test("可从错误文本中推断 429(rate limit)", () => { + expect(inferUpstreamErrorStatusCodeFromText('{"error":"Rate limit exceeded"}')).toEqual({ + statusCode: 429, + matcherId: "rate_limit", + }); + }); + + test("可从错误文本中推断 401(invalid api key)", () => { + expect(inferUpstreamErrorStatusCodeFromText('{"error":"Invalid API key"}')).toEqual({ + statusCode: 401, + matcherId: "unauthorized", + }); + }); + + test("可从错误文本中推断 403(access denied)", () => { + expect(inferUpstreamErrorStatusCodeFromText("Access denied")).toEqual({ + statusCode: 403, + matcherId: "forbidden", + }); + }); + + test("可从错误文本中推断 402(billing hard limit)", () => { + expect(inferUpstreamErrorStatusCodeFromText("billing_hard_limit_reached")).toEqual({ + statusCode: 402, + matcherId: "payment_required", + }); + }); + + test("可从错误文本中推断 404(model not found)", () => { + expect(inferUpstreamErrorStatusCodeFromText("model not found")).toEqual({ + statusCode: 404, + matcherId: "not_found", + }); + }); + + test("可从错误文本中推断 413(payload too large)", () => { + expect(inferUpstreamErrorStatusCodeFromText("payload too large")).toEqual({ + statusCode: 413, + matcherId: "payload_too_large", + }); + }); + + test("可从错误文本中推断 415(unsupported media type)", () => { + expect(inferUpstreamErrorStatusCodeFromText("Unsupported Media Type")).toEqual({ + statusCode: 415, + matcherId: "unsupported_media_type", + }); + }); + + test("仅包含泛化 error 字样时不推断(避免误判)", () => { + expect(inferUpstreamErrorStatusCodeFromText('{"message":"some error happened"}')).toBeNull(); + }); +}); diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index 9b5682226..faca099b0 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -32,6 +32,22 @@ export type UpstreamErrorDetectionResult = detail?: string; }; +/** + * 基于“响应体文本内容”的状态码推断结果。 + * + * 设计目标(偏保守): + * - 仅用于“假 200”场景:上游返回 HTTP 200,但 body 明显是错误页/错误 JSON; + * - 用于把内部结算/熔断/故障转移的 statusCode 调整为更贴近真实错误语义的 4xx/5xx; + * - 若未命中任何规则,应保持调用方既有默认行为(通常回退为 502)。 + */ +export type UpstreamErrorStatusInferenceResult = { + statusCode: number; + /** + * 命中的规则 id(用于内部审计/调试;不应作为用户展示文案)。 + */ + matcherId: string; +}; + type DetectionOptions = { /** * 仅对小体积 JSON 启用 message 关键字检测,避免误判与无谓开销。 @@ -69,6 +85,122 @@ const HTML_DOC_SNIFF_MAX_CHARS = 1024; const HTML_DOCTYPE_RE = /^]/i; const HTML_HTML_TAG_RE = /^]/i; +// 状态码推断:为避免在极端大响应体上执行正则带来额外开销,仅取前缀做匹配。 +// 说明:对“假 200”错误页/错误 JSON 来说,关键错误信息通常会出现在前段。 +const STATUS_INFERENCE_MAX_CHARS = 64 * 1024; + +// 注意:这些正则只用于“假 200”场景,且仅在 detectUpstreamErrorFromSseOrJsonText 已判定 isError=true 时才会被调用。 +// 因此允许包含少量“关键词启发式”,但仍应尽量避免过宽匹配,降低误判导致“错误码误推断”的概率。 +const ERROR_STATUS_MATCHERS: Array<{ statusCode: number; matcherId: string; re: RegExp }> = [ + { + statusCode: 429, + matcherId: "rate_limit", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+429\b|\b429\s+too\s+many\s+requests\b|\btoo\s+many\s+requests\b|\brate\s*limit(?:ed|ing)?\b|\bthrottl(?:e|ed|ing)\b|\bretry-after\b|\bRESOURCE_EXHAUSTED\b|\bRequestLimitExceeded\b|\bThrottling(?:Exception)?\b|\bError\s*1015\b|超出频率|请求过于频繁|限流|稍后重试)/iu, + }, + { + statusCode: 402, + matcherId: "payment_required", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+402\b|\bpayment\s+required\b|\binsufficient\s+(?:balance|funds|credits)\b|\b(?:out\s+of|no)\s+credits\b|\binsufficient_balance\b|\bbilling_hard_limit_reached\b|\bcard\s+(?:declined|expired)\b|\bpayment\s+(?:method|failed)\b|余额不足|欠费|请充值|支付(?:失败|方式))/iu, + }, + { + statusCode: 401, + matcherId: "unauthorized", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+401\b|\bunauthori(?:sed|zed)\b|\bunauthenticated\b|\bauthentication\s+failed\b|\b(?:invalid|incorrect|missing)\s+api[-_ ]?key\b|\binvalid\s+token\b|\bexpired\s+token\b|\bsignature\s+(?:invalid|mismatch)\b|\bUNAUTHENTICATED\b|未授权|鉴权失败|密钥无效|token\s*过期)/iu, + }, + { + statusCode: 403, + matcherId: "forbidden", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+403\b|\bforbidden\b|\bpermission\s+denied\b|\baccess\s+denied\b|\bnot\s+allowed\b|\baccount\s+(?:disabled|suspended|banned)\b|\bnot\s+whitelisted\b|\bPERMISSION_DENIED\b|\bAccessDenied(?:Exception)?\b|\bError\s*1020\b|\b(?:region|country)\b[\s\S]{0,40}\b(?:not\s+supported|blocked)\b|地区不支持|禁止访问|无权限|权限不足|账号被封|地区(?:限制|屏蔽))/iu, + }, + { + statusCode: 404, + matcherId: "not_found", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+404\b|\bnot\s+found\b|\b(?:model|deployment|endpoint|resource)\s+not\s+found\b|\bunknown\s+model\b|\bdoes\s+not\s+exist\b|\bNOT_FOUND\b|\bResourceNotFoundException\b|未找到|不存在|模型不存在)/iu, + }, + { + statusCode: 413, + matcherId: "payload_too_large", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+413\b|\bpayload\s+too\s+large\b|\brequest\s+entity\s+too\s+large\b|\bbody\s+too\s+large\b|\bContent-Length\b[\s\S]{0,40}\btoo\s+large\b|\bexceed(?:s|ed)?\b[\s\S]{0,40}\b(?:max(?:imum)?|limit)\b[\s\S]{0,40}\b(?:size|length)\b|请求体过大|内容过大|超过最大)/iu, + }, + { + statusCode: 415, + matcherId: "unsupported_media_type", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+415\b|\bunsupported\s+media\s+type\b|\binvalid\s+content-type\b|\bContent-Type\b[\s\S]{0,40}\b(?:must\s+be|required)\b|不支持的媒体类型|Content-Type\s*错误)/iu, + }, + { + statusCode: 409, + matcherId: "conflict", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+409\b|\bconflict\b|\bidempotency(?:-key)?\b|\bABORTED\b|冲突|幂等)/iu, + }, + { + statusCode: 422, + matcherId: "unprocessable_entity", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+422\b|\bunprocessable\s+entity\b|\bINVALID_ARGUMENT\b[\s\S]{0,40}\bvalidation\b|\bschema\s+validation\b|实体无法处理)/iu, + }, + { + statusCode: 408, + matcherId: "request_timeout", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+408\b|\brequest\s+timeout\b|请求\s*超时)/iu, + }, + { + statusCode: 451, + matcherId: "legal_restriction", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+451\b|\bunavailable\s+for\s+legal\s+reasons\b|\bexport\s+control\b|\bsanctions?\b|法律原因不可用|合规限制|出口管制)/iu, + }, + { + statusCode: 503, + matcherId: "service_unavailable", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+503\b|\bservice\s+unavailable\b|\boverloaded\b|\bserver\s+is\s+busy\b|\btry\s+again\s+later\b|\btemporarily\s+unavailable\b|\bmaintenance\b|\bUNAVAILABLE\b|\bServiceUnavailableException\b|\bError\s*521\b|服务不可用|过载|系统繁忙|维护中)/iu, + }, + { + statusCode: 504, + matcherId: "gateway_timeout", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+504\b|\bgateway\s+timeout\b|\bupstream\b[\s\S]{0,40}\btim(?:e|ed)\s*out\b|\bDEADLINE_EXCEEDED\b|\bError\s*522\b|\bError\s*524\b|网关超时|上游超时)/iu, + }, + { + statusCode: 500, + matcherId: "internal_server_error", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+500\b|\binternal\s+server\s+error\b|\bInternalServerException\b|\bINTERNAL\b|内部错误|服务器错误)/iu, + }, + { + statusCode: 400, + matcherId: "bad_request", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+400\b|\bbad\s+request\b|\bINVALID_ARGUMENT\b|\bjson\s+parse\b|\binvalid\s+json\b|\bunexpected\s+token\b|无效请求|格式错误|JSON\s*解析失败)/iu, + }, +]; + +/** + * 从上游响应体文本中推断一个“更贴近错误语义”的 HTTP 状态码(用于假200修正)。 + * + * 注意: + * - 该函数不会判断“是否为错误”,只做“状态码推断”;调用方应确保仅在已判定错误时才调用。 + * - 未命中时返回 null,调用方应保持现有默认错误码(通常为 502)。 + */ +export function inferUpstreamErrorStatusCodeFromText( + text: string +): UpstreamErrorStatusInferenceResult | null { + let trimmed = text.trim(); + if (!trimmed) return null; + + // 与 detectUpstreamErrorFromSseOrJsonText 保持一致:移除 UTF-8 BOM,避免关键字匹配失效。 + if (trimmed.charCodeAt(0) === 0xfeff) { + trimmed = trimmed.slice(1).trimStart(); + } + + const limited = + trimmed.length > STATUS_INFERENCE_MAX_CHARS + ? trimmed.slice(0, STATUS_INFERENCE_MAX_CHARS) + : trimmed; + + for (const matcher of ERROR_STATUS_MATCHERS) { + if (matcher.re.test(limited)) { + return { statusCode: matcher.statusCode, matcherId: matcher.matcherId }; + } + } + + return null; +} + /** * 判断文本是否“很像”一个完整的 HTML 文档(强信号)。 * diff --git a/src/types/message.ts b/src/types/message.ts index 56fab4abd..faa2e3f6f 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -71,6 +71,15 @@ export interface ProviderChainItem { // 修复:新增成功时的状态码 statusCode?: number; + /** + * 标记 statusCode 是否为“基于响应体内容推断”的结果(而非上游真实返回的 HTTP 状态码)。 + * + * 典型场景:上游返回 HTTP 200,但 body 为错误页/错误 JSON(假 200)。 + * 此时为了让熔断/故障转移/会话绑定与“真实错误语义”保持一致,CCH 会推断更合理的 4xx/5xx。 + * + * 该字段用于在决策链 / 技术时间线 / UI 中显著提示“此状态码为推断”,避免误读。 + */ + statusCodeInferred?: boolean; // 模型重定向信息(在供应商级别记录) modelRedirect?: { diff --git a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts index 39c70bd0a..c4c01dece 100644 --- a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts +++ b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts @@ -53,17 +53,19 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { test("verboseProviderError=false 时,不应附带 fake-200 raw body/details", async () => { const session = createSession(); - const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, { + const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 429, { body: "sanitized", providerId: 1, providerName: "p1", requestId: "req_123", rawBody: '{"error":"boom"}', rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", }); const res = await ProxyErrorHandler.handle(session, err); - expect(res.status).toBe(502); + expect(res.status).toBe(429); const body = await res.json(); expect(body.error.details).toBeUndefined(); @@ -74,17 +76,19 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); const session = createSession(); - const err = new ProxyError("FAKE_200_HTML_BODY", 502, { + const err = new ProxyError("FAKE_200_HTML_BODY", 429, { body: "redacted snippet", providerId: 1, providerName: "p1", requestId: "req_123", rawBody: "blocked", rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", }); const res = await ProxyErrorHandler.handle(session, err); - expect(res.status).toBe(502); + expect(res.status).toBe(429); const body = await res.json(); expect(body.request_id).toBe("req_123"); @@ -92,6 +96,9 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { upstreamError: { kind: "fake_200", code: "FAKE_200_HTML_BODY", + statusCode: 429, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", clientSafeMessage: expect.any(String), rawBody: "blocked", rawBodyTruncated: false, @@ -103,7 +110,7 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); const session = createSession(); - const err = new ProxyError("FAKE_200_HTML_BODY", 502, { + const err = new ProxyError("FAKE_200_HTML_BODY", 429, { body: "redacted snippet", providerId: 1, providerName: "p1", @@ -111,10 +118,12 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { rawBody: "Authorization: Bearer abc123 sk-1234567890abcdef1234567890 test@example.com", rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", }); const res = await ProxyErrorHandler.handle(session, err); - expect(res.status).toBe(502); + expect(res.status).toBe(429); const body = await res.json(); expect(body.request_id).toBe("req_123"); @@ -155,13 +164,15 @@ describe("ProxyErrorHandler.handle - verboseProviderError details", () => { mocks.getErrorOverrideAsync.mockResolvedValue({ response: null, statusCode: 418 }); const session = createSession(); - const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, { + const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 429, { body: "sanitized", providerId: 1, providerName: "p1", requestId: "req_123", rawBody: '{"error":"boom"}', rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", }); const res = await ProxyErrorHandler.handle(session, err); diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index adb19c60a..007ecb371 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -369,6 +369,65 @@ describe("ProxyForwarder - fake 200 HTML body", () => { expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); }); + test("假200 JSON error 命中 rate limit 关键字时,应推断为 429 并在决策链中标记为推断", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const jsonErrorBody = JSON.stringify({ error: "Rate limit exceeded" }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(jsonErrorBody, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(jsonErrorBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) + ); + + const failure = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure).toBeInstanceOf(ProxyError); + expect((failure as ProxyError).statusCode).toBe(429); + expect((failure as ProxyError).upstreamError?.statusCodeInferred).toBe(true); + + const chain = session.getProviderChain(); + expect( + chain.some( + (item) => + item.id === 1 && + item.reason === "retry_failed" && + item.statusCode === 429 && + item.statusCodeInferred === true + ) + ).toBe(true); + }); + test("200 + 非法 Content-Length 时应按缺失处理,避免漏检 HTML 假200", async () => { const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 16c531d24..599cece62 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -183,7 +183,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { ttfbMs: null, getRequestSequence: () => 1, addProviderToChain: function ( - this: ProxySession & { providerChain: unknown[] }, + this: ProxySession & { providerChain: Record[] }, prov: { id: number; name: string; @@ -193,7 +193,8 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { costMultiplier: number; groupTag: string; providerVendorId?: string; - } + }, + metadata?: Record ) { this.providerChain.push({ id: prov.id, @@ -204,7 +205,11 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { weight: prov.weight, costMultiplier: prov.costMultiplier, groupTag: prov.groupTag, - timestamp: Date.now(), + timestamp: + typeof metadata?.timestamp === "number" && Number.isFinite(metadata.timestamp) + ? metadata.timestamp + : Date.now(), + ...(metadata ?? {}), }); }, }); @@ -353,6 +358,17 @@ describe("Endpoint circuit breaker isolation", () => { expect.objectContaining({ message: expect.stringContaining("FAKE_200") }) ); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect( + chain.some( + (item) => + item.id === 1 && + item.reason === "retry_failed" && + item.statusCode === 401 && + item.statusCodeInferred === true + ) + ).toBe(true); }); it("non-200 HTTP status should call recordFailure but NOT recordEndpointFailure", async () => { From 8b39a0f2db2ec2a3d123d2ae65ad5bbd320e613c Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 23:44:54 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix(i18n):=20=E5=9B=9E=E9=80=80=20verbose?= =?UTF-8?q?ProviderErrorDesc=20=E5=8E=9F=E5=A7=8B=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en/settings/config.json | 2 +- messages/ja/settings/config.json | 2 +- messages/ru/settings/config.json | 2 +- messages/zh-CN/settings/config.json | 2 +- messages/zh-TW/settings/config.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index e25f5d9f4..c7bd7ae2b 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "e.g. Claude Code Hub", "siteTitleRequired": "Site title cannot be empty", "verboseProviderError": "Verbose Provider Error", - "verboseProviderErrorDesc": "When enabled, return more detailed provider error information in some upstream failure scenarios (e.g. all providers unavailable, fake 200, or empty responses). It may include upstream response excerpts in error responses with basic redaction, but may still contain sensitive content; enable only in trusted environments. When disabled, only return a simple error code.", + "verboseProviderErrorDesc": "When enabled, return detailed error messages when all providers are unavailable (including provider count, rate limit reasons, etc.); when disabled, only return a simple error code.", "timezoneLabel": "System Timezone", "timezoneDescription": "Set the system timezone for unified backend time boundary calculations and frontend date/time display. Leave empty to use the TZ environment variable or default to UTC.", "timezoneAuto": "Auto (use TZ env variable)", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index e0fd98167..7a9a6204e 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "サイトタイトルは空にできません", "verboseProviderError": "詳細なプロバイダーエラー", - "verboseProviderErrorDesc": "有効にすると、一部の上流障害シナリオ(例:すべてのプロバイダーが利用不可、偽200、空のレスポンスなど)で、より詳細なエラー情報を返します。エラー応答に上流レスポンスの抜粋(基本的なマスキング済み、機密情報を含む可能性あり)が含まれる場合があるため、信頼できる環境でのみ有効化してください。無効の場合は簡潔なエラーコードのみを返します。", + "verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。", "timezoneLabel": "システムタイムゾーン", "timezoneDescription": "バックエンドの時間境界計算とフロントエンドの日付/時刻表示を統一するためのシステムタイムゾーンを設定します。空のままにすると環境変数 TZ またはデフォルトの UTC が使用されます。", "timezoneAuto": "自動 (環境変数 TZ を使用)", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 51699cabb..a65535f31 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "например: Claude Code Hub", "siteTitleRequired": "Название сайта не может быть пустым", "verboseProviderError": "Подробные ошибки провайдеров", - "verboseProviderErrorDesc": "При включении возвращает более подробную информацию об ошибках провайдеров в некоторых сценариях (например, когда все провайдеры недоступны, «fake 200» или пустой ответ). В ответе об ошибке могут присутствовать фрагменты ответа апстрима (с базовым редактированием, но потенциально содержащие чувствительные данные); включайте только в доверенной среде. При отключении возвращает только простой код ошибки.", + "verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки.", "timezoneLabel": "Системная Временная Зона", "timezoneDescription": "Установите системную временную зону для единых вычислений временных границ в бэкенде и отображения даты/времени в интерфейсе. Оставьте пустым для использования переменной окружения TZ или UTC по умолчанию.", "timezoneAuto": "Авто (использовать переменную окружения TZ)", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index cea26f994..91c876140 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -33,7 +33,7 @@ "allowGlobalView": "允许查看全站使用量", "allowGlobalViewDesc": "关闭后,普通用户在仪表盘仅能查看自己密钥的使用统计。", "verboseProviderError": "详细供应商错误信息", - "verboseProviderErrorDesc": "开启后,在部分上游异常场景(例如所有供应商不可用、检测到假200或空响应等)返回更详细错误信息(可能包含上游响应片段,已做基础脱敏,但仍可能含敏感信息);仅建议在可信环境启用。关闭后仅返回简洁错误码。", + "verboseProviderErrorDesc": "开启后,当所有供应商不可用时返回详细错误信息(包含供应商数量、限流原因等);关闭后仅返回简洁错误码。", "enableHttp2": "启用 HTTP/2", "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 537454696..4a3c7ee01 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -77,7 +77,7 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "站台標題不能為空", "verboseProviderError": "詳細供應商錯誤資訊", - "verboseProviderErrorDesc": "開啟後,在部分上游異常場景(例如所有供應商不可用、偵測到假200或空回應等)返回更詳細錯誤資訊(可能包含上游回應片段,已做基礎脫敏,但仍可能含敏感資訊);僅建議在可信環境啟用。關閉後僅返回簡潔錯誤碼。", + "verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。", "timezoneLabel": "系統時區", "timezoneDescription": "設定系統時區,用於統一後端時間邊界計算和前端日期/時間顯示。留空時使用環境變數 TZ 或預設 UTC。", "timezoneAuto": "自動 (使用環境變數 TZ)", From 59c64308817c79deb67a5dc2d8441fd5d120c4c0 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Fri, 13 Feb 2026 00:12:03 +0800 Subject: [PATCH 23/23] =?UTF-8?q?fix(stream):=20404=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=B8=8D=E5=AD=98=E5=9C=A8=E4=B8=8D=E8=AE=A1=E5=85=A5=E7=86=94?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/v1/_lib/proxy/response-handler.ts | 57 +++++++++++-------- ...handler-endpoint-circuit-isolation.test.ts | 27 ++++++++- 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index aa92f508e..33f862e41 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -252,17 +252,23 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( detail: detected.detail ?? null, }); - // 计入熔断器:让后续请求能正确触发故障转移/熔断 - try { - // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(meta.providerId, new Error(detected.code)); - } catch (cbError) { - logger.warn("[ResponseHandler] Failed to record fake-200 error in circuit breaker", { - providerId: meta.providerId, - sessionId: session.sessionId ?? null, - error: cbError, - }); + const chainReason = effectiveStatusCode === 404 ? "resource_not_found" : "retry_failed"; + + // 计入熔断器:让后续请求能正确触发故障转移/熔断。 + // + // 注意:404 语义在 forwarder 中属于 RESOURCE_NOT_FOUND,不计入熔断器(避免把“资源/模型不存在”当作供应商故障)。 + if (effectiveStatusCode !== 404) { + try { + // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(meta.providerId, new Error(detected.code)); + } catch (cbError) { + logger.warn("[ResponseHandler] Failed to record fake-200 error in circuit breaker", { + providerId: meta.providerId, + sessionId: session.sessionId ?? null, + error: cbError, + }); + } } // NOTE: Do NOT call recordEndpointFailure here. Fake-200 errors are key-level @@ -276,7 +282,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( session.addProviderToChain(providerForChain, { endpointId: meta.endpointId, endpointUrl: meta.endpointUrl, - reason: "retry_failed", + reason: chainReason, attemptNumber: meta.attemptNumber, statusCode: effectiveStatusCode, statusCodeInferred, @@ -296,16 +302,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( errorMessage, }); - // 计入熔断器:让后续请求能正确触发故障转移/熔断 - try { - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(meta.providerId, new Error(errorMessage)); - } catch (cbError) { - logger.warn("[ResponseHandler] Failed to record non-200 error in circuit breaker", { - providerId: meta.providerId, - sessionId: session.sessionId ?? null, - error: cbError, - }); + const chainReason = effectiveStatusCode === 404 ? "resource_not_found" : "retry_failed"; + + // 计入熔断器:让后续请求能正确触发故障转移/熔断。 + // 注意:与 forwarder 口径保持一致:404 不计入熔断器(资源不存在不是供应商故障)。 + if (effectiveStatusCode !== 404) { + try { + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(meta.providerId, new Error(errorMessage)); + } catch (cbError) { + logger.warn("[ResponseHandler] Failed to record non-200 error in circuit breaker", { + providerId: meta.providerId, + sessionId: session.sessionId ?? null, + error: cbError, + }); + } } // NOTE: Do NOT call recordEndpointFailure here. Non-200 HTTP errors (401, 429, @@ -316,7 +327,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( session.addProviderToChain(providerForChain, { endpointId: meta.endpointId, endpointUrl: meta.endpointUrl, - reason: "retry_failed", + reason: chainReason, attemptNumber: meta.attemptNumber, statusCode: effectiveStatusCode, errorMessage: errorMessage, diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 599cece62..533f8247f 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -254,8 +254,8 @@ function setDeferredMeta(session: ProxySession, endpointId: number | null = 42) } /** Create an SSE stream that emits a fake-200 error body (valid HTTP 200 but error in content). */ -function createFake200StreamResponse(): Response { - const body = `data: ${JSON.stringify({ error: { message: "invalid api key" } })}\n\n`; +function createFake200StreamResponse(errorMessage: string = "invalid api key"): Response { + const body = `data: ${JSON.stringify({ error: { message: errorMessage } })}\n\n`; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { @@ -371,6 +371,29 @@ describe("Endpoint circuit breaker isolation", () => { ).toBe(true); }); + it("fake-200 inferred 404 should NOT call recordFailure and should be marked as resource_not_found", async () => { + const session = createSession(); + setDeferredMeta(session, 42); + + const response = createFake200StreamResponse("model not found"); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(mockRecordFailure).not.toHaveBeenCalled(); + expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect( + chain.some( + (item) => + item.id === 1 && + item.reason === "resource_not_found" && + item.statusCode === 404 && + item.statusCodeInferred === true + ) + ).toBe(true); + }); + it("non-200 HTTP status should call recordFailure but NOT recordEndpointFailure", async () => { const session = createSession(); // Set upstream status to 429 in deferred meta