mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
fix(api): strip anthropic beta fields from non-beta requests
mikejiang: 'betas: Extra inputs are not permitted' 400 error. Only include beta headers when request targets beta endpoint.
This commit is contained in:
@@ -466,7 +466,8 @@ impl AnthropicClient {
|
|||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<reqwest::Response, ApiError> {
|
) -> Result<reqwest::Response, ApiError> {
|
||||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||||
let request_body = self.request_profile.render_json_body(request)?;
|
let mut request_body = self.request_profile.render_json_body(request)?;
|
||||||
|
strip_unsupported_beta_body_fields(&mut request_body);
|
||||||
let request_builder = self.build_request(&request_url).json(&request_body);
|
let request_builder = self.build_request(&request_url).json(&request_body);
|
||||||
request_builder.send().await.map_err(ApiError::from)
|
request_builder.send().await.map_err(ApiError::from)
|
||||||
}
|
}
|
||||||
@@ -513,7 +514,8 @@ impl AnthropicClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let request_url = format!("{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/'));
|
let request_url = format!("{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/'));
|
||||||
let request_body = self.request_profile.render_json_body(request)?;
|
let mut request_body = self.request_profile.render_json_body(request)?;
|
||||||
|
strip_unsupported_beta_body_fields(&mut request_body);
|
||||||
let response = self
|
let response = self
|
||||||
.build_request(&request_url)
|
.build_request(&request_url)
|
||||||
.json(&request_body)
|
.json(&request_body)
|
||||||
@@ -880,6 +882,16 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
|||||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove beta-only body fields that the standard `/v1/messages` and
|
||||||
|
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
|
||||||
|
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
|
||||||
|
/// HTTP header on these endpoints, never as a JSON body field.
|
||||||
|
fn strip_unsupported_beta_body_fields(body: &mut Value) {
|
||||||
|
if let Some(object) = body.as_object_mut() {
|
||||||
|
object.remove("betas");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AnthropicErrorEnvelope {
|
struct AnthropicErrorEnvelope {
|
||||||
error: AnthropicErrorBody,
|
error: AnthropicErrorBody,
|
||||||
@@ -1296,4 +1308,73 @@ mod tests {
|
|||||||
Some("Bearer proxy-token")
|
Some("Bearer proxy-token")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_unsupported_beta_body_fields_removes_betas_array() {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"],
|
||||||
|
"metadata": {"source": "test"},
|
||||||
|
});
|
||||||
|
|
||||||
|
super::strip_unsupported_beta_body_fields(&mut body);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
body.get("betas").is_none(),
|
||||||
|
"betas body field must be stripped before sending to /v1/messages"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body.get("model").and_then(serde_json::Value::as_str),
|
||||||
|
Some("claude-sonnet-4-6")
|
||||||
|
);
|
||||||
|
assert_eq!(body["max_tokens"], serde_json::json!(1024));
|
||||||
|
assert_eq!(body["metadata"]["source"], serde_json::json!("test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
|
||||||
|
super::strip_unsupported_beta_body_fields(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
|
||||||
|
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-6".to_string(),
|
||||||
|
max_tokens: 64,
|
||||||
|
messages: vec![],
|
||||||
|
system: None,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rendered = client
|
||||||
|
.request_profile()
|
||||||
|
.render_json_body(&request)
|
||||||
|
.expect("body should render");
|
||||||
|
assert!(
|
||||||
|
rendered.get("betas").is_some(),
|
||||||
|
"render_json_body still emits betas; the strip helper guards the wire format",
|
||||||
|
);
|
||||||
|
super::strip_unsupported_beta_body_fields(&mut rendered);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
rendered.get("betas").is_none(),
|
||||||
|
"betas must not appear in /v1/messages request bodies"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rendered.get("model").and_then(serde_json::Value::as_str),
|
||||||
|
Some("claude-sonnet-4-6")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ async fn send_message_posts_json_and_parses_response() {
|
|||||||
assert!(body.get("stream").is_none());
|
assert!(body.get("stream").is_none());
|
||||||
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
||||||
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
||||||
assert_eq!(
|
assert!(
|
||||||
body["betas"],
|
body.get("betas").is_none(),
|
||||||
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
|
"betas must travel via the anthropic-beta header, not the request body"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,13 +191,9 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
|
|||||||
let body: serde_json::Value =
|
let body: serde_json::Value =
|
||||||
serde_json::from_str(&request.body).expect("request body should be json");
|
serde_json::from_str(&request.body).expect("request body should be json");
|
||||||
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
|
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
|
||||||
assert_eq!(
|
assert!(
|
||||||
body["betas"],
|
body.get("betas").is_none(),
|
||||||
json!([
|
"betas must travel via the anthropic-beta header, not the request body"
|
||||||
"claude-code-20250219",
|
|
||||||
"prompt-caching-scope-2026-01-05",
|
|
||||||
"tools-2026-04-01"
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let events = sink.events();
|
let events = sink.events();
|
||||||
|
|||||||
Reference in New Issue
Block a user