use super::*;
use crate::agent::cancellation::AgentCancellation;
use crate::config::AnthropicCacheTtl;
use crate::providers::{ChatMessage, Provider, ProviderConversationItem, ProviderToolResult};
use serde_json::json;
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::Duration;
#[derive(Debug, Default)]
struct CapturingTransport {
chunks: Vec<String>,
requests: Arc<Mutex<Vec<HttpRequest>>>,
}
impl CapturingTransport {
fn with_chunks(chunks: Vec<String>) -> Self {
Self {
chunks,
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn requests(&self) -> Arc<Mutex<Vec<HttpRequest>>> {
Arc::clone(&self.requests)
}
}
#[derive(Debug)]
struct BlockingRefreshTransport {
started: Mutex<Option<mpsc::Sender<()>>>,
finish: (Mutex<bool>, Condvar),
}
impl BlockingRefreshTransport {
fn new(started: mpsc::Sender<()>) -> Self {
Self {
started: Mutex::new(Some(started)),
finish: (Mutex::new(false), Condvar::new()),
}
}
fn finish(&self) {
let (lock, condvar) = &self.finish;
*lock.lock().unwrap() = true;
condvar.notify_all();
}
}
impl auth::ClaudeTokenRefreshTransport for BlockingRefreshTransport {
fn refresh(
&self,
_refresh_token: &str,
_cancellation: &AgentCancellation,
) -> anyhow::Result<auth::RefreshedClaudeToken> {
if let Some(started) = self.started.lock().unwrap().take() {
started.send(()).unwrap();
}
let (lock, condvar) = &self.finish;
let mut finished = lock.lock().unwrap();
while !*finished {
finished = condvar.wait(finished).unwrap();
}
Ok(auth::RefreshedClaudeToken {
access_token: "cc-refreshed".to_string(),
refresh_token: Some("cc-refresh-new".to_string()),
expires_at_ms: Some(4102444800000),
})
}
}
struct RecordingRefreshTransport {
tokens_seen: Arc<Mutex<Vec<String>>>,
response: auth::RefreshedClaudeToken,
before_response: Mutex<Option<Box<dyn FnOnce() + Send>>>,
}
impl std::fmt::Debug for RecordingRefreshTransport {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RecordingRefreshTransport")
.field("tokens_seen", &"<redacted>")
.field("response", &self.response)
.finish_non_exhaustive()
}
}
impl auth::ClaudeTokenRefreshTransport for RecordingRefreshTransport {
fn refresh(
&self,
refresh_token: &str,
_cancellation: &AgentCancellation,
) -> anyhow::Result<auth::RefreshedClaudeToken> {
self.tokens_seen
.lock()
.unwrap()
.push(refresh_token.to_string());
if let Some(before_response) = self.before_response.lock().unwrap().take() {
before_response();
}
Ok(self.response.clone())
}
}
impl HttpTransport for CapturingTransport {
fn stream_json(
&self,
request: HttpRequest,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
self.stream_json_cancellable(request, &AgentCancellation::default(), on_chunk)
}
fn stream_json_cancellable(
&self,
request: HttpRequest,
cancellation: &AgentCancellation,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
cancellation.check()?;
self.requests.lock().unwrap().push(request);
for chunk in &self.chunks {
cancellation.check()?;
on_chunk(chunk)?;
}
Ok(())
}
fn stream_json_cancellable_with_semantic_deadline(
&self,
request: HttpRequest,
cancellation: &AgentCancellation,
_semantic_deadline: &std::sync::atomic::AtomicU64,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
self.stream_json_cancellable(request, cancellation, on_chunk)
}
}
fn beta_tokens(http: &HttpRequest) -> Vec<&str> {
http.headers["anthropic-beta"].split(',').collect()
}
fn credential_json(access: &str, refresh: &str, expires_at: i64) -> String {
format!(
r#"{{"claudeAiOauth":{{"accessToken":"{access}","refreshToken":"{refresh}","expiresAt":{expires_at}}}}}"#
)
}
#[test]
fn claude_code_api_key_headers_exclude_oauth_headers() {
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
);
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(http.headers["x-api-key"], "test-key");
assert!(!http.headers.contains_key("authorization"));
assert_eq!(
beta_tokens(&http),
vec![
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14"
]
);
assert!(!http.headers["anthropic-beta"].contains("oauth-2025-04-20"));
assert!(
!http.body["system"]
.as_str()
.unwrap()
.contains(body::CLAUDE_CODE_BILLING_HEADER)
);
}
#[test]
fn claude_code_oauth_headers_exclude_api_key_headers() {
let oauth = auth::ClaudeOAuthCredential {
access_token: "cc-token".to_string(),
refresh_token: "cc-refresh".to_string(),
expires_at_ms: Some(4102444800000),
source: auth::ClaudeCredentialSource::Keychain,
store: Arc::new(auth::ClaudeCodeCredentialStore::with_credentials_path(
"/tmp/unused".into(),
)),
};
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::OAuth(oauth),
CapturingTransport::default(),
);
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(http.headers["authorization"], "Bearer cc-token");
assert_eq!(http.headers["x-app"], "cli");
assert!(http.headers["user-agent"].starts_with("claude-cli/"));
assert_eq!(
beta_tokens(&http),
vec![
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"claude-code-20250219",
"oauth-2025-04-20"
]
);
assert!(!http.headers.contains_key("x-api-key"));
assert!(
http.body["system"]
.as_str()
.unwrap()
.starts_with(body::CLAUDE_CODE_BILLING_HEADER)
);
assert!(
http.body["system"]
.as_str()
.unwrap()
.contains(body::CLAUDE_CODE_SYSTEM_PREFIX)
);
}
#[test]
fn claude_code_refresh_releases_auth_mutex_during_blocking_io() {
let (started_tx, started_rx) = mpsc::channel();
let refresh_transport = Arc::new(BlockingRefreshTransport::new(started_tx));
let store = auth::ClaudeCodeCredentialStore::with_readers(
"/tmp/unused".into(),
Arc::new(auth::test_support::StaticKeychainReader(None)),
refresh_transport.clone(),
);
let provider = Arc::new(ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::OAuth(auth::ClaudeOAuthCredential {
access_token: "cc-old".to_string(),
refresh_token: "cc-refresh".to_string(),
expires_at_ms: Some(1),
source: auth::ClaudeCredentialSource::Keychain,
store: Arc::new(store),
}),
CapturingTransport::with_chunks(vec!["data: {\"type\":\"message_stop\"}\n\n".to_string()]),
));
let streaming_provider = Arc::clone(&provider);
let stream_thread = std::thread::spawn(move || {
streaming_provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |_| Ok(()),
)
.unwrap();
});
started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let request_provider = Arc::clone(&provider);
let (built_tx, built_rx) = mpsc::channel();
let build_thread = std::thread::spawn(move || {
let result = request_provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.map(|request| request.headers["authorization"].clone());
built_tx.send(result).unwrap();
});
assert_eq!(
built_rx
.recv_timeout(Duration::from_millis(200))
.unwrap()
.unwrap(),
"Bearer cc-old"
);
refresh_transport.finish();
stream_thread.join().unwrap();
build_thread.join().unwrap();
}
#[test]
fn claude_code_concurrent_file_refresh_keeps_newer_persisted_credential() {
#[derive(Debug)]
struct OrderedRefreshTransport {
calls: Mutex<usize>,
first_started: Mutex<Option<mpsc::Sender<()>>>,
first_finish: (Mutex<bool>, Condvar),
}
impl auth::ClaudeTokenRefreshTransport for OrderedRefreshTransport {
fn refresh(
&self,
_refresh_token: &str,
_cancellation: &AgentCancellation,
) -> anyhow::Result<auth::RefreshedClaudeToken> {
let call = {
let mut calls = self.calls.lock().unwrap();
*calls += 1;
*calls
};
if call == 1 {
if let Some(started) = self.first_started.lock().unwrap().take() {
started.send(()).unwrap();
}
let (lock, condvar) = &self.first_finish;
let mut finished = lock.lock().unwrap();
while !*finished {
finished = condvar.wait(finished).unwrap();
}
return Ok(auth::RefreshedClaudeToken {
access_token: "cc-older".to_string(),
refresh_token: Some("cc-refresh-older".to_string()),
expires_at_ms: Some(4102444700000),
});
}
Ok(auth::RefreshedClaudeToken {
access_token: "cc-newer".to_string(),
refresh_token: Some("cc-refresh-newer".to_string()),
expires_at_ms: Some(4102444800000),
})
}
}
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
std::fs::write(
&path,
r#"{"claudeAiOauth":{"accessToken":"cc-old","refreshToken":"cc-refresh","expiresAt":1}}"#,
)
.unwrap();
let (started_tx, started_rx) = mpsc::channel();
let refresh_transport = Arc::new(OrderedRefreshTransport {
calls: Mutex::new(0),
first_started: Mutex::new(Some(started_tx)),
first_finish: (Mutex::new(false), Condvar::new()),
});
let store = auth::ClaudeCodeCredentialStore::with_readers(
path.clone(),
Arc::new(auth::test_support::StaticKeychainReader(None)),
refresh_transport.clone(),
);
let provider = Arc::new(ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap()),
CapturingTransport::with_chunks(vec!["data: {\"type\":\"message_stop\"}\n\n".to_string()]),
));
let first_provider = Arc::clone(&provider);
let first_thread = std::thread::spawn(move || {
first_provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("first")]),
&mut |_| Ok(()),
)
.unwrap();
});
started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let second_provider = Arc::clone(&provider);
std::thread::spawn(move || {
second_provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("second")]),
&mut |_| Ok(()),
)
.unwrap();
})
.join()
.unwrap();
{
let (lock, condvar) = &refresh_transport.first_finish;
*lock.lock().unwrap() = true;
condvar.notify_all();
}
first_thread.join().unwrap();
let text = std::fs::read_to_string(path).unwrap();
assert!(text.contains("cc-newer"), "{text}");
assert!(text.contains("cc-refresh-newer"), "{text}");
assert!(!text.contains("cc-older"), "{text}");
assert!(!text.contains("cc-refresh-older"), "{text}");
}
#[test]
fn claude_code_file_rotation_before_refresh_uses_latest_refresh_token() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
std::fs::write(&path, credential_json("cc-old", "cc-refresh-old", 1)).unwrap();
let tokens_seen = Arc::new(Mutex::new(Vec::new()));
let store = auth::ClaudeCodeCredentialStore::with_readers(
path.clone(),
Arc::new(auth::test_support::StaticKeychainReader(None)),
Arc::new(RecordingRefreshTransport {
tokens_seen: Arc::clone(&tokens_seen),
response: auth::RefreshedClaudeToken {
access_token: "cc-refreshed".to_string(),
refresh_token: Some("cc-refresh-refreshed".to_string()),
expires_at_ms: Some(4102444800000),
},
before_response: Mutex::new(None),
}),
);
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap()),
CapturingTransport::with_chunks(vec!["data: {\"type\":\"message_stop\"}\n\n".to_string()]),
);
std::fs::write(&path, credential_json("cc-newer", "cc-refresh-newer", 1)).unwrap();
provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |_| Ok(()),
)
.unwrap();
assert_eq!(*tokens_seen.lock().unwrap(), vec!["cc-refresh-newer"]);
let text = std::fs::read_to_string(path).unwrap();
assert!(text.contains("cc-refreshed"), "{text}");
assert!(text.contains("cc-refresh-refreshed"), "{text}");
}
#[test]
fn claude_code_file_rotation_during_refresh_keeps_external_credential() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
std::fs::write(&path, credential_json("cc-old", "cc-refresh-old", 1)).unwrap();
let tokens_seen = Arc::new(Mutex::new(Vec::new()));
let mutation_path = path.clone();
let store = auth::ClaudeCodeCredentialStore::with_readers(
path.clone(),
Arc::new(auth::test_support::StaticKeychainReader(None)),
Arc::new(RecordingRefreshTransport {
tokens_seen: Arc::clone(&tokens_seen),
response: auth::RefreshedClaudeToken {
access_token: "cc-stale-refresh-result".to_string(),
refresh_token: Some("cc-refresh-stale-result".to_string()),
expires_at_ms: Some(4102444800000),
},
before_response: Mutex::new(Some(Box::new(move || {
std::fs::write(
&mutation_path,
credential_json("cc-external", "cc-refresh-external", 4102444800000),
)
.unwrap();
}))),
}),
);
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap()),
CapturingTransport::with_chunks(vec!["data: {\"type\":\"message_stop\"}\n\n".to_string()]),
);
provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |_| Ok(()),
)
.unwrap();
assert_eq!(*tokens_seen.lock().unwrap(), vec!["cc-refresh-old"]);
let text = std::fs::read_to_string(path).unwrap();
assert!(text.contains("cc-external"), "{text}");
assert!(text.contains("cc-refresh-external"), "{text}");
assert!(!text.contains("cc-stale-refresh-result"), "{text}");
assert!(!text.contains("cc-refresh-stale-result"), "{text}");
}
#[test]
fn claude_code_body_includes_system_prefix_and_user_messages() {
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
);
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::system("sys"), ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(http.body["model"], "claude-sonnet-4-6");
assert!(
http.body["system"]
.as_str()
.unwrap()
.starts_with(body::CLAUDE_CODE_SYSTEM_PREFIX)
);
assert!(http.body["system"].as_str().unwrap().contains("\n\nsys"));
assert_eq!(http.body["messages"][0]["role"], "user");
assert!(http.body.get("cache_control").is_none());
}
#[test]
fn claude_code_body_uses_configured_max_output_tokens() {
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
)
.with_max_output_tokens(Some(64_000));
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(http.body["max_tokens"], 64_000);
}
#[test]
fn claude_code_body_and_api_key_headers_include_five_minute_cache_when_configured() {
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
)
.with_cache_ttl(Some(AnthropicCacheTtl::FiveMinutes));
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(http.body["cache_control"], json!({"type":"ephemeral"}));
assert!(http.body["cache_control"].get("ttl").is_none());
assert_eq!(
beta_tokens(&http),
vec![
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14"
]
);
}
#[test]
fn claude_code_body_and_api_key_headers_include_one_hour_cache_without_obsolete_beta() {
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
)
.with_cache_ttl(Some(AnthropicCacheTtl::OneHour));
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(
http.body["cache_control"],
json!({"type":"ephemeral","ttl":"1h"})
);
assert_eq!(
beta_tokens(&http),
vec![
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14"
]
);
}
#[test]
fn claude_code_oauth_headers_keep_required_betas_for_one_hour_cache() {
let oauth = auth::ClaudeOAuthCredential {
access_token: "cc-token".to_string(),
refresh_token: "cc-refresh".to_string(),
expires_at_ms: Some(4102444800000),
source: auth::ClaudeCredentialSource::Keychain,
store: Arc::new(auth::ClaudeCodeCredentialStore::with_credentials_path(
"/tmp/unused".into(),
)),
};
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::OAuth(oauth),
CapturingTransport::default(),
)
.with_cache_ttl(Some(AnthropicCacheTtl::OneHour));
let http = provider
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(
beta_tokens(&http),
vec![
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"claude-code-20250219",
"oauth-2025-04-20"
]
);
}
#[test]
fn claude_code_body_preserves_tool_continuation() {
let provider = ClaudeCodeProvider::new(
"fable",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
);
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_response_items(vec![json!({"type":"function_call","call_id":"call_1","name":"read","arguments":"{\"path\":\"a.txt\"}"})])
.with_tool_results(vec![ProviderToolResult {
call_id: "call_1".to_string(),
tool_name: "read".to_string(),
success: true,
output: "file text".to_string(),
}]);
let http = provider.build_http_request(&request).unwrap();
assert_eq!(http.body["model"], "claude-fable-5");
assert_eq!(http.body["messages"][1]["content"][0]["type"], "tool_use");
assert_eq!(
http.body["messages"][2]["content"][0]["type"],
"tool_result"
);
}
#[test]
fn claude_code_body_drops_codex_reasoning_and_preserves_visible_history_and_tool_continuation() {
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
);
let request = ProviderRequest::from_conversation(
"ignored",
vec![
ProviderConversationItem::Message(ChatMessage::user("before")),
ProviderConversationItem::ResponseItem(json!({
"type": "reasoning",
"id": "rs_codex",
"summary": [],
"status": "completed"
})),
ProviderConversationItem::ResponseItem(json!({
"type": "message",
"role": "assistant",
"content": "visible reply"
})),
ProviderConversationItem::ResponseItem(json!({
"type": "function_call",
"call_id": "call_codex",
"name": "read",
"arguments": "{\"path\":\"a.txt\"}"
})),
ProviderConversationItem::ToolResult(ProviderToolResult {
call_id: "call_codex".to_string(),
tool_name: "read".to_string(),
success: true,
output: "file text".to_string(),
}),
ProviderConversationItem::Message(ChatMessage::user("after")),
],
);
let http = provider.build_http_request(&request).unwrap();
let messages = http.body["messages"].as_array().unwrap();
let blocks = messages
.iter()
.flat_map(|message| message["content"].as_array().unwrap())
.collect::<Vec<_>>();
assert!(blocks.iter().all(|block| block["type"] != "reasoning"));
assert!(!http.body.to_string().contains("rs_codex"));
assert!(messages.iter().any(|message| {
message["role"] == "assistant"
&& message["content"].as_array().is_some_and(|content| {
content.iter().any(|block| block["text"] == "visible reply")
&& content.iter().any(|block| block["type"] == "tool_use")
})
}));
let before_index = blocks
.iter()
.position(|block| block["text"] == "before")
.unwrap();
let visible_index = blocks
.iter()
.position(|block| block["text"] == "visible reply")
.unwrap();
assert!(before_index < visible_index);
let tool_use_index = blocks
.iter()
.position(|block| block["type"] == "tool_use")
.unwrap();
assert!(visible_index < tool_use_index);
assert_eq!(blocks[tool_use_index]["id"], "call_codex");
let tool_result_index = blocks
.iter()
.position(|block| block["type"] == "tool_result")
.unwrap();
assert!(tool_use_index < tool_result_index);
assert_eq!(blocks[tool_result_index]["tool_use_id"], "call_codex");
let after_index = blocks
.iter()
.position(|block| block["text"] == "after")
.unwrap();
assert!(tool_result_index < after_index);
}
#[test]
fn claude_code_body_omits_tools_when_disabled() {
let provider = ClaudeCodeProvider::new(
"opus",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
CapturingTransport::default(),
);
let http = provider
.build_http_request(&ProviderRequest::new_without_tools(
"ignored",
vec![ChatMessage::user("hello")],
))
.unwrap();
assert_eq!(http.body["model"], "claude-opus-4-8");
assert!(http.body.get("tools").is_none());
}
#[test]
fn claude_code_stream_uses_anthropic_parser_for_text_tool_usage_and_done() {
let transport = CapturingTransport::with_chunks(vec![concat!(
"data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":2,\"cache_read_input_tokens\":7}}}\n\n",
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"read\"}}\n\n",
"data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"a.txt\\\"}\"}}\n\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"data: {\"type\":\"message_stop\"}\n\n"
).to_string()]);
let requests = transport.requests();
let provider = ClaudeCodeProvider::new(
"sonnet",
ClaudeCodeAuth::ApiKey {
key: "test-key".to_string(),
},
transport,
);
let mut events = Vec::new();
provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |event| {
events.push(event);
Ok(())
},
)
.unwrap();
assert_eq!(
requests.lock().unwrap()[0].url,
"https://api.anthropic.com/v1/messages?beta=true"
);
assert!(events.contains(&ProviderEvent::TextDelta("hi".to_string())));
assert!(events.iter().any(|event| matches!(event, ProviderEvent::ToolCall(call) if call.id == "call_1" && call.name == "read" && call.arguments == json!({"path":"a.txt"}))));
assert!(events.contains(&ProviderEvent::Done));
assert!(events.iter().any(
|event| matches!(event, ProviderEvent::Usage(usage) if usage.input == 2 && usage.cache_read == 7)
));
}