use serde_json::Value;
use crate::provider::{LLMError, LLMRequest, LLMResponse};
use crate::providers::anthropic_types::ThinkingConfig;
use super::request_builder::rewrite_thinking_for_model;
const REASONING_EXTRACTION_CATEGORY: &str = "reasoning_extraction";
const CREDIT_TOKEN_FIELD: &str = "fallback_credit_token";
const PREFILL_CLAIM_FIELD: &str = "fallback_has_prefill_claim";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RefusalRetryPlan {
pub(crate) model: String,
pub(crate) credit_token: String,
}
impl RefusalRetryPlan {
pub(crate) fn for_response(response: &LLMResponse, original_model: &str) -> Option<Self> {
if !matches!(response.finish_reason, crate::provider::FinishReason::Refusal) {
return None;
}
let has_text = response.content.as_deref().is_some_and(|text| !text.trim().is_empty());
let has_tool_calls = response.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
if has_text || has_tool_calls {
return None;
}
let detail = stop_details(response)?;
let category = detail.get("category").and_then(Value::as_str).unwrap_or_default();
if category == REASONING_EXTRACTION_CATEGORY {
return None;
}
if detail.get(PREFILL_CLAIM_FIELD).and_then(Value::as_bool) == Some(true) {
return None;
}
let model = detail.get("recommended_model").and_then(Value::as_str)?.trim();
let credit_token = detail.get(CREDIT_TOKEN_FIELD).and_then(Value::as_str)?.trim();
if model.is_empty() || credit_token.is_empty() || model == original_model {
return None;
}
Some(Self {
model: model.to_string(),
credit_token: credit_token.to_string(),
})
}
pub(crate) fn retry_request(&self, request: &LLMRequest, with_token: bool) -> LLMRequest {
let mut retry = request.clone();
retry.model = self.model.clone();
retry.fallbacks = None;
retry.fallback_credit_token = with_token.then(|| self.credit_token.clone());
retry
}
pub(crate) fn retry_body(&self, original: &Value, default_model: &str) -> RefusalRetryBody {
let mut body = original.clone();
let Some(object) = body.as_object_mut() else {
return RefusalRetryBody { body, with_token: false };
};
object.insert("model".to_string(), Value::String(self.model.clone()));
object.remove("fallbacks");
let effort = object
.get("output_config")
.and_then(|config| config.get("effort"))
.and_then(Value::as_str)
.map(str::to_string);
let rewritten = object
.get("thinking")
.and_then(|thinking| serde_json::from_value::<ThinkingConfig>(thinking.clone()).ok())
.and_then(|thinking| rewrite_thinking_for_model(&thinking, &self.model, default_model, effort.as_deref()))
.and_then(|thinking| serde_json::to_value(thinking).ok());
let with_token = rewritten.is_none();
if let Some(thinking) = rewritten {
object.insert("thinking".to_string(), thinking);
} else {
object.insert(CREDIT_TOKEN_FIELD.to_string(), Value::String(self.credit_token.clone()));
}
RefusalRetryBody { body, with_token }
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RefusalRetryBody {
pub(crate) body: Value,
pub(crate) with_token: bool,
}
pub(crate) fn strip_credit_token(body: &mut Value) {
if let Some(object) = body.as_object_mut() {
object.remove(CREDIT_TOKEN_FIELD);
}
}
pub(crate) fn rejects_credit_token(err: &LLMError) -> bool {
let LLMError::InvalidRequest { message, metadata } = err else {
return false;
};
let status_is_400 = metadata.as_ref().is_some_and(|metadata| metadata.status == Some(400));
let names_token = message.contains(CREDIT_TOKEN_FIELD)
|| metadata
.as_ref()
.and_then(|metadata| metadata.message.as_deref())
.is_some_and(|diagnostic| diagnostic.contains(CREDIT_TOKEN_FIELD));
status_is_400 && names_token
}
fn stop_details(response: &LLMResponse) -> Option<Value> {
response
.reasoning_details
.as_ref()?
.iter()
.filter_map(|detail| serde_json::from_str::<Value>(detail).ok())
.find(|detail| detail.get("type").and_then(Value::as_str) == Some("stop_details"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::FinishReason;
use serde_json::json;
fn refusal(detail: Value) -> LLMResponse {
LLMResponse {
finish_reason: FinishReason::Refusal,
reasoning_details: Some(vec![detail.to_string()]),
..Default::default()
}
}
fn detail(category: &str, recommended: Option<&str>, token: Option<&str>) -> Value {
let mut detail = json!({ "type": "stop_details", "category": category });
if let Some(model) = recommended {
detail["recommended_model"] = json!(model);
}
if let Some(token) = token {
detail["fallback_credit_token"] = json!(token);
}
detail
}
#[test]
fn plans_retry_only_for_pre_output_refusals_with_recommended_model_and_token() {
let response = refusal(detail("cyber", Some("claude-opus-4-8"), Some("tok")));
assert_eq!(
RefusalRetryPlan::for_response(&response, "claude-opus-5-5"),
Some(RefusalRetryPlan {
model: "claude-opus-4-8".to_string(),
credit_token: "tok".to_string(),
})
);
for response in [
refusal(detail("cyber", None, Some("tok"))),
refusal(detail("cyber", Some("claude-opus-4-8"), None)),
refusal(detail("reasoning_extraction", Some("claude-opus-4-8"), Some("tok"))),
refusal(detail("cyber", Some("claude-opus-5-5"), Some("tok"))),
] {
assert_eq!(RefusalRetryPlan::for_response(&response, "claude-opus-5-5"), None);
}
let mut partial = refusal(detail("cyber", Some("claude-opus-4-8"), Some("tok")));
partial.content = Some("Here is".to_string());
assert_eq!(RefusalRetryPlan::for_response(&partial, "claude-opus-5-5"), None);
let mut claimed = detail("cyber", Some("claude-opus-4-8"), Some("tok"));
claimed["fallback_has_prefill_claim"] = json!(true);
assert_eq!(RefusalRetryPlan::for_response(&refusal(claimed), "claude-opus-5-5"), None);
let mut unclaimed = detail("cyber", Some("claude-opus-4-8"), Some("tok"));
unclaimed["fallback_has_prefill_claim"] = json!(false);
assert!(RefusalRetryPlan::for_response(&refusal(unclaimed), "claude-opus-5-5").is_some());
let mut stopped = refusal(detail("cyber", Some("claude-opus-4-8"), Some("tok")));
stopped.finish_reason = FinishReason::Stop;
assert_eq!(RefusalRetryPlan::for_response(&stopped, "claude-opus-5-5"), None);
}
#[test]
fn retry_body_keeps_prompt_fields_and_swaps_model_token_and_fallbacks() {
let plan = RefusalRetryPlan {
model: "claude-opus-4-8".to_string(),
credit_token: "tok".to_string(),
};
let original = json!({
"model": "claude-opus-5-5",
"system": "sys",
"messages": [{ "role": "user", "content": "hi" }],
"thinking": { "type": "adaptive", "display": "summarized" },
"fallbacks": "default",
"max_tokens": 64000,
"stream": true
});
let RefusalRetryBody { mut body, with_token } = plan.retry_body(&original, "claude-opus-5-5");
assert!(with_token);
assert_eq!(body["model"], "claude-opus-4-8");
assert_eq!(body["fallback_credit_token"], "tok");
assert!(body.get("fallbacks").is_none());
assert_eq!(body["system"], original["system"]);
assert_eq!(body["messages"], original["messages"]);
assert_eq!(body["thinking"], original["thinking"]);
assert_eq!(body["max_tokens"], 64000);
assert_eq!(body["stream"], true);
strip_credit_token(&mut body);
assert!(body.get("fallback_credit_token").is_none());
}
#[test]
fn retry_body_with_rewritten_thinking_forfeits_the_credit_token() {
let plan = RefusalRetryPlan {
model: "claude-opus-4-8".to_string(),
credit_token: "tok".to_string(),
};
let original = json!({
"model": "claude-opus-5-5",
"messages": [{ "role": "user", "content": "hi" }],
"thinking": { "type": "adaptive", "display": "updates" },
"fallbacks": "default",
"max_tokens": 64000
});
let RefusalRetryBody { body, with_token } = plan.retry_body(&original, "claude-opus-5-5");
assert_eq!(body["thinking"], json!({ "type": "adaptive" }));
assert!(!with_token);
assert!(body.get("fallback_credit_token").is_none());
assert_eq!(body["model"], "claude-opus-4-8");
assert!(body.get("fallbacks").is_none());
}
#[test]
fn only_400s_naming_the_credit_token_are_resent_without_it() {
let named = LLMError::InvalidRequest {
message: "Anthropic: invalid request: fallback_credit_token has expired".to_string(),
metadata: Some(crate::provider::LLMErrorMetadata::new(
"Anthropic",
Some(400),
Some("invalid_request".to_string()),
None,
None,
None,
None,
)),
};
assert!(rejects_credit_token(&named));
let other = LLMError::InvalidRequest {
message: "Anthropic: invalid request: max_tokens too large".to_string(),
metadata: Some(crate::provider::LLMErrorMetadata::new(
"Anthropic",
Some(400),
None,
None,
None,
None,
None,
)),
};
assert!(!rejects_credit_token(&other));
}
}