Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 26 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ publish = false
members = ["crates/*"]

[workspace.package]
version = "0.1.14"
version = "0.1.16"
edition = "2024"
rust-version = "1.85"
license = "MIT"
Expand Down Expand Up @@ -97,6 +97,7 @@ cel-interpreter = { version = "0.10", features = ["json"] }
walkdir = "2"
moka = { version = "0.12", features = ["sync"] }
strsim = "0.11"
xxhash-rust = { version = "0.8", features = ["xxh64"] }
globset = "0.4"
rayon = "1"
# TASK-AGS-OBS-901 (Stage 10): promoted from archon-tui/Cargo.toml direct pins
Expand Down
3 changes: 3 additions & 0 deletions crates/archon-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ impl Agent {
system: system_with_memories,
messages: self.state.messages.clone(),
tools: self.config.tools.clone(),
request_origin: Some("main_session".into()),
thinking: {
let mode = archon_llm::thinking::select_thinking_mode(
&active_model,
Expand Down Expand Up @@ -2312,6 +2313,7 @@ impl Agent {
thinking: None,
speed: Some("fast".to_string()),
effort: Some("low".to_string()),
request_origin: Some("main_session".into()),
extra: serde_json::Value::Null,
};

Expand Down Expand Up @@ -2566,6 +2568,7 @@ impl Agent {
thinking: None,
speed: Some("fast".to_string()),
effort: Some("low".to_string()),
request_origin: Some("main_session".into()),
extra: serde_json::Value::Null,
};

Expand Down
1 change: 1 addition & 0 deletions crates/archon-core/src/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,7 @@ pub mod runner {
messages: messages.clone(),
tools: self.tool_definitions.clone(),
effort: self.effort.clone(),
request_origin: Some("subagent".into()),
..LlmRequest::default()
};

Expand Down
1 change: 1 addition & 0 deletions crates/archon-llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ once_cell.workspace = true
# overrides the spec's stale `rand = "0.8"` suggestion per phase-wide
# "workspace wins" convention already established for reqwest/thiserror).
rand.workspace = true
xxhash-rust.workspace = true
# TASK-AGS-709: ArcSwap-backed `ActiveProvider` for live provider swaps
# (workspace = 1; spec's stale `arc-swap = "1.6"` is overridden by the
# phase-wide "workspace wins" convention).
Expand Down
39 changes: 32 additions & 7 deletions crates/archon-llm/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,29 @@ impl AnthropicClient {
req = req.header(name, value);
}

tracing::debug!(
"API request: model={}, headers={:?}, body_len={}",
// Spoof mode: compute Claude Code billing header from the
// actual request body. The server validates this hash against
// the body bytes; any mismatch (or missing header) yields 429.
if matches!(
self.identity.mode,
crate::identity::IdentityMode::Spoof { .. }
) {
let cch = crate::cch::compute_cch(body.as_bytes());
let billing_value = format!(
"cc_version=0.1; cc_entrypoint=claude_code; {cch}; cc_workload=claude_code;"
);
tracing::debug!(%billing_value, "injecting x-anthropic-billing-header");
req = req.header("x-anthropic-billing-header", billing_value);
}

tracing::info!(
"API request: model={}, origin={}, headers={:?}, body_len={}",
request.model,
request.request_origin.as_deref().unwrap_or("unknown"),
headers.keys().collect::<Vec<_>>(),
body.len()
);
tracing::debug!("API request body: {}", &body[..body.len().min(2000)]);
tracing::info!("API request body: {}", &body[..body.len().min(2000)]);

let response = req
.body(body.clone())
Expand All @@ -125,7 +141,7 @@ impl AnthropicClient {

let response_body = response.text().await.unwrap_or_default();

tracing::debug!(
tracing::error!(
"API error response: status={}, retry-after={:?}, body={}",
status,
retry_after_header,
Expand All @@ -140,7 +156,9 @@ impl AnthropicClient {

match &err {
// 429: wait for retry-after then retry
ApiError::RateLimited { retry_after_secs } => {
ApiError::RateLimited {
retry_after_secs, ..
} => {
if attempt < MAX_RETRIES {
let delay = *retry_after_secs;
tracing::warn!(
Expand Down Expand Up @@ -378,6 +396,8 @@ pub struct MessageRequest {
pub speed: Option<String>,
/// When effort is not High, set to the effort level string (e.g. `"low"`, `"medium"`).
pub effort: Option<String>,
/// Tags request origin for log correlation: "main_session" | "subagent" | "pipeline".
pub request_origin: Option<String>,
}

impl Default for MessageRequest {
Expand All @@ -391,6 +411,7 @@ impl Default for MessageRequest {
thinking: None,
speed: None,
effort: None,
request_origin: None,
}
}
}
Expand All @@ -407,8 +428,11 @@ pub enum ApiError {
#[error("authentication error: {0}")]
AuthError(String),

#[error("rate limited: retry after {retry_after_secs}s")]
RateLimited { retry_after_secs: u64 },
#[error("rate limited: retry after {retry_after_secs}s | body: {body_preview}")]
RateLimited {
retry_after_secs: u64,
body_preview: String,
},

#[error("server overloaded (529)")]
Overloaded,
Expand Down Expand Up @@ -445,6 +469,7 @@ fn classify_error(status: u16, body: &str, retry_after_header: Option<&str>) ->
retry_after_secs: retry_after_header
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| extract_retry_after(body)),
body_preview: body[..body.len().min(300)].to_string(),
},
529 => ApiError::Overloaded,
500 | 502 | 503 => ApiError::ServerError {
Expand Down
48 changes: 48 additions & 0 deletions crates/archon-llm/src/cch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! CCH (Claude Code Hash) request signing.
//!
//! Computes an xxhash64 fingerprint of the serialised request body and
//! embeds it in the x-anthropic-billing-header. The server uses the hash
//! to verify the request originated from a legitimate Claude Code client.
//!
//! Algorithm and seed match claurst's reference implementation
//! (third-party Rust port of Claude Code) at
//! /tmp/claurst/src-rust/crates/api/src/cch.rs.

use xxhash_rust::xxh64::xxh64;

const CCH_SEED: u64 = 0x6E52_736A_C806_831E;
const CCH_MASK: u64 = 0xF_FFFF; // 5 hex digits

/// Compute the 5-hex-digit CCH hash for `body`. Format: `cch=<5hex>`.
pub fn compute_cch(body: &[u8]) -> String {
let hash = xxh64(body, CCH_SEED) & CCH_MASK;
format!("cch={hash:05x}")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cch_format_is_5_hex_with_prefix() {
let h = compute_cch(b"test");
assert!(h.starts_with("cch="));
assert_eq!(h.len(), 9); // "cch=" + 5 hex
}

#[test]
fn cch_is_deterministic() {
assert_eq!(compute_cch(b"same body"), compute_cch(b"same body"));
}

#[test]
fn cch_differs_for_different_bodies() {
assert_ne!(compute_cch(b"body a"), compute_cch(b"body b"));
}

#[test]
fn cch_known_vector() {
// xxh64(b"test body", 0x6E52736AC806831E) & 0xFFFFF
assert_eq!(compute_cch(b"test body"), "cch=08b7e");
}
}
Loading
Loading