openrouter_rs/api/
chat.rs

1use std::collections::HashMap;
2
3use derive_builder::Builder;
4use futures_util::{AsyncBufReadExt, StreamExt, stream::BoxStream};
5use serde::{Deserialize, Serialize};
6use surf::http::headers::AUTHORIZATION;
7
8use crate::{
9    error::OpenRouterError,
10    strip_option_map_setter, strip_option_vec_setter,
11    types::{
12        ProviderPreferences, ReasoningConfig, ResponseFormat, Role, completion::CompletionsResponse,
13    },
14    utils::handle_error,
15};
16
17/// Image URL with optional detail level for vision models.
18#[derive(Serialize, Deserialize, Debug, Clone)]
19pub struct ImageUrl {
20    /// URL of the image (can be a web URL or base64 data URI)
21    pub url: String,
22    /// Detail level: "auto", "low", or "high"
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub detail: Option<String>,
25}
26
27impl ImageUrl {
28    pub fn new(url: impl Into<String>) -> Self {
29        Self {
30            url: url.into(),
31            detail: None,
32        }
33    }
34
35    pub fn with_detail(url: impl Into<String>, detail: impl Into<String>) -> Self {
36        Self {
37            url: url.into(),
38            detail: Some(detail.into()),
39        }
40    }
41}
42
43/// A content part in a multi-modal message.
44#[derive(Serialize, Deserialize, Debug, Clone)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum ContentPart {
47    /// Text content
48    Text { text: String },
49    /// Image URL content
50    ImageUrl { image_url: ImageUrl },
51}
52
53impl ContentPart {
54    pub fn text(text: impl Into<String>) -> Self {
55        Self::Text { text: text.into() }
56    }
57
58    pub fn image_url(url: impl Into<String>) -> Self {
59        Self::ImageUrl {
60            image_url: ImageUrl::new(url),
61        }
62    }
63
64    pub fn image_url_with_detail(url: impl Into<String>, detail: impl Into<String>) -> Self {
65        Self::ImageUrl {
66            image_url: ImageUrl::with_detail(url, detail),
67        }
68    }
69}
70
71/// Message content - either a simple string or multi-part content.
72#[derive(Serialize, Deserialize, Debug, Clone)]
73#[serde(untagged)]
74pub enum Content {
75    /// Simple text content
76    Text(String),
77    /// Multi-part content (text, images, etc.)
78    Parts(Vec<ContentPart>),
79}
80
81impl From<String> for Content {
82    fn from(s: String) -> Self {
83        Self::Text(s)
84    }
85}
86
87impl From<&str> for Content {
88    fn from(s: &str) -> Self {
89        Self::Text(s.to_string())
90    }
91}
92
93impl From<Vec<ContentPart>> for Content {
94    fn from(parts: Vec<ContentPart>) -> Self {
95        Self::Parts(parts)
96    }
97}
98
99#[derive(Serialize, Deserialize, Debug, Clone)]
100pub struct Message {
101    pub role: Role,
102    pub content: Content,
103    /// Optional name for tool messages or function calls
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub name: Option<String>,
106    /// Tool call ID for tool response messages
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub tool_call_id: Option<String>,
109    /// Tool calls made by assistant
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub tool_calls: Option<Vec<crate::types::ToolCall>>,
112}
113
114impl Message {
115    pub fn new(role: Role, content: impl Into<Content>) -> Self {
116        Self {
117            role,
118            content: content.into(),
119            name: None,
120            tool_call_id: None,
121            tool_calls: None,
122        }
123    }
124
125    /// Create a message with multi-part content (text and images).
126    pub fn with_parts(role: Role, parts: Vec<ContentPart>) -> Self {
127        Self {
128            role,
129            content: Content::Parts(parts),
130            name: None,
131            tool_call_id: None,
132            tool_calls: None,
133        }
134    }
135
136    /// Create a tool response message
137    pub fn tool_response(tool_call_id: &str, content: impl Into<Content>) -> Self {
138        Self {
139            role: Role::Tool,
140            content: content.into(),
141            name: None,
142            tool_call_id: Some(tool_call_id.to_string()),
143            tool_calls: None,
144        }
145    }
146
147    /// Create a tool response message with a specific tool name
148    pub fn tool_response_named(tool_call_id: &str, tool_name: &str, content: impl Into<Content>) -> Self {
149        Self {
150            role: Role::Tool,
151            content: content.into(),
152            name: Some(tool_name.to_string()),
153            tool_call_id: Some(tool_call_id.to_string()),
154            tool_calls: None,
155        }
156    }
157
158    /// Create a message with a specific name
159    pub fn named(role: Role, name: &str, content: impl Into<Content>) -> Self {
160        Self {
161            role,
162            content: content.into(),
163            name: Some(name.to_string()),
164            tool_call_id: None,
165            tool_calls: None,
166        }
167    }
168
169    /// Create an assistant message with tool calls
170    pub fn assistant_with_tool_calls(content: impl Into<Content>, tool_calls: Vec<crate::types::ToolCall>) -> Self {
171        Self {
172            role: Role::Assistant,
173            content: content.into(),
174            name: None,
175            tool_call_id: None,
176            tool_calls: Some(tool_calls),
177        }
178    }
179}
180
181#[derive(Serialize, Deserialize, Debug, Clone, Builder)]
182#[builder(build_fn(error = "OpenRouterError"))]
183pub struct ChatCompletionRequest {
184    #[builder(setter(into))]
185    model: String,
186
187    messages: Vec<Message>,
188
189    #[builder(setter(skip), default)]
190    #[serde(skip_serializing_if = "Option::is_none")]
191    stream: Option<bool>,
192
193    #[builder(setter(strip_option), default)]
194    #[serde(skip_serializing_if = "Option::is_none")]
195    max_tokens: Option<u32>,
196
197    #[builder(setter(strip_option), default)]
198    #[serde(skip_serializing_if = "Option::is_none")]
199    temperature: Option<f64>,
200
201    #[builder(setter(strip_option), default)]
202    #[serde(skip_serializing_if = "Option::is_none")]
203    seed: Option<u32>,
204
205    #[builder(setter(strip_option), default)]
206    #[serde(skip_serializing_if = "Option::is_none")]
207    top_p: Option<f64>,
208
209    #[builder(setter(strip_option), default)]
210    #[serde(skip_serializing_if = "Option::is_none")]
211    top_k: Option<u32>,
212
213    #[builder(setter(strip_option), default)]
214    #[serde(skip_serializing_if = "Option::is_none")]
215    frequency_penalty: Option<f64>,
216
217    #[builder(setter(strip_option), default)]
218    #[serde(skip_serializing_if = "Option::is_none")]
219    presence_penalty: Option<f64>,
220
221    #[builder(setter(strip_option), default)]
222    #[serde(skip_serializing_if = "Option::is_none")]
223    repetition_penalty: Option<f64>,
224
225    #[builder(setter(custom), default)]
226    #[serde(skip_serializing_if = "Option::is_none")]
227    logit_bias: Option<HashMap<String, f64>>,
228
229    #[builder(setter(strip_option), default)]
230    #[serde(skip_serializing_if = "Option::is_none")]
231    top_logprobs: Option<u32>,
232
233    #[builder(setter(strip_option), default)]
234    #[serde(skip_serializing_if = "Option::is_none")]
235    min_p: Option<f64>,
236
237    #[builder(setter(strip_option), default)]
238    #[serde(skip_serializing_if = "Option::is_none")]
239    top_a: Option<f64>,
240
241    #[builder(setter(custom), default)]
242    #[serde(skip_serializing_if = "Option::is_none")]
243    transforms: Option<Vec<String>>,
244
245    #[builder(setter(custom), default)]
246    #[serde(skip_serializing_if = "Option::is_none")]
247    models: Option<Vec<String>>,
248
249    #[builder(setter(into, strip_option), default)]
250    #[serde(skip_serializing_if = "Option::is_none")]
251    route: Option<String>,
252
253    #[builder(setter(strip_option), default)]
254    #[serde(skip_serializing_if = "Option::is_none")]
255    provider: Option<ProviderPreferences>,
256
257    #[builder(setter(strip_option), default)]
258    #[serde(skip_serializing_if = "Option::is_none")]
259    response_format: Option<ResponseFormat>,
260
261    #[builder(setter(strip_option), default)]
262    #[serde(skip_serializing_if = "Option::is_none")]
263    reasoning: Option<ReasoningConfig>,
264
265    #[builder(setter(strip_option), default)]
266    #[serde(skip_serializing_if = "Option::is_none")]
267    include_reasoning: Option<bool>,
268
269    #[builder(setter(custom), default)]
270    #[serde(skip_serializing_if = "Option::is_none")]
271    tools: Option<Vec<crate::types::Tool>>,
272
273    #[builder(setter(strip_option), default)]
274    #[serde(skip_serializing_if = "Option::is_none")]
275    tool_choice: Option<crate::types::ToolChoice>,
276
277    #[builder(setter(strip_option), default)]
278    #[serde(skip_serializing_if = "Option::is_none")]
279    parallel_tool_calls: Option<bool>,
280}
281
282impl ChatCompletionRequestBuilder {
283    strip_option_vec_setter!(models, String);
284    strip_option_map_setter!(logit_bias, String, f64);
285    strip_option_vec_setter!(transforms, String);
286    strip_option_vec_setter!(tools, crate::types::Tool);
287
288    /// Enable reasoning with default settings (medium effort)
289    pub fn enable_reasoning(&mut self) -> &mut Self {
290        use crate::types::ReasoningConfig;
291        self.reasoning = Some(Some(ReasoningConfig::enabled()));
292        self
293    }
294
295    /// Set reasoning effort level
296    pub fn reasoning_effort(&mut self, effort: crate::types::Effort) -> &mut Self {
297        use crate::types::ReasoningConfig;
298        self.reasoning = Some(Some(ReasoningConfig::with_effort(effort)));
299        self
300    }
301
302    /// Set reasoning max tokens
303    pub fn reasoning_max_tokens(&mut self, max_tokens: u32) -> &mut Self {
304        use crate::types::ReasoningConfig;
305        self.reasoning = Some(Some(ReasoningConfig::with_max_tokens(max_tokens)));
306        self
307    }
308
309    /// Exclude reasoning from response (use reasoning internally but don't return it)
310    pub fn exclude_reasoning(&mut self) -> &mut Self {
311        use crate::types::ReasoningConfig;
312        self.reasoning = Some(Some(ReasoningConfig::excluded()));
313        self
314    }
315
316    /// Add a single tool to the request
317    pub fn tool(&mut self, tool: crate::types::Tool) -> &mut Self {
318        if let Some(Some(ref mut existing_tools)) = self.tools {
319            existing_tools.push(tool);
320        } else {
321            self.tools = Some(Some(vec![tool]));
322        }
323        self
324    }
325
326    /// Set tool choice to auto (model chooses whether to use tools)
327    pub fn tool_choice_auto(&mut self) -> &mut Self {
328        self.tool_choice = Some(Some(crate::types::ToolChoice::auto()));
329        self
330    }
331
332    /// Set tool choice to none (model will not use tools)
333    pub fn tool_choice_none(&mut self) -> &mut Self {
334        self.tool_choice = Some(Some(crate::types::ToolChoice::none()));
335        self
336    }
337
338    /// Set tool choice to required (model must use tools)
339    pub fn tool_choice_required(&mut self) -> &mut Self {
340        self.tool_choice = Some(Some(crate::types::ToolChoice::required()));
341        self
342    }
343
344    /// Force the model to use a specific tool
345    pub fn force_tool(&mut self, tool_name: &str) -> &mut Self {
346        self.tool_choice = Some(Some(crate::types::ToolChoice::force_tool(tool_name)));
347        self
348    }
349
350    /// Add a typed tool to the request
351    ///
352    /// This method allows adding strongly-typed tools using the TypedTool trait.
353    /// The tool's JSON Schema is automatically generated from the Rust type.
354    ///
355    /// # Examples
356    ///
357    /// ```rust
358    /// use openrouter_rs::types::typed_tool::TypedTool;
359    /// use serde::{Deserialize, Serialize};
360    /// use schemars::JsonSchema;
361    ///
362    /// #[derive(Serialize, Deserialize, JsonSchema)]
363    /// struct WeatherParams {
364    ///     location: String,
365    /// }
366    ///
367    /// impl TypedTool for WeatherParams {
368    ///     fn name() -> &'static str { "get_weather" }
369    ///     fn description() -> &'static str { "Get weather for location" }
370    /// }
371    ///
372    /// let request = ChatCompletionRequest::builder()
373    ///     .model("anthropic/claude-sonnet-4")
374    ///     .typed_tool::<WeatherParams>()
375    ///     .build()?;
376    /// # Ok::<(), Box<dyn std::error::Error>>(())
377    /// ```
378    pub fn typed_tool<T: crate::types::TypedTool>(&mut self) -> &mut Self {
379        let tool = T::create_tool();
380        self.tool(tool)
381    }
382
383    /// Add multiple typed tools to the request
384    ///
385    /// This is a convenience method for adding multiple typed tools at once.
386    /// Each tool type must implement the TypedTool trait.
387    ///
388    /// # Examples
389    ///
390    /// ```rust
391    /// # use openrouter_rs::types::typed_tool::TypedTool;
392    /// # use serde::{Deserialize, Serialize};
393    /// # use schemars::JsonSchema;
394    /// # #[derive(Serialize, Deserialize, JsonSchema)]
395    /// # struct WeatherParams { location: String }
396    /// # impl TypedTool for WeatherParams {
397    /// #     fn name() -> &'static str { "get_weather" }
398    /// #     fn description() -> &'static str { "Get weather" }
399    /// # }
400    /// # #[derive(Serialize, Deserialize, JsonSchema)]
401    /// # struct CalculatorParams { a: f64, b: f64 }
402    /// # impl TypedTool for CalculatorParams {
403    /// #     fn name() -> &'static str { "calculator" }
404    /// #     fn description() -> &'static str { "Calculate" }
405    /// # }
406    /// 
407    /// let request = ChatCompletionRequest::builder()
408    ///     .model("anthropic/claude-sonnet-4")
409    ///     .typed_tools_batch(&[
410    ///         WeatherParams::create_tool(),
411    ///         CalculatorParams::create_tool(),
412    ///     ])
413    ///     .build()?;
414    /// # Ok::<(), Box<dyn std::error::Error>>(())
415    /// ```
416    pub fn typed_tools_batch(&mut self, tools: &[crate::types::Tool]) -> &mut Self {
417        for tool in tools {
418            self.tool(tool.clone());
419        }
420        self
421    }
422
423    /// Force the model to use a specific typed tool
424    ///
425    /// This method combines the typed tool functionality with tool choice forcing.
426    /// The specified typed tool will be added to the tools list and forced as the choice.
427    ///
428    /// # Examples
429    ///
430    /// ```rust
431    /// # use openrouter_rs::types::typed_tool::TypedTool;
432    /// # use serde::{Deserialize, Serialize};
433    /// # use schemars::JsonSchema;
434    /// # #[derive(Serialize, Deserialize, JsonSchema)]
435    /// # struct WeatherParams { location: String }
436    /// # impl TypedTool for WeatherParams {
437    /// #     fn name() -> &'static str { "get_weather" }
438    /// #     fn description() -> &'static str { "Get weather" }
439    /// # }
440    ///
441    /// let request = ChatCompletionRequest::builder()
442    ///     .model("anthropic/claude-sonnet-4")
443    ///     .force_typed_tool::<WeatherParams>()
444    ///     .build()?;
445    /// # Ok::<(), Box<dyn std::error::Error>>(())
446    /// ```
447    pub fn force_typed_tool<T: crate::types::TypedTool>(&mut self) -> &mut Self {
448        let tool_name = T::name();
449        let tool = T::create_tool();
450        self.tool(tool);
451        self.force_tool(tool_name);
452        self
453    }
454}
455
456impl ChatCompletionRequest {
457    pub fn builder() -> ChatCompletionRequestBuilder {
458        ChatCompletionRequestBuilder::default()
459    }
460
461    pub fn new(model: &str, messages: Vec<Message>) -> Self {
462        Self::builder()
463            .model(model)
464            .messages(messages)
465            .build()
466            .expect("Failed to build ChatCompletionRequest")
467    }
468
469    /// Get the tools defined in this request
470    pub fn tools(&self) -> Option<&Vec<crate::types::Tool>> {
471        self.tools.as_ref()
472    }
473
474    /// Get the tool choice setting
475    pub fn tool_choice(&self) -> Option<&crate::types::ToolChoice> {
476        self.tool_choice.as_ref()
477    }
478
479    /// Get the parallel tool calls setting
480    pub fn parallel_tool_calls(&self) -> Option<bool> {
481        self.parallel_tool_calls
482    }
483
484    /// Get the messages in this request
485    pub fn messages(&self) -> &Vec<Message> {
486        &self.messages
487    }
488
489    fn stream(&self, stream: bool) -> Self {
490        let mut req = self.clone();
491        req.stream = Some(stream);
492        req
493    }
494}
495
496/// Send a chat completion request to a selected model.
497///
498/// # Arguments
499///
500/// * `base_url` - The base URL for the OpenRouter API.
501/// * `api_key` - The API key for authentication.
502/// * `x_title` - The name of the site for the request.
503/// * `http_referer` - The URL of the site for the request.
504/// * `request` - The chat completion request containing the model and messages.
505///
506/// # Returns
507///
508/// * `Result<CompletionsResponse, OpenRouterError>` - The response from the chat completion request.
509pub async fn send_chat_completion(
510    base_url: &str,
511    api_key: &str,
512    x_title: &Option<String>,
513    http_referer: &Option<String>,
514    request: &ChatCompletionRequest,
515) -> Result<CompletionsResponse, OpenRouterError> {
516    let url = format!("{base_url}/chat/completions");
517
518    // Ensure that the request is not streaming to get a single response
519    let request = request.stream(false);
520
521    let mut surf_req = surf::post(url)
522        .header(AUTHORIZATION, format!("Bearer {api_key}"))
523        .body_json(&request)?;
524
525    if let Some(x_title) = x_title {
526        surf_req = surf_req.header("X-Title", x_title);
527    }
528    if let Some(http_referer) = http_referer {
529        surf_req = surf_req.header("HTTP-Referer", http_referer);
530    }
531
532    let mut response = surf_req.await?;
533
534    if response.status().is_success() {
535        let body_text = response.body_string().await?;
536        let chat_response: CompletionsResponse = serde_json::from_str(&body_text)
537            .map_err(|e| {
538                eprintln!("Failed to deserialize response: {e}\nBody: {body_text}");
539                OpenRouterError::Serialization(e)
540            })?;
541        Ok(chat_response)
542    } else {
543        handle_error(response).await?;
544        unreachable!()
545    }
546}
547
548/// Stream chat completion events from a selected model.
549///
550/// # Arguments
551///
552/// * `base_url` - The base URL for the OpenRouter API.
553/// * `api_key` - The API key for authentication.
554/// * `request` - The chat completion request containing the model and messages.
555///
556/// # Returns
557///
558/// * `Result<BoxStream<'static, Result<CompletionsResponse, OpenRouterError>>, OpenRouterError>` - A stream of chat completion events or an error.
559pub async fn stream_chat_completion(
560    base_url: &str,
561    api_key: &str,
562    request: &ChatCompletionRequest,
563) -> Result<BoxStream<'static, Result<CompletionsResponse, OpenRouterError>>, OpenRouterError> {
564    let url = format!("{base_url}/chat/completions");
565
566    // Ensure that the request is streaming to get a continuous response
567    let request = request.stream(true);
568
569    let response = surf::post(url)
570        .header(AUTHORIZATION, format!("Bearer {api_key}"))
571        .body_json(&request)?
572        .await?;
573
574    if response.status().is_success() {
575        let lines = response
576            .lines()
577            .filter_map(async |line| match line {
578                Ok(line) => line
579                    .strip_prefix("data: ")
580                    .filter(|line| *line != "[DONE]")
581                    .map(serde_json::from_str::<CompletionsResponse>)
582                    .map(|event| event.map_err(OpenRouterError::Serialization)),
583                Err(error) => Some(Err(OpenRouterError::Io(error))),
584            })
585            .boxed();
586
587        Ok(lines)
588    } else {
589        handle_error(response).await?;
590        unreachable!()
591    }
592}