diff --git a/src/bridge.ts b/src/bridge.ts index 20e7c3fe09..3e1cc223ee 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -10,9 +10,13 @@ import { coerceIntegerToolArguments } from "./lib/tool-argument-integers"; import { adapterFailureFromMessage, classifyError, + clientStatusForClassifiedError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, + isPlanUsageCapMessage, + isUsageLimitCode, + USAGE_LIMIT_ERROR_CODE, type OcxErrorPayload, } from "./lib/errors"; import { redactSecretString } from "./lib/redact"; @@ -164,7 +168,11 @@ function adapterFailureFromEvent(event: Extract error.code = CYBER_POLICY_ERROR_CODE; error.type = cyberPolicyErrorType(event.errorType); httpStatus = 400; + } else if (isPlanUsageCapMessage(error.message) || isUsageLimitCode(error.code)) { + error.code = USAGE_LIMIT_ERROR_CODE; + error.type = USAGE_LIMIT_ERROR_CODE; } + httpStatus = clientStatusForClassifiedError(httpStatus, error.code); return { httpStatus, error }; } @@ -2138,10 +2146,11 @@ export function formatErrorResponse( error.code = CYBER_POLICY_ERROR_CODE; error.type = cyberPolicyErrorType(type); } - const finalStatus = error.code === CYBER_POLICY_ERROR_CODE ? 400 : status; + const finalStatus = clientStatusForClassifiedError(status, error.code); const headers = new Headers({ "Content-Type": "application/json" }); const retryAfter = options?.retryAfter?.trim(); if (error.code !== CYBER_POLICY_ERROR_CODE + && error.code !== USAGE_LIMIT_ERROR_CODE && retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) { diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 3868bc4b0c..ccf1c4c18b 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -1,5 +1,5 @@ import { parseResetCooldownMs } from "../codex/routing"; -import { classifyError, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, isCyberPolicyCode, isPlanUsageCapMessage, USAGE_LIMIT_ERROR_CODE } from "../lib/errors"; import type { OcxComboTarget } from "../types"; import { targetKey } from "./types"; import { @@ -27,6 +27,7 @@ const QUOTA_LIMIT_CODES = new Set([ "1320", "1321", "insufficient_quota", + USAGE_LIMIT_ERROR_CODE, ]); const TRANSIENT_REQUEST_RATE_CODES = new Set(["1302", "1305"]); const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; @@ -166,6 +167,7 @@ export function isTransientRequestRateLimit(input: { text.includes("usage limit reached") || text.includes("insufficient_quota") || text.includes("quota exhausted") + || isPlanUsageCapMessage(input.message ?? "") ) { return false; } @@ -471,6 +473,7 @@ export function comboFailureDecision( "subscription_required", "invalid_api_key", "insufficient_quota", + USAGE_LIMIT_ERROR_CODE, "payment_required", "billing_error", "insufficient_balance", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8fb5126487..1a4d582713 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -176,29 +176,108 @@ export function isClientClosedMessage(text: string): boolean { ); } +export const USAGE_LIMIT_ERROR_CODE = "usage_limit_exceeded"; + +const PROVIDER_ERROR_PREFIX = /^Provider error \d+:\s*/u; + +function nestedErrorMessage(parsed: unknown): string | undefined { + if (typeof parsed === "string") { + const trimmed = parsed.trim(); + return trimmed || undefined; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + const error = record.error; + if (typeof error === "string" && error.trim()) return error.trim(); + if (error && typeof error === "object" && !Array.isArray(error)) { + const nested = (error as Record).message; + if (typeof nested === "string" && nested.trim()) return nested.trim(); + } + if (typeof record.message === "string" && record.message.trim()) return record.message.trim(); + return undefined; +} + +/** + * OpenCodex wraps upstream bodies as `Provider error N: …`, and combo/chat paths can + * wrap that envelope again. Codex retries HTTP 429 before the user ever sees the + * inner text, so classification and the client message must use the innermost reason. + */ +export function unwrapNestedProviderErrorMessage(message: string): string { + let current = message.trim(); + for (let depth = 0; depth < 4; depth++) { + const stripped = current.replace(PROVIDER_ERROR_PREFIX, "").trim(); + if (stripped === current) break; + // Empty bodies have no inner reason; keep the status-bearing wrapper so Codex + // still sees that the upstream returned HTTP 429 rather than a bare "(empty body)". + if (!stripped || stripped === "(empty body)") break; + if ( + (stripped.startsWith("{") && stripped.endsWith("}")) + || (stripped.startsWith("[") && stripped.endsWith("]")) + ) { + try { + const inner = nestedErrorMessage(JSON.parse(stripped) as unknown); + if (inner) { + current = inner; + continue; + } + } catch { + return stripped; + } + } + return stripped; + } + return current; +} + +/** Hard plan/quota windows (Zhipu 5h cap, Codex usage-limit UI), not per-minute throttling. */ +export function isPlanUsageCapMessage(message: string): boolean { + if (message.includes("使用上限")) return true; + const lower = message.toLowerCase(); + return ( + lower.includes("usage_limit_exceeded") + || lower.includes("hit your usage limit") + || lower.includes("hourly usage limit") + || /\b5\s*-?\s*hours? usage (?:limit|cap)\b/.test(lower) + ); +} + +export function isUsageLimitCode(code: string | null | undefined): boolean { + return code === USAGE_LIMIT_ERROR_CODE; +} + +/** Codex retries HTTP 429 before showing the body; usage caps must leave that path. */ +export function clientStatusForClassifiedError( + status: number, + code: string | null | undefined, +): number { + if (isCyberPolicyCode(code) || isUsageLimitCode(code)) return 400; + return status; +} + export function classifyError(status: number, type: string, message: string): OcxErrorPayload { - const text = message.toLowerCase(); + const displayMessage = unwrapNestedProviderErrorMessage(message); + const text = displayMessage.toLowerCase(); if (type === "previous_response_not_found") { - return { message, type: "invalid_request_error", code: "previous_response_not_found" }; + return { message: displayMessage, type: "invalid_request_error", code: "previous_response_not_found" }; } // Preserve explicit cancel types used by compact/combo JSON errors; unify message-inferred // client closes (web-search abort text) onto client_closed_request for /api/logs. if (type === "client_cancelled") { - return { message, type: "client_cancelled", code: "client_cancelled" }; + return { message: displayMessage, type: "client_cancelled", code: "client_cancelled" }; } if ( status === 499 || type === "client_closed_request" || isClientClosedMessage(text) ) { - return { message, type: "invalid_request_error", code: "client_closed_request" }; + return { message: displayMessage, type: "invalid_request_error", code: "client_closed_request" }; } // Codex only shows the dedicated cyber UI when error.code === "cyber_policy". // The public wire does not establish invalid_request_error as the canonical type, so // message-only classification keeps the dedicated identity instead of inventing one. // Structured callers re-apply their real upstream type with cyberPolicyErrorType(). if (type === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(text)) { - return { message, type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE }; + return { message: displayMessage, type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE }; } // A LOCAL preflight refusal keeps its own code (#1524). The message necessarily says // "context window" -- that is what it is refusing on -- so the generic remap below would @@ -207,7 +286,7 @@ export function classifyError(status: number, type: string, message: string): Oc // fit", theirs means "the request is impossible", so collapsing them ended the chain at // the first candidate that was merely too small. if (type === "input_admission_refused") { - return { message, type: "invalid_request_error", code: "input_admission_refused" }; + return { message: displayMessage, type: "invalid_request_error", code: "input_admission_refused" }; } if ( text.includes("context_length_exceeded") || @@ -216,7 +295,7 @@ export function classifyError(status: number, type: string, message: string): Oc text.includes("maximum context") || text.includes("too many tokens") ) { - return { message, type: "invalid_request_error", code: "context_length_exceeded" }; + return { message: displayMessage, type: "invalid_request_error", code: "context_length_exceeded" }; } // "Cursor resource limit exceeded" is emitted only for explicit request-size overflow // details (isCursorRequestTooLargeDetail in cursor-errors.ts); "Cursor context limit @@ -224,16 +303,19 @@ export function classifyError(status: number, type: string, message: string): Oc // quota-style resource exhaustion arrives as "Cursor rate limit exceeded" and falls // through to 429 below. if (text.includes("cursor resource limit exceeded")) { - return { message, type: "invalid_request_error", code: "tool_catalog_too_large" }; + return { message: displayMessage, type: "invalid_request_error", code: "tool_catalog_too_large" }; } if (text.includes("cursor context limit exceeded")) { - return { message, type: "invalid_request_error", code: "context_length_exceeded" }; + return { message: displayMessage, type: "invalid_request_error", code: "context_length_exceeded" }; } // The Cursor adapter's classified rate-limit prefix is authoritative: its DETAIL may echo // quota wording ("... quota exhausted") that would otherwise hit the insufficient_quota // branch below and break the planned retry-with-backoff contract (WP3 review blocker 1). if (text.includes("cursor rate limit exceeded")) { - return { message, type: "rate_limit_error", code: "rate_limit_exceeded" }; + return { message: displayMessage, type: "rate_limit_error", code: "rate_limit_exceeded" }; + } + if (isPlanUsageCapMessage(displayMessage) || type === USAGE_LIMIT_ERROR_CODE) { + return { message: displayMessage, type: USAGE_LIMIT_ERROR_CODE, code: USAGE_LIMIT_ERROR_CODE }; } if ( text.includes("insufficient_quota") || @@ -243,7 +325,7 @@ export function classifyError(status: number, type: string, message: string): Oc text.includes("monthly quota exceeded") || text.includes("daily quota exceeded") ) { - return { message, type: "insufficient_quota", code: "insufficient_quota" }; + return { message: displayMessage, type: "insufficient_quota", code: "insufficient_quota" }; } if ( status === 429 || @@ -255,15 +337,15 @@ export function classifyError(status: number, type: string, message: string): Oc text.includes("throttlingexception") || text.includes("throttling") ) { - return { message, type: "rate_limit_error", code: "rate_limit_exceeded" }; + return { message: displayMessage, type: "rate_limit_error", code: "rate_limit_exceeded" }; } if (type === "origin_rejected") { - return { message, type: "invalid_request_error", code: "origin_rejected" }; + return { message: displayMessage, type: "invalid_request_error", code: "origin_rejected" }; } // Local ACL setup failures can contain provider-like auth wording (for example // "access denied" or "authentication") but represent unavailable infrastructure. if (status === 503 && isLocalAclHardeningMessage(text)) { - return { message, type: "server_error", code: "upstream_server_error" }; + return { message: displayMessage, type: "server_error", code: "upstream_server_error" }; } // HTTP 401 and explicit auth failures are authoritative even when provider text // also advertises an upgrade or subscription. @@ -272,30 +354,30 @@ export function classifyError(status: number, type: string, message: string): Oc type === "authentication_error" || isAuthenticationMessage(text) ) { - return { message, type: "authentication_error", code: "invalid_api_key" }; + return { message: displayMessage, type: "authentication_error", code: "invalid_api_key" }; } // An explicit permission enum must not acquire a more specific inferred reason. if (type === "PERMISSION_DENIED" || text.includes("permission_denied")) { - return { message, type: "permission_error", code: "permission_denied" }; + return { message: displayMessage, type: "permission_error", code: "permission_denied" }; } // Location denials outrank generic permission / subscription wording, but never an // authoritative 5xx. Message-only adapter terminals arrive here with inferred 403. if (status < 500 && (type === "location_not_supported" || isLocationUnsupportedMessage(text))) { - return { message, type: "permission_error", code: "location_not_supported" }; + return { message: displayMessage, type: "permission_error", code: "location_not_supported" }; } // Subscription labels are valid only in a known permission context. if ( (status === 403 || type === "permission_error") && isSubscriptionGateMessage(text) ) { - return { message, type: "permission_error", code: "subscription_required" }; + return { message: displayMessage, type: "permission_error", code: "subscription_required" }; } if ( status === 403 || type === "permission_error" || isPermissionMessage(text) ) { - return { message, type: "permission_error", code: "permission_denied" }; + return { message: displayMessage, type: "permission_error", code: "permission_denied" }; } if ( status === 503 || @@ -305,7 +387,7 @@ export function classifyError(status: number, type: string, message: string): Oc ) { // Codex recognizes "server_is_overloaded" and applies retry-after backoff // (responses.rs is_server_overloaded_error); generic "upstream_server_error" is not recognized. - return { message, type: "server_error", code: "server_is_overloaded" }; + return { message: displayMessage, type: "server_error", code: "server_is_overloaded" }; } if ( text.includes("validationexception") || @@ -317,21 +399,17 @@ export function classifyError(status: number, type: string, message: string): Oc text.includes("wrong region") || text.includes("invalid region") ) { - return { message, type: "invalid_request_error", code: "invalid_request_error" }; + return { message: displayMessage, type: "invalid_request_error", code: "invalid_request_error" }; } if (status >= 500) { - return { message, type: "server_error", code: "upstream_server_error" }; + return { message: displayMessage, type: "server_error", code: "upstream_server_error" }; } if (status === 400 || type === "invalid_request_error") { - return { message, type: "invalid_request_error", code: "invalid_request_error" }; + return { message: displayMessage, type: "invalid_request_error", code: "invalid_request_error" }; } - return { message, type, code: type || null }; + return { message: displayMessage, type, code: type || null }; } -/** - * True when a provider failure should participate in rate-limit / quota health blocking. - * Reuses {@link classifyError} so generic 429 wording and quota phrases stay aligned. - */ export function isRateLimitOrQuotaFailureMessage(message: string): boolean { const normalized = String(message ?? "").trim(); if (!normalized) return false; @@ -344,11 +422,13 @@ export function isRateLimitOrQuotaFailureMessage(message: string): boolean { || classified.code === "rate_limit_exceeded" || classified.type === "insufficient_quota" || classified.code === "insufficient_quota" + || classified.type === USAGE_LIMIT_ERROR_CODE + || classified.code === USAGE_LIMIT_ERROR_CODE ) { return true; } // Retained quota cue used by subagent health before classifyError covered it. - return normalized.toLowerCase().includes("usage limit"); + return normalized.toLowerCase().includes("usage limit") || normalized.includes("使用上限"); } /** Best-effort parse of a retry delay embedded in an upstream error message. */ @@ -369,11 +449,15 @@ export function parseRetryAfterFromMessage(message: string): number | undefined /** Infer HTTP status from adapter terminal error text (provider-agnostic keyword matching). */ export function inferHttpStatusFromAdapterMessage(message: string): number { - const lower = message.toLowerCase(); + const display = unwrapNestedProviderErrorMessage(message); + const lower = display.toLowerCase(); // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs. if (isClientClosedMessage(lower)) return 499; // Codex Transport maps cyber_policy only on HTTP 400 (SSE is code-based). if (isCyberPolicyMessage(lower)) return 400; + // Plan/quota windows must not look like retryable 429s — Codex retries those + // and replaces the upstream reason with "exceeded retry limit". + if (isPlanUsageCapMessage(display)) return 400; // See classifyError: this prefix now only means explicit request-size overflow (400); // quota-style Cursor resource exhaustion carries the rate-limit prefix and maps to 429. if (lower.includes("cursor resource limit exceeded")) return 400; @@ -431,10 +515,10 @@ export function inferHttpStatusFromAdapterMessage(message: string): number { /** Map an adapter terminal error message to HTTP status + classified Codex error payload. */ export function adapterFailureFromMessage(message: string): { httpStatus: number; error: OcxErrorPayload } { const httpStatus = inferHttpStatusFromAdapterMessage(message); - let finalMessage = message; + let finalMessage = unwrapNestedProviderErrorMessage(message); const retryAfterSeconds = parseRetryAfterFromMessage(message); - if (retryAfterSeconds && !/please try again in /i.test(message)) { - finalMessage = `${message} Please try again in ${retryAfterSeconds}s.`; + if (retryAfterSeconds && !/please try again in /i.test(finalMessage)) { + finalMessage = `${finalMessage} Please try again in ${retryAfterSeconds}s.`; } const errorType = httpStatus === 499 ? "client_closed_request" @@ -449,9 +533,10 @@ export function adapterFailureFromMessage(message: string): { httpStatus: number : httpStatus === 400 ? "invalid_request_error" : "upstream_error"; + const error = classifyError(httpStatus, errorType, finalMessage); return { - httpStatus, - error: classifyError(httpStatus, errorType, finalMessage), + httpStatus: clientStatusForClassifiedError(httpStatus, error.code), + error, }; } @@ -473,6 +558,7 @@ export function httpStatusFromTerminalError(error: { error.code === "permission_denied" || error.code === "subscription_required" ) return 403; + if (error.type === USAGE_LIMIT_ERROR_CODE || error.code === USAGE_LIMIT_ERROR_CODE) return 400; if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429; if (error.type === "server_error" && error.code === "server_is_overloaded") return 503; // Client-closed messages often arrive as invalid_request_error after classifyError; check message diff --git a/src/lib/retry-after.ts b/src/lib/retry-after.ts index 1b63d17f4d..5db69e3d90 100644 --- a/src/lib/retry-after.ts +++ b/src/lib/retry-after.ts @@ -1,5 +1,5 @@ import { parseRetryAfterMs } from "../combos"; -import { classifyError, parseRetryAfterFromMessage } from "./errors"; +import { classifyError, parseRetryAfterFromMessage, USAGE_LIMIT_ERROR_CODE } from "./errors"; /** Small default when a retryable 429 has no upstream Retry-After (#507). */ export const DEFAULT_RETRYABLE_429_RETRY_AFTER_SEC = "2"; @@ -48,7 +48,12 @@ export function resolveClientRetryAfter(opts: { if (opts.includeDefault === false) return undefined; if (opts.status !== 429) return undefined; const classified = classifyError(429, "rate_limit_error", message || "Too Many Requests"); - if (classified.type === "insufficient_quota" || classified.code === "insufficient_quota") { + if ( + classified.type === "insufficient_quota" + || classified.code === "insufficient_quota" + || classified.type === USAGE_LIMIT_ERROR_CODE + || classified.code === USAGE_LIMIT_ERROR_CODE + ) { return undefined; } return DEFAULT_RETRYABLE_429_RETRY_AFTER_SEC; diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 7e69010636..6b4a44d67e 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -18,7 +18,7 @@ import { responsesJsonToChatCompletion, responsesSseToChatCompletionsSse, } from "../chat/outbound"; -import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, clientStatusForClassifiedError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { estimateTokens } from "../lib/token-estimate"; @@ -367,8 +367,8 @@ async function handleChatCompletionsWithBudget( } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } - const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status; - const retryAfter = isCyberPolicyCode(classified.code) + const status = clientStatusForClassifiedError(upstream.status, classified.code); + const retryAfter = isCyberPolicyCode(classified.code) || classified.code === "usage_limit_exceeded" ? undefined : resolveClientRetryAfter({ status: upstream.status, @@ -464,8 +464,8 @@ async function handleChatCompletionsWithBudget( return finishJson(chatCompletionsErrorResponse( classified.code === "translation_buffer_limit" ? 502 - : isCyberPolicyCode(classified.code) ? 400 : 502, - message, + : clientStatusForClassifiedError(502, classified.code), + classified.message, classified.type, classified.code, )); diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts index 0d80733057..d95d28863f 100644 --- a/src/server/chat-native-sse.ts +++ b/src/server/chat-native-sse.ts @@ -1,5 +1,5 @@ import { chatCompletionsErrorBody } from "../chat/outbound"; -import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, clientStatusForClassifiedError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { isTranslatorBudgetExceededError, @@ -249,12 +249,13 @@ export function nativeChatSse( if (isCyberPolicyCode(error.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(error.type); - } else if (error.code !== undefined && error.code !== null) { + } else if (error.code !== undefined && error.code !== null && classified.code == null) { classified.code = error.code; } - const safe = chatCompletionsErrorBody(status, classified.message, classified.type, classified.code); + const clientStatus = clientStatusForClassifiedError(status, classified.code); + const safe = chatCompletionsErrorBody(clientStatus, classified.message, classified.type, classified.code); enqueue(controller, replaceSseDataPayload(block, JSON.stringify(safe)) + delimiter); - settle(isCyberPolicyCode(classified.code) ? 400 : status, classified.message); + settle(clientStatus, classified.message); try { void reader.cancel(new Error(classified.message)).catch(() => {}); } catch { /* already closed */ } controller.close(); return; diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 9abb99683e..a720cfb19c 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -10,6 +10,7 @@ import { applyChatEffortCap, chatCollabSurface, effortCapAppliesTo, resolvePinne import { mapReasoningEffort } from "../reasoning-effort"; import { classifyError, + clientStatusForClassifiedError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, @@ -454,8 +455,8 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } - const status = isCyberPolicyCode(classified.code) ? 400 : response.status; - const retryAfter = isCyberPolicyCode(classified.code) + const status = clientStatusForClassifiedError(response.status, classified.code); + const retryAfter = isCyberPolicyCode(classified.code) || classified.code === "usage_limit_exceeded" ? undefined : resolveClientRetryAfter({ status: response.status, diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 98174c3848..b86ddd0b68 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -794,6 +794,10 @@ describe("combo failure policy and advancement", () => { expect(comboFailureDecision(410, "gone", { code: "model_retired" })).toBe("hop"); expect(comboFailureDecision(499, "client cancelled")).toBe("stop"); expect(comboFailureDecision(422, "invalid_api_key")).toBe("hop"); + expect(comboFailureDecision(429, "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。")).toBe("hop"); + expect(comboFailureDecision(400, "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。", { + code: "usage_limit_exceeded", + })).toBe("hop"); // #1524: a LOCAL input-admission refusal means "this candidate cannot fit the request", // not "the request is impossible". The next candidate may have a larger context window, // so the chain must continue instead of ending at the first incompatible target. diff --git a/tests/server/error-fidelity.test.ts b/tests/server/error-fidelity.test.ts index 151637088a..cf59c33a53 100644 --- a/tests/server/error-fidelity.test.ts +++ b/tests/server/error-fidelity.test.ts @@ -96,6 +96,28 @@ describe("error fidelity", () => { type: "insufficient_quota", code: "insufficient_quota", }); + const zhipuCap = "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。"; + expect(classifyError(429, "rate_limit_error", `Provider error 429: ${JSON.stringify({ + error: { message: `Provider error 429: ${zhipuCap}`, type: "upstream_error", code: null }, + })}`)).toMatchObject({ + message: zhipuCap, + type: "usage_limit_exceeded", + code: "usage_limit_exceeded", + }); + }); + + test("formatErrorResponse unwraps Zhipu usage-cap 429s so Codex shows the upstream reason", async () => { + const zhipuCap = "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。"; + const response = formatErrorResponse(429, "upstream_error", `Provider error 429: ${zhipuCap}`); + expect(response.status).toBe(400); + expect(response.headers.get("Retry-After")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: { + message: zhipuCap, + type: "usage_limit_exceeded", + code: "usage_limit_exceeded", + }, + }); }); test("formatErrorResponse returns OpenAI-compatible classified error envelope", async () => { diff --git a/tests/server/errors-adapter-failure.test.ts b/tests/server/errors-adapter-failure.test.ts index b712ce3def..01d20e4216 100644 --- a/tests/server/errors-adapter-failure.test.ts +++ b/tests/server/errors-adapter-failure.test.ts @@ -15,6 +15,18 @@ describe("adapterFailureFromMessage", () => { }); }); + test("maps Zhipu 5-hour usage-cap text to 400 usage_limit_exceeded", () => { + const message = "Provider error 429: 已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。"; + expect(adapterFailureFromMessage(message)).toMatchObject({ + httpStatus: 400, + error: { + message: "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。", + type: "usage_limit_exceeded", + code: "usage_limit_exceeded", + }, + }); + }); + test("parses retry-after hints from upstream text", () => { const message = "rate limit exceeded: try again in 12.5 seconds"; expect(parseRetryAfterFromMessage(message)).toBe(13); diff --git a/tests/server/retry-after-429.test.ts b/tests/server/retry-after-429.test.ts index 168fe06b6a..ee5bfd2933 100644 --- a/tests/server/retry-after-429.test.ts +++ b/tests/server/retry-after-429.test.ts @@ -37,6 +37,13 @@ describe("resolveClientRetryAfter (#507)", () => { })).toBeUndefined(); }); + test("does not invent Retry-After for Zhipu 5-hour usage-cap 429s", () => { + expect(resolveClientRetryAfter({ + status: 429, + message: "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。", + })).toBeUndefined(); + }); + test("does not invent Retry-After for non-429 statuses", () => { expect(resolveClientRetryAfter({ status: 503, @@ -219,4 +226,20 @@ describe("consumeComboFailure Retry-After separation (#507 review)", () => { expect(failure.response.headers.get("Retry-After")).toBe("45"); expect(failure.retryAfter).toBe("45"); }); + + test("Zhipu 5-hour usage-cap 429 surfaces the upstream reason and does not look retryable", async () => { + const zhipuCap = "已达到 5 小时使用上限,2026-09-09 18:56:03 后可继续使用。"; + const upstream = new Response(JSON.stringify({ + error: { message: zhipuCap, type: "upstream_error", code: null }, + }), { status: 429 }); + const failure = await consumeComboFailure(upstream); + expect(failure.response.status).toBe(400); + expect(failure.response.headers.get("Retry-After")).toBeNull(); + const json = await failure.response.json() as { error?: { message?: string; code?: string; type?: string } }; + expect(json.error).toMatchObject({ + message: zhipuCap, + type: "usage_limit_exceeded", + code: "usage_limit_exceeded", + }); + }); });