Skip to main content

chat_openrouter/
lib.rs

1//! OpenRouter provider for chat-rs.
2//!
3//! OpenRouter is a unified gateway in front of hundreds of models from
4//! many vendors; the model slug selects which one (e.g.
5//! `anthropic/claude-sonnet-4`, `openai/gpt-4o`, `google/gemini-2.5-pro`).
6//!
7//! It exposes two OpenAI-compatible wires, and the builder picks one
8//! upstream and hands off to the matching wire crate — no wrapper type:
9//!
10//! - **Responses API (Beta)** (default) — wraps [`chat_responses`] and
11//!   builds a [`ResponsesClient`]. Stateless (no `previous_response_id`
12//!   round-trip), so response-id reuse is disabled and the full
13//!   conversation is sent each turn.
14//! - **Chat Completions** — opt in with [`OpenRouterBuilder::with_completions`];
15//!   wraps [`chat_completions`] and builds a [`CompletionsClient`].
16//!
17//! Both wire clients already implement `CompletionProvider` (and
18//! `StreamProvider` under the `stream` feature), so streaming is free
19//! on either path. OpenRouter has no WebSocket endpoint — streaming is
20//! SSE over HTTP — but the builder stays generic over [`Transport`].
21//!
22//! ```no_run
23//! use chat_openrouter::OpenRouterBuilder;
24//!
25//! // OPENROUTER_API_KEY env var is read automatically.
26//! // Default: Responses API.
27//! let responses = OpenRouterBuilder::new()
28//!     .with_model("anthropic/claude-sonnet-4")
29//!     .build();
30//!
31//! // Opt in to the Chat Completions API.
32//! let completions = OpenRouterBuilder::new()
33//!     .with_completions()
34//!     .with_model("openai/gpt-4o")
35//!     .build();
36//! ```
37
38use std::env;
39use std::marker::PhantomData;
40
41use chat_completions::CompletionsBuilder;
42pub use chat_completions::{CompletionsClient, ReqwestTransport};
43use chat_core::transport::Transport;
44use chat_responses::ResponsesBuilder;
45pub use chat_responses::ResponsesClient;
46
47/// Default OpenRouter base URL. The Responses API lives at
48/// `{base_url}/responses`, Chat Completions at `{base_url}/chat/completions`.
49pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
50
51const OPENROUTER_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
52
53pub struct WithoutModel;
54pub struct WithModel;
55
56/// Wire type-state: target the Responses API (default).
57pub struct Responses;
58/// Wire type-state: target the Chat Completions API.
59pub struct Completions;
60
61pub struct OpenRouterBuilder<M = WithoutModel, W = Responses, T: Transport = ReqwestTransport> {
62    model: Option<String>,
63    api_key: Option<String>,
64    base_url: String,
65    reasoning_effort: Option<String>,
66    description: Option<String>,
67    transport: Option<T>,
68    _m: PhantomData<M>,
69    _w: PhantomData<W>,
70}
71
72impl Default for OpenRouterBuilder<WithoutModel, Responses, ReqwestTransport> {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl OpenRouterBuilder<WithoutModel, Responses, ReqwestTransport> {
79    pub fn new() -> Self {
80        Self {
81            model: None,
82            api_key: None,
83            base_url: DEFAULT_OPENROUTER_BASE_URL.to_string(),
84            reasoning_effort: None,
85            description: None,
86            transport: Some(ReqwestTransport::default()),
87            _m: PhantomData,
88            _w: PhantomData,
89        }
90    }
91}
92
93impl<M, W, T: Transport> OpenRouterBuilder<M, W, T> {
94    /// Override the API key. If unset, `OPENROUTER_API_KEY` is read at build time.
95    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
96        self.api_key = Some(api_key.into());
97        self
98    }
99
100    /// Override the base URL. Defaults to `https://openrouter.ai/api/v1`.
101    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
102        self.base_url = url.into();
103        self
104    }
105
106    pub fn with_description(mut self, description: impl Into<String>) -> Self {
107        self.description = Some(description.into());
108        self
109    }
110
111    /// Supply a custom transport, replacing the default `ReqwestTransport`.
112    pub fn with_transport<T2: Transport>(self, transport: T2) -> OpenRouterBuilder<M, W, T2> {
113        OpenRouterBuilder {
114            model: self.model,
115            api_key: self.api_key,
116            base_url: self.base_url,
117            reasoning_effort: self.reasoning_effort,
118            description: self.description,
119            transport: Some(transport),
120            _m: PhantomData,
121            _w: PhantomData,
122        }
123    }
124}
125
126impl<W, T: Transport> OpenRouterBuilder<WithoutModel, W, T> {
127    /// Select the model. OpenRouter slugs are vendor-prefixed, e.g.
128    /// `anthropic/claude-sonnet-4` or `openai/gpt-4o`.
129    pub fn with_model(self, model: impl Into<String>) -> OpenRouterBuilder<WithModel, W, T> {
130        OpenRouterBuilder {
131            model: Some(model.into()),
132            api_key: self.api_key,
133            base_url: self.base_url,
134            reasoning_effort: self.reasoning_effort,
135            description: self.description,
136            transport: self.transport,
137            _m: PhantomData,
138            _w: PhantomData,
139        }
140    }
141}
142
143impl<M, T: Transport> OpenRouterBuilder<M, Responses, T> {
144    /// Opt in to the Chat Completions wire instead of the default Responses API.
145    pub fn with_completions(self) -> OpenRouterBuilder<M, Completions, T> {
146        OpenRouterBuilder {
147            model: self.model,
148            api_key: self.api_key,
149            base_url: self.base_url,
150            reasoning_effort: self.reasoning_effort,
151            description: self.description,
152            transport: self.transport,
153            _m: PhantomData,
154            _w: PhantomData,
155        }
156    }
157
158    /// Set the reasoning effort (`"low"` / `"medium"` / `"high"`) for
159    /// reasoning-capable models. Responses-wire only — Chat Completions
160    /// does not carry this field.
161    pub fn with_reasoning_effort(mut self, effort: impl Into<String>) -> Self {
162        self.reasoning_effort = Some(effort.into());
163        self
164    }
165}
166
167impl<M, W, T: Transport> OpenRouterBuilder<M, W, T> {
168    fn resolve_api_key(&mut self) -> String {
169        self.api_key
170            .take()
171            .or_else(|| env::var(OPENROUTER_API_KEY_ENV).ok())
172            .expect("No OpenRouter API key. Set OPENROUTER_API_KEY or call .with_api_key().")
173    }
174}
175
176impl<T: Transport> OpenRouterBuilder<WithModel, Responses, T> {
177    /// Build a Responses API client.
178    pub fn build(mut self) -> ResponsesClient<T> {
179        let api_key = self.resolve_api_key();
180        let mut rb = ResponsesBuilder::new()
181            .with_base_url(self.base_url)
182            .with_model(self.model.expect("model set"))
183            .with_api_key(api_key)
184            .with_transport(self.transport.expect("transport set"))
185            .without_previous_response_id();
186
187        if let Some(eff) = self.reasoning_effort {
188            rb = rb.with_reasoning_effort(eff);
189        }
190        if let Some(desc) = self.description {
191            rb = rb.with_description(desc);
192        }
193        rb.build()
194    }
195}
196
197impl<T: Transport> OpenRouterBuilder<WithModel, Completions, T> {
198    /// Build a Chat Completions client.
199    pub fn build(mut self) -> CompletionsClient<T> {
200        let api_key = self.resolve_api_key();
201        let mut cb = CompletionsBuilder::new()
202            .with_base_url(self.base_url)
203            .with_model(self.model.expect("model set"))
204            .with_api_key(api_key)
205            .with_transport(self.transport.expect("transport set"));
206
207        if let Some(desc) = self.description {
208            cb = cb.with_description(desc);
209        }
210        cb.build()
211    }
212}