procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! Adapter for Anthropic's `/v1/messages`.
//!
//! The client is the only part that talks to the network: the wire shapes live in `wire`, the
//! stream reassembly in `stream`, and the neutral vocabulary they translate to and from is in
//! `agent`.

mod stream;
mod wire;

use color_eyre::Result;
use reqwest::Client;
use tokio::sync::mpsc;

use self::stream::StreamState;
use self::wire::{MessagesRequest, MessagesResponse};
use crate::agent::{ContentPart, Message, StreamOutcome, ToolDefinition};
use crate::sse::EventReader;

pub struct ClaudeClient {
    client: Client,
    api_key: String,
    base_url: String,
    api_version: String,
    model: String,
    max_tokens: u32,
}

impl ClaudeClient {
    pub fn new(api_key: String) -> Self {
        Self {
            client: crate::llm::http_client(),
            api_key,
            base_url: "https://api.anthropic.com".to_string(),
            api_version: "2023-06-01".to_string(),
            model: "claude-sonnet-5".to_string(),
            max_tokens: 4096,
        }
    }

    pub fn with_model(mut self, model: String) -> Self {
        self.model = model;
        self
    }

    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
        self.max_tokens = max_tokens;
        self
    }

    #[allow(dead_code)]
    pub fn with_base_url(mut self, base_url: String) -> Self {
        self.base_url = base_url.trim_end_matches('/').to_string();
        self
    }

    #[allow(dead_code)]
    pub fn with_api_version(mut self, api_version: String) -> Self {
        self.api_version = api_version;
        self
    }

    fn endpoint(&self) -> String {
        format!("{}/v1/messages", self.base_url)
    }

    async fn dispatch(&self, request: &MessagesRequest<'_>) -> Result<reqwest::Response> {
        crate::llm::send_with_retry(|| {
            self.client
                .post(self.endpoint())
                .header("x-api-key", &self.api_key)
                .header("anthropic-version", &self.api_version)
                .header("content-type", "application/json")
                .json(request)
        })
        .await
    }

    // The non-streaming path, used for compaction: the summary is not shown as it arrives, and
    // replaying the caller's own system prompt and tools keeps the call a genuine prefix of the
    // conversation, so it shares the cached tool and system breakpoints with the main loop.
    pub async fn send_message(
        &self,
        history: &[Message],
        tools: Option<&[ToolDefinition]>,
        system: Option<&str>,
    ) -> Result<Vec<ContentPart>> {
        let request = MessagesRequest::build(
            self.model.clone(),
            self.max_tokens,
            history,
            tools,
            system,
            false,
        );

        let response = self.dispatch(&request).await?;
        let body: MessagesResponse = response.json().await?;
        Ok(body.content)
    }

    pub async fn send_message_streaming(
        &self,
        history: &[Message],
        tools: Option<&[ToolDefinition]>,
        system: Option<&str>,
        update_tx: &mpsc::UnboundedSender<String>,
    ) -> Result<StreamOutcome> {
        let request = MessagesRequest::build(
            self.model.clone(),
            self.max_tokens,
            history,
            tools,
            system,
            true,
        );

        let response = self.dispatch(&request).await?;

        let mut reader = EventReader::new(StreamState::new(update_tx));
        reader.read(response).await?;
        Ok(reader.into_sink().into_outcome())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn trailing_slash_in_base_url_does_not_double() {
        let client = ClaudeClient::new("k".to_string())
            .with_base_url("https://gateway.internal/".to_string());

        assert_eq!(client.endpoint(), "https://gateway.internal/v1/messages");
    }
}