use std::sync::Arc;
use aither_core::{
LanguageModel,
llm::{
Event, LLMRequest,
model::{Ability, Profile as ModelProfile, ToolChoice},
oneshot,
},
};
use futures_core::Stream;
use futures_lite::StreamExt;
use tracing::debug;
use zenwave::{Client, client, header};
use crate::{
constant::{ANTHROPIC_VERSION, CLAUDE_BASE_URL, DEFAULT_MAX_TOKENS, DEFAULT_MODEL},
error::ClaudeError,
request::{
MessagesRequest, ParameterSnapshot, apply_cache_strategy, build_output_config,
build_thinking, convert_native_tools, convert_tools, filter_tool_definitions,
to_claude_messages, tool_choice_payload,
},
response::{StreamState, parse_event, should_skip_event},
};
#[derive(Clone, Debug)]
pub struct Claude {
inner: Arc<Config>,
}
impl Claude {
pub fn new(api_key: impl Into<String>) -> Self {
Self::builder(api_key).build()
}
#[must_use]
pub fn builder(api_key: impl Into<String>) -> Builder {
Builder::new(api_key)
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).model = sanitize_model(model);
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).base_url = base_url.into();
self
}
#[must_use]
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
Arc::make_mut(&mut self.inner).default_max_tokens = max_tokens;
self
}
pub(crate) fn config(&self) -> Arc<Config> {
self.inner.clone()
}
}
impl LanguageModel for Claude {
type Error = ClaudeError;
#[allow(clippy::too_many_lines)]
fn respond(
&self,
request: LLMRequest,
) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
let cfg = self.config();
let (core_messages, parameters, tool_definitions) = request.into_parts();
let snapshot = ParameterSnapshot::from(¶meters);
let filtered_tool_definitions =
filter_tool_definitions(tool_definitions, &snapshot.tool_choice);
let missing_exact_tool = match &snapshot.tool_choice {
ToolChoice::Exact(name)
if filtered_tool_definitions.is_empty()
&& !native_tool_name_enabled(&snapshot, name) =>
{
Some(name.clone())
}
_ => None,
};
let mut claude_tool_payloads = convert_tools(&filtered_tool_definitions);
claude_tool_payloads.extend(convert_native_tools(&snapshot.native_tools));
let has_tools = !claude_tool_payloads.is_empty();
let mut claude_tools = has_tools.then_some(claude_tool_payloads);
let claude_tool_choice = tool_choice_payload(
&snapshot.tool_choice,
has_tools,
snapshot.parallel_tool_calls,
);
let max_tokens = snapshot.max_tokens.unwrap_or(cfg.default_max_tokens);
async_stream::stream! {
let (mut system_prompt, mut claude_messages) =
match to_claude_messages(&core_messages).await {
Ok(payload) => payload,
Err(error) => {
yield Err(ClaudeError::Api(error));
return;
}
};
if parameters.cache.openai.is_some() || parameters.cache.gemini.is_some() {
yield Err(ClaudeError::Api(
"Claude provider only accepts cache.claude settings".to_string(),
));
return;
}
if let Some(tool_name) = &missing_exact_tool {
yield Err(ClaudeError::Api(format!(
"Exact tool choice '{tool_name}' is not present in tool definitions"
)));
return;
}
let top_level_cache_control = if let Some(cache) = snapshot.cache {
match apply_cache_strategy(
&mut system_prompt,
&mut claude_messages,
&mut claude_tools,
cache,
) {
Ok(control) => control,
Err(error) => {
yield Err(ClaudeError::Api(error));
return;
}
}
} else {
None
};
let thinking = build_thinking(&snapshot);
let output_config = match build_output_config(&snapshot) {
Ok(config) => config,
Err(error) => {
yield Err(error);
return;
}
};
let request_body = MessagesRequest {
model: cfg.model.clone(),
max_tokens,
messages: claude_messages,
system: system_prompt,
stream: true,
temperature: snapshot.temperature,
top_p: snapshot.top_p,
top_k: snapshot.top_k,
stop_sequences: snapshot.stop_sequences.clone(),
tools: claude_tools,
tool_choice: claude_tool_choice,
thinking,
output_config,
cache_control: top_level_cache_control,
};
debug!("Claude request: {:?}", request_body);
let endpoint = cfg.request_url("/v1/messages");
let mut backend = client();
let builder = match backend
.post(endpoint)
.and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
.and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
.and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
.and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"))
.and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
.and_then(|b| b.json_body(&request_body))
{
Ok(b) => b,
Err(e) => {
yield Err(ClaudeError::Http(e));
return;
}
};
let sse_stream = match builder.sse().await {
Ok(stream) => stream,
Err(e) => {
yield Err(ClaudeError::Http(e));
return;
}
};
futures_lite::pin!(sse_stream);
let mut state = StreamState::new();
while let Some(event) = sse_stream.next().await {
let event = match event {
Ok(event) => event,
Err(e) => {
yield Err(ClaudeError::from(e));
return;
}
};
if should_skip_event(&event) {
continue;
}
match parse_event(&event, &mut state) {
Ok(llm_events) => {
for llm_event in llm_events {
yield Ok(llm_event);
}
}
Err(e) => {
yield Err(e);
return;
}
}
}
for call in state.tool_calls {
yield Ok(Event::ToolCall(aither_core::llm::ToolCall {
id: call.id,
name: call.name,
arguments: call.input,
reasoning_state: None,
}));
}
debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
}
}
fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
self.respond(oneshot(
"Continue the user provided text without additional commentary.",
prefix,
))
}
fn profile(&self) -> impl core::future::Future<Output = ModelProfile> + Send {
let cfg = self.inner.clone();
async move {
let context_length = match fetch_model_context_length(&cfg).await {
Ok(len) => len,
Err(e) => {
tracing::debug!("API did not return context_length: {e}");
aither_models::lookup(&cfg.model)
.and_then(aither_models::ModelEntry::max_input_tokens)
.unwrap_or_else(|| {
panic!(
"Claude model '{}' missing context metadata from provider and aither-models",
cfg.model
)
})
}
};
let mut profile = ModelProfile::new(
cfg.model.clone(),
"Anthropic",
cfg.model.clone(),
"Claude model by Anthropic",
context_length,
)
.with_abilities([Ability::ToolUse, Ability::Vision]);
for ability in &cfg.native_abilities {
if !profile.abilities.contains(ability) {
profile.abilities.push(*ability);
}
}
profile
}
}
}
fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
let tools = &snapshot.native_tools;
matches!(
name,
"web_search" if tools.web_search
) || matches!(
name,
"web_fetch" if tools.web_fetch
) || matches!(
name,
"code_execution" if tools.code_execution
) || matches!(
name,
"bash" if tools.bash
) || matches!(
name,
"str_replace_based_edit_tool" if tools.text_editor.is_some()
)
}
async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<ModelInfo>,
}
#[derive(serde::Deserialize)]
struct ModelInfo {
id: String,
#[serde(default, alias = "max_tokens")]
context_length: Option<u32>,
}
let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
let mut backend = client();
let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
if cfg.base_url.contains("anthropic.com") {
req = req
.header("x-api-key", cfg.api_key.clone())
.map_err(ClaudeError::Http)?
.header("anthropic-version", ANTHROPIC_VERSION)
.map_err(ClaudeError::Http)?;
} else {
req = req
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", cfg.api_key),
)
.map_err(ClaudeError::Http)?;
}
let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
for model in response.data {
if model.id == cfg.model
&& let Some(ctx) = model.context_length
{
return Ok(ctx);
}
}
Err(ClaudeError::Api(format!(
"Model '{}' not found or missing context_length",
cfg.model
)))
}
#[derive(Debug)]
pub struct Builder {
api_key: String,
base_url: String,
model: String,
default_max_tokens: u32,
native_abilities: Vec<Ability>,
}
impl Builder {
fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: CLAUDE_BASE_URL.to_string(),
model: DEFAULT_MODEL.to_string(),
default_max_tokens: DEFAULT_MAX_TOKENS,
native_abilities: Vec::new(),
}
}
#[must_use]
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
#[must_use]
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = sanitize_model(model);
self
}
#[must_use]
pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
self.default_max_tokens = max_tokens;
self
}
#[must_use]
pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
for ability in abilities {
if !self.native_abilities.contains(&ability) {
self.native_abilities.push(ability);
}
}
self
}
#[must_use]
pub fn enable_native_pdf(self) -> Self {
self.native_capabilities([Ability::Pdf])
}
#[must_use]
pub fn build(self) -> Claude {
Claude {
inner: Arc::new(Config {
api_key: self.api_key,
base_url: self.base_url,
model: self.model,
default_max_tokens: self.default_max_tokens,
native_abilities: self.native_abilities,
}),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub(crate) api_key: String,
pub(crate) base_url: String,
pub(crate) model: String,
pub(crate) default_max_tokens: u32,
pub(crate) native_abilities: Vec<Ability>,
}
impl Config {
pub(crate) fn request_url(&self, path: &str) -> String {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
}
fn sanitize_model(model: impl Into<String>) -> String {
model.into().trim().to_string()
}