diff --git a/src/app/v1/_lib/proxy/client-error-message.ts b/src/app/v1/_lib/proxy/client-error-message.ts index 2b52fcaa9..48c6603d2 100644 --- a/src/app/v1/_lib/proxy/client-error-message.ts +++ b/src/app/v1/_lib/proxy/client-error-message.ts @@ -7,8 +7,10 @@ import { ALL_PROVIDERS_UNAVAILABLE_MESSAGE } from "./errors"; export interface DeriveClientSafeUpstreamErrorMessageInput { rawText?: string; candidateMessage?: string; + candidateMessages?: Array; providerName?: string | null; forbiddenProviderLabels?: string[]; + allowDefaultProviderLabels?: boolean; } const MAX_CLIENT_ERROR_MESSAGE_CHARS = 240; @@ -36,7 +38,7 @@ const INTERNAL_ONLY_RE = new RegExp( `^(?:FAKE_200_[A-Z0-9_]+|EMPTY_RESPONSE|HTTP\\s+\\d{3}|No available providers?|No available provider endpoints|${ALL_PROVIDERS_UNAVAILABLE_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})$`, "iu" ); -const PROVIDER_PREFIX_RE = /\bProvider\s+[\w.-]+(?:\s+returned|\s*:|\s+-)/iu; +const PROVIDER_PREFIX_RE = /\bProvider\s+(?:returned\b|[\w.-]+(?:\s+returned|\s*:|\s+-))/iu; // Reject both anchored-at-start and embedded raw JSON blobs; embedded JSON would otherwise // leak provider-shaped payloads (e.g. `Bad request: {"error":{"message":"..."}}`). const RAW_JSON_BLOB_ANCHORED_RE = /^[[{]/u; @@ -161,17 +163,22 @@ function deriveCandidateFromRawText(rawText: string | undefined): string | null return detected.detail; } +/** + * 从上游原始响应文本或候选消息中提取并脱敏可安全返回给客户端的错误消息 + */ export function deriveClientSafeUpstreamErrorMessage( input: DeriveClientSafeUpstreamErrorMessageInput ): string | null { const labels = [ input.providerName ?? "", - ...DEFAULT_FORBIDDEN_PROVIDER_LABELS, + ...(input.allowDefaultProviderLabels ? [] : DEFAULT_FORBIDDEN_PROVIDER_LABELS), ...(input.forbiddenProviderLabels ?? []), ].filter(Boolean); - const candidates = [deriveCandidateFromRawText(input.rawText), input.candidateMessage].filter( - (value): value is string => typeof value === "string" && value.trim().length > 0 - ); + const candidates = [ + deriveCandidateFromRawText(input.rawText), + input.candidateMessage, + ...(input.candidateMessages ?? []), + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); for (const candidate of candidates) { const trimmed = candidate.trim(); diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index c4211edfb..7275f52fc 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -20,6 +20,7 @@ import { attachSessionIdToErrorResponse } from "./error-session-id"; import { ALL_PROVIDERS_UNAVAILABLE_MESSAGE, getErrorOverrideAsync, + hasMatchedErrorRuleWithoutOverrideResponse, isEmptyResponseError, isRateLimitError, ProxyError, @@ -113,16 +114,23 @@ function getGenericProxyErrorFallbackMessage( } } +/** + * 解析最终返回给客户端的错误消息 + * + * 优先级:显式覆写响应体 > 命中无覆写错误规则白名单透传 > 系统设置上游错误透传 > 通用状态码兜底文案 + */ export function resolveFinalClientErrorMessage({ error, currentFallbackMessage, settings, override, + matchedRuleWithoutOverride, }: { error: unknown; currentFallbackMessage: string; settings?: Pick | null; override?: ErrorOverrideForMessageResolver; + matchedRuleWithoutOverride?: boolean; }): string { if (override?.response) { return currentFallbackMessage; @@ -137,7 +145,10 @@ export function resolveFinalClientErrorMessage({ error, strippedFallback ); - const shouldPassThrough = settings?.passThroughUpstreamErrorMessage ?? true; + const isRuleMatchedWithoutResponseOverride = + matchedRuleWithoutOverride ?? hasMatchedErrorRuleWithoutOverrideResponse(error); + const shouldPassThrough = + isRuleMatchedWithoutResponseOverride || (settings?.passThroughUpstreamErrorMessage ?? true); if (!shouldPassThrough) { return fallback; } @@ -145,12 +156,11 @@ export function resolveFinalClientErrorMessage({ if (error instanceof ProxyError) { return ( deriveClientSafeUpstreamErrorMessage({ - rawText: error.upstreamError?.rawBody, - candidateMessage: - error.upstreamError?.safeClientMessageCandidate ?? - error.upstreamError?.body ?? - error.message, + rawText: error.upstreamError?.rawBody ?? error.upstreamError?.body, + candidateMessage: error.upstreamError?.safeClientMessageCandidate, + candidateMessages: [error.upstreamError?.body, error.message], providerName: error.upstreamError?.providerName ?? null, + allowDefaultProviderLabels: isRuleMatchedWithoutResponseOverride, }) ?? fallback ); } @@ -389,6 +399,7 @@ export class ProxyErrorHandler { currentFallbackMessage: clientErrorMessage, settings, override: { response: null, statusCode: override.statusCode }, + matchedRuleWithoutOverride: false, }); return await finalizeErrorResponse( ProxyResponses.buildError( @@ -408,6 +419,7 @@ export class ProxyErrorHandler { currentFallbackMessage: clientErrorMessage, settings, override: { response: null, statusCode: null }, + matchedRuleWithoutOverride: false, }); return await finalizeErrorResponse( ProxyResponses.buildError( @@ -439,6 +451,7 @@ export class ProxyErrorHandler { currentFallbackMessage: clientErrorMessage, settings, override: hasExplicitOverrideMessage ? override : null, + matchedRuleWithoutOverride: false, }); const responseBody = { ...override.response, diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index d5c5a04d2..3a5e6ca7c 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -747,6 +747,24 @@ export async function getErrorOverrideAsync( return undefined; } +/** + * 判断错误是否已命中错误规则且未配置响应体覆写 + * + * 当正则/包含/精确规则命中但未配置 overrideResponse 时, + * 代表将该错误加入客户端错误白名单并期望原样透传脱敏后的上游错误消息。 + */ +export function hasMatchedErrorRuleWithoutOverrideResponse(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const cached = errorDetectionCache.get(error); + return ( + cached?.matched === true && + !cached.overrideResponse && + cached.hasConfiguredOverrideResponse !== true + ); +} + /** * 检测是否为客户端中断错误 * diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index d2e24cc66..86dc1a5b8 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -9097,6 +9097,9 @@ export class ProxyForwarder { return true; } + /** + * 构建所有供应商不可用时的终态 503 ProxyError,并携带最后一次上游错误的安全候选文案 + */ private static buildAllProvidersUnavailableError(finalError?: Error | null): ProxyError { const safeClientMessageCandidate = finalError instanceof ProxyError && @@ -9105,10 +9108,8 @@ export class ProxyForwarder { finalError.upstreamError?.body) ? (deriveClientSafeUpstreamErrorMessage({ rawText: finalError.upstreamError?.rawBody ?? finalError.upstreamError?.body, - candidateMessage: - finalError.upstreamError?.safeClientMessageCandidate ?? - finalError.upstreamError?.body ?? - finalError.message, + candidateMessage: finalError.upstreamError?.safeClientMessageCandidate, + candidateMessages: [finalError.upstreamError?.body, finalError.message], providerName: finalError.upstreamError?.providerName ?? null, }) ?? undefined) : undefined; diff --git a/src/lib/error-rule-detector.ts b/src/lib/error-rule-detector.ts index 8caa9d9e3..f1278b396 100644 --- a/src/lib/error-rule-detector.ts +++ b/src/lib/error-rule-detector.ts @@ -28,6 +28,8 @@ export interface ErrorDetectionResult { description?: string; // 规则描述 /** 覆写响应体:如果配置了则用此响应替换原始错误响应 */ overrideResponse?: ErrorOverrideResponse; + /** 规则是否配置过响应体覆写(含因格式非法在加载时被拒绝的覆写) */ + hasConfiguredOverrideResponse?: boolean; /** 覆写状态码:如果配置了则用此状态码替换原始状态码 */ overrideStatusCode?: number; } @@ -42,6 +44,7 @@ interface RegexPattern { category: string; description?: string; overrideResponse?: ErrorOverrideResponse; + hasConfiguredOverrideResponse?: boolean; overrideStatusCode?: number; } @@ -55,6 +58,7 @@ interface ContainsPattern { category: string; description?: string; overrideResponse?: ErrorOverrideResponse; + hasConfiguredOverrideResponse?: boolean; overrideStatusCode?: number; } @@ -68,6 +72,7 @@ interface ExactPattern { category: string; description?: string; overrideResponse?: ErrorOverrideResponse; + hasConfiguredOverrideResponse?: boolean; overrideStatusCode?: number; } @@ -229,6 +234,9 @@ class ErrorRuleDetector { for (const rule of rules) { // 在加载阶段验证 overrideResponse 格式,过滤畸形数据 + const hasConfiguredOverrideResponse = + rule.hasConfiguredOverrideResponse === true || + (rule.overrideResponse !== null && rule.overrideResponse !== undefined); let validatedOverrideResponse: ErrorOverrideResponse | undefined; if (rule.overrideResponse) { if (isValidErrorOverrideResponse(rule.overrideResponse)) { @@ -251,6 +259,7 @@ class ErrorRuleDetector { category: rule.category, description: rule.description ?? undefined, overrideResponse: validatedOverrideResponse, + ...(hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}), overrideStatusCode: rule.overrideStatusCode ?? undefined, }); break; @@ -265,6 +274,7 @@ class ErrorRuleDetector { category: rule.category, description: rule.description ?? undefined, overrideResponse: validatedOverrideResponse, + ...(hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}), overrideStatusCode: rule.overrideStatusCode ?? undefined, }); break; @@ -289,6 +299,9 @@ class ErrorRuleDetector { category: rule.category, description: rule.description ?? undefined, overrideResponse: validatedOverrideResponse, + ...(hasConfiguredOverrideResponse + ? { hasConfiguredOverrideResponse: true } + : {}), overrideStatusCode: rule.overrideStatusCode ?? undefined, }); validRegexCount++; @@ -402,6 +415,7 @@ class ErrorRuleDetector { matchType: "contains", description: pattern.description, overrideResponse: pattern.overrideResponse, + ...(pattern.hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}), overrideStatusCode: pattern.overrideStatusCode, }; } @@ -418,6 +432,9 @@ class ErrorRuleDetector { matchType: "exact", description: exactMatch.description, overrideResponse: exactMatch.overrideResponse, + ...(exactMatch.hasConfiguredOverrideResponse + ? { hasConfiguredOverrideResponse: true } + : {}), overrideStatusCode: exactMatch.overrideStatusCode, }; } @@ -430,6 +447,7 @@ class ErrorRuleDetector { category, description, overrideResponse, + hasConfiguredOverrideResponse, overrideStatusCode, } of this.regexPatterns) { if (pattern.test(errorMessage)) { @@ -441,6 +459,7 @@ class ErrorRuleDetector { matchType: "regex", description, overrideResponse, + ...(hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}), overrideStatusCode, }; } diff --git a/src/repository/error-rules.ts b/src/repository/error-rules.ts index 9e330acd6..76b1d6653 100644 --- a/src/repository/error-rules.ts +++ b/src/repository/error-rules.ts @@ -65,6 +65,8 @@ export interface ErrorRule { description: string | null; /** 覆写响应体(JSON):匹配成功时用此响应替换原始错误响应,null 表示不覆写 */ overrideResponse: ErrorOverrideResponse | null; + /** 数据库中是否配置过非空 override_response(即使因格式非法被清理为 null) */ + hasConfiguredOverrideResponse?: boolean; /** 覆写状态码:null 表示透传上游状态码 */ overrideStatusCode: number | null; isEnabled: boolean; @@ -119,6 +121,9 @@ export async function getActiveErrorRules(): Promise { r.overrideResponse, `getActiveErrorRules id=${r.id}` ), + ...(r.overrideResponse !== null && r.overrideResponse !== undefined + ? { hasConfiguredOverrideResponse: true } + : {}), overrideStatusCode: r.overrideStatusCode, isEnabled: r.isEnabled, isDefault: r.isDefault, diff --git a/tests/unit/proxy/error-handler-client-message.test.ts b/tests/unit/proxy/error-handler-client-message.test.ts index 3c5ba79b9..1b2a30bae 100644 --- a/tests/unit/proxy/error-handler-client-message.test.ts +++ b/tests/unit/proxy/error-handler-client-message.test.ts @@ -150,6 +150,34 @@ describe("resolveFinalClientErrorMessage", () => { expect(message).toBe("custom override"); }); + + test("passes through message from ProxyError.fromUpstreamResponse when rawBody is undefined and body is JSON", async () => { + const upstreamResponse = new Response( + JSON.stringify({ + error: { + message: 'level "minimal" not supported, valid levels: low, medium, high', + type: "invalid_request_error", + }, + }), + { + status: 400, + headers: { "content-type": "application/json" }, + } + ); + const error = await ProxyError.fromUpstreamResponse(upstreamResponse, { + id: 89, + name: "cpa-us-gemini", + }); + + const message = resolveFinalClientErrorMessage({ + error, + currentFallbackMessage: error.getClientSafeMessage(), + settings: { passThroughUpstreamErrorMessage: true }, + override: null, + }); + + expect(message).toBe('level "minimal" not supported, valid levels: low, medium, high'); + }); }); describe("ProxyErrorHandler.handle client message", () => { diff --git a/tests/unit/proxy/error-handler-overrides.test.ts b/tests/unit/proxy/error-handler-overrides.test.ts index b3d9bd269..2a592edca 100644 --- a/tests/unit/proxy/error-handler-overrides.test.ts +++ b/tests/unit/proxy/error-handler-overrides.test.ts @@ -173,4 +173,84 @@ describe("ProxyErrorHandler.handle overrides", () => { expect(responseText).not.toContain("Quota exceeded"); expect(responseText).not.toContain("raw upstream failure"); }); + + test("passes through upstream error when regex rule matches without overrideResponse", async () => { + mocks.detectAsync.mockResolvedValue({ + matched: true, + ruleId: 624, + category: "invalid_request", + pattern: "invalid_request_error", + matchType: "regex", + }); + mocks.getCachedSystemSettings.mockResolvedValue({ + verboseProviderError: false, + passThroughUpstreamErrorMessage: false, + }); + const session = await createSession(); + const upstreamResponse = new Response( + JSON.stringify({ + error: { + message: "Client not allowed (detected: Anthropic/JS 0.73.0)", + type: "invalid_request_error", + }, + }), + { + status: 400, + headers: { "content-type": "application/json" }, + } + ); + const error = await ProxyError.fromUpstreamResponse(upstreamResponse, { + id: 89, + name: "cpa-us-gemini", + }); + + const response = await ProxyErrorHandler.handle(session, error); + const responseText = await response.text(); + + expect(response.status).toBe(400); + expect(responseText).toContain( + "Client not allowed (detected: Anthropic/JS 0.73.0) (cch_session_id: s_override)" + ); + expect(responseText).not.toContain("cpa-us-gemini"); + expect(responseText).not.toContain("上游请求参数无效"); + }); + + test("does not treat a rejected malformed override_response as an intentional pass-through whitelist", async () => { + mocks.detectAsync.mockResolvedValue({ + matched: true, + ruleId: 625, + category: "invalid_request", + pattern: "invalid_request_error", + matchType: "regex", + hasConfiguredOverrideResponse: true, + }); + mocks.getCachedSystemSettings.mockResolvedValue({ + verboseProviderError: false, + passThroughUpstreamErrorMessage: false, + }); + const session = await createSession(); + const upstreamResponse = new Response( + JSON.stringify({ + error: { + message: "raw upstream failure that should stay hidden when override was malformed", + type: "invalid_request_error", + }, + }), + { + status: 400, + headers: { "content-type": "application/json" }, + } + ); + const error = await ProxyError.fromUpstreamResponse(upstreamResponse, { + id: 89, + name: "cpa-us-gemini", + }); + + const response = await ProxyErrorHandler.handle(session, error); + const responseText = await response.text(); + + expect(response.status).toBe(400); + expect(responseText).toContain("上游请求参数无效,请检查后重试"); + expect(responseText).not.toContain("raw upstream failure that should stay hidden"); + }); });