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
117 changes: 107 additions & 10 deletions core/src/llm/anthropic.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Anthropic Claude LLM client

use super::http::{default_http_client, normalize_base_url, HttpClient};
use super::structured;
use super::types::*;
use super::LlmClient;
use crate::retry::{AttemptOutcome, RetryConfig};
Expand Down Expand Up @@ -152,17 +153,24 @@ impl AnthropicClient {
}
}

#[async_trait]
impl LlmClient for AnthropicClient {
async fn complete(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
) -> Result<LlmResponse> {
impl AnthropicClient {
/// Apply a structured-output directive to an Anthropic request.
///
/// Anthropic supports forced tool choice (`tool_choice`) but has no
/// `response_format`, so only `force_tool` is honored.
fn apply_directive(
request: &mut serde_json::Value,
directive: &structured::StructuredDirective,
) {
if let Some(tool) = &directive.force_tool {
request["tool_choice"] = serde_json::json!({ "type": "tool", "name": tool });
}
}

/// Execute a fully-built (non-streaming) request body.
async fn send_request(&self, request_body: serde_json::Value) -> Result<LlmResponse> {
{
let request_started_at = Instant::now();
let request_body = self.build_request(messages, system, tools);
let url = format!("{}/v1/messages", self.base_url);

let headers = vec![
Expand Down Expand Up @@ -259,17 +267,70 @@ impl LlmClient for AnthropicClient {
Ok(llm_response)
}
}
}

#[async_trait]
impl LlmClient for AnthropicClient {
async fn complete(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
) -> Result<LlmResponse> {
self.send_request(self.build_request(messages, system, tools))
.await
}

async fn complete_structured(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
directive: &structured::StructuredDirective,
) -> Result<LlmResponse> {
let mut request_body = self.build_request(messages, system, tools);
Self::apply_directive(&mut request_body, directive);
self.send_request(request_body).await
}

fn native_structured_support(&self) -> structured::NativeStructuredSupport {
structured::NativeStructuredSupport::ForcedTool
}

async fn complete_streaming(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
cancel_token: CancellationToken,
) -> Result<mpsc::Receiver<StreamEvent>> {
self.send_streaming(self.build_request(messages, system, tools), cancel_token)
.await
}

async fn complete_streaming_structured(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
directive: &structured::StructuredDirective,
cancel_token: CancellationToken,
) -> Result<mpsc::Receiver<StreamEvent>> {
let mut request_body = self.build_request(messages, system, tools);
Self::apply_directive(&mut request_body, directive);
self.send_streaming(request_body, cancel_token).await
}
}

impl AnthropicClient {
/// Execute a fully-built streaming request body (sets `stream: true`).
async fn send_streaming(
&self,
mut request_body: serde_json::Value,
cancel_token: CancellationToken,
) -> Result<mpsc::Receiver<StreamEvent>> {
{
let request_started_at = Instant::now();
let mut request_body = self.build_request(messages, system, tools);
request_body["stream"] = serde_json::json!(true);

let url = format!("{}/v1/messages", self.base_url);
Expand Down Expand Up @@ -739,4 +800,40 @@ mod tests {
assert_eq!(req["max_tokens"], 16_000);
assert_eq!(req["thinking"]["budget_tokens"], 8_000);
}

#[test]
fn test_apply_directive_forces_tool_choice() {
let mut req = serde_json::json!({ "model": "m", "messages": [] });
let directive = structured::StructuredDirective {
force_tool: Some("emit_person".to_string()),
response_format: None,
};
AnthropicClient::apply_directive(&mut req, &directive);
assert_eq!(req["tool_choice"]["type"], "tool");
assert_eq!(req["tool_choice"]["name"], "emit_person");
}

#[test]
fn test_apply_directive_ignores_response_format() {
// Anthropic has no response_format; both a response_format-only and an
// empty directive must be no-ops.
let mut req = serde_json::json!({ "model": "m" });
AnthropicClient::apply_directive(
&mut req,
&structured::StructuredDirective {
force_tool: None,
response_format: Some(structured::ResponseFormat::JsonObject),
},
);
assert!(req.get("response_format").is_none());
assert!(req.get("tool_choice").is_none());
}

#[test]
fn test_native_structured_support_is_forced_tool() {
assert_eq!(
make_client().native_structured_support(),
structured::NativeStructuredSupport::ForcedTool
);
}
}
38 changes: 38 additions & 0 deletions core/src/llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,44 @@ pub trait LlmClient: Send + Sync {
tools: &[ToolDefinition],
cancel_token: CancellationToken,
) -> Result<mpsc::Receiver<StreamEvent>>;

/// Report the strongest provider-native structured-output enforcement this
/// client supports. Used by [`structured`] to decide whether to force a
/// tool call, request a native `response_format`, or fall back to
/// prompt-and-parse. Defaults to no native support.
fn native_structured_support(&self) -> structured::NativeStructuredSupport {
structured::NativeStructuredSupport::None
}

/// Complete a conversation while honoring a structured-output directive
/// (forced `tool_choice` and/or native `response_format`).
///
/// The default implementation ignores the directive and behaves exactly
/// like [`LlmClient::complete`], so existing clients keep working unchanged;
/// providers that support native structured output override this.
async fn complete_structured(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
_directive: &structured::StructuredDirective,
) -> Result<LlmResponse> {
self.complete(messages, system, tools).await
}

/// Streaming counterpart of [`LlmClient::complete_structured`]. Defaults to
/// [`LlmClient::complete_streaming`], ignoring the directive.
async fn complete_streaming_structured(
&self,
messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
_directive: &structured::StructuredDirective,
cancel_token: CancellationToken,
) -> Result<mpsc::Receiver<StreamEvent>> {
self.complete_streaming(messages, system, tools, cancel_token)
.await
}
}

// Include test modules — these reference internal types via crate paths
Expand Down
Loading
Loading