diff --git a/Cargo.lock b/Cargo.lock index afab7b0401..7a74c5201f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,6 +1922,7 @@ dependencies = [ "tonic", "tracing", "url", + "xxhash-rust", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ecdc37bcb7..ee28f0a75b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,7 @@ tracing = "0.1.44" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } url = { version = "2.5.8", features = ["serde"] } +xxhash-rust = { version = "0.8.15", features = ["xxh64"] } backon = "1.5.2" eserde = "0.1.7" uuid = { version = "1.23.0", features = [ diff --git a/crates/forge_app/Cargo.toml b/crates/forge_app/Cargo.toml index 7256b533e1..69ca86d717 100644 --- a/crates/forge_app/Cargo.toml +++ b/crates/forge_app/Cargo.toml @@ -36,6 +36,7 @@ convert_case.workspace = true backon.workspace = true sha2.workspace = true dashmap.workspace = true +xxhash-rust.workspace = true url.workspace = true reqwest.workspace = true diff --git a/crates/forge_app/src/dto/anthropic/request.rs b/crates/forge_app/src/dto/anthropic/request.rs index f6c34c6f7c..fd49f764fe 100644 --- a/crates/forge_app/src/dto/anthropic/request.rs +++ b/crates/forge_app/src/dto/anthropic/request.rs @@ -2,13 +2,19 @@ use derive_setters::Setters; use forge_domain::{ContextMessage, Image}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Default, Setters)] +#[derive(Serialize, Deserialize, Default, Setters)] #[setters(into, strip_option)] pub struct Request { - pub max_tokens: u64, - pub messages: Vec, + // NOTE: Field declaration order controls JSON serialization order. + // `system` MUST appear before `messages` so that CCH signing (which + // computes a hash over the serialized body) produces the same byte + // sequence as the final outbound request body. Do NOT reorder these two + // fields. + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + pub max_tokens: u64, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -16,12 +22,10 @@ pub struct Request { #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(skip_serializing_if = "Vec::is_empty", default)] pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub top_k: Option, @@ -35,9 +39,13 @@ pub struct Request { pub output_format: Option, #[serde(skip_serializing_if = "Option::is_none")] pub anthropic_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub research_preview_2026_02: Option, + // messages MUST come after system — see note above. + pub messages: Vec, } -#[derive(Serialize, Default)] +#[derive(Serialize, Deserialize, Default)] pub struct SystemMessage { pub r#type: String, pub text: String, @@ -60,7 +68,7 @@ impl SystemMessage { } } -#[derive(Serialize, Default, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq)] pub struct Thinking { pub r#type: ThinkingType, pub budget_tokens: u64, @@ -70,7 +78,7 @@ pub struct Thinking { /// /// Only the variants officially supported by Anthropic's `output_config.effort` /// field. Mutually exclusive with the `thinking` object. -#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum OutputEffort { Low, @@ -81,19 +89,19 @@ pub enum OutputEffort { /// Output configuration for newer Anthropic models that support effort-based /// reasoning (e.g. `claude-opus-4-6`). Mutually exclusive with `thinking`. -#[derive(Serialize, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct OutputConfig { pub effort: OutputEffort, } -#[derive(Serialize, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum OutputFormat { #[serde(rename = "json_schema")] JsonSchema { schema: schemars::Schema }, } -#[derive(Serialize, Default, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ThinkingType { #[default] @@ -218,13 +226,13 @@ impl Request { } } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct Metadata { #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct Message { pub content: Vec, pub role: Role, @@ -341,7 +349,7 @@ impl From for Content { } } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ImageSource { #[serde(rename = "type")] pub type_: String, @@ -353,7 +361,7 @@ pub struct ImageSource { pub url: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum Content { Image { @@ -463,7 +471,7 @@ impl TryFrom for Content { } } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CacheControl { Ephemeral, @@ -476,7 +484,7 @@ pub enum Role { Assistant, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum ToolChoice { Auto { @@ -510,7 +518,7 @@ impl From for ToolChoice { } } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ToolDefinition { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs b/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs index 73698d43a8..f93bfb431f 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs @@ -1,24 +1,27 @@ +use std::borrow::Cow; + use forge_domain::Transformer; use crate::dto::anthropic::{Request, SystemMessage}; /// Adds authentication system message when OAuth is enabled. +#[derive(Clone)] pub struct AuthSystemMessage { - message: String, + message: Cow<'static, str>, } impl AuthSystemMessage { const AUTH_MESSAGE: &'static str = include_str!("claude_code.md"); - /// Creates a new AuthSystemMessage with the provided message. - pub fn new(message: String) -> Self { - Self { message } + /// Creates a new `AuthSystemMessage` with the provided message. + pub fn new(message: impl Into>) -> Self { + Self { message: message.into() } } } impl Default for AuthSystemMessage { fn default() -> Self { - Self::new(Self::AUTH_MESSAGE.to_string()) + Self::new(Self::AUTH_MESSAGE.trim()) } } @@ -29,7 +32,7 @@ impl Transformer for AuthSystemMessage { fn transform(&mut self, mut request: Self::Value) -> Self::Value { let auth_system_message = SystemMessage { r#type: "text".to_string(), - text: self.message.trim().to_string(), + text: self.message.trim().to_owned(), cache_control: None, }; diff --git a/crates/forge_app/src/dto/anthropic/transforms/cch_signing.rs b/crates/forge_app/src/dto/anthropic/transforms/cch_signing.rs new file mode 100644 index 0000000000..b35351a6f7 --- /dev/null +++ b/crates/forge_app/src/dto/anthropic/transforms/cch_signing.rs @@ -0,0 +1,698 @@ +use sha2::{Digest, Sha256}; + +use crate::dto::anthropic::{Content, Request, Role, SystemMessage}; + +/// CCH (Claude Code Hash) request signing for fast mode and research preview features. +/// +/// This helper implements the request signing mechanism used by Claude Code +/// to authenticate requests and unlock features like fast mode. The signing +/// involves: +/// +/// 1. Computing a 3-character version suffix from the raw text of the first user message +/// 2. Building a billing header with the version and a `cch=00000` placeholder +/// 3. Injecting the billing header as the first system message +/// 4. Serializing the full request to compact JSON (`Request` guarantees `system` before `messages`) +/// 5. Computing a 5-character xxHash64 of the serialized body (with placeholder) +/// 6. Replacing `cch=00000` in the serialized JSON with the real hash +/// +/// # JSON Ordering +/// +/// The hash is computed over the serialized request body. `system` MUST appear +/// before `messages` in the JSON output. The `Request` struct field declaration +/// order guarantees this — do NOT reorder those two fields in `Request`. +/// +/// # Replacement Safety +/// +/// The first `cch=00000` in the serialized JSON always belongs to the billing +/// header because `system` serializes before `messages`. Even if a user message +/// happens to contain the literal string `cch=00000`, it appears after the +/// billing header in the JSON and is therefore unaffected by the first-occurrence +/// replacement. +/// +/// # Reference +/// +/// Based on reverse engineering research of Claude Code's request signing +/// mechanism. The algorithm uses xxHash64 with a fixed seed for request +/// integrity verification. +#[derive(Clone)] +pub struct CchSigning { + /// Claude Code version string (e.g., "2.1.37") + version: String, + /// xxHash64 seed from binary analysis + seed: u64, + /// Salt for version suffix computation + salt: String, +} + +/// Default CCH constants from reverse engineering research. +impl Default for CchSigning { + fn default() -> Self { + Self::new( + env_or_default("FORGE_CC_VERSION", "2.1.37"), + env_or_default_u64("FORGE_CCH_SEED", 0x6E52736AC806831E), + env_or_default("FORGE_CCH_SALT", "59cf53e54c78"), + ) + } +} + +impl CchSigning { + /// CCH placeholder that gets replaced with the actual hash. + const CCH_PLACEHOLDER: &'static str = "cch=00000"; + + /// Creates a new `CchSigning` with custom parameters. + /// + /// For most use cases, use `CchSigning::default()` which loads constants + /// from environment variables or uses the default research values. + pub fn new(version: String, seed: u64, salt: String) -> Self { + Self { version, seed, salt } + } + + /// Computes the 3-character version suffix from the raw text of the first + /// user message. + /// + /// Algorithm: extract the characters at indices 4, 7, and 20 from the plain + /// text string (substituting `'0'` when the index is out of bounds), then + /// compute `SHA256(salt + chars + version)` and return the first 3 hex + /// characters. + /// + /// # Arguments + /// + /// * `first_message_text` - The raw text content of the first user message + /// + /// # Returns + /// + /// A 3-character lowercase hex string (e.g., `"fbe"`) + pub fn compute_version_suffix(&self, first_message_text: &str) -> String { + let chars: String = [4_usize, 7, 20] + .iter() + .map(|&i| first_message_text.chars().nth(i).unwrap_or('0')) + .collect(); + + let input = format!("{}{}{}", self.salt, chars, self.version); + let hash = Sha256::digest(input.as_bytes()); + + format!("{:x}", hash)[..3].to_string() + } + + /// Computes the 5-character CCH hash over `body`. + /// + /// Applies xxHash64 with the configured seed, masks the result to 20 bits + /// (`& 0xFFFFF`), and formats it as a zero-padded 5-character lowercase hex + /// string (e.g., `"a112b"`). + pub fn compute_cch_hash(&self, body: &str) -> String { + let hash = xxhash_rust::xxh64::xxh64(body.as_bytes(), self.seed); + format!("{:05x}", hash & 0xFFFFF) + } + + /// Builds the billing header text with the placeholder in place. + /// + /// Returns a string of the form: + /// `x-anthropic-billing-header: cc_version=2.1.37.fbe; cc_entrypoint=cli; cch=00000;` + fn build_billing_header(&self, version_suffix: &str) -> String { + format!( + "x-anthropic-billing-header: cc_version={}.{}; cc_entrypoint=cli; cch=00000;", + self.version, version_suffix, + ) + } + + /// Extracts the raw text of the first user text message from the request. + /// + /// Scans the message list in order, skips non-user messages, then returns a + /// borrowed reference to the first `Content::Text` block found in the first + /// user message that contains text. Returns `None` if there is no user text + /// message. + fn extract_first_user_message_text(request: &Request) -> Option<&str> { + request + .messages + .iter() + .filter(|msg| msg.role == Role::User) + .find_map(|msg| { + msg.content.iter().find_map(|block| { + if let Content::Text { text, .. } = block { + Some(text.as_str()) + } else { + None + } + }) + }) + } + + /// Injects the temporary billing header as the first system message. + fn prepend_billing_header(request: &mut Request, billing_header: String) { + let billing_system_message = SystemMessage { + r#type: "text".to_string(), + text: billing_header, + cache_control: None, + }; + let mut system_messages = request.system.take().unwrap_or_default(); + system_messages.insert(0, billing_system_message); + request.system = Some(system_messages); + } + + /// Serializes the final outbound request body, applying Claude Code signing + /// when a user text message is present. + /// + /// # Arguments + /// + /// * `request` - The fully transformed Anthropic request to serialize for the wire + /// + /// # Errors + /// + /// Returns an error if the request cannot be serialized to JSON. + pub fn serialize_signed_request(&self, mut request: Request) -> anyhow::Result> { + let Some(first_message_text) = Self::extract_first_user_message_text(&request) else { + tracing::debug!("CCH signing: no user text message found, skipping"); + return Ok(serde_json::to_vec(&request)?); + }; + + let version_suffix = self.compute_version_suffix(first_message_text); + tracing::debug!(version_suffix = %version_suffix, "CCH signing: computed version suffix"); + + let billing_header = self.build_billing_header(&version_suffix); + Self::prepend_billing_header(&mut request, billing_header); + + let body_with_placeholder = serialize_request_compact(&request)?; + let cch_hash = self.compute_cch_hash(&body_with_placeholder); + tracing::debug!(cch_hash = %cch_hash, "CCH signing: computed hash"); + + let signed_body = + body_with_placeholder.replacen(Self::CCH_PLACEHOLDER, &format!("cch={cch_hash}"), 1); + tracing::debug!("CCH signing: request signed successfully"); + + Ok(signed_body.into_bytes()) + } +} + +/// Serializes the request to compact JSON. +/// +/// `Request` declares `system` before `messages`, which is required for the +/// CCH hash to match the body sent over the wire. +fn serialize_request_compact(request: &Request) -> anyhow::Result { + Ok(serde_json::to_string(request)?) +} + +/// Returns the value of `var` from the environment, or `default` if unset. +fn env_or_default(var: &str, default: &str) -> String { + std::env::var(var).unwrap_or_else(|_| default.to_string()) +} + +/// Returns the value of `var` parsed as a `u64` (decimal or `0x`-prefixed hex), +/// or `default` if the variable is unset or unparseable. +fn env_or_default_u64(var: &str, default: u64) -> u64 { + std::env::var(var) + .ok() + .and_then(|v| { + if v.starts_with("0x") || v.starts_with("0X") { + u64::from_str_radix(&v[2..], 16).ok() + } else { + v.parse().ok() + } + }) + .unwrap_or(default) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::anthropic::{Message, Role}; + use pretty_assertions::assert_eq; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn signing() -> CchSigning { + CchSigning::new( + "2.1.37".to_string(), + 0x6E52736AC806831E, + "59cf53e54c78".to_string(), + ) + } + + fn make_request_with_text(text: &str) -> Request { + Request { + messages: vec![Message { + role: Role::User, + content: vec![Content::Text { text: text.to_string(), cache_control: None }], + }], + max_tokens: 32000, + ..Default::default() + } + } + + fn serialize_signed_request(signing: &CchSigning, request: Request) -> String { + String::from_utf8(signing.serialize_signed_request(request).unwrap()) + .expect("signed request body should be valid UTF-8") + } + + fn sign_request(signing: &CchSigning, request: Request) -> Request { + serde_json::from_str(&serialize_signed_request(signing, request)) + .expect("signed request body should deserialize") + } + + // ── compute_version_suffix ─────────────────────────────────────────────── + + /// Matches the Python PoC reference: + /// PROMPT = "Say 'hello' and nothing else." + /// S(0)a(1)y(2) (3)'(4)h(5)e(6)l(7)l(8)o(9)'(10) (11)a(12)n(13)d(14) (15)n(16)o(17)t(18)h(19)i(20) + /// chars at 4='\'', 7='l', 20='i' + #[test] + fn test_version_suffix_known_value() { + let signing = signing(); + let prompt = "Say 'hello' and nothing else."; + let suffix = signing.compute_version_suffix(prompt); + + // Verify the expected character extraction + let chars: Vec = prompt.chars().collect(); + assert_eq!(chars[4], '\''); + assert_eq!(chars[7], 'l'); + assert_eq!(chars[20], 'i'); + + // Output is always exactly 3 lowercase hex chars + assert_eq!(suffix.len(), 3); + assert!(suffix.chars().all(|c| c.is_ascii_hexdigit())); + + // Deterministic: same input → same output + assert_eq!(suffix, signing.compute_version_suffix(prompt)); + } + + #[test] + fn test_version_suffix_short_message_uses_zero_fallback() { + let signing = signing(); + // "Hi" has only indices 0 and 1 — indices 4, 7, 20 all fall back to '0' + let suffix_hi = signing.compute_version_suffix("Hi"); + let suffix_zeros = signing.compute_version_suffix("000"); + // Both produce the same suffix because the out-of-bounds fallback is '0' + // (index 2 for "000" is also in-bounds but '0', matching the fallback) + // More importantly: both are valid 3-char hex strings + assert_eq!(suffix_hi.len(), 3); + assert!(suffix_hi.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(suffix_hi, ""); // sanity + + // A message with '0' at indices 4, 7, 20 equals the fully-out-of-bounds case + let msg = "0000000000000000000000"; // index 4='0', 7='0', 20='0' + assert_eq!(suffix_hi, signing.compute_version_suffix(msg)); + assert_eq!(suffix_zeros, suffix_hi); + } + + #[test] + fn test_version_suffix_different_messages_produce_different_suffixes() { + let signing = signing(); + let a = signing.compute_version_suffix("Say 'hello' and nothing else."); + let b = signing.compute_version_suffix("Write a poem about the ocean."); + // Characters at indices 4,7,20 differ → suffixes differ + assert_ne!(a, b); + } + + // ── compute_cch_hash ───────────────────────────────────────────────────── + + #[test] + fn test_cch_hash_format() { + let signing = signing(); + let body = r#"{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.37.abc; cc_entrypoint=cli; cch=00000;"}],"max_tokens":32000,"messages":[]}"#; + let hash = signing.compute_cch_hash(body); + assert_eq!(hash.len(), 5); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_cch_hash_is_deterministic() { + let signing = signing(); + let body = r#"{"system":[],"messages":[]}"#; + assert_eq!( + signing.compute_cch_hash(body), + signing.compute_cch_hash(body) + ); + } + + #[test] + fn test_cch_hash_differs_for_different_bodies() { + let signing = signing(); + let a = signing.compute_cch_hash(r#"{"system":[],"messages":[]}"#); + let b = signing.compute_cch_hash(r#"{"system":[],"messages":[{"role":"user"}]}"#); + assert_ne!(a, b); + } + + #[test] + fn test_cch_hash_zero_padded_to_five_chars() { + // Use seed=0 so the raw hash value is predictable and small + let signing = CchSigning::new("2.1.37".to_string(), 0, "59cf53e54c78".to_string()); + let hash = signing.compute_cch_hash("x"); + assert_eq!(hash.len(), 5); + } + + // ── build_billing_header ───────────────────────────────────────────────── + + #[test] + fn test_billing_header_format() { + let signing = signing(); + let header = signing.build_billing_header("fbe"); + + assert_eq!( + header, + "x-anthropic-billing-header: cc_version=2.1.37.fbe; cc_entrypoint=cli; cch=00000;" + ); + } + + /// Ensures there is exactly one trailing semicolon after the placeholder — + /// the double-semicolon bug regression test. + #[test] + fn test_billing_header_no_double_semicolon() { + let signing = signing(); + let header = signing.build_billing_header("fbe"); + // placeholder appears once, ends with exactly one semicolon + assert!(header.ends_with("cch=00000;")); + assert!(!header.ends_with("cch=00000;;")); + } + + // ── extract_first_user_message_text ────────────────────────────────────── + + #[test] + fn test_extract_text_returns_raw_string_not_json() { + let request = make_request_with_text("Hello world"); + let text = CchSigning::extract_first_user_message_text(&request); + // Must be the raw string, NOT `"\"Hello world\""` or `[{"type":"text",...}]` + assert_eq!(text, Some("Hello world")); + } + + #[test] + fn test_extract_text_empty_messages_returns_none() { + let request = Request { messages: vec![], max_tokens: 100, ..Default::default() }; + assert_eq!(CchSigning::extract_first_user_message_text(&request), None); + } + + #[test] + fn test_extract_text_skips_assistant_messages() { + let request = Request { + messages: vec![ + Message { + role: Role::Assistant, + content: vec![Content::Text { + text: "assistant text".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::User, + content: vec![Content::Text { + text: "user text".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 100, + ..Default::default() + }; + + let actual = CchSigning::extract_first_user_message_text(&request); + let expected = Some("user text"); + assert_eq!(actual, expected); + } + + #[test] + fn test_extract_text_skips_user_messages_without_text() { + let request = Request { + messages: vec![ + Message { + role: Role::User, + content: vec![Content::Image { + source: crate::dto::anthropic::ImageSource { + type_: "base64".to_string(), + media_type: Some("image/png".to_string()), + data: Some("abc".to_string()), + url: None, + }, + cache_control: None, + }], + }, + Message { + role: Role::User, + content: vec![Content::Text { + text: "describe this image".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 100, + ..Default::default() + }; + + let actual = CchSigning::extract_first_user_message_text(&request); + let expected = Some("describe this image"); + assert_eq!(actual, expected); + } + + #[test] + fn test_extract_text_skips_tool_result_only_messages() { + let request = Request { + messages: vec![ + Message { + role: Role::User, + content: vec![Content::ToolResult { + tool_use_id: "toolu_123".to_string(), + content: Some("tool output".to_string()), + is_error: None, + cache_control: None, + }], + }, + Message { + role: Role::User, + content: vec![Content::Text { + text: "follow-up question".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 100, + ..Default::default() + }; + + let actual = CchSigning::extract_first_user_message_text(&request); + let expected = Some("follow-up question"); + assert_eq!(actual, expected); + } + + #[test] + fn test_extract_text_skips_non_text_content_within_message() { + let request = Request { + messages: vec![Message { + role: Role::User, + content: vec![ + Content::Image { + source: crate::dto::anthropic::ImageSource { + type_: "base64".to_string(), + media_type: Some("image/png".to_string()), + data: Some("abc".to_string()), + url: None, + }, + cache_control: None, + }, + Content::Text { text: "describe this".to_string(), cache_control: None }, + ], + }], + max_tokens: 100, + ..Default::default() + }; + + let actual = CchSigning::extract_first_user_message_text(&request); + let expected = Some("describe this"); + assert_eq!(actual, expected); + } + + // ── signed serialization (integration) ─────────────────────────────────── + + #[test] + fn test_serialize_signed_request_injects_billing_header_as_first_system_message() { + let signing = signing(); + let request = make_request_with_text("Say 'hello' and nothing else."); + + let signed = sign_request(&signing, request); + + let system = signed.system.as_ref().expect("system must be present"); + assert!(!system.is_empty(), "system must have at least one message"); + + let first = &system[0]; + assert!( + first.text.starts_with("x-anthropic-billing-header:"), + "first system message must be the billing header, got: {}", + first.text + ); + assert!(first.text.contains("cc_version=2.1.37.")); + assert!(first.text.contains("cc_entrypoint=cli")); + } + + #[test] + fn test_serialize_signed_request_returns_valid_json() { + let signing = signing(); + let request = make_request_with_text("Hello world"); + let body = signing.serialize_signed_request(request).unwrap(); + let result = serde_json::from_slice::(&body); + assert!(result.is_ok(), "deserialization failed: {:?}", result.err()); + let recovered = result.unwrap(); + let user_text = recovered.messages[0].content.iter().find_map(|c| { + if let Content::Text { text, .. } = c { + Some(text.as_str()) + } else { + None + } + }); + assert_eq!(user_text, Some("Hello world")); + } + + #[test] + fn test_serialize_signed_request_billing_header_has_no_placeholder_in_final_output() { + let signing = signing(); + let request = make_request_with_text("Say 'hello' and nothing else."); + + let signed = sign_request(&signing, request); + + let system = signed.system.as_ref().unwrap(); + let billing = &system[0].text; + assert!( + !billing.contains("cch=00000"), + "placeholder still present: {billing}" + ); + let cch_part = billing.split("cch=").nth(1).expect("cch= not found"); + let hash_chars: String = cch_part.chars().take(5).collect(); + assert_eq!(hash_chars.len(), 5); + assert!(hash_chars.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_serialize_signed_request_no_double_semicolon_in_billing_header() { + let signing = signing(); + let signed = sign_request(&signing, make_request_with_text("Hello world")); + let billing = &signed.system.as_ref().unwrap()[0].text; + assert!( + !billing.contains(";;"), + "double semicolon found in: {billing}" + ); + assert!(billing.ends_with(';')); + } + + #[test] + fn test_serialize_signed_request_preserves_existing_system_messages() { + let signing = signing(); + let mut request = make_request_with_text("Hello"); + request.system = Some(vec![SystemMessage { + r#type: "text".to_string(), + text: "You are a helpful assistant.".to_string(), + cache_control: None, + }]); + + let signed = sign_request(&signing, request); + + let system = signed.system.as_ref().unwrap(); + assert_eq!(system.len(), 2, "billing header + original system message"); + assert!(system[0].text.starts_with("x-anthropic-billing-header:")); + assert_eq!(system[1].text, "You are a helpful assistant."); + } + + #[test] + fn test_serialize_signed_request_user_message_containing_placeholder_not_replaced() { + let signing = signing(); + let request = make_request_with_text( + "If you see cch=00000 in a request, that is the CCH placeholder.", + ); + + let signed = sign_request(&signing, request); + + let system = signed.system.as_ref().unwrap(); + let billing = &system[0].text; + assert!( + !billing.contains("cch=00000"), + "placeholder still in billing header" + ); + + let user_msg = &signed.messages[0]; + let user_text = user_msg.content.iter().find_map(|c| { + if let Content::Text { text, .. } = c { + Some(text.as_str()) + } else { + None + } + }); + assert_eq!( + user_text, + Some("If you see cch=00000 in a request, that is the CCH placeholder.") + ); + } + + #[test] + fn test_serialize_signed_request_without_user_message_returns_plain_json() { + let signing = signing(); + let request = Request { max_tokens: 100, ..Default::default() }; + let expected = serde_json::to_vec(&request).unwrap(); + let actual = signing.serialize_signed_request(request).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_serialize_signed_request_same_input_produces_same_hash() { + let signing = signing(); + let r1 = make_request_with_text("deterministic test"); + let r2 = make_request_with_text("deterministic test"); + + let s1 = sign_request(&signing, r1); + let s2 = sign_request(&signing, r2); + + let h1 = &s1.system.as_ref().unwrap()[0].text; + let h2 = &s2.system.as_ref().unwrap()[0].text; + assert_eq!(h1, h2, "same input must produce same signed output"); + } + + #[test] + fn test_transform_serialized_body_field_order_system_before_messages() { + // Verify that the Request struct itself serializes system before messages, + // so the HTTP body sent over the wire matches what was hashed. + let mut request = make_request_with_text("ordering test"); + request.system = Some(vec![SystemMessage { + r#type: "text".to_string(), + text: "sys".to_string(), + cache_control: None, + }]); + + let json = serde_json::to_string(&request).unwrap(); + let system_pos = json.find("\"system\"").expect("system key not found"); + let messages_pos = json.find("\"messages\"").expect("messages key not found"); + assert!( + system_pos < messages_pos, + "`system` ({system_pos}) must appear before `messages` ({messages_pos}) in JSON" + ); + } + + /// The hash is computed by `serialize_request_compact` and the wire body is + /// produced by `serde_json::to_vec(&request)`. They MUST be byte-identical, + /// otherwise the server receives a body that doesn't match the `cch` hash. + #[test] + fn test_hashed_bytes_match_wire_bytes() { + let mut request = make_request_with_text("wire match test"); + request.system = Some(vec![SystemMessage { + r#type: "text".to_string(), + text: + "x-anthropic-billing-header: cc_version=2.1.37.abc; cc_entrypoint=cli; cch=00000;" + .to_string(), + cache_control: None, + }]); + + let hashed = serialize_request_compact(&request).unwrap(); + let wire = serde_json::to_string(&request).unwrap(); + + assert_eq!( + hashed, wire, + "serialization used for hashing must be identical to wire serialization" + ); + } + + // ── env helpers ────────────────────────────────────────────────────────── + + #[test] + fn test_env_or_default_returns_default_when_unset() { + let result = env_or_default("FORGE_TEST_CCH_NONEXISTENT_VAR", "fallback"); + assert_eq!(result, "fallback"); + } + + #[test] + fn test_env_or_default_u64_returns_default_when_unset() { + let result = env_or_default_u64("FORGE_TEST_CCH_NONEXISTENT_U64", 42); + assert_eq!(result, 42); + } +} diff --git a/crates/forge_app/src/dto/anthropic/transforms/mod.rs b/crates/forge_app/src/dto/anthropic/transforms/mod.rs index 3ea6d5f183..d18322624f 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/mod.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/mod.rs @@ -1,5 +1,6 @@ mod auth_system_message; mod capitalize_tool_names; +mod cch_signing; mod drop_invalid_toolcalls; mod enforce_schema; mod reasoning_transform; @@ -9,6 +10,7 @@ mod set_cache; pub use auth_system_message::AuthSystemMessage; pub use capitalize_tool_names::CapitalizeToolNames; +pub use cch_signing::CchSigning; pub use drop_invalid_toolcalls::DropInvalidToolUse; pub use enforce_schema::EnforceStrictObjectSchema; pub use reasoning_transform::ReasoningTransform; diff --git a/crates/forge_repo/src/provider/anthropic.rs b/crates/forge_repo/src/provider/anthropic.rs index c4df9cfc26..711c2ac0dd 100644 --- a/crates/forge_repo/src/provider/anthropic.rs +++ b/crates/forge_repo/src/provider/anthropic.rs @@ -8,9 +8,9 @@ use forge_app::domain::{ ChatCompletionMessage, Context, Model, ModelId, ResultStream, Transformer, }; use forge_app::dto::anthropic::{ - AuthSystemMessage, CapitalizeToolNames, DropInvalidToolUse, EnforceStrictObjectSchema, - EventData, ListModelResponse, ReasoningTransform, RemoveOutputFormat, Request, SanitizeToolIds, - SetCache, + AuthSystemMessage, CapitalizeToolNames, CchSigning, DropInvalidToolUse, + EnforceStrictObjectSchema, EventData, ListModelResponse, ReasoningTransform, + RemoveOutputFormat, Request, SanitizeToolIds, SetCache, }; use forge_config::RetryConfig; use forge_domain::{ChatRepository, Provider, ProviderId}; @@ -29,11 +29,20 @@ struct Anthropic { provider: Provider, anthropic_version: String, use_oauth: bool, + auth_system_message: AuthSystemMessage, + cch_signing: CchSigning, } impl Anthropic { pub fn new(http: Arc, provider: Provider, version: String, use_oauth: bool) -> Self { - Self { http, provider, anthropic_version: version, use_oauth } + Self { + http, + provider, + anthropic_version: version, + use_oauth, + auth_system_message: AuthSystemMessage::default(), + cch_signing: CchSigning::default(), + } } fn get_headers(&self) -> Vec<(String, String)> { @@ -69,11 +78,21 @@ impl Anthropic { // Add beta flags (not needed for Vertex AI) if self.provider.id != ProviderId::VERTEX_AI_ANTHROPIC { if self.use_oauth { + let is_claude_code = self.should_sign_claude_code_request(); + // OAuth requires multiple beta flags including structured outputs - headers.push(( - "anthropic-beta".to_string(), - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,structured-outputs-2025-11-13".to_string(), - )); + let beta_flags = if is_claude_code { + // claude_code provider gets research preview flags for fast mode + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,structured-outputs-2025-11-13,research-preview-2026-02-01,adaptive-thinking-2026-01-28".to_string() + } else { + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,structured-outputs-2025-11-13".to_string() + }; + headers.push(("anthropic-beta".to_string(), beta_flags)); + + // Add x-app header for claude_code provider + if is_claude_code { + headers.push(("x-app".to_string(), "cli".to_string())); + } } else { // API key auth also needs beta flags for structured outputs and thinking headers.push(( @@ -94,6 +113,34 @@ impl Anthropic { self.provider.id == ProviderId::OPENCODE_ZEN } + /// Returns `true` when Claude Code request signing should be applied. + fn should_sign_claude_code_request(&self) -> bool { + self.use_oauth && self.provider.id == ProviderId::CLAUDE_CODE + } + + /// Applies semantic Anthropic request mutations in the intended order. + fn transform_request(&self, mut request: Request) -> Request { + if self.use_oauth { + request = self.auth_system_message.clone().transform(request); + } + + let base_pipeline = CapitalizeToolNames + .pipe(DropInvalidToolUse) + .pipe(SanitizeToolIds); + + if self.provider.id == ProviderId::VERTEX_AI_ANTHROPIC { + base_pipeline + .pipe(RemoveOutputFormat) + .pipe(SetCache) + .transform(request) + } else { + base_pipeline + .pipe(EnforceStrictObjectSchema) + .pipe(SetCache) + .transform(request) + } + } + pub async fn chat( &self, model: &ModelId, @@ -112,24 +159,14 @@ impl Anthropic { request = request.model(model.as_str().to_string()); } - let pipeline = AuthSystemMessage::default() - .when(|_| self.use_oauth) - .pipe(CapitalizeToolNames) - .pipe(DropInvalidToolUse) - .pipe(SanitizeToolIds); + let request = self.transform_request(request); - // Vertex AI does not support output_format, so we skip schema enforcement - // and remove any output_format field - let request = if self.provider.id == ProviderId::VERTEX_AI_ANTHROPIC { - pipeline - .pipe(RemoveOutputFormat) - .pipe(SetCache) - .transform(request) + let json_bytes = if self.should_sign_claude_code_request() { + self.cch_signing + .serialize_signed_request(request) + .with_context(|| "Failed to serialize signed Claude Code request")? } else { - pipeline - .pipe(EnforceStrictObjectSchema) - .pipe(SetCache) - .transform(request) + serde_json::to_vec(&request).with_context(|| "Failed to serialize request")? }; let url = if self.provider.id == ProviderId::VERTEX_AI_ANTHROPIC { @@ -143,9 +180,6 @@ impl Anthropic { debug!(url = %url, model = %model, "Connecting Upstream"); - let json_bytes = - serde_json::to_vec(&request).with_context(|| "Failed to serialize request")?; - let parsed_url = Url::parse(&url).with_context(|| format!("Invalid URL: {}", url))?; let headers = create_headers(self.get_headers()); @@ -296,6 +330,7 @@ mod tests { }; use reqwest::header::HeaderMap; use reqwest_eventsource::EventSource; + use std::sync::{Arc, Mutex}; use super::*; use crate::provider::mock_server::{MockServer, normalize_ports}; @@ -312,6 +347,75 @@ mod tests { } } + #[derive(Clone, Debug)] + struct CapturedRequest { + url: Url, + headers: HeaderMap, + body: Vec, + } + + #[derive(Clone, Default)] + struct CapturingHttpClient { + captured_request: Arc>>, + } + + impl CapturingHttpClient { + fn new() -> Self { + Self::default() + } + + fn captured_request(&self) -> Option { + self.captured_request.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl HttpInfra for CapturingHttpClient { + async fn http_get( + &self, + _url: &Url, + _headers: Option, + ) -> anyhow::Result { + Err(anyhow::anyhow!("GET not implemented in capturing mock")) + } + + async fn http_post( + &self, + url: &Url, + headers: Option, + body: Bytes, + ) -> anyhow::Result { + *self.captured_request.lock().unwrap() = Some(CapturedRequest { + url: url.clone(), + headers: headers.unwrap_or_default(), + body: body.to_vec(), + }); + Err(anyhow::anyhow!( + "POST intentionally not completed in capturing mock" + )) + } + + async fn http_delete(&self, _url: &Url) -> anyhow::Result { + Err(anyhow::anyhow!("DELETE not implemented in capturing mock")) + } + + async fn http_eventsource( + &self, + url: &Url, + headers: Option, + body: Bytes, + ) -> anyhow::Result { + *self.captured_request.lock().unwrap() = Some(CapturedRequest { + url: url.clone(), + headers: headers.unwrap_or_default(), + body: body.to_vec(), + }); + Err(anyhow::anyhow!( + "EventSource intentionally not completed in capturing mock" + )) + } + } + #[async_trait::async_trait] impl HttpInfra for MockHttpClient { async fn http_get( @@ -380,6 +484,61 @@ mod tests { )) } + fn oauth_credential(provider_id: ProviderId) -> forge_domain::AuthCredential { + forge_domain::AuthCredential { + id: provider_id, + auth_details: forge_domain::AuthDetails::OAuth { + tokens: forge_domain::OAuthTokens::new( + "oauth-token", + None::, + chrono::Utc::now() + chrono::Duration::hours(1), + ), + config: forge_domain::OAuthConfig { + auth_url: reqwest::Url::parse("https://example.com/auth").unwrap(), + token_url: reqwest::Url::parse("https://example.com/token").unwrap(), + client_id: forge_domain::ClientId::from("client-id".to_string()), + scopes: vec![], + redirect_uri: None, + use_pkce: false, + token_refresh_url: None, + custom_headers: None, + extra_auth_params: None, + }, + }, + url_params: std::collections::HashMap::new(), + } + } + + fn create_provider_with_oauth(id: ProviderId, chat_url: Url, model_url: Url) -> Provider { + Provider { + id: id.clone(), + provider_type: forge_domain::ProviderType::Llm, + response: Some(forge_app::domain::ProviderResponse::Anthropic), + url: chat_url, + credential: Some(oauth_credential(id)), + auth_methods: vec![forge_domain::AuthMethod::ApiKey], + url_params: vec![], + models: Some(forge_domain::ModelSource::Url(model_url)), + custom_headers: None, + } + } + + fn create_claude_code_anthropic( + http: Arc, + base_url: &str, + ) -> anyhow::Result> { + let chat_url = Url::parse(base_url)?.join("messages")?; + let model_url = Url::parse(base_url)?.join("models")?; + let provider = create_provider_with_oauth(ProviderId::CLAUDE_CODE, chat_url, model_url); + + Ok(Anthropic::new( + http, + provider, + "2023-06-01".to_string(), + true, + )) + } + fn create_mock_models_response() -> serde_json::Value { serde_json::json!({ "data": [ @@ -700,6 +859,139 @@ mod tests { ); } + #[test] + fn test_get_headers_with_claude_code_oauth_includes_cli_headers() { + let chat_url = Url::parse("https://api.anthropic.com/v1/messages").unwrap(); + let model_url = Url::parse("https://api.anthropic.com/v1/models").unwrap(); + let provider = create_provider_with_oauth(ProviderId::CLAUDE_CODE, chat_url, model_url); + + let fixture = Anthropic::new( + Arc::new(MockHttpClient::new()), + provider, + "2023-06-01".to_string(), + true, + ); + + let actual = fixture.get_headers(); + + assert!( + actual + .iter() + .any(|(k, v)| k == "authorization" && v == "Bearer oauth-token") + ); + assert!(actual.iter().any(|(k, v)| k == "x-app" && v == "cli")); + + let beta_header = actual.iter().find(|(k, _)| k == "anthropic-beta").unwrap(); + let (_, beta_value) = beta_header; + assert!(beta_value.contains("research-preview-2026-02-01")); + assert!(beta_value.contains("adaptive-thinking-2026-01-28")); + } + + #[tokio::test] + async fn test_chat_claude_code_oauth_signs_final_outbound_request() -> anyhow::Result<()> { + let http = Arc::new(CapturingHttpClient::new()); + let anthropic = create_claude_code_anthropic(http.clone(), "https://example.com/v1/")?; + let model = ModelId::new("claude-3-5-sonnet-20241022"); + let context = Context::default().add_message(ContextMessage::user( + "Say 'hello' and nothing else.", + model.clone().into(), + )); + + let actual = anthropic.chat(&model, context).await; + assert!( + actual.is_err(), + "capturing mock should stop the request before streaming" + ); + + let captured = http + .captured_request() + .expect("expected the final outbound request to be captured"); + assert_eq!(captured.url.as_str(), "https://example.com/v1/messages"); + assert_eq!( + captured + .headers + .get("x-app") + .and_then(|value| value.to_str().ok()), + Some("cli") + ); + + let body = + String::from_utf8(captured.body).expect("request body should be valid UTF-8 JSON"); + let request: Request = serde_json::from_str(&body)?; + let system = request + .system + .as_ref() + .expect("signed request should contain system messages"); + assert!( + system[0].text.starts_with("x-anthropic-billing-header:"), + "first system message should be the billing header" + ); + assert!( + system[0].cache_control.is_none(), + "billing header should not be marked cacheable" + ); + assert!( + system + .iter() + .skip(1) + .any(|message| message.cache_control.is_some()), + "auth system message should already be cached before signing" + ); + + let actual_hash: String = system[0] + .text + .split("cch=") + .nth(1) + .expect("billing header should contain cch") + .chars() + .take(5) + .collect(); + assert_eq!(actual_hash.len(), 5); + assert!(actual_hash.chars().all(|c| c.is_ascii_hexdigit())); + + let placeholder_body = body.replacen(&format!("cch={actual_hash}"), "cch=00000", 1); + let expected_hash = anthropic.cch_signing.compute_cch_hash(&placeholder_body); + assert_eq!( + actual_hash, expected_hash, + "final outbound JSON must match the embedded cch hash" + ); + + Ok(()) + } + + #[test] + fn test_transform_request_adds_auth_message_without_signing_for_non_claude_code_oauth() { + let chat_url = Url::parse("https://api.anthropic.com/v1/messages").unwrap(); + let model_url = Url::parse("https://api.anthropic.com/v1/models").unwrap(); + let provider = create_provider_with_oauth(ProviderId::ANTHROPIC, chat_url, model_url); + let fixture = Anthropic::new( + Arc::new(MockHttpClient::new()), + provider, + "2023-06-01".to_string(), + true, + ); + let request = Request::try_from(Context::default().add_message(ContextMessage::user( + "Hello", + ModelId::new("claude-3-5-sonnet-20241022").into(), + ))) + .unwrap() + .model("claude-3-5-sonnet-20241022".to_string()) + .max_tokens(4000u64); + + let actual = fixture.transform_request(request); + let system = actual + .system + .expect("oauth requests should include auth system message"); + assert!( + !system[0].text.starts_with("x-anthropic-billing-header:"), + "non-Claude-Code OAuth requests should not be CCH-signed" + ); + assert!( + system.iter().any(|message| message.cache_control.is_some()), + "auth system message should still participate in cache setup" + ); + } + #[test] fn test_vertex_ai_removes_output_format() { use forge_domain::ResponseFormat;