toast-api 0.1.7

An unofficial CLI client and API server for Claude/Deepseek
Documentation
use crate::config::BASE_URL;
use anyhow::{anyhow, Result};
use chrono::Local;
use rquest::header::{HeaderMap, HeaderValue};
use rquest::Client;
use rquest_util::Emulation;
use serde_json::json;
use std::time::Duration;
use uuid::Uuid;

/// Session data for Claude API
#[derive(Clone)]
pub struct Session {
    pub cookie: String,
    pub user_agent: String,
    pub organization_id: String,
}
/// Representation of a file attachment for send_message
#[derive(Clone, Debug)]
pub struct Attachment {
    /// File name (basename)
    pub file_name: String,
    /// File size in bytes
    pub size: u64,
    /// File content as text
    pub content: String,
}

/// Claude API client
pub struct Claude {
    http: Client,
    session: Session,
    model: &'static str,
}

impl Claude {
    /// Create a new Claude client
    pub fn new(session: Session, model: &'static str) -> Result<Self> {
        let http = Client::builder()
            .emulation(Emulation::Firefox136)
            .timeout(Duration::from_secs(240))
            // Add retry settings to handle transient errors
            .connect_timeout(Duration::from_secs(30))
            .build()?;
        Ok(Self {
            http,
            session,
            model,
        })
    }

    fn default_headers(&self) -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert("Host", HeaderValue::from_static("claude.ai"));
        headers.insert(
            "User-Agent",
            HeaderValue::from_str(&self.session.user_agent).unwrap(),
        );
        headers
    }

    /// Create a new chat conversation
    pub async fn create_chat(&self) -> Result<String> {
        let uuid = Uuid::new_v4().to_string();
        let url = format!(
            "{}/api/organizations/{}/chat_conversations",
            BASE_URL, self.session.organization_id
        );
        let body = json!({ "name": "", "uuid": uuid }).to_string();

        // Add retry logic
        let mut attempts = 0;
        let max_attempts = 3;

        while attempts < max_attempts {
            attempts += 1;

            match self
                .http
                .post(&url)
                .headers(self.default_headers())
                .header("Content-Type", "application/json")
                .header("Cookie", &self.session.cookie)
                .body(body.clone())
                .send()
                .await
            {
                Ok(res) => {
                    let status = res.status();

                    if status == 201 {
                        return Ok(uuid);
                    } else {
                        match res.text().await {
                            Ok(text) => {
                                if attempts == max_attempts {
                                    return Err(anyhow!("create_chat failed: {:?}", text));
                                }
                            }
                            Err(e) => {
                                if attempts == max_attempts {
                                    return Err(anyhow!(
                                        "create_chat failed: unable to read response: {}",
                                        e
                                    ));
                                }
                            }
                        }
                    }
                }
                Err(e) => {
                    if attempts == max_attempts {
                        return Err(anyhow!("create_chat failed: network error: {}", e));
                    }
                }
            }

            // Wait before retrying
            tokio::time::sleep(Duration::from_secs(1)).await;
        }

        Err(anyhow!(
            "create_chat failed after {} attempts",
            max_attempts
        ))
    }

    /// Delete an existing chat conversation
    pub async fn delete_chat(&self, chat_id: &str) -> Result<()> {
        let url = format!(
            "{}/api/organizations/{}/chat_conversations/{}",
            BASE_URL, self.session.organization_id, chat_id
        );

        let body = format!("\"{chat_id}\"");

        // Add retry logic
        let mut attempts = 0;
        let max_attempts = 3;

        while attempts < max_attempts {
            attempts += 1;

            match self
                .http
                .delete(&url)
                .headers(self.default_headers())
                .header("Content-Type", "application/json")
                .header("Cookie", &self.session.cookie)
                .body(body.clone())
                .send()
                .await
            {
                Ok(res) => {
                    let status = res.status();

                    if status == 204 {
                        return Ok(());
                    } else {
                        // Only fail on final attempt
                        if attempts == max_attempts {
                            match res.text().await {
                                Ok(text) => {
                                    return Err(anyhow!("delete_chat failed: {:?}", text));
                                }
                                Err(e) => {
                                    return Err(anyhow!(
                                        "delete_chat failed: unable to read response: {}",
                                        e
                                    ));
                                }
                            }
                        }
                    }
                }
                Err(e) => {
                    if attempts == max_attempts {
                        return Err(anyhow!("delete_chat failed: network error: {}", e));
                    }
                }
            }

            // Wait before retrying
            tokio::time::sleep(Duration::from_secs(1)).await;
        }

        Err(anyhow!(
            "delete_chat failed after {} attempts",
            max_attempts
        ))
    }

    /// Send a message to the chat and return the completion
    /// Send a message to the chat with optional attachments and return the completion
    pub async fn send_message(
        &self,
        chat_id: &str,
        prompt: &str,
        attachments: &[Attachment],
    ) -> Result<String> {
        let url = format!(
            "{}/api/organizations/{}/chat_conversations/{}/completion",
            BASE_URL, self.session.organization_id, chat_id
        );
        let mut payload = json!({
            "attachments": [],
            "files": [],
            "prompt": prompt,
            "model": self.model,
            "timezone": Local::now().offset().to_string()
        });
        if !attachments.is_empty() {
            let atts: Vec<_> = attachments
                .iter()
                .map(|a| {
                    json!({
                        "extracted_content": a.content,
                        "file_name": a.file_name,
                        "file_size": a.size.to_string(),
                        "file_type": "text/plain"
                    })
                })
                .collect();
            payload["attachments"] = serde_json::Value::Array(atts);
        }

        // Add retry logic for network errors
        let mut attempts = 0;
        let max_attempts = 3;
        let mut last_error = None;

        while attempts < max_attempts {
            attempts += 1;

            match self
                .http
                .post(&url)
                .headers(self.default_headers())
                .header("Content-Type", "application/json")
                .header("Accept", "text/event-stream, text/event-stream")
                .header("Cookie", &self.session.cookie)
                .body(payload.to_string())
                .send()
                .await
            {
                Ok(res) => {
                    let status = res.status();

                    // Check status before proceeding
                    if !status.is_success() {
                        // Try to get more details about the error
                        let error_text = match res.text().await {
                            Ok(text) => text,
                            Err(_) => "Unable to read error response".to_string(),
                        };
                        return Err(anyhow!(
                            "API request failed with status: {} - {}",
                            status,
                            error_text
                        ));
                    }

                    // Process the body
                    match super::utils::decode_body(res).await {
                        Ok(bytes) => {
                            let _preview = std::str::from_utf8(&bytes[..bytes.len().min(500)])
                                .unwrap_or("Invalid UTF-8")
                                .replace('\n', "\\n");
                            // eprintln!("RESPONSE BODY: {} (first 500 bytes)", preview);

                            let result = super::utils::parse_stream(&bytes)?;
                            return Ok(result);
                        }
                        Err(e) => {
                            eprintln!("RESPONSE DECODE ERROR: {e}");
                            last_error = Some(e);
                            if attempts < max_attempts {
                                // Wait before retrying
                                tokio::time::sleep(Duration::from_secs(1)).await;
                                continue;
                            }
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NETWORK ERROR: {e}");
                    last_error = Some(anyhow!("Network error: {}", e));
                    if attempts < max_attempts {
                        // Wait before retrying
                        tokio::time::sleep(Duration::from_secs(1)).await;
                        continue;
                    }
                }
            }
        }

        // All attempts failed
        Err(last_error
            .unwrap_or_else(|| anyhow!("Failed to connect to API after {} attempts", max_attempts)))
    }
}