Files
optimclaw/src/llm/anthropic_oauth.rs
T
41ed0a0f98 feat(agent): thread per-tool reasoning through provider, session, and all surfaces (#1513)
* feat(agent): thread per-tool reasoning from LLM through to REPL, HTTP, SSE, and DB

Add end-to-end agent reasoning summaries so users can see *why* the
agent chose specific tools, not just what it did.

- Add `reasoning: Option<String>` to `ToolCall` (all providers)
- Populate from LLM response content in `Reasoning::respond_with_tools`
  and `select_tools`, with per-tool override when providers supply it
- Extend `Turn` with `narrative` and `TurnToolCall` with `rationale` +
  `tool_call_id` for identity-based result matching
- Persist reasoning in DB via existing tool_calls JSON (no migration)
- Add `StatusUpdate::ReasoningUpdate` and `SseEvent::ReasoningUpdate` +
  `SseEvent::JobReasoning` for real-time streaming
- Emit reasoning events in both chat dispatcher and worker job path
- Add `/reasoning [N|all]` command for inspecting turn reasoning
- Surface `narrative` and `rationale` in HTTP `/api/chat/history`

Based on the design from #361 and #456, reconstructed cleanly with
Option<String> to minimize blast radius (vs mandatory String that broke
compilation in #456).

Closes #456

Co-Authored-By: panosAthDBX <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback from Gemini and Copilot

- Fix `_ => Ok(None)` in agent_loop.rs to avoid accidental shutdown
- Fix fallback in record_tool_result_for/record_tool_error_for to use
  first pending call instead of last_mut (parallel execution safety)
- Include per-tool decisions in WASM channel reasoning messages
- Apply truncate_at_tool_tags + clean_response to shared_reasoning in
  select_tools (parity with respond_with_tools)
- Persist turn-level narrative to DB in tool_calls JSON wrapper
- Parse both old (array) and new (object) tool_calls formats in
  build_turns_from_db_messages for backward compatibility
- Populate reasoning from action.reasoning in execute_plan ToolCalls

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address second round of review comments + merge fixes

- Add reasoning: None to new github_copilot.rs ToolCall sites (from staging merge)
- Run cargo fmt on 4 files with formatting diffs
- Truncate narrative to 1000 chars before DB persistence
- Clone turn data and drop session lock in /reasoning command
- Extract ToolDecisionDto::from_json_array shared helper (deduplicate
  worker/job.rs and orchestrator/api.rs)
- Add unit tests for wrapped tool_calls JSON format with narrative

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address third round of review comments (Copilot + serrrfirat)

- Reword ToolCall.reasoning docstring to reflect provider-supplied or
  fallback contract
- Sanitize narrative through SafetyLayer before storage/emission
- Clean per-tool reasoning via truncate_at_tool_tags + clean_response
  in select_tools (parity with shared reasoning)
- Convert 4 approval-path recording sites in thread_ops.rs to
  identity-based record_tool_result_for/record_tool_error_for
- Preserve tool_call_id and reasoning through restore_from_messages
- Fix has_result/has_error to reject JSON null values
- Truncate tool_call_id to 128 chars before DB persistence
- Add 4 unit tests for record_tool_result_for/error_for edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian review — sanitize JobDelegate reasoning + warn on dropped results

- Sanitize narrative and per-tool rationale through SafetyLayer in
  JobDelegate reasoning events (parity with ChatDelegate)
- Add tracing::warn when record_tool_result_for/error_for drops a
  result because no matching or pending tool call exists
- Add 3 unit tests for reasoning normalization (thinking tags,
  tool tags, empty-after-cleaning)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address 4 remaining unreplied review comments

- Clean per-tool reasoning in respond_with_tools via truncate_at_tool_tags
  + clean_response (parity with select_tools)
- Handle wrapped JSON format in rebuild_chat_messages_from_db so cold
  hydration works after persist_tool_calls format change
- Update persist_tool_calls doc comment to describe new JSON shape
- Sanitize per-tool rationale through SafetyLayer in ChatDelegate before
  emission and storage (parity with JobDelegate)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian review round 2

- Add tracing::debug on fallback-to-pending path in record_tool_result_for
  and record_tool_error_for (item 1)
- Add comment explaining why /reasoning is special-cased in agent_loop.rs
  (item 4)
- Items 2 (narrative persistence), 3 (rationale sanitization), and 5
  (catch-all fix) were already addressed in prior commits

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: panosAthDBX <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-25 08:35:41 -07:00

707 lines
25 KiB
Rust

//! Anthropic OAuth provider (direct HTTP, `Authorization: Bearer`).
//!
//! This provider exists because the `rig-core` Anthropic client hardcodes the
//! `x-api-key` header, which is rejected by Anthropic's OAuth tokens from
//! `claude login`. OAuth tokens require `Authorization: Bearer <token>` instead.
//!
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
use std::collections::HashSet;
use async_trait::async_trait;
use reqwest::Client;
use rust_decimal::Decimal;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use crate::llm::config::RegistryProviderConfig;
use crate::llm::costs;
use crate::llm::error::LlmError;
use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
/// Required beta flag to enable OAuth Bearer auth on api.anthropic.com.
/// Without this header, the API returns 401 "OAuth authentication is currently not supported."
const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
const DEFAULT_MAX_TOKENS: u32 = 8192;
/// Anthropic provider using OAuth Bearer authentication.
pub struct AnthropicOAuthProvider {
client: Client,
/// OAuth token, wrapped in RwLock so it can be updated after a successful
/// Keychain refresh (fixes #1136: stale token reuse after expiry).
token: std::sync::RwLock<SecretString>,
model: String,
base_url: Option<String>,
active_model: std::sync::RwLock<String>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
}
impl AnthropicOAuthProvider {
pub fn new(config: &RegistryProviderConfig) -> Result<Self, LlmError> {
let token = config
.oauth_token
.clone()
.ok_or_else(|| LlmError::AuthFailed {
provider: "anthropic_oauth".to_string(),
})?;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "anthropic_oauth".to_string(),
reason: format!("Failed to build HTTP client: {}", e),
})?;
let active_model = std::sync::RwLock::new(config.model.clone());
let base_url = if config.base_url.is_empty() {
None
} else {
Some(config.base_url.clone())
};
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self {
client,
token: std::sync::RwLock::new(token),
model: config.model.clone(),
base_url,
active_model,
unsupported_params,
})
}
/// Strip unsupported fields from a `CompletionRequest` in place.
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
strip_unsupported_completion_params(&self.unsupported_params, req);
}
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
strip_unsupported_tool_params(&self.unsupported_params, req);
}
fn api_url(&self) -> String {
if let Some(ref base) = self.base_url {
let base = base.trim_end_matches('/');
format!("{}/v1/messages", base)
} else {
ANTHROPIC_API_URL.to_string()
}
}
/// Read the current token from the RwLock.
fn current_token(&self) -> String {
match self.token.read() {
Ok(guard) => guard.expose_secret().to_string(),
Err(poisoned) => poisoned.into_inner().expose_secret().to_string(),
}
}
/// Update the stored token after a successful Keychain refresh.
fn update_token(&self, new_token: SecretString) {
match self.token.write() {
Ok(mut guard) => *guard = new_token,
Err(poisoned) => *poisoned.into_inner() = new_token,
}
}
async fn send_request<R: for<'de> Deserialize<'de>>(
&self,
body: &AnthropicRequest,
) -> Result<R, LlmError> {
let url = self.api_url();
tracing::debug!("Sending request to Anthropic OAuth: {}", url);
let response = self
.client
.post(&url)
.bearer_auth(self.current_token())
.header("anthropic-version", ANTHROPIC_API_VERSION)
.header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.map_err(|e| LlmError::RequestFailed {
provider: "anthropic_oauth".to_string(),
reason: e.to_string(),
})?;
let status = response.status();
if !status.is_success() {
// Parse Retry-After header before consuming the body.
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
let response_text = response
.text()
.await
.unwrap_or_else(|e| format!("(failed to read error body: {e})"));
if status.as_u16() == 401 {
// OAuth tokens from `claude login` expire in ~8-12h. Attempt
// to re-extract a fresh token from the OS credential store
// (macOS Keychain / Linux credentials file) before giving up.
//
// Brief delay to give Claude Code time to complete its async
// Keychain refresh write (fixes race in #1136).
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if let Some(fresh) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
let fresh_token = SecretString::from(fresh);
// Retry once with the refreshed token
let retry = self
.client
.post(&url)
.bearer_auth(fresh_token.expose_secret())
.header("anthropic-version", ANTHROPIC_API_VERSION)
.header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.map_err(|e| LlmError::RequestFailed {
provider: "anthropic_oauth".to_string(),
reason: e.to_string(),
})?;
if retry.status().is_success() {
// Persist the refreshed token so subsequent requests
// don't hit 401 again (fixes #1136).
self.update_token(fresh_token);
tracing::info!("Anthropic OAuth token refreshed from credential store");
let text = retry.text().await.map_err(|e| LlmError::RequestFailed {
provider: "anthropic_oauth".to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
return serde_json::from_str(&text).map_err(|e| {
let truncated = crate::agent::truncate_for_preview(&text, 512);
LlmError::InvalidResponse {
provider: "anthropic_oauth".to_string(),
reason: format!("JSON parse error: {}. Raw: {}", e, truncated),
}
});
}
tracing::warn!(
"Anthropic OAuth 401 retry with refreshed token also failed ({})",
retry.status()
);
}
return Err(LlmError::AuthFailed {
provider: "anthropic_oauth".to_string(),
});
}
if status.as_u16() == 429 {
return Err(LlmError::RateLimited {
provider: "anthropic_oauth".to_string(),
retry_after,
});
}
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
return Err(LlmError::RequestFailed {
provider: "anthropic_oauth".to_string(),
reason: format!("HTTP {}: {}", status, truncated),
});
}
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "anthropic_oauth".to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
tracing::debug!(
"Anthropic OAuth response: status={}, bytes={}",
status,
response_text.len()
);
serde_json::from_str(&response_text).map_err(|e| {
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
LlmError::InvalidResponse {
provider: "anthropic_oauth".to_string(),
reason: format!("JSON parse error: {}. Raw: {}", e, truncated),
}
})
}
}
#[async_trait]
impl LlmProvider for AnthropicOAuthProvider {
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_completion_params(&mut req);
let (system, messages) = convert_messages(req.messages);
let request = AnthropicRequest {
model,
messages,
system,
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
temperature: req.temperature,
tools: None,
tool_choice: None,
};
let response: AnthropicResponse = self.send_request(&request).await?;
let (content, _tool_calls) = extract_response_content(&response);
let finish_reason = match response.stop_reason.as_deref() {
Some("end_turn") | Some("stop") => FinishReason::Stop,
Some("max_tokens") => FinishReason::Length,
Some("tool_use") => FinishReason::ToolUse,
_ => FinishReason::Unknown,
};
Ok(CompletionResponse {
content: content.unwrap_or_default(),
finish_reason,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
})
}
async fn complete_with_tools(
&self,
mut req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_tool_params(&mut req);
let (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = req
.tools
.into_iter()
.map(|t| AnthropicTool {
name: t.name,
description: t.description,
input_schema: t.parameters,
})
.collect();
// Map tool_choice from OpenAI format to Anthropic format
let tool_choice = req.tool_choice.map(|tc| match tc.as_str() {
"auto" => AnthropicToolChoice {
choice_type: "auto".to_string(),
name: None,
},
"required" => AnthropicToolChoice {
choice_type: "any".to_string(),
name: None,
},
"none" => AnthropicToolChoice {
choice_type: "none".to_string(),
name: None,
},
specific => AnthropicToolChoice {
choice_type: "tool".to_string(),
name: Some(specific.to_string()),
},
});
let request = AnthropicRequest {
model,
messages,
system,
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
temperature: req.temperature,
tools: if tools.is_empty() { None } else { Some(tools) },
tool_choice,
};
let response: AnthropicResponse = self.send_request(&request).await?;
let (content, tool_calls) = extract_response_content(&response);
let finish_reason = match response.stop_reason.as_deref() {
Some("end_turn") | Some("stop") => FinishReason::Stop,
Some("max_tokens") => FinishReason::Length,
Some("tool_use") => FinishReason::ToolUse,
_ => {
if !tool_calls.is_empty() {
FinishReason::ToolUse
} else {
FinishReason::Unknown
}
}
};
Ok(ToolCompletionResponse {
content,
tool_calls,
finish_reason,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
})
}
fn model_name(&self) -> &str {
&self.model
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
let model = self.active_model_name();
costs::model_cost(&model).unwrap_or_else(costs::default_cost)
}
fn active_model_name(&self) -> String {
match self.active_model.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
match self.active_model.write() {
Ok(mut guard) => {
*guard = model.to_string();
}
Err(poisoned) => {
*poisoned.into_inner() = model.to_string();
}
}
Ok(())
}
}
// --- Anthropic Messages API types ---
#[derive(Debug, Serialize)]
struct AnthropicRequest {
model: String,
messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<AnthropicTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<AnthropicToolChoice>,
}
#[derive(Debug, Serialize)]
struct AnthropicMessage {
role: String,
content: AnthropicContent,
}
/// Anthropic content can be a simple string or a list of content blocks.
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum AnthropicContent {
Text(String),
Blocks(Vec<AnthropicContentBlock>),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum AnthropicContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
},
}
#[derive(Debug, Serialize)]
struct AnthropicTool {
name: String,
description: String,
input_schema: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct AnthropicToolChoice {
#[serde(rename = "type")]
choice_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicResponseBlock>,
#[serde(default)]
stop_reason: Option<String>,
usage: AnthropicUsage,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum AnthropicResponseBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
}
#[derive(Debug, Deserialize)]
struct AnthropicUsage {
#[serde(default)]
input_tokens: u32,
#[serde(default)]
output_tokens: u32,
#[serde(default)]
cache_creation_input_tokens: u32,
#[serde(default)]
cache_read_input_tokens: u32,
}
/// Convert ChatMessage list to Anthropic format.
///
/// Extracts system messages to the top-level `system` parameter (Anthropic
/// doesn't allow system messages in the `messages` array). Tool-call/tool-result
/// pairs are converted to content blocks.
fn convert_messages(messages: Vec<ChatMessage>) -> (Option<String>, Vec<AnthropicMessage>) {
let mut system_parts: Vec<String> = Vec::new();
let mut anthropic_msgs: Vec<AnthropicMessage> = Vec::new();
for msg in messages {
match msg.role {
Role::System => {
if !msg.content.is_empty() {
system_parts.push(msg.content);
}
}
Role::User => {
anthropic_msgs.push(AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Text(msg.content),
});
}
Role::Assistant => {
if let Some(tool_calls) = msg.tool_calls {
// Assistant message with tool calls → content blocks
let mut blocks: Vec<AnthropicContentBlock> = Vec::new();
if !msg.content.is_empty() {
blocks.push(AnthropicContentBlock::Text { text: msg.content });
}
for tc in tool_calls {
blocks.push(AnthropicContentBlock::ToolUse {
id: tc.id,
name: tc.name,
input: tc.arguments,
});
}
anthropic_msgs.push(AnthropicMessage {
role: "assistant".to_string(),
content: AnthropicContent::Blocks(blocks),
});
} else {
anthropic_msgs.push(AnthropicMessage {
role: "assistant".to_string(),
content: AnthropicContent::Text(msg.content),
});
}
}
Role::Tool => {
let Some(tool_call_id) = msg.tool_call_id else {
tracing::warn!("Skipping Tool message without tool_call_id");
continue;
};
// Tool results go into a user message with tool_result blocks
let block = AnthropicContentBlock::ToolResult {
tool_use_id: tool_call_id,
content: msg.content,
};
// If the last message is already a user message with blocks,
// append to it (Anthropic requires consecutive tool results
// in one user message).
if let Some(last) = anthropic_msgs.last_mut()
&& last.role == "user"
&& let AnthropicContent::Blocks(ref mut blocks) = last.content
{
blocks.push(block);
continue;
}
anthropic_msgs.push(AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Blocks(vec![block]),
});
}
}
}
let system = if system_parts.is_empty() {
None
} else {
Some(system_parts.join("\n\n"))
};
(system, anthropic_msgs)
}
/// Extract text content and tool calls from an Anthropic response.
fn extract_response_content(response: &AnthropicResponse) -> (Option<String>, Vec<ToolCall>) {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
for block in &response.content {
match block {
AnthropicResponseBlock::Text { text } => {
text_parts.push(text.clone());
}
AnthropicResponseBlock::ToolUse { id, name, input } => {
tool_calls.push(ToolCall {
id: id.clone(),
name: name.clone(),
arguments: input.clone(),
reasoning: None,
});
}
}
}
let content = if text_parts.is_empty() {
None
} else {
Some(text_parts.join(""))
};
(content, tool_calls)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_messages_extracts_system() {
let messages = vec![
ChatMessage::system("You are helpful."),
ChatMessage::user("Hello"),
];
let (system, msgs) = convert_messages(messages);
assert_eq!(system, Some("You are helpful.".to_string()));
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
}
#[test]
fn test_convert_messages_multiple_systems() {
let messages = vec![
ChatMessage::system("System 1"),
ChatMessage::system("System 2"),
ChatMessage::user("Hello"),
];
let (system, msgs) = convert_messages(messages);
assert_eq!(system, Some("System 1\n\nSystem 2".to_string()));
assert_eq!(msgs.len(), 1);
}
#[test]
fn test_convert_messages_tool_calls() {
let tool_calls = vec![ToolCall {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}];
let messages = vec![
ChatMessage::user("Search for test"),
ChatMessage::assistant_with_tool_calls(Some("Let me search.".to_string()), tool_calls),
ChatMessage::tool_result("call_1", "search", "found it"),
];
let (system, msgs) = convert_messages(messages);
assert!(system.is_none());
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[1].role, "assistant");
// Tool result should be a user message
assert_eq!(msgs[2].role, "user");
}
#[test]
fn test_extract_response_text_only() {
let response = AnthropicResponse {
content: vec![AnthropicResponseBlock::Text {
text: "Hello!".to_string(),
}],
stop_reason: Some("end_turn".to_string()),
usage: AnthropicUsage {
input_tokens: 10,
output_tokens: 5,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
};
let (content, tool_calls) = extract_response_content(&response);
assert_eq!(content, Some("Hello!".to_string()));
assert!(tool_calls.is_empty());
}
#[test]
fn test_extract_response_with_tool_use() {
let response = AnthropicResponse {
content: vec![
AnthropicResponseBlock::Text {
text: "Let me search.".to_string(),
},
AnthropicResponseBlock::ToolUse {
id: "call_1".to_string(),
name: "search".to_string(),
input: serde_json::json!({"q": "test"}),
},
],
stop_reason: Some("tool_use".to_string()),
usage: AnthropicUsage {
input_tokens: 20,
output_tokens: 15,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
};
let (content, tool_calls) = extract_response_content(&response);
assert_eq!(content, Some("Let me search.".to_string()));
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].name, "search");
}
/// Regression test for #1136: token field must be mutable via RwLock
/// so that a refreshed token persists across subsequent requests.
#[test]
fn test_token_update_persists() {
let original = SecretString::from("old_token".to_string());
let token = std::sync::RwLock::new(original);
// Read the original
assert_eq!(token.read().unwrap().expose_secret(), "old_token");
// Simulate a successful refresh
let refreshed = SecretString::from("new_token".to_string());
*token.write().unwrap() = refreshed;
// Subsequent reads see the updated token
assert_eq!(token.read().unwrap().expose_secret(), "new_token");
}
}