rho-coding-agent 0.29.1

A lightweight agent harness inspired by Pi
Documentation
use crate::{
    model::ModelIdentity,
    protocol::anthropic_messages::{
        collect_anthropic_sse_response, convert_anthropic_response, split_system_and_messages,
        to_anthropic_tool, AnthropicCacheControl, AnthropicContentBlock, AnthropicMessage,
        AnthropicOutputConfig, AnthropicRequest, AnthropicResponse, AnthropicRole,
        AnthropicSystemBlock, AnthropicThinkingConfig, ProviderContextReplay,
    },
    provider_backend::{
        stream_timeout::provider_client, ModelError, ModelEvent, ModelProvider, ModelRequest,
        ModelResponse,
    },
    reasoning::ReasoningLevel,
};

const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com/v1";
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_MAX_TOKENS: u32 = 4096;
const ANTHROPIC_ANSWER_RESERVE_TOKENS: u32 = 1_024;

pub struct AnthropicProvider {
    client: reqwest::Client,
    api_key: String,
    api_base: String,
    model: String,
    max_tokens: fn(&str) -> u32,
    reasoning: ReasoningLevel,
}

impl AnthropicProvider {
    pub fn new(model: String, api_key: String, max_tokens: fn(&str) -> u32) -> Self {
        let reasoning = default_reasoning(&model);
        Self {
            client: provider_client(),
            api_key,
            api_base: ANTHROPIC_API_BASE.into(),
            model,
            max_tokens,
            reasoning,
        }
    }

    fn request_body(
        &self,
        request: ModelRequest<'_>,
        stream: bool,
    ) -> Result<AnthropicRequest, ModelError> {
        let target = self.identity().expect("Anthropic provider has an identity");
        let max_tokens = (self.max_tokens)(&self.model);
        let (thinking, output_config) = thinking_config(&self.model, self.reasoning, max_tokens);
        let (system, mut messages) = split_system_and_messages(
            request.messages.to_vec(),
            &target,
            provider_context_replay(thinking.as_ref()),
        )?;
        mark_cache_control_points(&mut messages);
        let mut tools = request
            .tools
            .iter()
            .cloned()
            .map(to_anthropic_tool)
            .collect::<Vec<_>>();
        if let Some(tool) = tools.last_mut() {
            tool.cache_control = Some(AnthropicCacheControl::ephemeral());
        }
        Ok(AnthropicRequest {
            model: self.model.clone(),
            max_tokens,
            system: system.map(|text| {
                vec![AnthropicSystemBlock::text(
                    text,
                    Some(AnthropicCacheControl::ephemeral()),
                )]
            }),
            messages,
            tools: (!tools.is_empty()).then_some(tools),
            cache_control: None,
            thinking,
            output_config,
            stream,
        })
    }

    async fn send_messages(&self, request: ModelRequest<'_>) -> Result<ModelResponse, ModelError> {
        let body = self.request_body(request, false)?;
        let response = self
            .client
            .post(self.messages_url())
            .header("x-api-key", &self.api_key)
            .header("anthropic-version", ANTHROPIC_VERSION)
            .json(&body)
            .send()
            .await?;
        let response = error_for_status_with_body(response).await?;
        let response: AnthropicResponse = response.json().await?;
        convert_anthropic_response(response)
    }

    async fn send_messages_stream(
        &self,
        request: ModelRequest<'_>,
        on_event: &mut dyn FnMut(ModelEvent) -> Result<(), ModelError>,
    ) -> Result<ModelResponse, ModelError> {
        let body = self.request_body(request, true)?;
        let response = self
            .client
            .post(self.messages_url())
            .header("x-api-key", &self.api_key)
            .header("anthropic-version", ANTHROPIC_VERSION)
            .json(&body)
            .send()
            .await?;
        let response = error_for_status_with_body(response).await?;
        collect_anthropic_sse_response(response, on_event).await
    }

    fn messages_url(&self) -> String {
        format!("{}/messages", self.api_base.trim_end_matches('/'))
    }
}

fn provider_context_replay(thinking: Option<&AnthropicThinkingConfig>) -> ProviderContextReplay {
    match thinking {
        Some(
            AnthropicThinkingConfig::Enabled { .. } | AnthropicThinkingConfig::Adaptive { .. },
        ) => ProviderContextReplay::Enabled,
        Some(AnthropicThinkingConfig::Disabled) | None => ProviderContextReplay::Disabled,
    }
}

fn thinking_config(
    model: &str,
    reasoning: ReasoningLevel,
    max_tokens: u32,
) -> (
    Option<AnthropicThinkingConfig>,
    Option<AnthropicOutputConfig>,
) {
    if reasoning == ReasoningLevel::Off {
        let thinking =
            supports_disabled_thinking(model).then_some(AnthropicThinkingConfig::Disabled);
        return (thinking, None);
    }
    if supports_adaptive_thinking(model) {
        return (
            Some(AnthropicThinkingConfig::Adaptive {
                display: "summarized",
            }),
            Some(AnthropicOutputConfig {
                effort: adaptive_effort(model, reasoning),
            }),
        );
    }

    let requested_budget = match reasoning {
        ReasoningLevel::Off => return (None, None),
        ReasoningLevel::Minimal => 1_024,
        ReasoningLevel::Low => 2_048,
        ReasoningLevel::Medium => 4_096,
        ReasoningLevel::High => 8_192,
        ReasoningLevel::Xhigh => 16_384,
        ReasoningLevel::Max => 32_768,
    };
    let available = max_tokens.saturating_sub(ANTHROPIC_ANSWER_RESERVE_TOKENS);
    let thinking = (available >= 1_024).then_some(AnthropicThinkingConfig::Enabled {
        budget_tokens: requested_budget.min(available),
    });
    (thinking, None)
}

fn supports_adaptive_thinking(model: &str) -> bool {
    const MODELS: &[&str] = &[
        "claude-opus-4-6",
        "claude-opus-4-7",
        "claude-opus-4-8",
        "claude-sonnet-4-6",
        "claude-sonnet-5",
        "claude-fable-5",
        "claude-mythos-5",
        "claude-mythos-preview",
    ];
    MODELS.iter().any(|prefix| model_matches(model, prefix))
}

fn default_reasoning(model: &str) -> ReasoningLevel {
    if adaptive_thinking_is_mandatory(model) {
        ReasoningLevel::Low
    } else {
        ReasoningLevel::Off
    }
}

fn adaptive_thinking_is_mandatory(model: &str) -> bool {
    const MODELS: &[&str] = &["claude-fable-5", "claude-mythos-5", "claude-mythos-preview"];
    MODELS.iter().any(|prefix| model_matches(model, prefix))
}

fn supports_disabled_thinking(model: &str) -> bool {
    model_matches(model, "claude-sonnet-5")
}

fn adaptive_effort(model: &str, reasoning: ReasoningLevel) -> &'static str {
    match reasoning {
        ReasoningLevel::Off | ReasoningLevel::Minimal | ReasoningLevel::Low => "low",
        ReasoningLevel::Medium => "medium",
        ReasoningLevel::High => "high",
        ReasoningLevel::Xhigh if supports_xhigh_effort(model) => "xhigh",
        ReasoningLevel::Xhigh => "high",
        ReasoningLevel::Max => "max",
    }
}

fn supports_xhigh_effort(model: &str) -> bool {
    const MODELS: &[&str] = &[
        "claude-opus-4-7",
        "claude-opus-4-8",
        "claude-sonnet-5",
        "claude-fable-5",
        "claude-mythos-5",
    ];
    MODELS.iter().any(|prefix| model_matches(model, prefix))
}

fn model_matches(model: &str, prefix: &str) -> bool {
    model == prefix
        || model
            .strip_prefix(prefix)
            .is_some_and(|suffix| suffix.starts_with('-'))
}

fn mark_cache_control_points(messages: &mut [AnthropicMessage]) {
    let marker = AnthropicCacheControl::ephemeral();
    for message in messages.iter_mut().rev() {
        if message.role == AnthropicRole::User {
            let Some(block) = message.content.last_mut() else {
                return;
            };
            if let AnthropicContentBlock::Text { cache_control, .. }
            | AnthropicContentBlock::ToolResult { cache_control, .. } = block
            {
                *cache_control = Some(marker);
                return;
            }
        }
    }

    for message in messages.iter_mut().rev() {
        if message.role != AnthropicRole::Assistant {
            continue;
        }
        if let Some(AnthropicContentBlock::Text { cache_control, .. }) = message
            .content
            .iter_mut()
            .rev()
            .find(|block| matches!(block, AnthropicContentBlock::Text { .. }))
        {
            *cache_control = Some(marker);
            return;
        }
    }
}

async fn error_for_status_with_body(
    response: reqwest::Response,
) -> Result<reqwest::Response, ModelError> {
    let status = response.status();
    if status.is_success() {
        return Ok(response);
    }
    let body = response.text().await.unwrap_or_default();
    Err(ModelError::HttpStatus { status, body })
}

#[async_trait::async_trait(?Send)]
impl ModelProvider for AnthropicProvider {
    fn identity(&self) -> Option<ModelIdentity> {
        Some(ModelIdentity::new(
            "anthropic",
            "anthropic-messages",
            &self.model,
        ))
    }

    fn set_reasoning(&mut self, reasoning: ReasoningLevel) -> bool {
        if reasoning == ReasoningLevel::Off && adaptive_thinking_is_mandatory(&self.model) {
            return false;
        }
        self.reasoning = reasoning;
        true
    }

    async fn send_turn(&self, request: ModelRequest<'_>) -> Result<ModelResponse, ModelError> {
        self.send_messages(request).await
    }

    async fn send_turn_stream(
        &self,
        request: ModelRequest<'_>,
        on_event: &mut dyn FnMut(ModelEvent) -> Result<(), ModelError>,
    ) -> Result<ModelResponse, ModelError> {
        let cancellation = request.cancellation.clone();
        tokio::select! {
            result = self.send_messages_stream(request, on_event) => result,
            () = cancellation.cancelled() => Err(ModelError::Interrupted),
        }
    }
}

#[cfg(test)]
#[path = "provider_tests.rs"]
mod tests;