Files
Aether/crates/aether-provider/transport/src/claude_code/request.rs
T

453 lines
16 KiB
Rust

use std::collections::BTreeMap;
use std::sync::OnceLock;
use regex::Regex;
use serde_json::{Map, Value};
use super::super::auth::build_openai_passthrough_headers;
use super::profile::{
current_claude_code_transport_identity_profile, ClaudeCodeTransportIdentityProfile,
};
const DUMMY_THINKING_SIGNATURE: &str = "skip_thought_signature_validator";
pub fn build_claude_code_passthrough_headers(
headers: &http::HeaderMap,
auth_header: &str,
auth_value: &str,
extra_headers: &BTreeMap<String, String>,
stream: bool,
) -> BTreeMap<String, String> {
let mut out = build_openai_passthrough_headers(
headers,
auth_header,
auth_value,
extra_headers,
Some("application/json"),
);
// The common passthrough filter intentionally strips Anthropic identity
// headers. Restore only the client beta input; the versioned profile owns
// all fixed identity values and the final beta policy.
let mut incoming_beta_values = headers
.get("anthropic-beta")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.into_iter()
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
incoming_beta_values.extend(
extra_headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("anthropic-beta"))
.map(|(_, value)| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
);
if !incoming_beta_values.is_empty() {
out.insert("anthropic-beta".to_string(), incoming_beta_values.join(","));
}
let profile = current_claude_code_transport_identity_profile();
profile.apply_fixed_headers(&mut out, stream);
profile.apply_beta_policy(&mut out, None);
out
}
/// Reconcile header/body identity using one immutable profile at the common
/// outbound boundary. Preserve the planner's content negotiation and count-token contract.
pub fn finalize_claude_code_request_identity(
headers: &mut BTreeMap<String, String>,
body: &mut Value,
profile: &ClaudeCodeTransportIdentityProfile,
operation: Option<aether_ai_formats::ApiOperation>,
) {
let accept = headers.get("accept").cloned();
let stream = body.get("stream").and_then(Value::as_bool).unwrap_or(false)
|| accept
.as_deref()
.is_some_and(|v| v.contains("text/event-stream"));
profile.apply_fixed_headers(headers, stream);
if stream {
crate::headers::force_identity_accept_encoding(headers);
}
profile.apply_beta_policy(headers, operation);
if let Some(accept) = accept {
headers.insert("accept".to_string(), accept);
}
if operation != Some(aether_ai_formats::ApiOperation::ClaudeCountTokens) {
let beta = headers.get("anthropic-beta").cloned().unwrap_or_default();
sanitize_claude_code_request_body_for_beta_header(body, &beta, profile);
}
}
pub fn sanitize_claude_code_request_body(body: &mut Value) {
let profile = current_claude_code_transport_identity_profile();
let beta_header = profile.merge_beta_tokens(None, None);
sanitize_claude_code_request_body_for_beta_header(body, &beta_header, &profile);
}
pub fn sanitize_claude_code_request_body_for_beta_header(
body: &mut Value,
beta_header: &str,
profile: &ClaudeCodeTransportIdentityProfile,
) {
let Some(body_object) = body.as_object_mut() else {
return;
};
synchronize_billing_header_version(body_object, profile.billing_cli_version());
for gate in profile.body_capability_gates() {
if !profile.beta_header_enables_body_field(beta_header, gate.body_field) {
body_object.remove(gate.body_field);
}
}
let thinking_enabled = body_object
.get("thinking")
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("type"))
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "enabled" | "adaptive"));
if let Some(gate) = profile.body_capability_gate("context_management") {
if thinking_enabled
&& gate.inject_when_thinking_enabled
&& profile.beta_header_enables_body_field(beta_header, gate.body_field)
&& !body_object.contains_key(gate.body_field)
{
if let Some(default_edit_type) = gate.default_edit_type {
body_object.insert(
gate.body_field.to_string(),
serde_json::json!({
"edits": [{
"type": default_edit_type,
"keep": "all"
}]
}),
);
}
}
}
let Some(messages) = body_object
.get_mut("messages")
.and_then(Value::as_array_mut)
else {
return;
};
for message in messages {
let Some(message_object) = message.as_object_mut() else {
continue;
};
let role = message_object
.get("role")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
let Some(content) = message_object
.get_mut("content")
.and_then(Value::as_array_mut)
else {
continue;
};
let mut filtered = Vec::with_capacity(content.len());
for block in std::mem::take(content) {
let Value::Object(block_object) = block else {
filtered.push(block);
continue;
};
if keep_claude_code_block(&block_object, &role, thinking_enabled) {
filtered.push(Value::Object(block_object));
}
}
*content = filtered;
}
}
fn synchronize_billing_header_version(body: &mut Map<String, Value>, cli_version: &str) {
static CC_VERSION: OnceLock<Regex> = OnceLock::new();
let Some(system) = body.get_mut("system").and_then(Value::as_array_mut) else {
return;
};
let regex = CC_VERSION.get_or_init(|| {
Regex::new(r"cc_version=\d+\.\d+\.\d+").expect("billing version regex must compile")
});
let replacement = format!("cc_version={cli_version}");
for block in system {
let Some(block) = block.as_object_mut() else {
continue;
};
let Some(text) = block
.get("text")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
continue;
};
if !text.starts_with("x-anthropic-billing-header") {
continue;
}
let updated = regex.replace_all(&text, replacement.as_str());
if updated != text {
block.insert("text".to_string(), Value::String(updated.into_owned()));
}
}
}
fn keep_claude_code_block(
block_object: &Map<String, Value>,
role: &str,
thinking_enabled: bool,
) -> bool {
let block_type = block_object
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if matches!(block_type, "thinking" | "redacted_thinking") {
let signature = block_object
.get("signature")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
return thinking_enabled
&& role.eq_ignore_ascii_case("assistant")
&& !signature.is_empty()
&& signature != DUMMY_THINKING_SIGNATURE;
}
if block_type.is_empty() && block_object.contains_key("thinking") {
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::{
build_claude_code_passthrough_headers, sanitize_claude_code_request_body,
sanitize_claude_code_request_body_for_beta_header,
};
use crate::claude_code::current_claude_code_transport_identity_profile;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn claude_code_headers_use_versioned_identity_and_merge_preserved_betas() {
let mut headers = http::HeaderMap::new();
headers.insert(
"anthropic-beta",
http::HeaderValue::from_static("context-1m-2025-08-07,custom-beta"),
);
headers.insert(
"user-agent",
http::HeaderValue::from_static("Claude-Code/Test"),
);
let built = build_claude_code_passthrough_headers(
&headers,
"authorization",
"Bearer upstream-token",
&BTreeMap::new(),
true,
);
assert_eq!(
built.get("anthropic-beta").map(String::as_str),
Some(
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,effort-2025-11-24,context-management-2025-06-27,extended-cache-ttl-2025-04-11,context-1m-2025-08-07,custom-beta"
)
);
assert_eq!(
built.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
assert_eq!(
built.get("accept").map(String::as_str),
Some("application/json")
);
assert_eq!(
built.get("x-stainless-helper-method").map(String::as_str),
Some("stream")
);
assert_eq!(built.get("x-app").map(String::as_str), Some("cli"));
assert_eq!(
built.get("x-stainless-package-version").map(String::as_str),
Some("0.112.1")
);
assert_eq!(
built.get("x-stainless-runtime-version").map(String::as_str),
Some("v26.3.0")
);
assert_eq!(
built.get("x-stainless-timeout").map(String::as_str),
Some("600")
);
assert_eq!(
built.get("user-agent").map(String::as_str),
Some("claude-cli/2.1.284 (external, cli)")
);
assert_eq!(
built.get("authorization").map(String::as_str),
Some("Bearer upstream-token")
);
}
#[test]
fn claude_code_body_sanitizer_drops_invalid_thinking_blocks() {
let mut body = json!({
"thinking": {"type":"enabled"},
"messages": [{
"role":"assistant",
"content":[
{"type":"thinking","thinking":"keep","signature":"sig_valid"},
{"type":"thinking","thinking":"drop-empty","signature":""},
{"type":"redacted_thinking","data":"keep-redacted","signature":"sig_redacted"},
{"type":"redacted_thinking","data":"drop-no-signature"},
{"thinking":"drop-no-type"},
{"type":"text","text":"ok"}
]
}]
});
sanitize_claude_code_request_body(&mut body);
assert_eq!(
body["messages"][0]["content"],
json!([
{"type":"thinking","thinking":"keep","signature":"sig_valid"},
{"type":"redacted_thinking","data":"keep-redacted","signature":"sig_redacted"},
{"type":"text","text":"ok"}
])
);
}
#[test]
fn context_management_body_is_gated_by_the_matching_beta_token() {
let profile = current_claude_code_transport_identity_profile();
let original = json!({
"context_management": {
"edits": [{"type":"clear_thinking_20251015", "keep":"all"}]
},
"messages": []
});
let mut without_beta = original.clone();
sanitize_claude_code_request_body_for_beta_header(
&mut without_beta,
"oauth-2025-04-20",
&profile,
);
assert!(without_beta.get("context_management").is_none());
let mut with_beta = original.clone();
sanitize_claude_code_request_body_for_beta_header(
&mut with_beta,
"oauth-2025-04-20, context-management-2025-06-27",
&profile,
);
assert_eq!(with_beta, original);
}
#[test]
fn default_profile_keeps_header_and_injected_context_management_in_sync() {
let headers = build_claude_code_passthrough_headers(
&http::HeaderMap::new(),
"authorization",
"Bearer upstream-token",
&BTreeMap::new(),
false,
);
let mut body = json!({
"thinking": {"type":"adaptive"},
"messages": []
});
sanitize_claude_code_request_body(&mut body);
assert!(headers["anthropic-beta"]
.split(',')
.any(|token| token == "context-management-2025-06-27"));
assert_eq!(
body["context_management"],
json!({"edits":[{"type":"clear_thinking_20251015", "keep":"all"}]})
);
}
#[test]
fn billing_attribution_uses_the_profile_cli_version() {
let mut body = json!({
"system": [{
"type":"text",
"text":"x-anthropic-billing-header: cc_version=2.0.0.abc; cc_entrypoint=cli;"
}],
"messages": []
});
sanitize_claude_code_request_body(&mut body);
assert_eq!(
body["system"][0]["text"],
"x-anthropic-billing-header: cc_version=2.1.284.abc; cc_entrypoint=cli;"
);
}
}
#[cfg(test)]
mod final_identity_tests {
use super::*;
use crate::claude_code::{ClaudeCodeClientProfile, CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04};
use std::sync::Arc;
fn profile() -> ClaudeCodeTransportIdentityProfile {
ClaudeCodeTransportIdentityProfile::new(
&CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04,
Arc::new(ClaudeCodeClientProfile::cli("2.1.286").unwrap()),
)
}
#[test]
fn terminal_snapshot_reconciles_billing_and_headers_without_changing_negotiation() {
let mut headers = BTreeMap::from([
("accept".into(), "text/event-stream".into()),
("User-Agent".into(), "old-client".into()),
("X-Stainless-Package-Version".into(), "old-sdk".into()),
("Anthropic-Beta".into(), "custom-beta".into()),
]);
let mut body = serde_json::json!({"stream":true, "system":[{"type":"text", "text":"x-anthropic-billing-header: cc_version=2.1.280.abc; cc_entrypoint=cli;"}]});
finalize_claude_code_request_identity(&mut headers, &mut body, &profile(), None);
assert_eq!(headers["user-agent"], "claude-cli/2.1.286 (external, cli)");
assert_eq!(headers["accept"], "text/event-stream");
assert_eq!(headers["accept-encoding"], "identity");
assert_eq!(headers["x-stainless-package-version"], "0.112.1");
assert!(!headers.contains_key("User-Agent"));
assert!(!headers.contains_key("X-Stainless-Package-Version"));
assert!(!headers.contains_key("Anthropic-Beta"));
assert!(headers["anthropic-beta"].contains("custom-beta"));
assert!(body["system"][0]["text"]
.as_str()
.unwrap()
.contains("cc_version=2.1.286.abc;"));
let before = body.clone();
finalize_claude_code_request_identity(&mut headers, &mut body, &profile(), None);
assert_eq!(body, before);
}
#[test]
fn count_token_body_remains_untouched() {
let mut headers = BTreeMap::new();
let mut body = serde_json::json!({"model":"claude-test", "messages":[], "thinking":{"type":"enabled"}});
let before = body.clone();
finalize_claude_code_request_identity(
&mut headers,
&mut body,
&profile(),
Some(aether_ai_formats::ApiOperation::ClaudeCountTokens),
);
assert_eq!(body, before);
assert_eq!(headers["user-agent"], "claude-cli/2.1.286 (external, cli)");
}
}