Skip to main content

llm_relay/client/
chat.rs

1use tracing::{debug, error, info};
2
3use super::LlmClient;
4use super::error::LlmError;
5use crate::convert::{thinking::build_thinking_params, to_openai};
6use crate::types::anthropic::{Message, MessagesRequest, MessagesResponse};
7use crate::types::common::{Provider, ResponseFormat, ThinkingConfig, ToolDefinition};
8use crate::types::openai::{self, ChatRequest};
9
10/// Options for a chat request.
11#[derive(Default)]
12pub struct ChatOptions<'a> {
13    pub system: Option<&'a str>,
14    pub tools: Option<&'a [ToolDefinition]>,
15    pub thinking: Option<&'a ThinkingConfig>,
16    pub temperature: Option<f32>,
17    pub response_format: Option<&'a ResponseFormat>,
18}
19
20impl LlmClient {
21    /// Send a chat completion request using Anthropic message format.
22    ///
23    /// Automatically converts to OpenAI format if the provider is OpenAI-compatible.
24    /// Always returns the response in Anthropic format (canonical).
25    pub async fn chat(
26        &self,
27        messages: &[Message],
28        options: ChatOptions<'_>,
29    ) -> Result<MessagesResponse, LlmError> {
30        info!(
31            "Sending request to LLM (provider: {}, model: {}, messages: {})",
32            self.config.provider,
33            self.config.model,
34            messages.len()
35        );
36
37        match self.config.provider {
38            Provider::Anthropic => self.chat_anthropic(messages, &options).await,
39            Provider::OpenAiCompatible => self.chat_openai_compat(messages, &options).await,
40        }
41    }
42
43    /// Simple text-in, full-response-out call.
44    ///
45    /// Sends a single user message and returns the full response.
46    /// Use `.text()` on the result to extract just the text content.
47    pub async fn complete(
48        &self,
49        user: &str,
50        options: ChatOptions<'_>,
51    ) -> Result<MessagesResponse, LlmError> {
52        let messages = vec![Message::user_text(user)];
53        self.chat(&messages, options).await
54    }
55
56    /// Send a raw OpenAI-format chat request.
57    ///
58    /// Bypasses Anthropic format conversion — sends and receives OpenAI types directly.
59    pub async fn chat_openai_raw(
60        &self,
61        request: &ChatRequest,
62    ) -> Result<openai::ChatResponse, LlmError> {
63        let url = format!("{}/v1/chat/completions", self.config.base_url);
64        debug!("POST {url} (model: {})", request.model);
65
66        let response = self
67            .http
68            .post(&url)
69            .header("Authorization", format!("Bearer {}", self.config.api_key))
70            .header("content-type", "application/json")
71            .json(request)
72            .send()
73            .await?;
74
75        let status = response.status();
76        if !status.is_success() {
77            let body = response.text().await.unwrap_or_default();
78            error!("API error {status}: {body}");
79            return Err(LlmError::ApiError {
80                status: status.as_u16(),
81                body,
82            });
83        }
84
85        let resp: openai::ChatResponse = response.json().await.map_err(|e| {
86            error!("Failed to parse response: {e}");
87            LlmError::ParseResponse(e.to_string())
88        })?;
89
90        Ok(resp)
91    }
92
93    // --- Private implementation ---
94
95    async fn chat_anthropic(
96        &self,
97        messages: &[Message],
98        options: &ChatOptions<'_>,
99    ) -> Result<MessagesResponse, LlmError> {
100        let (thinking, output_config) = build_thinking_params(options.thinking);
101
102        let request_body = MessagesRequest {
103            model: self.config.model.clone(),
104            max_tokens: self.config.max_tokens,
105            system: options.system.map(|s| s.to_string()),
106            messages: messages.to_vec(),
107            tools: options.tools.map(|t| t.to_vec()),
108            thinking,
109            output_config,
110        };
111
112        let url = format!("{}/v1/messages", self.config.base_url);
113        debug!("POST {url} (model: {})", self.config.model);
114
115        let response = self
116            .http
117            .post(&url)
118            .header("x-api-key", &self.config.api_key)
119            .header("anthropic-version", "2023-06-01")
120            .header("content-type", "application/json")
121            .json(&request_body)
122            .send()
123            .await?;
124
125        let status = response.status();
126        if !status.is_success() {
127            let body = response.text().await.unwrap_or_default();
128            error!("API error {status}: {body}");
129            return Err(LlmError::ApiError {
130                status: status.as_u16(),
131                body,
132            });
133        }
134
135        let resp: MessagesResponse = response.json().await.map_err(|e| {
136            error!("Failed to parse response: {e}");
137            LlmError::ParseResponse(e.to_string())
138        })?;
139
140        info!(
141            "LLM responded (stop_reason: {}, content blocks: {})",
142            resp.stop_reason,
143            resp.content.len()
144        );
145        Ok(resp)
146    }
147
148    async fn chat_openai_compat(
149        &self,
150        messages: &[Message],
151        options: &ChatOptions<'_>,
152    ) -> Result<MessagesResponse, LlmError> {
153        let openai_messages = to_openai::messages_to_openai(options.system, messages);
154
155        let tools = options.tools.map(to_openai::tools_to_openai);
156
157        let request_body = openai::ChatRequest {
158            model: self.config.model.clone(),
159            max_tokens: Some(self.config.max_tokens),
160            messages: openai_messages,
161            temperature: options.temperature,
162            tools,
163            response_format: options.response_format.cloned(),
164        };
165
166        let url = format!("{}/v1/chat/completions", self.config.base_url);
167        debug!("POST {url} (model: {})", self.config.model);
168
169        let response = self
170            .http
171            .post(&url)
172            .header("Authorization", format!("Bearer {}", self.config.api_key))
173            .header("content-type", "application/json")
174            .json(&request_body)
175            .send()
176            .await?;
177
178        let status = response.status();
179        if !status.is_success() {
180            let body = response.text().await.unwrap_or_default();
181            error!("API error {status}: {body}");
182            return Err(LlmError::ApiError {
183                status: status.as_u16(),
184                body,
185            });
186        }
187
188        let openai_resp: openai::ChatResponse = response.json().await.map_err(|e| {
189            error!("Failed to parse response: {e}");
190            LlmError::ParseResponse(e.to_string())
191        })?;
192
193        let resp = to_openai::response_to_anthropic(openai_resp).map_err(LlmError::Conversion)?;
194
195        info!(
196            "LLM responded (stop_reason: {}, content blocks: {})",
197            resp.stop_reason,
198            resp.content.len()
199        );
200        Ok(resp)
201    }
202}