fix(agents): classify Anthropic refusal and OpenAI content_filter as failover-eligible refusals#99164
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed July 7, 2026, 10:40 PM ET / 02:40 UTC. Summary PR surface: Source +24, Tests +30, Docs +35. Total +89 across 13 files. Reproducibility: yes. at source level: current main still maps OpenAI Review metrics: 2 noteworthy metrics.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review findings
Review detailsBest possible solution: Land only after maintainers approve explicit default-off refusal semantics, the config schema/runtime/tests are complete or the unsupported config docs are removed, refusals bypass auth health and probe-slot state, both Anthropic stream paths are normalized, and redacted live proof shows the fallback path. Do we have a high-confidence way to reproduce the issue? Yes at source level: current main still maps OpenAI Is this the best way to solve the issue? No: a distinct refusal reason is plausible, but this head is not the best fix until the opt-in config, auth-health semantics, sibling provider paths, probe policy, and real behavior proof are corrected. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against fdc98242a1e5. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +24, Tests +30, Docs +35. Total +89 across 13 files. View PR surface stats
Security concerns:
Acceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (3 earlier review cycles)
|
d8cacd8 to
2aa2b13
Compare
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
🦞🧹 I asked ClawSweeper to review this item again. |
b687c75 to
facaf0c
Compare
|
🦞🧹 I asked ClawSweeper to review this item again. |
e67f018 to
3437993
Compare
…-in refusalFallback flag Cherry-pick from PR openclaw#99164 with conflict resolution for current main.
3437993 to
b8ff40a
Compare
b8ff40a to
c81908d
Compare
…failover-eligible refusals Provider refusals from safety classifiers and OpenAI content_filter finish reasons were mapped to stopReason=error but never classified as a failover reason. This caused the run-loop to skip the configurd fallback chain, surfacing a generic LLM request failed. error to users instead of retrying with fallback models as Anthropic itself recommends. Add refusal to FailoverReason, detect Anthropic refusal and content_filter messages in classifyFailoverClassificationFromMessage, and treat refusal as a transient failure eligible for cooldown probing. Fixes openclaw#98976 Co-Authored-By: Claude Opus 4.8 <[email protected]>
…atterns, auth registry cleanup, docs, schema help
c81908d to
da33935
Compare
|
Closing this PR. The rebase onto latest
The |
|
Closing as not planned — see comment above for details. |
What Problem This Solves
Provider refusals (Anthropic safety classifiers, OpenAI
content_filter) silently bypass the model fallback chain. When a provider returns a refusal, the agent run-loop surfaces a generic "LLM request failed." error without ever consulting the configured fallback models — even though Anthropic's own refusal copy recommends configuring a fallback model.classifyFailoverClassificationFromMessagehas no refusal pattern →classifyAssistantFailoverReasonreturns null →failoverFailure=false→shouldRotateAssistant=false→continue_normal→ fallback chain never checked"refusal"toFailoverReason, match Anthropic refusal andcontent_filtermessages in the unified failover classifier, route Anthropicsensitivestop reason through the refusal path, and gate the fallback on the opt-inrefusalFallbackconfig flagsrc/agents/embedded-agent-helpers/types.ts(+1, new failover reason)src/agents/embedded-agent-helpers/errors.ts(+3/−8, refusal detection viaisRefusalErrorMessagefrom failover-matches.ts; preserve refusal text when all fallbacks exhausted)src/agents/embedded-agent-helpers/failover-matches.ts(+8,ERROR_PATTERNS.refusalregex patterns +isRefusalErrorMessageexport)src/agents/embedded-agent-helpers/failover-matches.test.ts(refusal classification tests)src/agents/failover-policy.ts(+2, cooldown probe eligibility for refusal)src/agents/failover-policy.test.ts(refusal cooldown policy tests)src/agents/anthropic-transport-stream.ts(+1/−2, routesensitivestop reason throughapplyAnthropicRefusalso it produceserrorCode: "provider_refusal")src/agents/auth-profiles/types.ts(+1,AuthProfileFailureReasonmirror)src/agents/runtime-plan/types.ts(+1,AgentRuntimeFailoverReasonmirror)src/agents/embedded-agent-runner/run/failover-policy.ts(refusal skips auth rotation, goes directly to fallback_model)src/agents/agent-command.ts(pass config to result classifier callback)src/agents/embedded-agent-runner/result-fallback-classifier.ts(gate result-payload refusals onrefusalFallback+ config param)src/config/types.agent-defaults.ts(+15,refusalFallbackconfig flag)src/config/zod-schema.agent-defaults.ts(+1, Zod schema for strict validation)src/config/schema.help.ts(+2,refusalFallbackhelp documentation)src/config/schema.labels.ts(+1,refusalFallbackconfig label)docs/concepts/model-failover.md(+34, Provider refusals documentation section)packages/gateway-protocol/src/schema/cron.ts,src/cron/run-log/entry-codec.ts,src/agents/auth-profiles/failure-copy.ts— all downstream registries updatedresolveFailoverStatus(refusal has no HTTP analog),resolveSessionSuspensionReason(defaultcircuit_openis correct),buildFailoverRemediationHint(only triggers for auth). Refusal is intentionally excluded fromAUTH_FAILURE_REASONSandFAILURE_REASON_PRIORITYbecause it is not an auth-profile-level failure.Change Type (select all)
Scope (select all)
Linked Issue/PR
Motivation
Three production incidents in one morning logged
failoverReason: nullfor Anthropic refusals withAnthropic refusal (category: bio) ...in the error body. Each time the provider message explicitly said "API integrators: you can reduce refusals for your users by configuring a fallback model — see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback". But OpenClaw never triggered the fallback chain because the failover classification pipeline had no concept of a refusal.After fix, a refusal skips profile rotation (content decision, not credential problem) and goes directly to model fallback. The next candidate handles the turn without polluting auth cooldown state.
Approach Analysis
The core question is: when a provider's safety classifier refuses a prompt, should OpenClaw automatically route to the next configured fallback model?
Three approaches were considered:
Approach A: Always fallback on refusal
Route all refusals through the failover chain, same as
rate_limitandtimeout.Approach B: Never fallback on refusal (current behavior)
Keep refusals as terminal errors. Users who want to retry must manually switch models or resubmit.
Approach C (chosen): Opt-in via
refusalFallbackflagAdd a configuration flag
agents.defaults.refusalFallback(default:false) that users enable when they want refusals to enter the fallback chain.falsemeans zero behavior change for existing users.Config:
agents.defaults.refusalFallbackThis behavior is opt-in via a new config flag:
{ "agents": { "defaults": { "refusalFallback": true } } }Default:
false— provider refusals are surfaced as terminal errors (existing behavior preserved).false(default)trueTrade-offs
Pro (why turn it on):
biocategory)Con (why keep it off):
Recommendation: Turn on if your fallback chain uses models with comparable safety guardrails (e.g., Claude Sonnet → Claude Haiku, or GPT-5.5 → GPT-5.4). Keep off if you need every provider safety block to be terminal and visible.
Evidence
stop_reason=refusal/sensitive, OpenAIfinish_reason=content_filter) are classified as failover-eligible, triggering the configured model fallback chain instead of silently dying with "LLM request failed."fix/provider-refusal-fallback-98976Classification matrix:
"Anthropic refusal (category: bio): ...""refusal""refusal""anthropic REFUSAL (category: legal): ...""refusal""refusal"(regex is case-insensitive)"Provider finish_reason: content_filter""refusal""refusal""Finish_reason: content_filter""refusal""refusal"(regex is case-insensitive)"LLM request failed."null(not refusal)null"rate limit exceeded""rate_limit"(not refusal)"rate_limit""The request was refused by the proxy."null(not refusal)nullCooldown probe matrix:
"refusal"(new)"rate_limit""overloaded"Failover decision matrix:
"refusal"true"fallback_model""refusal"false"surface_error""refusal"true"surface_error""rate_limit"true"rotate_profile"Unit test results:
Observed result after fix:
classifyFailoverReason("Anthropic refusal ...")returns"refusal"— wasnullstartsWithsensitivestop reason now routes throughapplyAnthropicRefusal, producingerrorCode: "provider_refusal"refusalis excluded fromAUTH_FAILURE_REASONSandFAILURE_REASON_PRIORITY— it is not an auth-level failurerefusalFallbackconfig has schema help, label, and public documentationclassifyAssistantFailoverReasonwitherrorCode: "provider_refusal"returns"refusal"What was not tested: Live end-to-end with real Anthropic API key (not available in this environment). The classification, policy, and failover decision logic is fully source-verified; a real API-key test is needed to confirm the transport-level
sensitive→applyAnthropicRefusalmapping and the full refusal→fallback flow in a running gateway. This is a follow-up item for a maintainer or contributor with Anthropic API key access.Root Cause
classifyFailoverClassificationFromMessageinsrc/agents/embedded-agent-helpers/errors.tshad no pattern for "Anthropic refusal" or "Provider finish_reason: content_filter". When an assistant message arrived withstopReason="error"and refusal text, the classifier returnednull,isFailoverAssistantErrorreturnedfalse,shouldRotateAssistantreturnedfalse, andresolveRunFailoverDecisionreturned{ action: "continue_normal" }— which ends the turn with no fallback attempt.Provenance:
introduced bythe originalshared/anthropic-refusal.ts— the refusal→error mapping was wired but never connected to failover classification.Confidence:
clear— single classification entry point, all callers downstream react to the returned reason.User-visible / Behavior Changes
When
refusalFallback: trueis set and a provider refuses a prompt:refusalFallbackis unset orfalse, behavior is unchangedAdditionally, Anthropic
sensitivestop reason is now properly handled — previously it was mapped as a generic error; now it produceserrorCode: "provider_refusal"with proper diagnostics, consistent withrefusalstop reason behavior.Security Impact
Compatibility / Migration
refusalFallbackdefaults tofalse, preserving existing behavior.AUTH_FAILURE_REASONSno longer includes"refusal"(it was never used in practice for auth profile state).agents.defaults.refusalFallbackboolean (optional, default: false) with schema help/label/docsBest-fix Verdict
classifyFailoverClassificationFromMessage) is the right boundary because every failover path (assistant-stage, prompt-stage, signal-level, detail-level) already goes through it. Adding a new reason here automatically propagates to the full fallback pipeline without touching any of the downstream decision helpers.failover-error.ts'sresolveFailoverReasonFromErrorpath. Rejected — that path only fires for thrown errors, whereas refusal is a normal stream result withstopReason="error"on the assistant message. The message-level classifier is the correct gate.Risks and Mitigations
/\banthropic refusal\b/iprovides word-boundary matching with case insensitivity — more robust than the originalstartsWith. The underlyingapplyAnthropicRefusalfunction always produces"Anthropic refusal"as the message prefix.content_filterfinish reason could be confused with a future provider that uses different wording. Mitigation: regex/\bfinish_reason:\s*content_filter\b/imatches the canonical output ofmapOpenAIStopReason.refusalFallback(default: false). Each provider runs its own independent safety checks.sensitivestop reason from Anthropic was previously treated as a generic error; changing it toapplyAnthropicRefusalchanges its error behavior. Mitigation:applyAnthropicRefusalis already the standard refusal handler used forrefusalstop reason — this change makessensitiveconsistent with the existing refusal path.Fixes #98976