diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 2b22e3724b9..f512988cdb1 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -699,6 +699,7 @@ provider, see [Fallback off](/guides/fallback-off/). | HTTP 410 with an explicit model end-of-life, retired, deprecated, sunset, decommissioned, or no-longer-available signal | Cool that target and hop. Unrelated 410 responses remain terminal. | | Classified authentication, subscription, quota, rate-limit, overload, or upstream-server error | Cool the target and hop, even when the status alone is not sufficient. | | Confirmed context-window overflow before output, including HTTP 400 | Try the next untried eligible target without cooling this target; another model may have a larger window. Exhaustion returns the first confirmed HTTP 400 context-length error; a context refusal sent with another status still advances but is not retained. | +| HTTP 400 or 422 moderation verdict from the provider or a gateway (a structured `moderation_refusal` or `content_filter` code; moderation wording without such a code still stops) | Try the next target without cooling this one; another provider's filter may accept the prompt. A body that also carries a terminal refusal (cyber or bio policy, `invalid_prompt`, `content_policy_violation`, safety-system wording) still stops. | | Client cancellation (499), `origin_rejected`, cyber-policy refusal, or other invalid request | Stop and return the error; another target would not make the request valid. | | Exact HTTP 400 saying a model is not supported when using Codex with a ChatGPT account | Try the next declared target before output commitment. The message must be the whole refusal in `detail`, `error.message`, or bare text; competing `detail` and `error` envelopes stay terminal. This does not quarantine the account or cool every model on the provider. | | Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | For an effort refusal, try the next lower published rung on the same target while the send budget allows; if no rung works, hop before output commitment without cooling if the refusal matches a recognized compatibility shape, otherwise stay terminal. Other listed mismatches hop without cooling; see request-local target compatibility below. | diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index ecd110db6bb..b86d67b61b4 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -128,6 +128,7 @@ ocx combo set balanced \ | HTTP 401、403、404、408、429、または任意の 5xx |ターゲットを冷却し、次の適格なターゲットに移動します。 | |モデルのサポート終了、retired、deprecated、sunset、decommissioned、または利用不可を明示する HTTP 410 |そのターゲットをクールダウンし、次へ進みます。無関係な 410 はターミナル エラーのままです。 | |機密認証、サブスクリプション、クォータ、レート制限、過負荷、またはアップストリーム サーバー エラー |ステータスだけでは物足りない場合でもターゲットを冷やしてホップさせましょう。 | +| プロバイダーまたはゲートウェイの HTTP 400 / 422 モデレーション判定(構造化された `moderation_refusal` または `content_filter` コード。コードのないモデレーションの文言だけでは停止します) |このターゲットを冷却せずに次のターゲットを試行します。別のプロバイダーのフィルターはプロンプトを受け入れる可能性があります。同じ本文に終了扱いの拒否(サイバーまたはバイオポリシー、`invalid_prompt`、`content_policy_violation`、安全システムの文言)が含まれる場合は停止します。 | |クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | | 出力前に確認したコンテキスト超過(HTTP 400 を含む) | クールダウンせず次の未試行の適格な対象へ進み、使い切った場合は最初に確認した HTTP 400 のコンテキストエラーを返します(他のステータスのコンテキスト拒否も次へ進みますが、保持されません)。 | | ChatGPT アカウントで Codex を使う際に、そのモデルをサポートしないことを示す正確な HTTP 400 の拒否 | 出力を確定する前に、次の設定済みターゲットを試します。拒否文全体が `detail`、`error.message`、または本文そのものにある場合に限ります。`detail` と `error` が競合する応答は終了扱いです。この拒否だけでアカウントを再認証待ちにしたり、プロバイダーの全モデルをクールダウンしたりしません。 | diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index e9a9e2c116e..53589443fda 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -134,6 +134,7 @@ ocx combo set balanced \ | HTTP 401, 403, 404, 408, 429, 또는 모든 5xx | 대상을 쿨다운으로 보내고 다음 적합한 대상으로 넘어갑니다. | | 모델 수명 종료, retired, deprecated, sunset, decommissioned, 또는 더 이상 사용할 수 없다는 신호가 명시된 HTTP 410 | 해당 대상만 쿨다운으로 보내고 다음 대상으로 넘어갑니다. 관련 없는 410은 종결 오류로 유지합니다. | | 인증, 구독, 쿼터, 속도 제한, 과부하, 또는 상위 서버 오류로 분류됨 | 상태 코드만으로는 충분하지 않더라도 대상을 쿨다운으로 보내고 넘어갑니다. | +| 제공자나 게이트웨이의 HTTP 400 또는 422 moderation 판정(구조화된 `moderation_refusal` 또는 `content_filter` 코드. 코드 없이 moderation 문구만 있으면 계속 멈춥니다) | 이 대상을 쿨다운하지 않고 다음 대상을 시도합니다. 다른 제공자의 필터는 프롬프트를 받아들일 수 있습니다. 같은 본문에 종결 거부(cyber 또는 bio 정책, `invalid_prompt`, `content_policy_violation`, 안전 시스템 문구)가 함께 있으면 계속 멈춥니다. | | 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | | 출력 전 확인된 컨텍스트 초과 오류(HTTP 400 포함) | 쿨다운 없이 다음 미시도 적격 대상을 시도합니다. 모두 실패하면 처음 확인된 HTTP 400 컨텍스트 오류를 반환합니다(다른 상태 코드의 컨텍스트 거부도 다음 대상으로 넘어가지만 보관되지 않습니다). | | ChatGPT 계정으로 Codex를 사용할 때 해당 모델을 지원하지 않는다는 정확한 HTTP 400 거부 | 출력이 시작되기 전에 다음 설정 대상으로 넘어갑니다. 거부 문장 전체가 `detail`, `error.message` 또는 일반 텍스트에 있어야 하며, `detail`과 `error`가 충돌하면 중단합니다. 이 사유만으로 계정을 재인증 상태로 바꾸거나 공급자의 모든 모델에 쿨다운을 걸지는 않습니다. | diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 6d72cf00f1a..4b2316f031f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -154,6 +154,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | HTTP 401、403、404、408、429,或任何 5xx | 使该目标进入冷却,并跳转到下一个合格目标。 | | HTTP 410,并明确表明模型已到生命周期终点、retired、deprecated、sunset、decommissioned 或不再可用 | 仅冷却该目标并继续跳转。无关的 410 仍然是终止错误。 | | 被分类为认证、订阅、配额、速率限制、过载或上游服务器错误 | 即使仅凭状态码不足以判断,也会使该目标进入冷却并跳转。 | +| 提供方或网关的 HTTP 400 或 422 审核判定(结构化的 `moderation_refusal` 或 `content_filter` 代码;只有审核文字而没有该代码时仍会停止) | 不冷却该目标,尝试下一个目标;其他提供方的过滤器可能接受该提示。若同一响应体中还带有终止性拒绝(cyber 或 bio 策略、`invalid_prompt`、`content_policy_violation`、安全系统文字),仍会停止。 | | 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | | 输出前确认的上下文超限(包括 HTTP 400) | 不冷却该目标,尝试下一个尚未尝试的合格目标;耗尽后返回第一个确认的 HTTP 400 上下文错误(其他状态码的上下文拒绝也会切换到下一个目标,但不会被保留)。 | | HTTP 400 明确表示使用 ChatGPT 账户运行 Codex 时不支持该模型 | 在输出提交前尝试下一个已配置目标。完整拒绝句必须位于 `detail`、`error.message` 或纯文本中;同时包含 `detail` 与 `error` 的混合封装仍会终止请求。此原因本身不会将账户标为需要重新登录,也不会让供应商的所有模型进入冷却。 | diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 12c565a4524..dcbf75d4b77 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -1,5 +1,5 @@ import { parseResetCooldownMs } from "../codex/routing"; -import { classifyError, isCyberPolicyCode, isTerminalRefusalCode } from "../lib/errors"; +import { CYBER_POLICY_FALLBACK_MESSAGE, TERMINAL_REFUSAL_FALLBACK_MESSAGE, classifyError, isCyberPolicyCode, isCyberPolicyMessage, isRateLimitOrQuotaFailureMessage, isSubscriptionGateMessage, isTerminalRefusalCode, safetyRefusalCodeFromMessage } from "../lib/errors"; import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal"; @@ -988,6 +988,321 @@ function isResponseFormatCapabilityRefusal( return false; } +/** The classification text the combo path bounds a body to (src/server/responses/core-errors.ts). */ +const MODERATION_EVIDENCE_LIMIT = 500; + +/** The structured codes that state a provider or gateway moderation verdict. */ +const MODERATION_REFUSAL_CODES = new Set(["moderation_refusal", "content_filter"]); + +/** + * Every other code or type a moderation refusal body may carry. Anything outside this list, at any + * depth, is evidence of something else and the body keeps dev's decision: the hop is granted only + * to bodies whose structured fields all agree that this is a moderation refusal. + */ +const MODERATION_COMPATIBLE_VALUES = new Set([ + ...MODERATION_REFUSAL_CODES, + "invalid_request_error", "error", "ai_apicallerror", "responsibleaipolicyviolation", "400", "422", +]); + +/** Keys whose values are structured verdicts; folded (lowercase, underscores and hyphens removed). */ +const MODERATION_VERDICT_KEYS = new Set(["code", "type", "errorcode", "errortype", "reason"]); + +/** Fold a key for comparison: lowercase, underscores and hyphens removed. */ +function foldKey(key: string): string { + return key.toLowerCase().replace(/[_-]/g, ""); +} + +/** Keys that state an HTTP status; any stated status other than 400/422 is evidence of its own. */ +const MODERATION_STATUS_KEYS = new Set(["status", "statuscode", "httpstatus", "httpstatuscode"]); + +/** Identifier fields a refusal envelope may carry: a bare token or number, never prose. */ +const MODERATION_ID_KEYS = new Set(["id", "requestid", "object", "model", "name", "sequencenumber", "createdat"]); + +/** + * Keys a gateway's routing metadata (providerMetadata) must not use: a field that names a state, + * status, code or safety verdict is evidence of its own, so only plain routing tokens pass. + */ +const ROUTING_METADATA_EVIDENCE_KEY = /safety|violation|block|polic|account|suspend|disabl|deactivat|ban|status|state|error|code|reason|quota|billing|limit|credit|payment/i; + +/** + * Azure's per-category filter results: the moderation verdict's own detail. Only its own shape + * passes: category objects of flags and numbers, a `severity` of safe/low/medium/high, bare + * `id`/`url`/`license` tokens, and compatible codes. Any other text, or a key that names a + * state, status, error or message, is evidence of its own. + */ +const MODERATION_RESULT_KEY = /^(?:contentfilterresults?|promptfilterresults)$/; +const FILTER_RESULT_EVIDENCE_KEY = /account|status|state|quota|billing|payment|credit|limit|error|message|reason|note|description|detail(?!s$)/i; + +/** + * Response-object fields of a Responses terminal event (`response.failed`) that echo the caller's + * request or report usage. They say nothing about the failure and are not read. + */ +const RESPONSE_REQUEST_ECHO_KEYS = new Set([ + "instructions", "tools", "toolchoice", "text", "reasoning", "input", "output", "prompt", "include", + "user", "safetyidentifier", "promptcachekey", "promptcacheretention", "previousresponseid", "conversation", + "temperature", "topp", "toplogprobs", "maxoutputtokens", "maxtoolcalls", "paralleltoolcalls", "truncation", + "store", "servicetier", "background", "usage", +]); + +/** + * The whole prose a moderation refusal may carry, after whitespace is collapsed: one verdict + * sentence (the gateway's own refusal copy, OpenAI's and Azure's included), optionally followed by + * the category list, the advice to change the prompt and retry, and Azure's documentation pointer. + * Any other wording (an account, model, parameter or billing problem, in any phrasing) does not + * match, so the body keeps dev's decision; only known shapes are admitted. + */ +const MODERATION_REFUSAL_PROSE = new RegExp("^(?:" + + "(?:content )?(?:refused|flagged|blocked|rejected|filtered)(?: by (?:the )?(?:moderation|content filter))?\\.?" + + "|(?:(?:the|your) )?(?:input|request|prompt|content|message|response|output)(?: (?:was|has been|is))?" + + " (?:refused|flagged|blocked|rejected|filtered)(?: by (?:the )?(?:moderation|content filter)(?: system)?)?\\.?" + + "|the response was filtered due to the prompt triggering azure openai'?s content management policy\\.?" + + "|(?:your )?(?:input|request|prompt|content) violated (?:the )?content (?:safety|management|filtering) polic(?:y|ies)\\.?" + + ")(?:" + + " categor(?:y|ies): [a-z][a-z/_-]*(?:(?:,? and |, )[a-z][a-z/_-]*)*\\.?" + + "| please (?:modify|revise|rephrase|change|adjust|edit) (?:your|the) (?:prompt|input|request|message)(?: and (?:retry|try again))?\\.?" + + "| (?:please )?(?:retry|try again)(?: later)?\\.?" + + "| to learn more about our content filtering policies please read our documentation: https://[^ ]+" + + ")*$", "i"); + +/** Whether a prose string is a known moderation refusal copy and nothing else (bounded, linear). */ +function isModerationRefusalProse(text: string): boolean { + if (text.length > 2_000) return false; + return MODERATION_REFUSAL_PROSE.test(text.replace(/\s+/g, " ").trim()); +} + +/** + * Safety-system wording in any of its upstream phrasings ("rejected as a result of our safety + * system", "for safety reasons", "considered high risk"). A safety refusal is the upstream's own + * terminal verdict, broader than the exact copies safetyRefusalCodeFromMessage names. + */ +const SAFETY_SYSTEM_WORDING = /\bsafety[\s_-]+(?:systems?|reasons?)\b|\bhigh[\s_-]+risk\b/i; + +/** Billing, quota and account-state wording: evidence about the provider, not about the prompt. */ +const PROVIDER_STATE_WORDING = + /\bcredit[\s_-]+balance\b|\bquota\b|\bbilling\b|\bpayment[\s_-]+required\b|\binsufficient[\s_-]+(?:quota|balance|funds|credits?)\b|\b(?:account|organi[sz]ation|api[\s_-]*key)\b[^.]{0,40}?\b(?:suspended|disabled|deactivated|banned|inactive|not[\s_-]+active)\b|\brate[\s_-]+limit/i; + +/** A refusal names the prompt or input it refused; any other `param` is a validation error. */ +function isAllowedModerationParam(param: string): boolean { + const p = param.trim().toLowerCase(); + return p === "prompt" || p === "input" || p === "messages" + || p.startsWith("input[") || p.startsWith("input.") || p.startsWith("messages[") || p.startsWith("messages."); +} + +/** + * Any hard-refusal token in any spelling. A moderation code beside one is not evidence that another + * target may serve the request: the client must not retry these. + */ +const HARD_STOP_TOKEN = + /(? String.fromCharCode(Number.parseInt(hex, 16))); +} + +/** + * Whether any hard-refusal token appears in any spelling: JSON escapes decoded (including \n, \t + * and \r), camel humps split, and hyphens, dots, slashes and whitespace folded to underscores. + */ +function namesHardStopToken(text: string): boolean { + return HARD_STOP_TOKEN.test(withJsonEscapesDecoded(text) + .replace(/\\[ntr]/g, "_") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[-.\s/]+/g, "_")); +} + +/** Whether the caller's status and structured code leave room for a moderation hop at all. */ +function moderationCodeAllowsHop(status: number, normalizedCode: string): boolean { + if (status !== 400 && status !== 422) return false; + return normalizedCode === "" || MODERATION_COMPATIBLE_VALUES.has(normalizedCode); +} + +/** + * The moderation hop for one decision. A caller that read the complete bounded body (the combo + * failure reader, the stream preflight, the policy path) passes its whole-body verdict; the + * structured code of this decision can still veto it. Without one, the message decides. + */ +function providerModerationHop( + status: number, message: string, rawCode: string | null | undefined, wholeBody?: boolean, malformedResponseFormat?: boolean, +): boolean { + // A malformed response_format diagnostic the reader saw in the whole body stays terminal. + if (malformedResponseFormat === true) return false; + if (wholeBody === undefined) return isProviderModerationRefusal(status, message, rawCode); + return wholeBody && moderationCodeAllowsHop(status, normalizedFailureCode(rawCode)); +} + +/** Strip this proxy's wrapper prefixes and a single-line SSE data field; undefined unless JSON. */ +function moderationEnvelope(message: string): unknown { + let text = message.trim(); + for (let depth = 0; depth < 3; depth += 1) { + const prefix = ["Provider error 400: ", "Provider error 422: ", "Provider error: ", "Gateway error 400: ", "Gateway error 422: ", "Gateway error: "] + .find(candidate => text.startsWith(candidate)); + if (prefix) text = text.slice(prefix.length).trim(); + if (text.startsWith("data:") && !text.includes("\n")) text = text.slice("data:".length).trim(); + } + try { return JSON.parse(text); } catch { return undefined; } +} + +/** + * Walk a refusal envelope (bounded) against the closed shape of a moderation refusal. Returns whether + * a moderation code was seen, or null when the body carries anything else: an unknown field, prose + * that is not refusal copy, a param outside the prompt or input, a stated status other than 400/422, + * an incompatible code, or more nesting than a refusal envelope has. Only known shapes hop; anything + * unrecognized keeps dev's decision. + */ +function moderationVerdictFields(payload: unknown): boolean | null { + let seen = false; + let nodes = 0; + const verdict = (field: unknown, key: string, depth: number): boolean => { + if (field === null || field === undefined) return true; + // A Responses terminal event names itself in its root `type` (`response.failed`): that is the + // transport's label for the carrier, not the upstream's verdict. + if (depth === 0 && key === "type" && typeof field === "string" && /^response\.[a-z_.]+$/.test(field)) return true; + if (typeof field !== "string" && typeof field !== "number") return false; + const normalized = normalizedFailureCode(field); + if (normalized === "") return typeof field === "string"; + if (!MODERATION_COMPATIBLE_VALUES.has(normalized)) return false; + if (MODERATION_REFUSAL_CODES.has(normalized)) seen = true; + return true; + }; + const visitFilterResult = (value: unknown, depth: number): boolean => { + if (++nodes > 512 || depth > 8) return false; + if (value === null || typeof value === "boolean" || typeof value === "number") return true; + if (Array.isArray(value)) return value.every(item => visitFilterResult(item, depth + 1)); + if (typeof value !== "object") return false; + return Object.entries(value as Record).every(([key, field]) => { + const folded = foldKey(key); + if (MODERATION_VERDICT_KEYS.has(folded)) return verdict(field, key, depth); + if (!/^[a-z][a-z0-9_]*$/i.test(key) || FILTER_RESULT_EVIDENCE_KEY.test(key)) return false; + if (typeof field === "string") { + return folded === "severity" ? /^(?:safe|low|medium|high)$/i.test(field) + : (folded === "id" || folded === "url" || folded === "license") && !/\s/.test(field); + } + return visitFilterResult(field, depth + 1); + }); + }; + // A gateway's routing metadata: plain tokens, numbers and flags only, under keys that name no state. + const visitRoutingMetadata = (value: unknown, depth: number): boolean => { + if (++nodes > 512 || depth > 8) return false; + if (value === null || typeof value === "number" || typeof value === "boolean") return true; + if (typeof value === "string") return !/\s/.test(value); + if (Array.isArray(value)) return value.every(item => visitRoutingMetadata(item, depth + 1)); + if (typeof value !== "object") return false; + return Object.entries(value as Record).every(([key, field]) => + !ROUTING_METADATA_EVIDENCE_KEY.test(key) && visitRoutingMetadata(field, depth + 1)); + }; + const visit = (value: unknown, depth: number, responseObject: boolean): boolean => { + if (++nodes > 512 || depth > 8) return false; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + const responseCarrier = depth === 0 && typeof record.type === "string" && /^response\.[a-z_.]+$/.test(record.type); + for (const [key, field] of Object.entries(record)) { + const folded = foldKey(key); + if (responseObject && RESPONSE_REQUEST_ECHO_KEYS.has(folded)) continue; + if (MODERATION_VERDICT_KEYS.has(folded)) { + if (!verdict(field, key, depth)) return false; + } else if (MODERATION_STATUS_KEYS.has(folded)) { + if (field === null) continue; + const stated = typeof field === "number" ? field + : typeof field === "string" && /^\d{3}$/.test(field.trim()) ? Number(field) : undefined; + if (stated !== undefined ? stated !== 400 && stated !== 422 + : !(typeof field === "string" && /^(?:failed|incomplete|error)$/i.test(field.trim()))) return false; + } else if (folded === "message") { + if (field !== null && field !== "" && !(typeof field === "string" && isModerationRefusalProse(field))) return false; + } else if (folded === "param") { + // A param names what the filter inspected: the prompt or input. Any other param is a + // validation error. + if (field !== null && typeof field === "object" && !Array.isArray(field)) { + // An SDK error object in the param slot (an AI gateway's wrapped error) is read as a record. + if (!visit(field, depth + 1, false)) return false; + } else if (field !== null && field !== "" && !(typeof field === "string" && isAllowedModerationParam(field))) return false; + } else if (MODERATION_ID_KEYS.has(folded)) { + if (field !== null && typeof field !== "number" && !(typeof field === "string" && !/\s/.test(field))) return false; + } else if (folded === "isretryable") { + if (field !== null && typeof field !== "boolean") return false; + } else if (depth === 0 && folded === "providermetadata") { + if (!visitRoutingMetadata(field, depth + 1)) return false; + } else if (MODERATION_RESULT_KEY.test(folded)) { + if (!visitFilterResult(field, depth + 1)) return false; + } else if (folded === "error") { + if (field === null) continue; + if (typeof field === "string" ? !isModerationRefusalProse(field) : !visit(field, depth + 1, false)) return false; + } else if (folded === "innererror" || (responseObject && folded === "incompletedetails")) { + if (field !== null && !visit(field, depth + 1, false)) return false; + } else if (responseObject && folded === "metadata") { + // The caller's own request metadata; a refusal never needs it, so only an empty one passes. + if (field !== null && !(typeof field === "object" && !Array.isArray(field) && Object.keys(field).length === 0)) return false; + } else if (responseCarrier && key === "response") { + if (!visit(field, depth + 1, true)) return false; + } else { + return false; + } + } + return true; + }; + return visit(payload, 0, false) ? seen : null; +} + +/** + * A provider or gateway moderation refusal on HTTP 400/422 is target-local in a heterogeneous + * combo: this provider's filter refused the prompt, and another target may serve it. + * + * Granted narrowly, on structured evidence only. The verdict must be a structured + * `moderation_refusal` or `content_filter` code (the caller's or one in the body), every other + * code or type anywhere in the body must be compatible with it, and no hard-refusal token, safety or + * terminal copy, malformed response_format diagnostic, subscription gate, or anything the shared + * classifier files more specifically than a generic invalid request may appear. Prose alone never + * hops: wording can be echoed back, and a body that carries both prose and another signal is not + * evidence that the next target may serve the request. + */ +export function isProviderModerationRefusal( + status: number, + message: string, + code?: string | null, + /** True when `message` is the complete bounded body rather than a possibly cut classification text. */ + complete = false, +): boolean { + const normalizedCode = normalizedFailureCode(code); + if (!moderationCodeAllowsHop(status, normalizedCode)) return false; + const callerStatesModeration = MODERATION_REFUSAL_CODES.has(normalizedCode); + if (!message) return callerStatesModeration; + // Hop only on evidence seen whole: the combo path classifies at most the first + // MODERATION_EVIDENCE_LIMIT characters of a body, and the body readers pass `complete` instead. + if (!complete && message.length >= MODERATION_EVIDENCE_LIMIT) return false; + // The same bound as the bounded body readers (64 KiB), so no carrier silently loses a refusal. + if (message.length > 65_536) return false; + const lower = message.toLowerCase(); + if (namesHardStopToken(message) || isHardPolicyOrOriginRefusal(message) + || safetyRefusalCodeFromMessage(message) !== undefined || SAFETY_SYSTEM_WORDING.test(message) + || isCyberPolicyMessage(message) + || lower.includes(CYBER_POLICY_FALLBACK_MESSAGE.toLowerCase()) + || lower.includes(TERMINAL_REFUSAL_FALLBACK_MESSAGE.toLowerCase()) + || isMalformedResponseFormatRequest(status, message) + || isSubscriptionGateMessage(lower) + // Quota, billing and account-state wording is provider evidence with its own cooldown, even + // where the classifier files a 400 carrying it as a generic invalid request. + || isRateLimitOrQuotaFailureMessage(message) || isAnthropicBillingFailure(400, message, undefined) + || PROVIDER_STATE_WORDING.test(message) + // Classified as a 400: classifyError maps an untyped 422 to upstream_error. + || classifyError(400, "upstream_error", message).code !== "invalid_request_error") { + return false; + } + const envelope = moderationEnvelope(message); + if (envelope === undefined) { + // Plain text carries no structured fields; only the caller's code can state the verdict, and a + // body that looks like JSON but does not parse (cut or malformed) never hops. + return callerStatesModeration && !/^\s*(?:data:\s*)?[[{]/.test(message) && isModerationRefusalProse(message); + } + // An envelope root that states a verdict code of its own (invalid_request_error, origin, + // context length, cancellation...) keeps it, as everywhere else in this file. + if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) { + const rootCode = (envelope as Record).code; + if (typeof rootCode === "string" && rootCodeStatesVerdict(rootCode)) return false; + } + const seen = moderationVerdictFields(envelope); + return seen !== null && (seen || callerStatesModeration); +} + /** Return the narrowest cooldown scope supported by this target's failure evidence. */ export function comboFailureCooldownScope( status: number, @@ -996,6 +1311,8 @@ export function comboFailureCooldownScope( code?: string | number | null; malformedResponseFormat?: boolean; openRouterOpaque400?: boolean; + /** Whole-body moderation verdict from a reader that saw the complete bounded body. */ + providerModerationRefusal?: boolean; }, ): ComboFailureCooldownScope { const rawCode = typeof options?.code === "number" ? String(options.code) : options?.code; @@ -1021,6 +1338,9 @@ export function comboFailureCooldownScope( // through the same provider row carries the same key and will fail identically. if (status === 401 || status === 402 || status === 403) return "provider"; if (PROVIDER_SCOPED_FAILURE_CODES.has(code)) return "provider"; + // A moderation verdict is target-local, but only after quota and credential evidence had its + // say: a refusing key beside moderation prose still cools its provider. + if (providerModerationHop(status, message, rawCode, options?.providerModerationRefusal, options?.malformedResponseFormat)) return "none"; // OpenRouter's opaque numeric-code wrapper may be target-specific (for example a gateway // rejecting a model's translated payload). It may advance the combo, but says nothing about // target health and must not quarantine the target. @@ -1158,6 +1478,8 @@ export function comboFailureDecision( verifiedPlanRefusal?: boolean; malformedResponseFormat?: boolean; openRouterOpaque400?: boolean; + /** Whole-body moderation verdict from a reader that saw the complete bounded body. */ + providerModerationRefusal?: boolean; }, ): ComboFailureDecision { const rootRawCode = typeof options?.rootCode === "number" ? String(options.rootCode) : options?.rootCode; @@ -1184,6 +1506,8 @@ function decideComboFailure( verifiedPlanRefusal?: boolean; malformedResponseFormat?: boolean; openRouterOpaque400?: boolean; + /** Whole-body moderation verdict from a reader that saw the complete bounded body. */ + providerModerationRefusal?: boolean; }, ): ComboFailureDecision { if (status === 499) return "stop"; @@ -1272,6 +1596,7 @@ function decideComboFailure( // reaches that list and returns terminal before anything can ask whether the next target // could have served the request (#4903). if (isResponseFormatCapabilityRefusal(status, message, rawCode)) return "hop"; + if (providerModerationHop(status, message, rawCode, options?.providerModerationRefusal, options?.malformedResponseFormat)) return "hop"; if (isAnthropicBillingFailure(status, message, rawCode)) return "hop"; // Kiro's body validator rejects the request for the identity it was routed under. The account // layer rotates logins first; this is the no-alternate case, so the next combo target is the diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 375c233bdc4..161834fe721 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -214,7 +214,7 @@ export function safetyRefusalCodeFromMessage(text: string): string | undefined { return undefined; } -function isSubscriptionGateMessage(text: string): boolean { +export function isSubscriptionGateMessage(text: string): boolean { return ( text.includes("requires a subscription") || text.includes("requires subscription") || diff --git a/src/server/messages-native-preflight.ts b/src/server/messages-native-preflight.ts index 94714bcf1fb..524a609be4c 100644 --- a/src/server/messages-native-preflight.ts +++ b/src/server/messages-native-preflight.ts @@ -1,4 +1,7 @@ import { comboFailureDecision } from "../combos"; +import { isProviderModerationRefusal } from "../combos/failover"; +import { attachComboFailureEvidence } from "../lib/client-error-body"; +import { explicitErrorEventStatus } from "./responses/combo-stream-preflight"; import { httpStatusFromTerminalError } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { sseFieldValue } from "../lib/sse-decoder"; @@ -70,6 +73,8 @@ export async function preflightNativeMessagesResponse( let terminal = false; let failure: Rec | undefined; let failureStatus = 502; + /** The whole error event, kept when a moderation verdict decided the hop. */ + let failureEvidence: string | undefined; let usage: Rec = {}; const inspector = new BoundedSseFrameBuffer(); const decoder = new TextDecoder(); @@ -93,13 +98,29 @@ export async function preflightNativeMessagesResponse( // Protocol liveness is not output. } else if (payload.type === "error" && isRec(payload.error)) { const error = payload.error; - const status = httpStatusFromTerminalError({ type: typeof error.type === "string" ? error.type : undefined, + const wholeEvent = JSON.stringify(payload); + // A status the event states for itself (any spelling the responses preflight reads) is + // evidence of its own: a framed 429 or 503 is never read as a moderation refusal. + const framed = explicitErrorEventStatus(payload); + const framedAllowsModeration = framed === undefined || framed === 400 || framed === 422; + // Otherwise a structured moderation verdict on the whole event is a request-level refusal, + // whatever status the terminal mapping infers for its code; judge it like the HTTP reader. + const moderation = framedAllowsModeration && isProviderModerationRefusal(400, wholeEvent, undefined, true); + const mapped = httpStatusFromTerminalError({ type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined, message: typeof error.message === "string" ? error.message : undefined }) ?? 502; + const status = moderation && mapped !== 400 && mapped !== 422 ? 400 : mapped; if (logCtx.terminalSource !== "synthetic" && logCtx.errorCode !== UNDECLARED_TOOL_CALL_ERROR_CODE && comboFailureDecision(status, typeof error.message === "string" ? error.message : "", { code: typeof error.code === "string" ? error.code : undefined, - }) === "hop") { failure = error; failureStatus = status; } + rootCode: typeof payload.code === "string" ? payload.code : undefined, + // The whole event, as the HTTP and stream readers judge it, so transports cannot disagree. + providerModerationRefusal: status === 400 || status === 422 + ? framedAllowsModeration && isProviderModerationRefusal(status, wholeEvent, undefined, true) : undefined, + }) === "hop") { + failure = error; failureStatus = status; + failureEvidence = moderation && (status === 400 || status === 422) ? wholeEvent : undefined; + } else committed = true; } else if (payload.type === "message_stop") terminal = true; else committed = true; @@ -140,11 +161,13 @@ export async function preflightNativeMessagesResponse( release(); const headers = new Headers(response.headers); headers.set("content-type", "application/json"); headers.delete("content-length"); headers.delete("content-encoding"); - return { kind: "failed", response: Response.json({ error: { + const failed = Response.json({ error: { ...(typeof failure.type === "string" ? { type: failure.type } : {}), ...(typeof failure.code === "string" ? { code: failure.code } : {}), message, - } }, { status: failureStatus, headers }) }; + } }, { status: failureStatus, headers }); + // The rebuilt envelope drops the evidence (root code, innererror) the verdict was judged on. + return { kind: "failed", response: failureEvidence === undefined ? failed : attachComboFailureEvidence(failed, failureEvidence) }; } let index = 0; const body = new ReadableStream({ diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index a8a7398cbdf..16f85248867 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -6,6 +6,7 @@ import { hasConflictingCodexModelRefusalEnvelopes, isMalformedResponseFormatRecord, isOpenRouterOpaque400EnvelopeRoot, + isProviderModerationRefusal, isOpenRouterOpaque400Record, type CodexAccountModelRefusal, } from "../../combos/failover"; @@ -20,7 +21,7 @@ import { createSseInspector } from "../relay"; import { sseDataPayload } from "../sse-payload-rewrite"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; import { REQUIRED_TOOL_CONTRACT_CODE } from "../../lib/errors"; -import { isPausedTurn } from "../../lib/client-error-body"; +import { attachComboFailureEvidence, isPausedTurn } from "../../lib/client-error-body"; import { requiredToolReasoningPrelude } from "./combo-required-tool-stream"; import { ProtocolPreOutputEof } from "../../lib/upstream-retry"; @@ -193,8 +194,29 @@ const RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS = new Set([ "upstream_stall_timeout", ]); -// SSE uses the last event field, with an empty value resetting it to the default. -// A mismatched or unreadable frame is not a proven lifecycle prelude. +export function explicitErrorEventStatus(payload: unknown): number | undefined { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const event = payload as Record; + const nested = event.error; + const error = nested && typeof nested === "object" && !Array.isArray(nested) + ? nested as Record + : event; + return [ + event.status, + event.status_code, + event.http_status, + error.status, + error.status_code, + error.http_status, + ] + .map(value => typeof value === "number" && Number.isInteger(value) + ? value + : typeof value === "string" && /^\d{3}$/.test(value.trim()) + ? Number(value) + : undefined) + .find(value => value !== undefined && value >= 400 && value <= 599); +} + function ambiguousSseFrame(frame: string): boolean { let event: string | undefined; let hasNonComment = false; @@ -228,20 +250,7 @@ function bareErrorStatus(payload: unknown): number | undefined { const error = nested && typeof nested === "object" && !Array.isArray(nested) ? nested as Record : event; - const explicitStatus = [ - event.status, - event.status_code, - event.http_status, - error.status, - error.status_code, - error.http_status, - ] - .map(value => typeof value === "number" && Number.isInteger(value) - ? value - : typeof value === "string" && /^\d{3}$/.test(value.trim()) - ? Number(value) - : undefined) - .find(value => value !== undefined && value >= 400 && value <= 599); + const explicitStatus = explicitErrorEventStatus(payload); const rawCode = error.code ?? event.code; const code = typeof rawCode === "string" ? rawCode @@ -249,6 +258,11 @@ function bareErrorStatus(payload: unknown): number | undefined { ? String(rawCode) : null; if (explicitStatus === undefined && code === "invalid_request_error") return 400; + // A structured moderation verdict with no stated status is the request-level 400 the HTTP and + // native readers judge it as, not an unknown code's 5xx. + if (explicitStatus === undefined && isProviderModerationRefusal(400, JSON.stringify(event), undefined, true)) { + return 400; + } // Status is the record's own evidence; whether that record speaks for the envelope is the // decision's question, so a root hard stop still reports 400 instead of inventing a 5xx. if (explicitStatus === undefined && isOpenRouterOpaque400Record(envelopeErrorRecord(event))) { @@ -312,12 +326,15 @@ function bareErrorIsRetryable(payload: unknown): boolean { }; const malformedResponseFormat = status === 400 ? isMalformedResponseFormatRecord(rawRecord, message) : undefined; const openRouterOpaque400 = status === 400 ? isOpenRouterOpaque400EnvelopeRoot(event) : undefined; + // The parsed event is the whole terminal record, so its moderation verdict is not cut short. + const providerModerationRefusal = status === 400 || status === 422 + ? isProviderModerationRefusal(status, JSON.stringify(event), undefined, true) : undefined; // A conflicting root and record code hop only when each would hop on its own, so a root hard // stop is never hidden behind a retryable record code (and a retryable pair keeps dev's hop). return comboFailureDecision(status, message, { code: recordCode ?? rootCode, rootCode, codexModelRefusal: terminalModelRefusal(status, event), - malformedResponseFormat, openRouterOpaque400, + malformedResponseFormat, openRouterOpaque400, providerModerationRefusal, }) === "hop"; } @@ -559,7 +576,14 @@ function failedTerminalResponse( ...(usage && typeof usage === "object" && !Array.isArray(usage) ? { usage } : {}), }, }), { - status: logCtx.terminalHttpStatus ?? bareErrorStatus(terminalPayload) ?? 502, + // A structured moderation verdict on any carrier (`error` or `response.failed`) is the + // request-level 400 the HTTP and native readers judge it as. The verdict already vetoes any + // stated status other than 400/422, so this only replaces a status the log tap inferred (the + // synthetic 502 for an unrecognized code). + status: (logCtx.terminalHttpStatus !== 400 && logCtx.terminalHttpStatus !== 422 + && isProviderModerationRefusal(400, JSON.stringify(terminalPayload), undefined, true)) + ? 400 + : logCtx.terminalHttpStatus ?? bareErrorStatus(terminalPayload) ?? 502, headers, }); } @@ -724,6 +748,17 @@ export async function preflightComboStreamResponse( && !outputCommitted && retryableTerminalPayload) { await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined); const failed = failedTerminalResponse(response, retryableTerminalPayload, logCtx); + // The rebuilt envelope keeps only the record. When either the whole frame or that slim + // envelope reads as a moderation verdict, the combo loop must judge the whole frame: a + // verdict needs the evidence the envelope dropped (a root code, an innererror), and a veto + // the envelope dropped (a root verdict, a nested hard code) must not turn into a hop. + if (failed.status === 400 || failed.status === 422) { + const whole = JSON.stringify(retryableTerminalPayload); + if (isProviderModerationRefusal(failed.status, whole, undefined, true) + || isProviderModerationRefusal(failed.status, await failed.clone().text(), undefined, true)) { + attachComboFailureEvidence(failed, whole); + } + } return { kind: "failed", response: failed, ...(failed.status === 400 ? { codexModelRefusal: terminalModelRefusal(failed.status, retryableTerminalPayload) } : {}) }; diff --git a/src/server/responses/core-combo-failure.ts b/src/server/responses/core-combo-failure.ts index 82955bbce20..b6753b16b1c 100644 --- a/src/server/responses/core-combo-failure.ts +++ b/src/server/responses/core-combo-failure.ts @@ -8,6 +8,7 @@ import { codexAccountModelRefusalHardStopCode, isMalformedResponseFormatRequest, isOpenRouterOpaque400Envelope, + isProviderModerationRefusal, upstreamEnvelopeRootCode, type CodexAccountModelRefusal, } from "../../combos/failover"; @@ -66,6 +67,7 @@ export async function consumeComboFailure( let codexModelRefusal: CodexAccountModelRefusal | undefined; let malformedResponseFormat: boolean | undefined; let openRouterOpaque400: boolean | undefined; + let providerModerationRefusal: boolean | undefined; let usage: OcxUsage | undefined; let upstreamCode: string | undefined; let upstreamRootCode: string | undefined; @@ -107,6 +109,11 @@ export async function consumeComboFailure( malformedResponseFormat = isMalformedResponseFormatRequest(response.status, body.text); openRouterOpaque400 = isOpenRouterOpaque400Envelope(body.text); } + // Judged on the whole body: the classification text below is cut at 500 characters, and a + // hard refusal after the cut must still veto a moderation hop. + if (response.status === 400 || response.status === 422) { + providerModerationRefusal = isProviderModerationRefusal(response.status, body.text, undefined, true); + } // Status-independent: the nested record is what normalization selects, so a root verdict // beside it has to be captured here or the conflict rule never sees it. upstreamRootCode = upstreamEnvelopeRootCode(body.text); @@ -189,6 +196,7 @@ export async function consumeComboFailure( ...(codexModelRefusal !== undefined ? { codexModelRefusal } : {}), ...(malformedResponseFormat !== undefined ? { malformedResponseFormat } : {}), ...(openRouterOpaque400 !== undefined ? { openRouterOpaque400 } : {}), + ...(providerModerationRefusal !== undefined ? { providerModerationRefusal } : {}), ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}), // A rewritten identity (policy, replay refusal) is the whole verdict; the root's own code // would only re-open a decision that rewrite already settled. diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index e872ff521bf..331c332ece8 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1449,6 +1449,7 @@ export async function executeComboResponses( verifiedPlanRefusal: failure.verifiedPlanRefusal, malformedResponseFormat: failure.malformedResponseFormat, openRouterOpaque400: failure.openRouterOpaque400, + providerModerationRefusal: failure.providerModerationRefusal, // The adapter the failed leg actually resolved: a configured `kiro` provider can route a // model through another wire via `modelAdapters`, and an unresolved leg grants no hop. failedAdapter: childLog.providerAdapter, @@ -1527,6 +1528,7 @@ export async function executeComboResponses( code: failure.upstreamCode, malformedResponseFormat: failure.malformedResponseFormat, openRouterOpaque400: failure.openRouterOpaque400, + providerModerationRefusal: failure.providerModerationRefusal, }); const failedTargetKey = targetKey(pick.target); let failedTargetCooldownRecorded = false; diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 976139eb7f8..1d7fd594d28 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -37,6 +37,8 @@ export interface ConsumedComboFailure { malformedResponseFormat?: boolean; /** Complete bounded-envelope OpenRouter opaque-400 verdict captured before display truncation. */ openRouterOpaque400?: boolean; + /** Complete bounded-envelope provider moderation verdict captured before display truncation. */ + providerModerationRefusal?: boolean; /** Structured upstream `error.code` when present in the failure body. */ upstreamCode?: string; /** diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 7db10d29f0f..1badfc77ee0 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -1,4 +1,4 @@ -import { comboFailureDecision } from "../../combos/failover"; +import { comboFailureDecision, isProviderModerationRefusal } from "../../combos/failover"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { isVerifiedModelNotInPlanResponse } from "../../lib/errors"; import { isShadowSourceModel } from "../../lib/shadow-call"; @@ -138,6 +138,9 @@ async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal code: upstreamCodeForComboFailure(response, errorCodeFromText(text)), // A passthrough candidate returns its upstream body unchanged, so the code is not provenance. verifiedPlanRefusal: isVerifiedModelNotInPlanResponse(response), + // The text is the whole bounded body, judged as the combo reader judges it. + ...(text && (response.status === 400 || response.status === 422) + ? { providerModerationRefusal: isProviderModerationRefusal(response.status, text, undefined, true) } : {}), }) === "hop"; } catch { return false; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 423de96bbd2..cb8c23e618e 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -587,7 +587,7 @@ Account-row projection is loaded with `import()` after each account probe in `sr ## Combo model refusal evidence -`src/combos/failover.ts` recognizes a bounded, exact HTTP 400 plan/model refusal, not credential or entitlement evidence. It also permits OpenRouter's exact opaque numeric-400 wrapper (`Provider returned error` with `metadata.raw: "ERROR"`, without `type`) to try another combo target. Typed uncoded 400s, malformed `response_format` schema errors and other terminal `invalid_request_error` cases stop and cool the target. An envelope root that states a terminal code of its own stops too (`origin_rejected`, `content_policy_violation`, `context_length_exceeded`, `invalid_request_error`, the shared terminal-refusal set and the non-replayable post-send codes, not a blanket inequality); a root that states only an ordinary hop signal adds no verdict, and an empty code counts as absent. The root code and the record it wraps are both consulted, so a hop needs each to authorize one and a root hard stop is never hidden behind a retryable nested record. Request-local target incompatibilities recognized by `isRequestLocalTargetIncompatibility` and `isResponseFormatCapabilityRefusal` may advance without cooling. +`src/combos/failover.ts` recognizes a bounded, exact HTTP 400 plan/model refusal, not credential or entitlement evidence. It also permits OpenRouter's exact opaque numeric-400 wrapper (`Provider returned error` with `metadata.raw: "ERROR"`, without `type`) to try another combo target. Typed uncoded 400s, malformed `response_format` schema errors and other terminal `invalid_request_error` cases stop and cool the target. An envelope root that states a terminal code of its own stops too (`origin_rejected`, `content_policy_violation`, `context_length_exceeded`, `invalid_request_error`, the shared terminal-refusal set and the non-replayable post-send codes, not a blanket inequality); a root that states only an ordinary hop signal adds no verdict, and an empty code counts as absent. The root code and the record it wraps are both consulted, so a hop needs each to authorize one and a root hard stop is never hidden behind a retryable nested record. Request-local target incompatibilities recognized by `isRequestLocalTargetIncompatibility` and `isResponseFormatCapabilityRefusal` may advance without cooling. So may a provider or gateway moderation verdict on HTTP 400/422 (`isProviderModerationRefusal`), granted on structured evidence only and only for the known shape of a refusal: a `moderation_refusal` or `content_filter` code (the caller's or one in the body) and nothing else. The walk (`moderationVerdictFields`) admits a closed set of fields: verdict codes and types on a short compatible list, a stated status of 400/422 (or a Responses `failed`/`incomplete` state), a `param` naming the prompt or input (or an AI SDK error object read as a record), bare identifiers (`id`, `request_id`, `model`, `name`...), `innererror`, Azure's per-category filter results in their own shape only (category objects of flags and numbers, a `severity` of safe/low/medium/high, bare `id`/`url`/`license` tokens and compatible codes; any other text, or a key naming a state, status, error or message, stops), and a gateway's `providerMetadata` holding plain routing tokens under keys that name no state. Any other field stops. Prose (`message`, a string `error`, plain text with a caller's code) must be known refusal copy as a whole, after whitespace is collapsed: one verdict sentence (OpenAI's "The input was refused by moderation", Azure's content-management sentence, a bare "filtered"...) optionally followed by the category list, the advice to change the prompt and retry, and Azure's documentation pointer; anything else, an account, model, parameter or billing problem in any phrasing, keeps dev's decision. A Responses event's own `response.*` type is read as the carrier, whose response object is read without the fields that echo the caller's request (`instructions`, `tools`, `text`, `reasoning`, `input`, `output`, `usage`...); its `metadata` must be empty. Prose alone never hops (owner decision on #8428). It never outranks a stop: an envelope root verdict code, a hard-refusal token in any spelling (JSON-escaped, camelCase, or with hyphens, dots, slashes or spaces for underscores), safety or terminal-refusal copy, a malformed `response_format` diagnostic, subscription-gate wording, or any text the shared `classifyError` files more specifically than a generic invalid request keeps the terminal decision and its cooldown, and quota, billing or account-state evidence (including such wording beside a moderation code) still cools the provider. The verdict needs the whole body: `consumeComboFailure`, the responses stream preflight, the Messages native preflight and `policy-fallback.ts` judge the complete bounded body or event and pass `providerModerationRefusal`; a preflight that rebuilds a slim envelope attaches the whole event as combo failure evidence whenever either reads as a moderation verdict, and an event that states a non-400/422 status of its own is never a moderation refusal; and without it a classification text of 500 characters or more (possibly cut) never hops. `src/server/responses/core-combo-failure.ts` captures its finite classification before display truncation; `src/server/responses/combo-stream-preflight.ts` preserves it before rebuilding terminal SSE errors. `src/server/responses/core-options.ts` carries the value to the combo decision. An inspected non-match diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 8f75fe93644..eef2f801d5c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -597,4 +597,4 @@ status-only fallback. Rebuilt failures remain non-replayable and cyber-policy fa `Retry-After` nor quota-reset metadata. The [Responses failover contract](responses-failover.md) owns the bounded recovery and replay decisions. -On the `responses` inbound wire only (`terminalRefusalAsFailed`, set by the same test as `enforceDeclaredToolNames` in `src/server/responses/adapter-delivery.ts`, `src/server/responses/run-turn-execution.ts` and the sidecar loops started from `src/server/responses/sidecar-execution.ts`), an adapter `incomplete` with `reason: "content_filter"` and `retryable: false` is delivered as `response.failed` whose `error` and `last_error` carry `invalid_request_error` / `invalid_prompt` with `retryable: false` and the turn's usage, built by `src/bridge/terminal-refusal.ts`: Codex retries every `response.incomplete` without reading `incomplete_details.retryable` and ends a turn only on a terminal-refusal `error.code` (#4312). The Anthropic and Kiro adapters emit that event today. Inside a combo a refusal with no output never reaches the client as a stream: `src/server/responses/combo-stream-preflight.ts` converts the zero-output `response.failed` into an HTTP 400 JSON error with the same `invalid_prompt` code, and `comboFailureDecision` stops the chain on it instead of sending the refused prompt to the next target (`tests/routing/combo-terminal-refusal.test.ts`). The web-search and image sidecar loops (`src/web-search/loop.ts`, `src/images/loop.ts`) check `isTerminalRefusalIncomplete` before looping, on every inbound wire: a refused iteration that carried a sidecar call ends the turn instead of running the call and re-asking the model with the refused context (`tests/web-search/web-search-terminal-refusal.test.ts`, `tests/images/loop-terminal-refusal.test.ts`); `src/web-search/run-turn-loop.ts` already stops on any incomplete. Every other incomplete, the chat and Anthropic inbound wires, and `buildResponseJSON` keep the incomplete shape. A `response.failed` built from an adapter error carries `retryable: false` for every code in `isTerminalRefusalCode` (`src/lib/errors.ts`), not only cyber policy. `tests/adapters/bridge-terminal-refusal.test.ts` pins both, `tests/server/server-terminal-refusal-wiring.test.ts` pins which inbound wire opts in, and `tests/server/server-kiro-refusal-e2e.test.ts` pins the adapter, runTurn and both sidecar-loop deliveries. +On the `responses` inbound wire only (`terminalRefusalAsFailed`, set by the same test as `enforceDeclaredToolNames` in `src/server/responses/adapter-delivery.ts`, `src/server/responses/run-turn-execution.ts` and the sidecar loops started from `src/server/responses/sidecar-execution.ts`), an adapter `incomplete` with `reason: "content_filter"` and `retryable: false` is delivered as `response.failed` whose `error` and `last_error` carry `invalid_request_error` / `invalid_prompt` with `retryable: false` and the turn's usage, built by `src/bridge/terminal-refusal.ts`: Codex retries every `response.incomplete` without reading `incomplete_details.retryable` and ends a turn only on a terminal-refusal `error.code` (#4312). The Anthropic and Kiro adapters emit that event today. Inside a combo a refusal with no output never reaches the client as a stream: `src/server/responses/combo-stream-preflight.ts` converts the zero-output `response.failed` into an HTTP 400 JSON error with the same `invalid_prompt` code, and `comboFailureDecision` stops the chain on it instead of sending the refused prompt to the next target (`tests/routing/combo-terminal-refusal.test.ts`). That covers a model's own refusal; a provider or gateway moderation verdict on HTTP 400/422 is target-local and may advance (see `isProviderModerationRefusal` in [Combo model refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence)). The web-search and image sidecar loops (`src/web-search/loop.ts`, `src/images/loop.ts`) check `isTerminalRefusalIncomplete` before looping, on every inbound wire: a refused iteration that carried a sidecar call ends the turn instead of running the call and re-asking the model with the refused context (`tests/web-search/web-search-terminal-refusal.test.ts`, `tests/images/loop-terminal-refusal.test.ts`); `src/web-search/run-turn-loop.ts` already stops on any incomplete. Every other incomplete, the chat and Anthropic inbound wires, and `buildResponseJSON` keep the incomplete shape. A `response.failed` built from an adapter error carries `retryable: false` for every code in `isTerminalRefusalCode` (`src/lib/errors.ts`), not only cyber policy. `tests/adapters/bridge-terminal-refusal.test.ts` pins both, `tests/server/server-terminal-refusal-wiring.test.ts` pins which inbound wire opts in, and `tests/server/server-kiro-refusal-e2e.test.ts` pins the adapter, runTurn and both sidecar-loop deliveries. diff --git a/tests/claude-integration/messages-native-combo-framing.test.ts b/tests/claude-integration/messages-native-combo-framing.test.ts index 1435b2e32a3..624abf1cf56 100644 --- a/tests/claude-integration/messages-native-combo-framing.test.ts +++ b/tests/claude-integration/messages-native-combo-framing.test.ts @@ -114,3 +114,20 @@ for (const [name, block, failed] of [ else await result.response.body?.cancel(); }); } + +test("native combo preflight judges a moderation refusal on the whole event, like the HTTP reader", async () => { + const run = async (event: Record) => { + const raw = prelude + "event: error\n" + "data: " + JSON.stringify(event) + "\n\n"; + const result = await preflightNativeMessagesResponse(new Response(raw, { + headers: { "content-type": "text/event-stream" }, + }), { model: "fixture", provider: "anth" }, createTestTranslatorBudget()); + await result.response.body?.cancel(); + return result.kind; + }; + const error = { type: "invalid_request_error", code: "content_filter", message: "The input was refused by moderation." }; + // A provider moderation verdict is target-local and advances the combo. + expect(await run({ type: "error", error })).toBe("failed"); + // A root verdict code beside it stops, as consumeComboFailure and the stream preflight decide. + expect(await run({ type: "error", code: "invalid_request_error", error })).toBe("accepted"); + expect(await run({ type: "error", code: "cyber_policy", error })).toBe("accepted"); +}); diff --git a/tests/routing/combo-failure-transport-parity.test.ts b/tests/routing/combo-failure-transport-parity.test.ts index bfdff799a00..ba41bc92443 100644 --- a/tests/routing/combo-failure-transport-parity.test.ts +++ b/tests/routing/combo-failure-transport-parity.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { comboFailureCooldownScope, comboFailureDecision } from "../../src/combos/failover"; +import { comboFailureCooldownScope, comboFailureDecision, isProviderModerationRefusal } from "../../src/combos/failover"; import { consumeComboFailure } from "../../src/server/responses/core-combo-failure"; import { preflightComboStreamResponse } from "../../src/server/responses/combo-stream-preflight"; +import { preflightNativeMessagesResponse } from "../../src/server/messages-native-preflight"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; import type { RequestLogContext } from "../../src/server/request-log"; /** @@ -150,3 +152,350 @@ describe("combo failure verdict parity across HTTP and SSE", () => { }); } }); + +describe("a moderation verdict survives the rebuilt preflight envelope", () => { + const loop = async (response: Response) => { + const failure = await consumeComboFailure(response); + const options = { + code: failure.upstreamCode, rootCode: failure.upstreamRootCode, + malformedResponseFormat: failure.malformedResponseFormat, openRouterOpaque400: failure.openRouterOpaque400, + providerModerationRefusal: failure.providerModerationRefusal, + }; + return { + decision: comboFailureDecision(failure.response.status, failure.classificationText, options), + scope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal }), + }; + }; + + test("a root-coded responses stream refusal hops without cooling, as over HTTP", async () => { + for (const code of ["content_filter", "moderation_refusal"]) { + const event = { type: "error", status: 400, code, error: { type: "invalid_request_error", message: "The input was refused by moderation." } }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(event) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), logCtx()); + expect(result.kind).toBe("failed"); + expect({ code, ...(await loop(result.response)) }).toEqual({ code, decision: "hop", scope: "none" }); + } + }); + + test("an Azure-style native Messages refusal keeps its moderation evidence", async () => { + const event = { type: "error", error: { code: "content_filter", message: "The response was filtered.", + innererror: { code: "ResponsibleAIPolicyViolation", content_filter_result: { hate: { filtered: true, severity: "high" } } } } }; + const prelude = 'event: message_start\ndata: {"type":"message_start","message":{"content":[],"usage":{"input_tokens":5,"output_tokens":0}}}\n\n'; + const result = await preflightNativeMessagesResponse(new Response( + prelude + "event: error\ndata: " + JSON.stringify(event) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), { model: "fixture", provider: "anth" }, createTestTranslatorBudget()); + expect(result.kind).toBe("failed"); + expect(result.response.status).toBe(400); + expect(await loop(result.response)).toEqual({ decision: "hop", scope: "none" }); + }); +}); + +test("a native refusal frame that states its own non-400 status keeps the record mapping and its cooldown", async () => { + const prelude = 'event: message_start\ndata: {"type":"message_start","message":{"content":[],"usage":{"input_tokens":5,"output_tokens":0}}}\n\n'; + for (const status of [429, 503]) { + const event = { type: "error", status, error: { code: "content_filter", message: "The input was refused by moderation." } }; + const result = await preflightNativeMessagesResponse(new Response( + prelude + "event: error\ndata: " + JSON.stringify(event) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), { model: "fixture", provider: "anth" }, createTestTranslatorBudget()); + expect(result.kind).toBe("failed"); + expect(result.response.status).not.toBe(400); + const failure = await consumeComboFailure(result.response); + expect(comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).not.toBe("none"); + } +}); + +describe("vetoed moderation bodies agree across transports", () => { + const loopOf = async (response: Response) => { + const failure = await consumeComboFailure(response); + const options = { + code: failure.upstreamCode, rootCode: failure.upstreamRootCode, + malformedResponseFormat: failure.malformedResponseFormat, openRouterOpaque400: failure.openRouterOpaque400, + providerModerationRefusal: failure.providerModerationRefusal, + }; + return { + decision: comboFailureDecision(failure.response.status, failure.classificationText, options), + scope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal }), + }; + }; + + test("a response.failed frame whose whole body vetoes moderation does not hop after the rebuilt envelope", async () => { + let checked = 0; + const error = { code: "content_filter", message: "The input was refused by moderation." }; + for (const frame of [ + { type: "response.failed", response: { status: "failed", error: { ...error, innererror: { code: "cyber_policy" } } } }, + { type: "response.failed", code: "context_length_exceeded", response: { status: "failed", error } }, + { type: "response.failed", response: { status: "failed", error: { ...error, metadata: { code: "cyber_policy" } } } }, + ]) { + const ctx: RequestLogContext = { ...logCtx(), terminalHttpStatus: 400 }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(frame) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), ctx); + if (result.kind !== "failed") continue; + checked += 1; + expect({ frame: JSON.stringify(frame).slice(0, 60), ...(await loopOf(result.response)) }) + .toEqual({ frame: JSON.stringify(frame).slice(0, 60), decision: "stop", scope: "target" }); + } + expect(checked).toBeGreaterThan(0); + }); + + test("a native frame that states 429 in any spelling is never a moderation hop", async () => { + const prelude = 'event: message_start\ndata: {"type":"message_start","message":{"content":[],"usage":{"input_tokens":5,"output_tokens":0}}}\n\n'; + const record = { type: "invalid_request_error", code: "content_filter", message: "Filtered" }; + for (const framed of [{ status: 429 }, { status: "429" }, { status_code: 429 }, { http_status: 429 }]) { + const event = { type: "error", ...framed, error: record }; + const result = await preflightNativeMessagesResponse(new Response( + prelude + "event: error\ndata: " + JSON.stringify(event) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), { model: "fixture", provider: "anth" }, createTestTranslatorBudget()); + if (result.kind !== "failed") continue; + const outcome = await loopOf(result.response); + expect({ framed, scope: outcome.scope }).not.toEqual({ framed, scope: "none" }); + } + }); +}); + +test("a genuine coded refusal in a response.failed frame hops without cooling, like the bare error frame and HTTP", async () => { + const error = { type: "invalid_request_error", code: "content_filter", message: "The input was refused by moderation.", + innererror: { code: "ResponsibleAIPolicyViolation", content_filter_result: { hate: { filtered: true, severity: "high" } } } }; + const frame = { type: "response.failed", status: "failed", response: { status: "failed", error } }; + const ctx: RequestLogContext = { ...logCtx(), terminalHttpStatus: 400 }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(frame) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), ctx); + expect(result.kind).toBe("failed"); + const failure = await consumeComboFailure(result.response); + const options = { + code: failure.upstreamCode, rootCode: failure.upstreamRootCode, + malformedResponseFormat: failure.malformedResponseFormat, openRouterOpaque400: failure.openRouterOpaque400, + providerModerationRefusal: failure.providerModerationRefusal, + }; + expect(comboFailureDecision(failure.response.status, failure.classificationText, options)).toBe("hop"); + expect(comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).toBe("none"); +}); + +describe("stated statuses and structured flags beside a coded moderation verdict", () => { + const loopOf = async (response: Response) => { + const failure = await consumeComboFailure(response); + const options = { + code: failure.upstreamCode, rootCode: failure.upstreamRootCode, + malformedResponseFormat: failure.malformedResponseFormat, openRouterOpaque400: failure.openRouterOpaque400, + providerModerationRefusal: failure.providerModerationRefusal, + }; + return { + decision: comboFailureDecision(failure.response.status, failure.classificationText, options), + scope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal }), + }; + }; + const coded = { type: "invalid_request_error", message: "The input was refused by moderation.", code: "content_filter" }; + + test("a body that states a non-400 status of its own is not a moderation refusal over HTTP", async () => { + for (const stated of [{ status: 429 }, { status: 503 }, { status_code: "429" }, { http_status: "429" }]) { + const body = JSON.stringify({ error: { ...coded, ...stated } }); + const outcome = await loopOf(new Response(body, { status: 400 })); + expect({ stated, outcome }).toEqual({ stated, outcome: { decision: "stop", scope: "target" } }); + } + }); + + test("structured safety or reason fields veto the hop", async () => { + for (const extra of [{ safety_violation: true }, { blocked: true, reason: "policy" }, { reason: "rate_limit_exceeded" }]) { + const body = JSON.stringify({ error: { ...coded, ...extra } }); + expect({ extra, verdict: isProviderModerationRefusal(400, body, undefined, true) }).toEqual({ extra, verdict: false }); + expect((await loopOf(new Response(body, { status: 400 }))).scope).not.toBe("none"); + } + }); + + test("Azure per-category filter results stay compatible", async () => { + const body = JSON.stringify({ error: { code: "content_filter", message: "The response was filtered.", status: 400, + innererror: { code: "ResponsibleAIPolicyViolation", content_filter_result: { hate: { filtered: true, severity: "high" }, jailbreak: { filtered: false, detected: false } } } } }); + expect(await loopOf(new Response(body, { status: 400 }))).toEqual({ decision: "hop", scope: "none" }); + }); + + test("a status-less coded stream frame keeps the no-cool verdict", async () => { + const event = { type: "error", code: "moderation_refusal" }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(event) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), logCtx()); + expect(result.kind).toBe("failed"); + expect(result.response.status).toBe(400); + expect(await loopOf(result.response)).toEqual({ decision: "hop", scope: "none" }); + }); +}); + +describe("provider evidence and status-less response.failed frames beside a coded moderation verdict", () => { + test("billing, quota and account-state wording beside a moderation code keeps the provider decision", async () => { + for (const text of [ + "Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.", + "quota exceeded", + "insufficient quota for this request", + "Payment required to continue", + "Account suspended", + ]) { + const body = JSON.stringify({ error: { code: "content_filter", message: text } }); + expect({ text, verdict: isProviderModerationRefusal(400, body, undefined, true) }).toEqual({ text, verdict: false }); + const failure = await consumeComboFailure(new Response(body, { status: 400 })); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).not.toBe("none"); + } + }); + + test("a status-less response.failed moderation frame is the same 400 no-cool hop as over HTTP", async () => { + const frame = { type: "response.failed", response: { status: "failed", error: { code: "content_filter", message: "The input was refused by moderation." } } }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(frame) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), logCtx()); + expect(result.kind).toBe("failed"); + expect(result.response.status).toBe(400); + const failure = await consumeComboFailure(result.response); + const options = { code: failure.upstreamCode, rootCode: failure.upstreamRootCode, providerModerationRefusal: failure.providerModerationRefusal }; + expect(comboFailureDecision(400, failure.classificationText, options)).toBe("hop"); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).toBe("none"); + }); +}); + +describe("every prose field beside a coded verdict must describe the moderation refusal", () => { + const coded = (message: string, extra: Record = {}) => + JSON.stringify({ error: { type: "invalid_request_error", code: "content_filter", message, ...extra } }); + const azureResults = { + hate: { filtered: true, severity: "high" }, self_harm: { filtered: false, severity: "safe" }, + jailbreak: { filtered: false, detected: false }, + custom_blocklists: { filtered: false, details: [{ filtered: false, id: "list1" }] }, + }; + const azureWith = (extra: Record) => JSON.stringify({ error: { + code: "content_filter", + message: "The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please modify your prompt and retry.", + innererror: { code: "ResponsibleAIPolicyViolation", content_filter_result: { ...azureResults, ...extra } }, + } }); + + test("parameter, model and account prose beside a moderation code keeps dev's decision", async () => { + for (const body of [ + coded("Invalid 'temperature': maximum value is 2."), + coded("The model gpt-9 does not exist"), + coded("This account has been deactivated."), + coded("Your account is disabled."), + coded("This organization has been disabled."), + coded("Your API key is disabled."), + coded("The request was rejected because the account is not active."), + coded("The input was refused by moderation.", { accountSuspended: true }), + coded("The input was refused by moderation.", { metadata: { account_status: "suspended" } }), + coded("The input was refused by moderation.", { httpStatus: 429 }), + coded("The input was refused by moderation.", { hint: "The model gpt-9 does not exist" }), + coded("The input was refused by moderation.", { note: "Invalid 'temperature': maximum value is 2." }), + coded("The input was refused by moderation.", { metadata: { raw: "Your account is past due" } }), + coded("The input was refused by moderation.", { param: "temperature" }), + coded("The input was refused by moderation.", { param: "max_tokens" }), + coded("The input was refused by moderation.", { details: ["The model gpt-9 does not exist"] }), + coded("The input was refused by moderation.", { details: ["Your account is past due"] }), + coded("The input was refused by moderation.", { note: "Refused by moderation. Your account is past due." }), + coded("The input was refused by moderation.", { note: "Refused by moderation; your organization is over its spend limit." }), + coded("The input was refused by moderation.", { note: "The model gpt-9 does not exist BUT the input was refused by moderation." }), + ]) { + expect({ body, verdict: isProviderModerationRefusal(400, body, undefined, true) }).toEqual({ body, verdict: false }); + const failure = await consumeComboFailure(new Response(body, { status: 400 })); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).not.toBe("none"); + } + }); + + test("genuine coded refusals still hop", () => { + for (const body of [ + coded("The input was refused by moderation. Categories: harassment."), + JSON.stringify({ error: { code: "content_filter", message: "The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please modify your prompt and retry.", innererror: { code: "ResponsibleAIPolicyViolation" } } }), + JSON.stringify({ error: { code: "moderation_refusal", message: "Your request was flagged by moderation." } }), + coded("The input was refused by moderation.", { request_id: "req_abc123" }), + coded("The input was refused by moderation.", { param: "input" }), + coded("The input was refused by moderation. Categories: sexual, hate. Please modify your prompt and retry."), + coded("The input was refused by moderation. Categories: hate, harassment, self-harm."), + coded("The input was refused by moderation. Categories: sexual and hate."), + coded("filtered"), + coded("Content filtered by moderation."), + azureWith({}), + JSON.stringify({ error: { code: "content_filter", message: "The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please modify your prompt and retry. To learn more about our content filtering policies please read our documentation: https://go.microsoft.com/fwlink/?linkid=2198766" } }), + ]) { + expect({ body, verdict: isProviderModerationRefusal(400, body, undefined, true) }).toEqual({ body, verdict: true }); + } + }); + + test("only known refusal copy and known fields pass beside a coded verdict", async () => { + for (const body of [ + // Evidence joined by separators other than a sentence break. + coded("Refused by moderation \u2014 your account is past due."), + coded("Refused by moderation: your account is past due."), + coded("Filtered by moderation (your account is past due)."), + coded("Filtered by moderation / your account is past due."), + coded("Filtered by moderation\tyour account is past due."), + coded("Filtered by moderation;your account is past due."), + coded("Filtered by moderation so your account is past due."), + // Follow-up clauses that carry evidence of their own. + coded("Content filtered by moderation. Learn more: the model gpt-9 does not exist."), + coded("Content filtered by moderation. For more information about your suspended account."), + coded("Content filtered by moderation. Retry with model gpt-9 which does not exist."), + coded("Content filtered by moderation. Please modify your input for the suspended account."), + coded("The input was refused by moderation. Categories: model gpt-9 does not exist."), + // Bare tokens under fields a refusal does not carry. + coded("The input was refused by moderation.", { metadata: { account: "suspended" } }), + coded("The input was refused by moderation.", { state: "past_due" }), + coded("The input was refused by moderation.", { account_status: "disabled" }), + // Azure's filter results carry only their own shape. + azureWith({ note: "Your account is past due" }), + azureWith({ hate: { filtered: true, severity: "high", note: "account past due" } }), + azureWith({ account: "suspended" }), + azureWith({ hate: { filtered: true, severity: "past_due" } }), + azureWith({ error: { code: "content_filter_error", message: "The contents are not filtered" } }), + azureWith({ hate: { filtered: true, severity: "high", status: 429 } }), + ]) { + expect({ body, verdict: isProviderModerationRefusal(400, body, undefined, true) }).toEqual({ body, verdict: false }); + const failure = await consumeComboFailure(new Response(body, { status: 400 })); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).not.toBe("none"); + } + }); + + test("a response.failed carrier's echoed instructions and tools do not veto its coded refusal", async () => { + const frame = { + type: "response.failed", + response: { + status: "failed", + model: "gpt-test", + instructions: "You are a helpful assistant. Answer briefly.", + tools: [{ type: "function", name: "lookup", description: "Look up an order by its number." }], + error: { code: "content_filter", message: "The input was refused by moderation." }, + }, + }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(frame) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), logCtx()); + expect(result.kind).toBe("failed"); + const failure = await consumeComboFailure(result.response); + const options = { code: failure.upstreamCode, rootCode: failure.upstreamRootCode, providerModerationRefusal: failure.providerModerationRefusal }; + expect(comboFailureDecision(failure.response.status, failure.classificationText, options)).toBe("hop"); + expect(comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).toBe("none"); + }); + + test("evidence on a response.failed carrier outside its error record still vetoes the no-cool hop", async () => { + const error = { code: "content_filter", message: "The input was refused by moderation." }; + for (const extra of [ + { status_code: 429 }, + { safety_violation: true }, + { note: "The model gpt-9 does not exist" }, + { incomplete_details: { reason: "Your account is past due" } }, + { metadata: { raw: "Your account is past due" } }, + { metadata: { httpStatus: 503 } }, + { metadata: { safety_violation: true } }, + { metadata: { client: "my app v2" } }, + ]) { + const frame = { type: "response.failed", response: { status: "failed", ...extra, error } }; + const result = await preflightComboStreamResponse(new Response( + "data: " + JSON.stringify(frame) + "\n\n", { headers: { "content-type": "text/event-stream" } }, + ), logCtx()); + expect(result.kind).toBe("failed"); + const failure = await consumeComboFailure(result.response); + const scope = comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal }); + expect({ extra, scope }).not.toEqual({ extra, scope: "none" }); + } + }); + + test("a long whitespace run in upstream prose is judged in linear time", () => { + // The clause splitter once backtracked across whitespace runs: about 2 s for this body. + const body = JSON.stringify({ error: { code: "content_filter", message: "moderation" + " ".repeat(60_000) + "x" } }); + const started = performance.now(); + expect(isProviderModerationRefusal(400, body, undefined, true)).toBe(false); + expect(performance.now() - started).toBeLessThan(250); + }); +}); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index a8de6627438..1b7351c4894 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -14,6 +14,7 @@ import { comboFailureDecision, isMalformedResponseFormatRequest, isOpenRouterOpaque400Envelope, + isProviderModerationRefusal, } from "../../src/combos/failover"; import { consumeComboFailure } from "../../src/server/responses/core-combo-failure"; import { normalizeUpstreamErrorText } from "../../src/server/responses/core-errors"; @@ -1239,3 +1240,630 @@ describe("HTTP hard-code evidence excludes diagnostic types", () => { } }); }); + +describe("provider moderation refusal", () => { + const moderationPayload = { + error: { + message: "The input was refused by moderation. Categories: harassment.", + type: "AI_APICallError", + param: { + error: "The input was refused by moderation. Categories: harassment.", + statusCode: 400, + name: "AI_APICallError", + message: "The input was refused by moderation. Categories: harassment.", + isRetryable: false, + type: "AI_APICallError", + }, + }, + providerMetadata: { + gateway: { + routing: { + originalModelId: "humansand/ampersand-v0.1", + resolvedProvider: "humansand", + fallbacksAvailable: [], + }, + }, + }, + }; + + test("the observed gateway refusal hops only with a structured moderation code", () => { + // Owner decision on #8428: prose alone never hops. The observed body carries no code, so it + // keeps dev's stop; the same body with a structured moderation code hops without cooling. + const rawBody = JSON.stringify(moderationPayload); + expect(isProviderModerationRefusal(400, rawBody)).toBe(false); + expect(comboFailureDecision(400, rawBody)).toBe("stop"); + const coded = JSON.stringify({ ...moderationPayload, error: { ...moderationPayload.error, code: "moderation_refusal" } }); + for (const message of [coded, `Provider error 400: ${coded}`, `data: ${coded}`]) { + // The body readers judge the whole body; a wrapped copy is longer than the cut classification text. + const providerModerationRefusal = isProviderModerationRefusal(400, message, undefined, true); + expect(comboFailureDecision(400, message, { providerModerationRefusal })).toBe("hop"); + expect(comboFailureCooldownScope(400, message, { providerModerationRefusal })).toBe("none"); + } + }); + + test("moderation phrases alone stay terminal; the structured code is what hops", () => { + const phrases = [ + "The input was refused by moderation. Categories: harassment.", + "The input was refused by moderation.", + "The prompt was refused by moderation.", + "The response was refused by moderation.", + "Prompt was blocked by moderation.", + "Input was flagged by moderation.", + ]; + for (const phrase of phrases) { + const body = JSON.stringify({ error: { message: phrase } }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + expect(comboFailureDecision(400, body)).toBe("stop"); + const coded = JSON.stringify({ error: { code: "moderation_refusal", message: phrase } }); + expect(comboFailureDecision(400, coded)).toBe("hop"); + expect(comboFailureCooldownScope(400, coded)).toBe("none"); + } + }); + + test("hops when error code is moderation_refusal or content_filter", () => { + for (const code of ["moderation_refusal", "content_filter"]) { + const body = JSON.stringify({ error: { code, message: "filtered" } }); + expect(isProviderModerationRefusal(400, body, code)).toBe(true); + expect(comboFailureDecision(400, body, { code })).toBe("hop"); + expect(comboFailureCooldownScope(400, body, { code })).toBe("none"); + for (const message of [body, `data: ${body}`]) { + expect(isProviderModerationRefusal(400, message)).toBe(true); + expect(comboFailureDecision(400, message)).toBe("hop"); + expect(comboFailureCooldownScope(400, message)).toBe("none"); + } + } + }); + + test("recognizes explicit moderation codes with empty or absent message", () => { + for (const code of ["content_filter", "moderation_refusal"]) { + expect(isProviderModerationRefusal(400, "", code)).toBe(true); + expect(comboFailureDecision(400, "", { code })).toBe("hop"); + expect(comboFailureCooldownScope(400, "", { code })).toBe("none"); + + const body = JSON.stringify({ error: { code } }); + expect(isProviderModerationRefusal(400, body)).toBe(true); + expect(comboFailureDecision(400, body)).toBe("hop"); + } + }); + + test("hard stops like cyber_policy and upstream_no_response outrank moderation wording", () => { + for (const code of ["cyber_policy", "CYBER_POLICY", "upstream_no_response", "UPSTREAM_NO_RESPONSE", "origin_rejected"]) { + const body = JSON.stringify({ + error: { + code, + message: "The input was refused by moderation.", + }, + }); + expect(isProviderModerationRefusal(400, body, code)).toBe(false); + expect(comboFailureDecision(400, body, { code })).toBe("stop"); + + for (const message of [body, `data: ${body}`]) { + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureDecision(400, message)).toBe("stop"); + } + } + }); + + test("truncated 422 with uppercase CYBER_POLICY code stays terminal", () => { + const raw = JSON.stringify({ + error: { + message: "The input was refused by moderation. Categories: harassment.", + code: "CYBER_POLICY", + }, + }); + // Truncated at 500 characters + const truncated = `Provider error 422: ${raw.slice(0, 80)}`; + expect(comboFailureDecision(422, truncated, { code: "CYBER_POLICY" })).toBe("stop"); + expect(isProviderModerationRefusal(422, truncated, "CYBER_POLICY")).toBe(false); + }); + + test("reflected validation text quoting moderation phrases stays terminal", () => { + const reflected = [ + "Invalid parameter 'prompt': text cannot contain 'blocked by moderation'", + "Validation error: argument quotes 'refused by moderation'", + "Invalid parameter: value quotes 'blocked by moderation'", + "The value 'blocked by moderation' is not supported for field 'prompt'", + ]; + for (const message of reflected) { + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureDecision(400, message)).toBe("stop"); + + const body = JSON.stringify({ error: { message } }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + expect(comboFailureDecision(400, body)).toBe("stop"); + } + }); + + test("quoted moderation phrases in validation values stay terminal", () => { + const reflected = [ + "Invalid enum value 'blocked by moderation' for param 'prompt'", + 'The value "blocked by moderation" is invalid', + "param 'mode' does not accept \"flagged by moderation\"", + "Unknown value 'content management policy' supplied for 'reason'", + "Unsupported value 'refused by moderation' at messages[0].content", + ]; + for (const message of reflected) { + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureDecision(400, message)).toBe("stop"); + + const body = JSON.stringify({ error: { message, param: "prompt" } }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + expect(comboFailureDecision(400, body)).toBe("stop"); + } + }); + + test("truncated envelope quoting a moderation value stays terminal", () => { + const truncated = '{"error":{"message":"Invalid enum value \\"blocked by moderation\\" for param","param":"prompt"'; + expect(isProviderModerationRefusal(400, truncated)).toBe(false); + expect(comboFailureDecision(400, truncated)).toBe("stop"); + }); + + test("truncated or malformed JSON with moderation wording in unrelated fields fails closed", () => { + const malformed = '{"error":{"message":"Invalid input","note":"blocked by moderation"'; + expect(isProviderModerationRefusal(400, malformed)).toBe(false); + expect(comboFailureDecision(400, malformed)).toBe("stop"); + + const unrelatedField = JSON.stringify({ + error: { + message: "Invalid JSON input syntax", + param: "notes", + unrelated_note: "blocked by moderation", + }, + }); + expect(isProviderModerationRefusal(400, unrelatedField)).toBe(false); + expect(comboFailureDecision(400, unrelatedField)).toBe("stop"); + }); + + test("a moderation verdict after a validation prefix stays terminal without a code", () => { + const directValidation = "Validation error: the prompt was blocked by moderation"; + expect(isProviderModerationRefusal(400, directValidation)).toBe(false); + expect(comboFailureDecision(400, directValidation)).toBe("stop"); + + const body = JSON.stringify({ error: { message: directValidation } }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + // A validation error is evidence of its own: even with a caller's code it is not refusal copy. + expect(isProviderModerationRefusal(400, directValidation, "content_filter")).toBe(false); + }); + + test("accepts indexed content parameter paths in coded moderation refusals", () => { + for (const param of ["input[0].content", "messages[0].content", "messages[1].content[0].text", "input.prompt"]) { + const body = JSON.stringify({ + error: { + message: "The input was refused by moderation. Categories: harassment.", + param, + }, + code: "moderation_refusal", + }); + expect(isProviderModerationRefusal(400, body)).toBe(true); + expect(comboFailureDecision(400, body)).toBe("hop"); + } + }); + + test("multi-event SSE body with unparsed diagnostic note stays terminal", () => { + const multiEventSse = 'data: {"event":"start"}\ndata: {"note":"blocked by moderation"}\ndata: {"error":"bad_request"}'; + expect(isProviderModerationRefusal(400, multiEventSse)).toBe(false); + expect(comboFailureDecision(400, multiEventSse)).toBe("stop"); + }); + + test("mixed-code hard stops outrank competing wrappers and nested moderation evidence", () => { + // 422 JSON and single-line SSE with a root upstream_no_response code and nested moderation evidence + const rootUpstreamNoResponse = JSON.stringify({ + code: "upstream_no_response", + error: { + message: "The input was refused by moderation. Categories: harassment.", + }, + }); + expect(isProviderModerationRefusal(422, rootUpstreamNoResponse)).toBe(false); + expect(comboFailureDecision(422, rootUpstreamNoResponse)).toBe("stop"); + + const sseRootUpstreamNoResponse = `data: ${rootUpstreamNoResponse}`; + expect(isProviderModerationRefusal(422, sseRootUpstreamNoResponse)).toBe(false); + expect(comboFailureDecision(422, sseRootUpstreamNoResponse)).toBe("stop"); + + // Moderation-coded wrapper around an inner hard code + const moderationWrapperHardCode = JSON.stringify({ + error: { + code: "moderation_refusal", + type: "upstream_error", + message: JSON.stringify({ + error: { + code: "cyber_policy", + message: "Blocked by cyber policy", + }, + }), + }, + }); + expect(isProviderModerationRefusal(400, moderationWrapperHardCode)).toBe(false); + expect(comboFailureDecision(400, moderationWrapperHardCode)).toBe("stop"); + }); + + test("a coded refusal over 500 characters hops on the whole body, never on the cut text", async () => { + const requestId = "req_" + "x".repeat(600); + const raw = JSON.stringify({ + error: { + code: "content_filter", + message: "The input was refused by moderation. Categories: harassment.", + request_id: requestId, + }, + }); + // A cut envelope does not parse, so it carries no structured verdict of its own. + const truncated = raw.slice(0, 200); + expect(isProviderModerationRefusal(400, truncated)).toBe(false); + expect(comboFailureDecision(400, truncated)).toBe("stop"); + + // Pass through consumeComboFailure where production truncation occurs + const response = new Response(raw, { status: 400 }); + const failure = await consumeComboFailure(response); + expect(failure.providerModerationRefusal).toBe(true); + expect(comboFailureDecision(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).toBe("hop"); + expect(comboFailureCooldownScope(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).toBe("none"); + }); + + test("malformed JSON restricts recovery to error envelope and rejects non-error message fields", () => { + // moderation wording in diagnostic.message rather than error.message + const diagnosticMessage = '{"diagnostic":{"message":"blocked by moderation"},"error":{"message":"invalid param"}}'.slice(0, 75); + expect(isProviderModerationRefusal(400, diagnosticMessage)).toBe(false); + expect(comboFailureDecision(400, diagnosticMessage)).toBe("stop"); + + // malformed 422 with upstream_no_response code and moderation phrasing + const malformedHardStop = '{"code":"upstream_no_response","error":{"message":"The input was refused by moderation"'.slice(0, 70); + expect(isProviderModerationRefusal(422, malformedHardStop)).toBe(false); + expect(comboFailureDecision(422, malformedHardStop)).toBe("stop"); + }); + + test("outer non-moderation parameter check precedes message unwrapping", () => { + const outerBadParam = JSON.stringify({ + error: { + param: "temperature", + message: JSON.stringify({ + error: { + message: "The input was refused by moderation. Categories: harassment.", + }, + }), + }, + }); + expect(isProviderModerationRefusal(400, outerBadParam)).toBe(false); + expect(comboFailureDecision(400, outerBadParam)).toBe("stop"); + }); + + test("wrapper limit exhaustion fails closed on unexamined structured text", () => { + const deepConcealedHardCode = JSON.stringify({ + code: "moderation_refusal", + error: { + message: JSON.stringify({ + error: { + message: JSON.stringify({ + error: { + message: JSON.stringify({ + error: { + code: "upstream_no_response", + message: "timed out", + }, + }), + }, + }), + }, + }), + }, + }); + expect(isProviderModerationRefusal(400, deepConcealedHardCode)).toBe(false); + expect(comboFailureDecision(400, deepConcealedHardCode)).toBe("stop"); + }); + + test("a param on a record other than the coded verdict keeps the body terminal", async () => { + const raw = JSON.stringify({ + request_info: { param: "unrelated_header" }, + error: { + code: "content_filter", + message: "The input was refused by moderation. Categories: harassment.", + padding: "y".repeat(600), + }, + }); + const response = new Response(raw, { status: 400 }); + const failure = await consumeComboFailure(response); + expect(failure.providerModerationRefusal).toBe(false); + expect(comboFailureDecision(failure.response.status, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).toBe("stop"); + }); + + test("a param outside the prompt or input keeps the body terminal even on the coded record", () => { + const body = JSON.stringify({ + error: { + code: "content_filter", + message: "Your input violated content safety policy.", + param: "instructions", + }, + }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + expect(comboFailureDecision(400, body)).toBe("stop"); + expect(comboFailureCooldownScope(400, body)).not.toBe("none"); + }); + + test("root content_filter code does not exempt nested validation error with disallowed param", () => { + const body = JSON.stringify({ + code: "content_filter", + error: { + message: "Invalid temperature: must be between 0 and 2", + param: "temperature", + }, + }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + expect(comboFailureDecision(400, body)).toBe("stop"); + }); + + test("truncated body with root moderation message and nested validation error prefers error envelope", () => { + const raw = JSON.stringify({ + message: "The input was refused by moderation. Categories: harassment.", + extra: { info: "nested" }, + error: { + message: "Invalid parameter: prompt is missing", + param: "prompt", + padding: "z".repeat(600), + }, + }); + const truncated = raw.slice(0, 180); + expect(isProviderModerationRefusal(400, truncated)).toBe(false); + expect(comboFailureDecision(400, truncated)).toBe("stop"); + }); + + test("generic 400 errors without moderation phrasing stay terminal", () => { + for (const message of [ + "Invalid JSON in request body", + "Missing required parameter 'messages'", + "Unknown model 'some-model'", + ]) { + const body = JSON.stringify({ error: { message } }); + expect(isProviderModerationRefusal(400, body)).toBe(false); + expect(comboFailureDecision(400, body)).toBe("stop"); + } + }); +}); + + +describe("provider moderation refusal never outranks a hard refusal", () => { + const body = (error: Record, root: Record = {}) => + JSON.stringify({ ...root, error: { type: "invalid_request_error", ...error } }); + const cases: Array<[string, string, string | undefined, string | undefined]> = [ + ["invalid_prompt with content-policy prose", body({ code: "invalid_prompt", message: "The response was filtered due to the prompt triggering Azure OpenAI's content management policy." }), "invalid_prompt", undefined], + ["bio_policy with moderation prose", body({ code: "bio_policy", message: "The input was refused by moderation. Categories: biology." }), "bio_policy", undefined], + ["misalignment code with moderation prose", body({ code: "misalignment_policy_violation", message: "The input was flagged by moderation." }), "misalignment_policy_violation", undefined], + ["content_filter code with safety-system prose", body({ code: "content_filter", message: "This request was blocked by our safety systems." }), "content_filter", undefined], + ["plain moderation text that also carries safety copy", "The input was refused by moderation. We've limited access to this content for safety reasons.", undefined, undefined], + ["terminal root code over a nested moderation code", body({ code: "content_filter", message: "The input was refused by moderation." }, { code: "invalid_prompt" }), "content_filter", "invalid_prompt"], + ["nested hard code beside a moderation code", body({ code: "content_filter", message: "Blocked.", innererror: { code: "bio_policy" } }), "content_filter", undefined], + ["unstripped gateway prefix with a non-replayable root", 'Gateway error 400: {"code":"upstream_no_response","error":{"code":"content_filter"}}', "content_filter", undefined], + ["validation error that merely names moderation categories", body({ message: "Invalid metadata value: moderation categories: harassment" }), undefined, undefined], + ["content_policy_violation root over a nested moderation code", body({ code: "content_filter", message: "The input was refused by moderation." }, { code: "content_policy_violation" }), "content_filter", "content_policy_violation"], + ["content_policy_violation code with moderation prose", body({ code: "content_policy_violation", message: "Your request was refused by moderation." }), "content_policy_violation", undefined], + ["an echoed value whose quote holds an apostrophe", body({ message: "Invalid enum value 'it's refused by moderation' for field mode" }), undefined, undefined], + ["an echoed value in backticks", body({ message: "Unknown tool name `refused by moderation` in tools[0]" }), undefined, undefined], + ["a JSON-escaped nested hard code", '{"error":{"code":"content_filter","message":"refused by moderation","inner":{"code":"\\u0062io_policy"}}}', "content_filter", undefined], + // The policy-fallback path passes only error.code; the parsed root still speaks. + ...(["context_length_exceeded", "invalid_request_error", "client_closed_request", "client_cancelled"] as const).map(root => + [`a ${root} root read from the body when only the nested code is passed`, body({ code: "content_filter", message: "The input was refused by moderation." }, { code: root }), "content_filter", undefined] as [string, string, string | undefined, string | undefined]), + ["an echoed value in curly double quotes", body({ message: "Invalid value: \u201cblocked by moderation\u201d for messages[0].content", param: "messages[0].content" }), undefined, undefined], + ["an echoed value in guillemets", body({ message: "Invalid value: \u00abblocked by moderation\u00bb for messages[0].content", param: "messages[0].content" }), undefined, undefined], + ["an echoed value in curly single quotes holding an apostrophe", body({ message: "Invalid value: \u2018it\u2019s refused by moderation\u2019 for mode" }), undefined, undefined], + ]; + for (const [label, message, code, rootCode] of cases) { + test(label, () => { + expect(isProviderModerationRefusal(400, message, code)).toBe(false); + expect(comboFailureDecision(400, message, { code, rootCode })).toBe("stop"); + expect(comboFailureCooldownScope(400, message, { code })).not.toBe("none"); + }); + } + + test("the reported moderation refusal still hops without cooling the target", () => { + const message = body({ code: "moderation_refusal", param: "input", message: "The input was refused by moderation. Categories: harassment." }); + expect(comboFailureDecision(400, message, { code: "moderation_refusal" })).toBe("hop"); + expect(comboFailureCooldownScope(400, message, { code: "moderation_refusal" })).toBe("none"); + }); +}); + +describe("provider moderation refusal judged on the whole body", () => { + test("a hard refusal past the classification cut still stops the combo", async () => { + for (const hard of ["invalid_prompt", "bio_policy", "content_policy_violation", "upstream_no_response"]) { + const raw = JSON.stringify({ error: { + code: "content_filter", + message: "The input was refused by moderation.", + meta: { pad: "p".repeat(600), underlying: { code: hard } }, + } }); + const failure = await consumeComboFailure(new Response(raw, { status: 400 })); + expect(failure.classificationText.length).toBeLessThanOrEqual(500); + expect(failure.providerModerationRefusal).toBe(false); + const options = { code: failure.upstreamCode, rootCode: failure.upstreamRootCode, providerModerationRefusal: failure.providerModerationRefusal }; + expect({ hard, decision: comboFailureDecision(400, failure.classificationText, options) }).toEqual({ hard, decision: "stop" }); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).not.toBe("none"); + // Without the whole-body verdict, a text that may have been cut never hops either. + expect(isProviderModerationRefusal(400, raw.slice(0, 500), "content_filter")).toBe(false); + } + }); + + test("CJK and fullwidth quoted echoes are not verdicts", () => { + for (const [open, close] of [["\u300c", "\u300d"], ["\u300e", "\u300f"], ["\uff02", "\uff02"], ["\u201a", "\u2018"]]) { + const message = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid value " + open + "refused by moderation" + close + " for field mode" } }); + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureDecision(400, message)).toBe("stop"); + } + }); + + test("an error type passed as the code does not veto a coded moderation refusal", () => { + // The policy-fallback path reads error.type when error.code is null; the canonical uncoded + // OpenAI refusal is prose only and stays terminal. + const uncoded = JSON.stringify({ error: { message: "The input was refused by moderation. Categories: harassment.", type: "invalid_request_error", param: null, code: null } }); + expect(comboFailureDecision(400, uncoded, { code: "invalid_request_error" })).toBe("stop"); + const coded = JSON.stringify({ error: { message: "The input was refused by moderation.", type: "invalid_request_error", param: "input", code: "moderation_refusal" } }); + expect(comboFailureDecision(400, coded, { code: "invalid_request_error" })).toBe("hop"); + expect(comboFailureDecision(400, coded)).toBe("hop"); + }); +}); + +describe("provider moderation refusal keeps provider evidence and escaped echoes", () => { + test("quota or credential codes beside moderation prose still cool the provider", () => { + for (const code of ["insufficient_quota", "invalid_api_key"]) { + const message = JSON.stringify({ error: { code, message: "The input was refused by moderation." } }); + expect({ code, scope: comboFailureCooldownScope(400, message, { code }) }).not.toEqual({ code, scope: "none" }); + } + }); + + test("a backslash-escaped quoted echo is not a verdict", () => { + const message = 'Invalid value: \\"refused by moderation\\" for field mode'; + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureDecision(400, message)).toBe("stop"); + }); +}); + +describe("provider moderation refusal: type-only verdicts, nested escapes and cyber copy", () => { + for (const verdict of ["client_closed_request", "client_cancelled", "context_length_exceeded"]) { + for (const where of ["root", "error"] as const) { + test(verdict + " stated as the " + where + " type stops", async () => { + const error = { code: "content_filter", message: "The input was refused by moderation.", ...(where === "error" ? { type: verdict } : {}) }; + const raw = JSON.stringify({ ...(where === "root" ? { type: verdict } : {}), error }); + const failure = await consumeComboFailure(new Response(raw, { status: 400 })); + const options = { code: failure.upstreamCode, rootCode: failure.upstreamRootCode, providerModerationRefusal: failure.providerModerationRefusal }; + expect(comboFailureDecision(400, failure.classificationText, options)).toBe("stop"); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal })).not.toBe("none"); + expect(isProviderModerationRefusal(400, raw, "content_filter")).toBe(false); + }); + } + } + + test("nested and doubled quoted echoes are not verdicts", () => { + for (const message of [ + String.raw`Invalid 'input': "it's a \"refused by moderation\" value".`, + 'Invalid value: ""refused by moderation"" for field mode', + ]) { + expect({ message, hop: isProviderModerationRefusal(400, message) }).toEqual({ message, hop: false }); + expect(comboFailureDecision(400, message)).toBe("stop"); + } + }); + + test("this proxy's own cyber-policy copy beside moderation prose stops", () => { + const message = JSON.stringify({ error: { code: "content_filter", message: "The input was refused by moderation. Request blocked by the upstream cybersecurity policy." } }); + expect(isProviderModerationRefusal(400, message, "content_filter")).toBe(false); + expect(comboFailureDecision(400, message, { code: "content_filter" })).toBe("stop"); + }); +}); + +describe("provider moderation refusal: any spelling of a verdict, terminal prose, closing-only quotes", () => { + const verdict = "The input was refused by moderation. Categories: harassment."; + const carriers: Array<[string, (code: string) => Record]> = [ + ["details", code => ({ error: { message: verdict, details: { code } } })], + ["last_error", code => ({ error: { message: verdict }, last_error: { code } })], + ["metadata", code => ({ error: { message: verdict, metadata: { code } } })], + ["innererror", code => ({ error: { code: "content_filter", message: verdict, innererror: { code } } })], + ["response.error", code => ({ response: { error: { code, message: verdict } } })], + ]; + for (const hard of ["content-policy-violation", "client-cancelled", "context length exceeded", "bio.policy", "upstream-no-response"]) { + for (const [carrier, build] of carriers) { + test("a " + hard + " code in " + carrier + " stops", async () => { + const raw = JSON.stringify(build(hard)); + const failure = await consumeComboFailure(new Response(raw, { status: 400 })); + const options = { code: failure.upstreamCode, rootCode: failure.upstreamRootCode, providerModerationRefusal: failure.providerModerationRefusal }; + expect(comboFailureDecision(400, failure.classificationText, options)).toBe("stop"); + expect(isProviderModerationRefusal(400, raw)).toBe(false); + }); + } + } + + test("terminal prose beside a moderation verdict stops", () => { + for (const tail of ["The upstream refused this request.", "client closed request", "You exceeded your current quota, please check your plan."]) { + const message = verdict + " " + tail; + expect({ tail, hop: isProviderModerationRefusal(400, message) }).toEqual({ tail, hop: false }); + expect(comboFailureCooldownScope(400, message)).not.toBe("none"); + } + }); + + test("a closing-only delimiter still quotes an echoed value", () => { + for (const [open, close] of [["\u201d", "\u201d"], ["\u00bb", "\u00bb"], ["\u2019", "\u2019"], ["\u300d", "\u300d"]]) { + const message = "Invalid value: " + open + "blocked by moderation" + close + " for field mode"; + expect({ open, hop: isProviderModerationRefusal(400, message) }).toEqual({ open, hop: false }); + } + }); + + test("content-management-policy prose needs the content_filter code", () => { + const message = JSON.stringify({ error: { message: "The response was filtered due to the prompt triggering Azure OpenAI's content management policy." } }); + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(isProviderModerationRefusal(400, message, "content_filter")).toBe(true); + }); +}); + +describe("provider moderation refusal: escaped whitespace, 422, rate-limit codes, policy path", () => { + test("a JSON-escaped whitespace spelling of a hard code stops", async () => { + for (const sep of ["\n", "\t", "\r\n"]) { + for (const hard of ["content_policy_violation", "upstream_no_response", "client_cancelled", "client_closed_request"]) { + const raw = JSON.stringify({ error: { message: "The input was refused by moderation." + sep + hard } }); + const failure = await consumeComboFailure(new Response(raw, { status: 400 })); + expect({ hard, verdict: failure.providerModerationRefusal }).toEqual({ hard, verdict: false }); + expect(isProviderModerationRefusal(400, raw)).toBe(false); + } + } + }); + + test("a coded 422 moderation refusal hops without cooling; prose alone does not", () => { + const coded = JSON.stringify({ error: { code: "content_filter", message: "The input was refused by moderation." } }); + expect(comboFailureDecision(422, coded)).toBe("hop"); + expect(comboFailureCooldownScope(422, coded)).toBe("none"); + const prose = JSON.stringify({ error: { message: "The prompt was blocked by moderation. Categories: violence" } }); + expect(comboFailureDecision(422, prose)).toBe("stop"); + }); + + test("a structured rate-limit or overload code beside moderation prose keeps its own decision", () => { + for (const code of ["rate_limit_exceeded", "slow_down", "server_is_overloaded"]) { + const message = JSON.stringify({ error: { code, message: "The input was refused by moderation." } }); + expect(isProviderModerationRefusal(400, message, code)).toBe(false); + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureCooldownScope(400, message, { code })).not.toBe("none"); + } + }); +}); + +describe("provider moderation refusal: diagnostics dev keeps terminal and verdict types", () => { + const verdict = "The input was refused by moderation. Categories: harassment."; + test("a malformed response_format diagnostic beside a moderation verdict stops", async () => { + for (const raw of [ + JSON.stringify({ error: { code: "content_filter", message: verdict, detail: "Invalid schema for response_format 'reply': 'type' is a required property." } }), + verdict + " response_format.json_schema.schema must be a valid JSON schema.", + ]) { + const failure = await consumeComboFailure(new Response(raw, { status: 400 })); + const options = { code: failure.upstreamCode, rootCode: failure.upstreamRootCode, providerModerationRefusal: failure.providerModerationRefusal, malformedResponseFormat: failure.malformedResponseFormat }; + expect(comboFailureDecision(400, failure.classificationText, options)).toBe("stop"); + expect(comboFailureCooldownScope(400, failure.classificationText, { code: failure.upstreamCode, providerModerationRefusal: failure.providerModerationRefusal, malformedResponseFormat: failure.malformedResponseFormat })).not.toBe("none"); + } + }); + + test("subscription-gate wording beside a moderation verdict stops", () => { + const message = verdict + " This model requires a subscription."; + expect(isProviderModerationRefusal(400, message)).toBe(false); + expect(comboFailureDecision(400, message)).toBe("stop"); + }); + + test("camelCase and slashed spellings of a hard code stop", () => { + for (const hard of ["contentPolicyViolation", "clientClosedRequest", "content/policy/violation", "upstreamNoResponse"]) { + const message = JSON.stringify({ error: { message: verdict, details: { code: hard } } }); + expect({ hard, hop: isProviderModerationRefusal(400, message) }).toEqual({ hard, hop: false }); + } + }); + + test("a verdict stated only as an error type stops", () => { + for (const type of ["previous_response_not_found", "tool_catalog_too_large", "input_admission_refused", "invalid_parameter"]) { + const message = JSON.stringify({ error: { message: verdict, type } }); + expect({ type, hop: isProviderModerationRefusal(400, message) }).toEqual({ type, hop: false }); + expect(comboFailureDecision(400, message)).toBe("stop"); + } + }); +}); + +describe("provider moderation refusal: safety-system wording and framed native status", () => { + test("safety-system wording beside a moderation code stops", () => { + for (const text of [ + "Your request was rejected as a result of our safety system. If you believe this is an error, you can check the status of your account.", + "Your request was rejected by the safety system.", + "The request was rejected because it was considered high risk", + ]) { + const message = JSON.stringify({ type: "error", error: { code: "content_filter", message: text } }); + expect({ text, hop: isProviderModerationRefusal(400, message, "content_filter") }).toEqual({ text, hop: false }); + expect(comboFailureDecision(400, message, { code: "content_filter" })).toBe("stop"); + expect(comboFailureCooldownScope(400, message, { code: "content_filter" })).not.toBe("none"); + } + }); +});