From 523ce7474a3917a5619c1962ed8da7ccff9749b3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 09:05:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(api):=20sanitize=20Anthropic=20body=20?= =?UTF-8?q?=E2=80=94=20strip=20frequency/presence=5Fpenalty,=20convert=20s?= =?UTF-8?q?top=E2=86=92stop=5Fsequences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageRequest now carries OpenAI-compatible tuning params (c667d47), but the Anthropic API does not support frequency_penalty or presence_penalty, and uses 'stop_sequences' instead of 'stop'. Without this fix, setting these params with a Claude model would produce 400 errors. Changes to strip_unsupported_beta_body_fields: - Remove frequency_penalty and presence_penalty from Anthropic request body - Convert stop → stop_sequences (only when non-empty) - temperature and top_p are preserved (Anthropic supports both) Tests added: - strip_removes_openai_only_fields_and_converts_stop - strip_does_not_add_empty_stop_sequences 87 api lib tests passing, 0 failing. cargo check --workspace: clean. --- rust/crates/api/src/providers/anthropic.rs | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 301b6fc..830ec4a 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -930,6 +930,15 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool { fn strip_unsupported_beta_body_fields(body: &mut Value) { if let Some(object) = body.as_object_mut() { object.remove("betas"); + // These fields are OpenAI-compatible only; Anthropic rejects them. + object.remove("frequency_penalty"); + object.remove("presence_penalty"); + // Anthropic uses "stop_sequences" not "stop". Convert if present. + if let Some(stop_val) = object.remove("stop") { + if stop_val.as_array().map_or(false, |a| !a.is_empty()) { + object.insert("stop_sequences".to_string(), stop_val); + } + } } } @@ -1439,6 +1448,46 @@ mod tests { assert_eq!(body, original); } + #[test] + fn strip_removes_openai_only_fields_and_converts_stop() { + let mut body = serde_json::json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1024, + "temperature": 0.7, + "frequency_penalty": 0.5, + "presence_penalty": 0.3, + "stop": ["\n"], + }); + + super::strip_unsupported_beta_body_fields(&mut body); + + // temperature is kept (Anthropic supports it) + assert_eq!(body["temperature"], serde_json::json!(0.7)); + // frequency_penalty and presence_penalty are removed + assert!(body.get("frequency_penalty").is_none(), + "frequency_penalty must be stripped for Anthropic"); + assert!(body.get("presence_penalty").is_none(), + "presence_penalty must be stripped for Anthropic"); + // stop is renamed to stop_sequences + assert!(body.get("stop").is_none(), "stop must be renamed"); + assert_eq!(body["stop_sequences"], serde_json::json!(["\n"])); + } + + #[test] + fn strip_does_not_add_empty_stop_sequences() { + let mut body = serde_json::json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1024, + "stop": [], + }); + + super::strip_unsupported_beta_body_fields(&mut body); + + assert!(body.get("stop").is_none()); + assert!(body.get("stop_sequences").is_none(), + "empty stop should not produce stop_sequences"); + } + #[test] fn rendered_request_body_strips_betas_for_standard_messages_endpoint() { let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");