Skip to main content

openrouter/
client.rs

1//! `Client` and `ClientBuilder`.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6#[cfg(target_arch = "wasm32")]
7use std::rc::Rc;
8
9use url::Url;
10
11use futures::FutureExt;
12
13use crate::error::{Error, Result};
14use crate::request;
15use crate::retry::RetryConfig;
16use crate::stream::EventStream;
17use crate::types::{
18    ActivityOptions, ActivityResponse, AssignKeysRequest, AssignKeysResponse, AssignMembersRequest,
19    AssignMembersResponse, BulkAddWorkspaceMembersResponse, BulkRemoveWorkspaceMembersResponse,
20    BulkWorkspaceMembersRequest, ChatCompletionRequest, ChatCompletionResponse, CompletionRequest,
21    CompletionResponse, CreateGuardrailRequest, CreateKeyRequest, CreateKeyResponse,
22    CreateWorkspaceRequest, CreateWorkspaceResponse, CreditsResponse, DeleteGuardrailResponse,
23    DeleteKeyResponse, DeleteWorkspaceResponse, GetKeyByHashResponse, GetWorkspaceResponse,
24    Guardrail, KeyResponse, ListGuardrailKeyAssignmentsResponse,
25    ListGuardrailMemberAssignmentsResponse, ListGuardrailsOptions, ListGuardrailsResponse,
26    ListKeysOptions, ListKeysResponse, ListModelsOptions, ListOrganizationMembersOptions,
27    ListOrganizationMembersResponse, ListWorkspacesOptions, ListWorkspacesResponse,
28    ModelEndpointsResponse, ModelsResponse, Provider, ProvidersResponse, RerankRequest,
29    RerankResponse, SpeechFormat, SpeechRequest, SpeechResponse, UpdateGuardrailRequest,
30    UpdateKeyRequest, UpdateKeyResponse, UpdateWorkspaceRequest, UpdateWorkspaceResponse,
31    VideoContentResponse, VideoGenerationRequest, VideoGenerationResponse, VideoModelsResponse,
32    ZdrEndpointsResponse,
33};
34
35const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1/";
36const DEFAULT_STREAM_RECONNECTS: u32 = 3;
37
38/// The OpenRouter client. Cheap to `Clone` (internally an `Arc`).
39#[derive(Clone, Debug)]
40pub struct Client {
41    inner: Arc<ClientInner>,
42}
43
44#[derive(Debug)]
45struct ClientInner {
46    api_key: String,
47    base_url: Url,
48    http: reqwest::Client,
49    retry: RetryConfig,
50    stream_reconnects: u32,
51    app_name: Option<String>,
52    referer: Option<String>,
53}
54
55impl Client {
56    /// Start a new `ClientBuilder`.
57    pub fn builder() -> ClientBuilder {
58        ClientBuilder::default()
59    }
60
61    /// Build a client with only an API key, using all other defaults.
62    pub fn new(api_key: impl Into<String>) -> Result<Self> {
63        Self::builder().api_key(api_key).build()
64    }
65
66    /// Configured API key.
67    pub fn api_key(&self) -> &str {
68        &self.inner.api_key
69    }
70
71    /// Configured base URL.
72    pub fn base_url(&self) -> &Url {
73        &self.inner.base_url
74    }
75
76    /// Underlying `reqwest::Client`.
77    pub fn http(&self) -> &reqwest::Client {
78        &self.inner.http
79    }
80
81    /// Active retry configuration.
82    pub fn retry(&self) -> &RetryConfig {
83        &self.inner.retry
84    }
85
86    /// Maximum number of reconnect attempts after a transient stream failure.
87    pub fn stream_reconnects(&self) -> u32 {
88        self.inner.stream_reconnects
89    }
90
91    /// Optional app-attribution name (sent as `X-Title` in Phase 2+).
92    pub fn app_name(&self) -> Option<&str> {
93        self.inner.app_name.as_deref()
94    }
95
96    /// Optional referer (sent as `HTTP-Referer` in Phase 2+).
97    pub fn referer(&self) -> Option<&str> {
98        self.inner.referer.as_deref()
99    }
100
101    /// Send a chat-completions request and decode the unary response.
102    ///
103    /// Retries transient failures per the client's [`RetryConfig`]. If `req.stream`
104    /// is set, it is forced to `Some(false)` so a caller-set `stream: true` cannot
105    /// subvert the unary endpoint.
106    pub async fn chat_complete(
107        &self,
108        mut req: ChatCompletionRequest,
109    ) -> Result<ChatCompletionResponse> {
110        req.stream = Some(false);
111        apply_model_suffix(&mut req.model, &mut req.provider);
112        request::execute_json(self, "chat/completions", &req).await
113    }
114
115    /// Send a legacy text-completions request and decode the unary response.
116    ///
117    /// `req.stream` is forced to `Some(false)` for the same reason as
118    /// [`Client::chat_complete`].
119    pub async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse> {
120        req.stream = Some(false);
121        apply_model_suffix(&mut req.model, &mut req.provider);
122        request::execute_json(self, "completions", &req).await
123    }
124
125    /// Open a streaming chat-completions request.
126    ///
127    /// Returns an [`EventStream<ChatCompletionResponse>`]; each yielded chunk
128    /// carries `delta` (instead of `message`) on its `choices`. The stream
129    /// terminates cleanly on the `[DONE]` SSE marker.
130    ///
131    /// Transient mid-stream disconnects (timeouts, 5xx, 429) trigger a
132    /// reconnect with exponential backoff capped at `MAX_RECONNECT_BACKOFF`,
133    /// re-sending the original request body. Dropping the returned stream
134    /// cancels the underlying connection.
135    pub async fn chat_complete_stream(
136        &self,
137        mut req: ChatCompletionRequest,
138    ) -> Result<EventStream<ChatCompletionResponse>> {
139        req.stream = Some(true);
140        apply_model_suffix(&mut req.model, &mut req.provider);
141        self.open_event_stream("chat/completions", &req).await
142    }
143
144    /// Open a streaming legacy completions request. Semantics mirror
145    /// [`Client::chat_complete_stream`]; chunks deserialize into
146    /// `CompletionResponse` with the streaming `text` delta on each choice.
147    pub async fn complete_stream(
148        &self,
149        mut req: CompletionRequest,
150    ) -> Result<EventStream<CompletionResponse>> {
151        req.stream = Some(true);
152        apply_model_suffix(&mut req.model, &mut req.provider);
153        self.open_event_stream("completions", &req).await
154    }
155
156    /// List available models on OpenRouter.
157    ///
158    /// `GET /models`. Supports an optional category filter
159    /// ([`ListModelsOptions::category`]) and supported-parameter filter.
160    /// The `supported_parameters` field of each [`crate::Model`] is the union
161    /// of parameters across all providers — a single provider may not offer
162    /// every listed parameter.
163    pub async fn list_models(&self, opts: Option<&ListModelsOptions>) -> Result<ModelsResponse> {
164        let query = opts.map(ListModelsOptions::to_query).unwrap_or_default();
165        request::execute_json_get(self, "models", &query).await
166    }
167
168    /// List per-provider endpoints for a single model.
169    ///
170    /// `GET /models/{author}/{slug}/endpoints`. The response includes pricing,
171    /// status, context length, uptime, quantization, and supported parameters
172    /// for every provider serving the model — useful for routing or price
173    /// comparison.
174    pub async fn list_model_endpoints(
175        &self,
176        author: &str,
177        slug: &str,
178    ) -> Result<ModelEndpointsResponse> {
179        if author.is_empty() {
180            return Err(Error::InvalidInput("author cannot be empty"));
181        }
182        if slug.is_empty() {
183            return Err(Error::InvalidInput("slug cannot be empty"));
184        }
185        let path = format!(
186            "models/{}/{}/endpoints",
187            percent_encode_segment(author),
188            percent_encode_segment(slug),
189        );
190        request::execute_json_get(self, &path, &[]).await
191    }
192
193    /// List all providers available through OpenRouter.
194    ///
195    /// `GET /providers`. Returns the provider name, slug, and policy /
196    /// status-page URLs (when published).
197    pub async fn list_providers(&self) -> Result<ProvidersResponse> {
198        request::execute_json_get(self, "providers", &[]).await
199    }
200
201    /// Retrieve the authenticated user's purchased credits and total usage.
202    ///
203    /// `GET /credits`. Use [`crate::CreditsData::remaining`] for the available
204    /// balance.
205    pub async fn get_credits(&self) -> Result<CreditsResponse> {
206        request::execute_json_get(self, "credits", &[]).await
207    }
208
209    /// Retrieve metadata about the currently authenticated API key.
210    ///
211    /// `GET /key`. Returns label, configured spend limit, current usage,
212    /// remaining balance, free-tier flag, provisioning-key flag, and any
213    /// configured rate limit.
214    pub async fn get_key(&self) -> Result<KeyResponse> {
215        request::execute_json_get(self, "key", &[]).await
216    }
217
218    /// Daily activity grouped by model endpoint for the last 30 completed UTC days.
219    ///
220    /// `GET /activity`. **Requires a provisioning key** — using a regular
221    /// inference key returns 401. If [`ActivityOptions::date`] is set
222    /// (`YYYY-MM-DD`), results are filtered to that single UTC day.
223    ///
224    /// When ingesting on a schedule, wait ~30 minutes past the UTC boundary
225    /// before requesting the previous day: events are aggregated by request
226    /// start time, and some reasoning models take a few minutes to complete.
227    pub async fn get_activity(&self, opts: Option<&ActivityOptions>) -> Result<ActivityResponse> {
228        let query = opts.map(ActivityOptions::to_query).unwrap_or_default();
229        request::execute_json_get(self, "activity", &query).await
230    }
231
232    /// List all API keys on the account.
233    ///
234    /// `GET /keys`. **Requires a provisioning key.** Supports `offset` and
235    /// `include_disabled` filters via [`ListKeysOptions`].
236    pub async fn list_keys(&self, opts: Option<&ListKeysOptions>) -> Result<ListKeysResponse> {
237        let query = opts
238            .copied()
239            .map(ListKeysOptions::to_query)
240            .unwrap_or_default();
241        request::execute_json_get(self, "keys", &query).await
242    }
243
244    /// Look up a single API key by its `hash` (returned from
245    /// [`Client::list_keys`] or [`Client::create_key`]).
246    ///
247    /// `GET /keys/{hash}`. **Requires a provisioning key.**
248    pub async fn get_key_by_hash(&self, hash: &str) -> Result<GetKeyByHashResponse> {
249        if hash.is_empty() {
250            return Err(Error::InvalidInput("hash cannot be empty"));
251        }
252        let path = format!("keys/{}", percent_encode_segment(hash));
253        request::execute_json_get(self, &path, &[]).await
254    }
255
256    /// Create a new API key.
257    ///
258    /// `POST /keys`. **Requires a provisioning key.** The plaintext key is
259    /// returned **only once** in [`CreateKeyResponse::key`] — store it
260    /// immediately, it cannot be recovered later.
261    pub async fn create_key(&self, req: &CreateKeyRequest) -> Result<CreateKeyResponse> {
262        if req.name.is_empty() {
263            return Err(Error::InvalidInput("name is required"));
264        }
265        request::execute_json(self, "keys", req).await
266    }
267
268    /// Update an existing API key by hash. Pass only the fields you want to
269    /// change on [`UpdateKeyRequest`].
270    ///
271    /// `PATCH /keys/{hash}`. **Requires a provisioning key.**
272    pub async fn update_key(
273        &self,
274        hash: &str,
275        req: &UpdateKeyRequest,
276    ) -> Result<UpdateKeyResponse> {
277        if hash.is_empty() {
278            return Err(Error::InvalidInput("hash cannot be empty"));
279        }
280        let path = format!("keys/{}", percent_encode_segment(hash));
281        request::execute_json_method(self, reqwest::Method::PATCH, &path, Some(req)).await
282    }
283
284    /// Delete an API key by hash.
285    ///
286    /// `DELETE /keys/{hash}`. **Requires a provisioning key.** This operation
287    /// is irreversible — the deleted key cannot be restored, and any clients
288    /// still using it will immediately start receiving 401s.
289    pub async fn delete_key(&self, hash: &str) -> Result<DeleteKeyResponse> {
290        if hash.is_empty() {
291            return Err(Error::InvalidInput("hash cannot be empty"));
292        }
293        let path = format!("keys/{}", percent_encode_segment(hash));
294        request::execute_json_method::<(), _>(self, reqwest::Method::DELETE, &path, None).await
295    }
296
297    /// List guardrails for the organization.
298    ///
299    /// `GET /guardrails`. **Requires a provisioning key.**
300    pub async fn list_guardrails(
301        &self,
302        opts: Option<&ListGuardrailsOptions>,
303    ) -> Result<ListGuardrailsResponse> {
304        let query = opts
305            .copied()
306            .map(ListGuardrailsOptions::to_query)
307            .unwrap_or_default();
308        request::execute_json_get(self, "guardrails", &query).await
309    }
310
311    /// Create a new guardrail. `name` is required.
312    ///
313    /// `POST /guardrails`. **Requires a provisioning key.**
314    pub async fn create_guardrail(&self, req: &CreateGuardrailRequest) -> Result<Guardrail> {
315        if req.name.is_empty() {
316            return Err(Error::InvalidInput("name is required"));
317        }
318        request::execute_json(self, "guardrails", req).await
319    }
320
321    /// Fetch a single guardrail by ID.
322    ///
323    /// `GET /guardrails/{id}`. **Requires a provisioning key.**
324    pub async fn get_guardrail(&self, id: &str) -> Result<Guardrail> {
325        if id.is_empty() {
326            return Err(Error::InvalidInput("id cannot be empty"));
327        }
328        let path = format!("guardrails/{}", percent_encode_segment(id));
329        request::execute_json_get(self, &path, &[]).await
330    }
331
332    /// Update an existing guardrail. Pass only the fields you want to change
333    /// on [`UpdateGuardrailRequest`].
334    ///
335    /// `PATCH /guardrails/{id}`. **Requires a provisioning key.**
336    pub async fn update_guardrail(
337        &self,
338        id: &str,
339        req: &UpdateGuardrailRequest,
340    ) -> Result<Guardrail> {
341        if id.is_empty() {
342            return Err(Error::InvalidInput("id cannot be empty"));
343        }
344        let path = format!("guardrails/{}", percent_encode_segment(id));
345        request::execute_json_method(self, reqwest::Method::PATCH, &path, Some(req)).await
346    }
347
348    /// Delete a guardrail by ID. **Irreversible.**
349    ///
350    /// `DELETE /guardrails/{id}`. **Requires a provisioning key.**
351    pub async fn delete_guardrail(&self, id: &str) -> Result<DeleteGuardrailResponse> {
352        if id.is_empty() {
353            return Err(Error::InvalidInput("id cannot be empty"));
354        }
355        let path = format!("guardrails/{}", percent_encode_segment(id));
356        request::execute_json_method::<(), _>(self, reqwest::Method::DELETE, &path, None).await
357    }
358
359    /// List key assignments across all guardrails.
360    ///
361    /// `GET /guardrails/key-assignments`. **Requires a provisioning key.**
362    pub async fn list_all_guardrail_key_assignments(
363        &self,
364        opts: Option<&ListGuardrailsOptions>,
365    ) -> Result<ListGuardrailKeyAssignmentsResponse> {
366        let query = opts
367            .copied()
368            .map(ListGuardrailsOptions::to_query)
369            .unwrap_or_default();
370        request::execute_json_get(self, "guardrails/key-assignments", &query).await
371    }
372
373    /// List key assignments for a specific guardrail.
374    ///
375    /// `GET /guardrails/{id}/key-assignments`. **Requires a provisioning key.**
376    pub async fn list_guardrail_key_assignments(
377        &self,
378        id: &str,
379        opts: Option<&ListGuardrailsOptions>,
380    ) -> Result<ListGuardrailKeyAssignmentsResponse> {
381        if id.is_empty() {
382            return Err(Error::InvalidInput("id cannot be empty"));
383        }
384        let path = format!("guardrails/{}/key-assignments", percent_encode_segment(id));
385        let query = opts
386            .copied()
387            .map(ListGuardrailsOptions::to_query)
388            .unwrap_or_default();
389        request::execute_json_get(self, &path, &query).await
390    }
391
392    /// Assign API keys (by hash) to a guardrail.
393    ///
394    /// `POST /guardrails/{id}/key-assignments`. **Requires a provisioning
395    /// key.**
396    pub async fn assign_keys_to_guardrail(
397        &self,
398        id: &str,
399        req: &AssignKeysRequest,
400    ) -> Result<AssignKeysResponse> {
401        if id.is_empty() {
402            return Err(Error::InvalidInput("id cannot be empty"));
403        }
404        if req.key_hashes.is_empty() {
405            return Err(Error::InvalidInput("key_hashes cannot be empty"));
406        }
407        let path = format!("guardrails/{}/key-assignments", percent_encode_segment(id));
408        request::execute_json(self, &path, req).await
409    }
410
411    /// Remove key assignments from a guardrail.
412    ///
413    /// `DELETE /guardrails/{id}/key-assignments` (with body). **Requires a
414    /// provisioning key.**
415    pub async fn unassign_keys_from_guardrail(
416        &self,
417        id: &str,
418        req: &AssignKeysRequest,
419    ) -> Result<()> {
420        if id.is_empty() {
421            return Err(Error::InvalidInput("id cannot be empty"));
422        }
423        if req.key_hashes.is_empty() {
424            return Err(Error::InvalidInput("key_hashes cannot be empty"));
425        }
426        let path = format!("guardrails/{}/key-assignments", percent_encode_segment(id));
427        request::execute_no_content_method(self, reqwest::Method::DELETE, &path, Some(req)).await
428    }
429
430    /// List member assignments across all guardrails.
431    ///
432    /// `GET /guardrails/member-assignments`. **Requires a provisioning key.**
433    pub async fn list_all_guardrail_member_assignments(
434        &self,
435        opts: Option<&ListGuardrailsOptions>,
436    ) -> Result<ListGuardrailMemberAssignmentsResponse> {
437        let query = opts
438            .copied()
439            .map(ListGuardrailsOptions::to_query)
440            .unwrap_or_default();
441        request::execute_json_get(self, "guardrails/member-assignments", &query).await
442    }
443
444    /// List member assignments for a specific guardrail.
445    ///
446    /// `GET /guardrails/{id}/member-assignments`. **Requires a provisioning
447    /// key.**
448    pub async fn list_guardrail_member_assignments(
449        &self,
450        id: &str,
451        opts: Option<&ListGuardrailsOptions>,
452    ) -> Result<ListGuardrailMemberAssignmentsResponse> {
453        if id.is_empty() {
454            return Err(Error::InvalidInput("id cannot be empty"));
455        }
456        let path = format!(
457            "guardrails/{}/member-assignments",
458            percent_encode_segment(id)
459        );
460        let query = opts
461            .copied()
462            .map(ListGuardrailsOptions::to_query)
463            .unwrap_or_default();
464        request::execute_json_get(self, &path, &query).await
465    }
466
467    /// Assign organization members (by user id) to a guardrail.
468    ///
469    /// `POST /guardrails/{id}/member-assignments`. **Requires a provisioning
470    /// key.**
471    pub async fn assign_members_to_guardrail(
472        &self,
473        id: &str,
474        req: &AssignMembersRequest,
475    ) -> Result<AssignMembersResponse> {
476        if id.is_empty() {
477            return Err(Error::InvalidInput("id cannot be empty"));
478        }
479        if req.member_user_ids.is_empty() {
480            return Err(Error::InvalidInput("member_user_ids cannot be empty"));
481        }
482        let path = format!(
483            "guardrails/{}/member-assignments",
484            percent_encode_segment(id)
485        );
486        request::execute_json(self, &path, req).await
487    }
488
489    /// Remove member assignments from a guardrail.
490    ///
491    /// `DELETE /guardrails/{id}/member-assignments` (with body). **Requires a
492    /// provisioning key.**
493    pub async fn unassign_members_from_guardrail(
494        &self,
495        id: &str,
496        req: &AssignMembersRequest,
497    ) -> Result<()> {
498        if id.is_empty() {
499            return Err(Error::InvalidInput("id cannot be empty"));
500        }
501        if req.member_user_ids.is_empty() {
502            return Err(Error::InvalidInput("member_user_ids cannot be empty"));
503        }
504        let path = format!(
505            "guardrails/{}/member-assignments",
506            percent_encode_segment(id)
507        );
508        request::execute_no_content_method(self, reqwest::Method::DELETE, &path, Some(req)).await
509    }
510
511    /// Submit a new video generation job.
512    ///
513    /// `POST /videos`. Returns the initial response (job id, polling URL,
514    /// status). Poll [`Client::get_video`] until
515    /// [`crate::VideoStatus::is_terminal`] returns true, or use
516    /// [`Client::wait_for_video`]. `model` and `prompt` are required.
517    pub async fn create_video(
518        &self,
519        req: &VideoGenerationRequest,
520    ) -> Result<VideoGenerationResponse> {
521        if req.model.is_empty() {
522            return Err(Error::InvalidInput("model is required"));
523        }
524        if req.prompt.is_empty() {
525            return Err(Error::InvalidInput("prompt is required"));
526        }
527        request::execute_json(self, "videos", req).await
528    }
529
530    /// Fetch the current status of a video generation job.
531    ///
532    /// `GET /videos/{job_id}`.
533    pub async fn get_video(&self, job_id: &str) -> Result<VideoGenerationResponse> {
534        if job_id.is_empty() {
535            return Err(Error::InvalidInput("job_id cannot be empty"));
536        }
537        let path = format!("videos/{}", percent_encode_segment(job_id));
538        request::execute_json_get(self, &path, &[]).await
539    }
540
541    /// Download the generated video bytes for a completed job.
542    ///
543    /// `GET /videos/{job_id}/content`. Pass `index = 0` for the default
544    /// output; non-zero `index` selects an additional output when the
545    /// provider produced multiple videos. Returns the bytes plus the
546    /// upstream `Content-Type` (typically `application/octet-stream`).
547    pub async fn get_video_content(
548        &self,
549        job_id: &str,
550        index: u32,
551    ) -> Result<VideoContentResponse> {
552        if job_id.is_empty() {
553            return Err(Error::InvalidInput("job_id cannot be empty"));
554        }
555        let path = format!("videos/{}/content", percent_encode_segment(job_id));
556        let query: Vec<(&'static str, String)> = if index > 0 {
557            vec![("index", index.to_string())]
558        } else {
559            Vec::new()
560        };
561        let (content, content_type) = request::execute_bytes_get(self, &path, &query).await?;
562        Ok(VideoContentResponse {
563            content,
564            content_type,
565        })
566    }
567
568    /// List the video generation models available through OpenRouter,
569    /// including each model's supported aspect ratios, resolutions,
570    /// durations, and pricing SKUs.
571    ///
572    /// `GET /videos/models`.
573    pub async fn list_video_models(&self) -> Result<VideoModelsResponse> {
574        request::execute_json_get(self, "videos/models", &[]).await
575    }
576
577    /// Poll [`Client::get_video`] until the job reaches a terminal status.
578    ///
579    /// Sleeps `interval` between polls. Returns the final response. The
580    /// caller is responsible for any overall timeout — wrap this in a
581    /// [`tokio::time::timeout`] if you need one.
582    pub async fn wait_for_video(
583        &self,
584        job_id: &str,
585        interval: Duration,
586    ) -> Result<VideoGenerationResponse> {
587        loop {
588            let resp = self.get_video(job_id).await?;
589            if resp.status.is_terminal() {
590                return Ok(resp);
591            }
592            tokio::time::sleep(interval).await;
593        }
594    }
595
596    /// Synthesize speech audio from text.
597    ///
598    /// `POST /audio/speech`. Returns the raw audio bytes alongside the
599    /// upstream `Content-Type` and the resolved format. `input`, `model`,
600    /// and `voice` must be non-empty. The format defaults to PCM upstream
601    /// when [`SpeechRequest::response_format`] is unset.
602    pub async fn create_speech(&self, req: &SpeechRequest) -> Result<SpeechResponse> {
603        if req.input.is_empty() {
604            return Err(Error::InvalidInput("input is required"));
605        }
606        if req.model.is_empty() {
607            return Err(Error::InvalidInput("model is required"));
608        }
609        if req.voice.is_empty() {
610            return Err(Error::InvalidInput("voice is required"));
611        }
612        let (audio, content_type) = request::execute_bytes_post(self, "audio/speech", req).await?;
613        let format = req.response_format.unwrap_or(SpeechFormat::Pcm);
614        Ok(SpeechResponse {
615            audio,
616            content_type,
617            format,
618        })
619    }
620
621    /// Rerank documents against a query using a reranking model
622    /// (e.g. `cohere/rerank-v3.5`).
623    ///
624    /// `POST /rerank`. Returns results sorted by descending relevance score.
625    /// `model`, `query`, and at least one document are required.
626    pub async fn rerank(&self, req: &RerankRequest) -> Result<RerankResponse> {
627        if req.model.is_empty() {
628            return Err(Error::InvalidInput("model is required"));
629        }
630        if req.query.is_empty() {
631            return Err(Error::InvalidInput("query is required"));
632        }
633        if req.documents.is_empty() {
634            return Err(Error::InvalidInput("documents must not be empty"));
635        }
636        request::execute_json(self, "rerank", req).await
637    }
638
639    /// List endpoints compatible with Zero Data Retention.
640    ///
641    /// `GET /endpoints/zdr`. Returns the endpoints that honor ZDR across all
642    /// providers — useful as a preview before enforcing ZDR on a guardrail or
643    /// key. No authentication tier requirement beyond a normal API key.
644    pub async fn list_zdr_endpoints(&self) -> Result<ZdrEndpointsResponse> {
645        request::execute_json_get(self, "endpoints/zdr", &[]).await
646    }
647
648    /// List members of the organization associated with the authenticated
649    /// management key.
650    ///
651    /// `GET /organization/members`. **Requires a provisioning key.** Supports
652    /// `offset` / `limit` pagination via [`ListOrganizationMembersOptions`].
653    pub async fn list_organization_members(
654        &self,
655        opts: Option<&ListOrganizationMembersOptions>,
656    ) -> Result<ListOrganizationMembersResponse> {
657        let query = opts
658            .copied()
659            .map(ListOrganizationMembersOptions::to_query)
660            .unwrap_or_default();
661        request::execute_json_get(self, "organization/members", &query).await
662    }
663
664    /// List workspaces on the organization.
665    ///
666    /// `GET /workspaces`. **Requires a provisioning (management) API key.**
667    /// Supports `offset` / `limit` pagination via [`ListWorkspacesOptions`].
668    pub async fn list_workspaces(
669        &self,
670        opts: Option<&ListWorkspacesOptions>,
671    ) -> Result<ListWorkspacesResponse> {
672        let query = opts
673            .copied()
674            .map(ListWorkspacesOptions::to_query)
675            .unwrap_or_default();
676        request::execute_json_get(self, "workspaces", &query).await
677    }
678
679    /// Create a new workspace.
680    ///
681    /// `POST /workspaces`. **Requires a provisioning key.** `name` and `slug`
682    /// must be non-empty.
683    pub async fn create_workspace(
684        &self,
685        req: &CreateWorkspaceRequest,
686    ) -> Result<CreateWorkspaceResponse> {
687        if req.name.is_empty() {
688            return Err(Error::InvalidInput("name is required"));
689        }
690        if req.slug.is_empty() {
691            return Err(Error::InvalidInput("slug is required"));
692        }
693        request::execute_json(self, "workspaces", req).await
694    }
695
696    /// Fetch a single workspace by UUID or slug.
697    ///
698    /// `GET /workspaces/{id_or_slug}`. **Requires a provisioning key.**
699    pub async fn get_workspace(&self, id_or_slug: &str) -> Result<GetWorkspaceResponse> {
700        if id_or_slug.is_empty() {
701            return Err(Error::InvalidInput("id_or_slug cannot be empty"));
702        }
703        let path = format!("workspaces/{}", percent_encode_segment(id_or_slug));
704        request::execute_json_get(self, &path, &[]).await
705    }
706
707    /// Update an existing workspace by UUID or slug. Pass only the fields you
708    /// want to change on [`UpdateWorkspaceRequest`].
709    ///
710    /// `PATCH /workspaces/{id_or_slug}`. **Requires a provisioning key.**
711    pub async fn update_workspace(
712        &self,
713        id_or_slug: &str,
714        req: &UpdateWorkspaceRequest,
715    ) -> Result<UpdateWorkspaceResponse> {
716        if id_or_slug.is_empty() {
717            return Err(Error::InvalidInput("id_or_slug cannot be empty"));
718        }
719        let path = format!("workspaces/{}", percent_encode_segment(id_or_slug));
720        request::execute_json_method(self, reqwest::Method::PATCH, &path, Some(req)).await
721    }
722
723    /// Delete a workspace by UUID or slug.
724    ///
725    /// `DELETE /workspaces/{id_or_slug}`. **Requires a provisioning key.** The
726    /// default workspace cannot be deleted, and any workspace with active API
727    /// keys returns an error.
728    pub async fn delete_workspace(&self, id_or_slug: &str) -> Result<DeleteWorkspaceResponse> {
729        if id_or_slug.is_empty() {
730            return Err(Error::InvalidInput("id_or_slug cannot be empty"));
731        }
732        let path = format!("workspaces/{}", percent_encode_segment(id_or_slug));
733        request::execute_json_method::<(), _>(self, reqwest::Method::DELETE, &path, None).await
734    }
735
736    /// Bulk-add organization members to a workspace. Members are assigned the
737    /// same role they hold in the organization.
738    ///
739    /// `POST /workspaces/{id_or_slug}/members/add`. **Requires a provisioning
740    /// key.**
741    pub async fn add_workspace_members(
742        &self,
743        id_or_slug: &str,
744        user_ids: &[String],
745    ) -> Result<BulkAddWorkspaceMembersResponse> {
746        if id_or_slug.is_empty() {
747            return Err(Error::InvalidInput("id_or_slug cannot be empty"));
748        }
749        if user_ids.is_empty() {
750            return Err(Error::InvalidInput("user_ids cannot be empty"));
751        }
752        let path = format!(
753            "workspaces/{}/members/add",
754            percent_encode_segment(id_or_slug)
755        );
756        let body = BulkWorkspaceMembersRequest { user_ids };
757        request::execute_json(self, &path, &body).await
758    }
759
760    /// Bulk-remove members from a workspace. Members with active API keys in
761    /// the workspace cannot be removed.
762    ///
763    /// `POST /workspaces/{id_or_slug}/members/remove`. **Requires a
764    /// provisioning key.**
765    pub async fn remove_workspace_members(
766        &self,
767        id_or_slug: &str,
768        user_ids: &[String],
769    ) -> Result<BulkRemoveWorkspaceMembersResponse> {
770        if id_or_slug.is_empty() {
771            return Err(Error::InvalidInput("id_or_slug cannot be empty"));
772        }
773        if user_ids.is_empty() {
774            return Err(Error::InvalidInput("user_ids cannot be empty"));
775        }
776        let path = format!(
777            "workspaces/{}/members/remove",
778            percent_encode_segment(id_or_slug)
779        );
780        let body = BulkWorkspaceMembersRequest { user_ids };
781        request::execute_json(self, &path, &body).await
782    }
783
784    /// Internal: serialize the request once, open the first stream, and build
785    /// a reconnect closure that re-issues the same body on transient failure.
786    pub(crate) async fn open_event_stream<Req, Resp>(
787        &self,
788        path: &'static str,
789        req: &Req,
790    ) -> Result<EventStream<Resp>>
791    where
792        Req: serde::Serialize + ?Sized,
793        Resp: serde::de::DeserializeOwned,
794    {
795        let body_bytes = serde_json::to_vec(req)?;
796        let initial = request::open_stream_bytes(self, path, body_bytes.clone()).await?;
797        let client = self.clone();
798        #[cfg(not(target_arch = "wasm32"))]
799        let reopen: crate::stream::Reopen = Arc::new(move || {
800            let client = client.clone();
801            let body_bytes = body_bytes.clone();
802            async move { request::open_stream_bytes(&client, path, body_bytes).await }.boxed()
803        });
804        #[cfg(target_arch = "wasm32")]
805        let reopen: crate::stream::Reopen = Rc::new(move || {
806            let client = client.clone();
807            let body_bytes = body_bytes.clone();
808            async move { request::open_stream_bytes(&client, path, body_bytes).await }.boxed_local()
809        });
810        Ok(EventStream::new(
811            initial,
812            reopen,
813            self.inner.stream_reconnects,
814        ))
815    }
816}
817
818/// Builder for [`Client`].
819#[derive(Debug, Default)]
820pub struct ClientBuilder {
821    api_key: Option<String>,
822    base_url: Option<Url>,
823    http_client: Option<reqwest::Client>,
824    timeout: Option<Duration>,
825    retry: Option<RetryConfig>,
826    stream_reconnects: Option<u32>,
827    app_name: Option<String>,
828    referer: Option<String>,
829}
830
831impl ClientBuilder {
832    /// Set the API key (required).
833    pub fn api_key(mut self, key: impl Into<String>) -> Self {
834        self.api_key = Some(key.into());
835        self
836    }
837
838    /// Override the base URL. Must be an absolute URL ending in `/`.
839    pub fn base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
840        let mut parsed = Url::parse(url.as_ref())
841            .map_err(|_| Error::InvalidInput("base_url is not a valid URL"))?;
842        if !parsed.path().ends_with('/') {
843            let new_path = format!("{}/", parsed.path());
844            parsed.set_path(&new_path);
845        }
846        self.base_url = Some(parsed);
847        Ok(self)
848    }
849
850    /// Supply a pre-configured `reqwest::Client`. When set, [`Self::timeout`]
851    /// is ignored — configure it on the supplied client instead.
852    pub fn http_client(mut self, client: reqwest::Client) -> Self {
853        self.http_client = Some(client);
854        self
855    }
856
857    /// Request timeout (used only when no custom `http_client` is supplied).
858    pub fn timeout(mut self, d: Duration) -> Self {
859        self.timeout = Some(d);
860        self
861    }
862
863    /// Configure retries with a max attempt count and base delay.
864    pub fn retry(mut self, max: u32, base_delay: Duration) -> Self {
865        let cfg = RetryConfig {
866            max_retries: max,
867            initial_delay: base_delay,
868            ..RetryConfig::default()
869        };
870        self.retry = Some(cfg);
871        self
872    }
873
874    /// Supply a fully-specified [`RetryConfig`].
875    pub fn retry_config(mut self, cfg: RetryConfig) -> Self {
876        self.retry = Some(cfg);
877        self
878    }
879
880    /// Set the number of reconnect attempts after a transient stream failure.
881    ///
882    /// Set this to zero when retrying a streamed generation could duplicate
883    /// output or cost. The default is three, matching `openrouter-go`.
884    pub fn stream_reconnects(mut self, max: u32) -> Self {
885        self.stream_reconnects = Some(max);
886        self
887    }
888
889    /// App attribution: sent as `X-Title` by the request layer.
890    pub fn app_name(mut self, name: impl Into<String>) -> Self {
891        self.app_name = Some(name.into());
892        self
893    }
894
895    /// Referer attribution: sent as `HTTP-Referer` by the request layer.
896    pub fn referer(mut self, referer: impl Into<String>) -> Self {
897        self.referer = Some(referer.into());
898        self
899    }
900
901    /// Finalize and produce a [`Client`].
902    pub fn build(self) -> Result<Client> {
903        let api_key = self.api_key.ok_or(Error::MissingField("api_key"))?;
904        if api_key.is_empty() {
905            return Err(Error::InvalidInput("api_key must not be empty"));
906        }
907        let base_url = match self.base_url {
908            Some(u) => u,
909            None => Url::parse(DEFAULT_BASE_URL).expect("DEFAULT_BASE_URL is a valid URL"),
910        };
911        let http = match self.http_client {
912            Some(c) => c,
913            None => {
914                #[cfg(not(target_arch = "wasm32"))]
915                let mut b = reqwest::Client::builder();
916                #[cfg(target_arch = "wasm32")]
917                let b = reqwest::Client::builder();
918                #[cfg(not(target_arch = "wasm32"))]
919                if let Some(t) = self.timeout {
920                    b = b.timeout(t);
921                }
922                #[cfg(target_arch = "wasm32")]
923                let _ = self.timeout;
924                b.build().map_err(Error::Http)?
925            }
926        };
927        let retry = self.retry.unwrap_or_default();
928        let stream_reconnects = self.stream_reconnects.unwrap_or(DEFAULT_STREAM_RECONNECTS);
929        Ok(Client {
930            inner: Arc::new(ClientInner {
931                api_key,
932                base_url,
933                http,
934                retry,
935                stream_reconnects,
936                app_name: self.app_name,
937                referer: self.referer,
938            }),
939        })
940    }
941}
942
943/// Percent-encode a single URL path segment.
944///
945/// Encodes everything outside the unreserved set (RFC 3986 §2.3) plus `/`,
946/// which is enough for OpenRouter identifiers (author slug, model slug, key
947/// hash). Avoids pulling in `percent-encoding` for a few-byte helper.
948pub(crate) fn percent_encode_segment(s: &str) -> String {
949    let mut out = String::with_capacity(s.len());
950    for &b in s.as_bytes() {
951        let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
952        if unreserved {
953            out.push(b as char);
954        } else {
955            out.push('%');
956            out.push_str(&format!("{b:02X}"));
957        }
958    }
959    out
960}
961
962/// Strip a `:nitro` or `:floor` suffix from `model` and project it onto
963/// `provider.sort` (`throughput` / `price` respectively). A caller-set
964/// `provider.sort` always wins — the suffix never overrides it.
965pub(crate) fn apply_model_suffix(model: &mut String, provider: &mut Option<Provider>) {
966    let sort = if let Some(stripped) = model.strip_suffix(":nitro") {
967        let new_model = stripped.to_string();
968        *model = new_model;
969        "throughput"
970    } else if let Some(stripped) = model.strip_suffix(":floor") {
971        let new_model = stripped.to_string();
972        *model = new_model;
973        "price"
974    } else {
975        return;
976    };
977    let p = provider.get_or_insert_with(Provider::default);
978    if p.sort.is_none() {
979        p.sort = Some(sort.to_string());
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    fn assert_send_sync<T: Send + Sync>() {}
988
989    #[test]
990    fn client_is_send_sync() {
991        assert_send_sync::<Client>();
992    }
993
994    #[test]
995    fn builder_happy_path() {
996        let c = Client::builder()
997            .api_key("sk-test")
998            .app_name("demo")
999            .referer("https://demo.example")
1000            .timeout(Duration::from_secs(10))
1001            .build()
1002            .unwrap();
1003        assert_eq!(c.api_key(), "sk-test");
1004        assert_eq!(c.app_name(), Some("demo"));
1005        assert_eq!(c.referer(), Some("https://demo.example"));
1006        assert_eq!(c.base_url().as_str(), DEFAULT_BASE_URL);
1007        assert_eq!(c.stream_reconnects(), DEFAULT_STREAM_RECONNECTS);
1008    }
1009
1010    #[test]
1011    fn stream_reconnects_can_be_disabled() {
1012        let c = Client::builder()
1013            .api_key("sk-test")
1014            .stream_reconnects(0)
1015            .build()
1016            .unwrap();
1017        assert_eq!(c.stream_reconnects(), 0);
1018    }
1019
1020    #[test]
1021    fn missing_api_key_errors() {
1022        let err = Client::builder().build().unwrap_err();
1023        assert!(matches!(err, Error::MissingField("api_key")));
1024    }
1025
1026    #[test]
1027    fn empty_api_key_errors() {
1028        let err = Client::builder().api_key("").build().unwrap_err();
1029        assert!(matches!(err, Error::InvalidInput(_)));
1030    }
1031
1032    #[test]
1033    fn invalid_base_url_errors() {
1034        let err = Client::builder().base_url("not a url").unwrap_err();
1035        assert!(matches!(err, Error::InvalidInput(_)));
1036    }
1037
1038    #[test]
1039    fn base_url_path_gains_trailing_slash() {
1040        let c = Client::builder()
1041            .api_key("k")
1042            .base_url("https://example.com/v2")
1043            .unwrap()
1044            .build()
1045            .unwrap();
1046        assert!(c.base_url().as_str().ends_with('/'));
1047    }
1048
1049    #[test]
1050    fn clone_shares_inner() {
1051        let c1 = Client::new("k").unwrap();
1052        let c2 = c1.clone();
1053        assert!(Arc::ptr_eq(&c1.inner, &c2.inner));
1054    }
1055
1056    #[test]
1057    fn retry_helper_sets_fields() {
1058        let c = Client::builder()
1059            .api_key("k")
1060            .retry(7, Duration::from_millis(250))
1061            .build()
1062            .unwrap();
1063        assert_eq!(c.retry().max_retries, 7);
1064        assert_eq!(c.retry().initial_delay, Duration::from_millis(250));
1065    }
1066
1067    #[test]
1068    fn nitro_suffix_maps_to_throughput_sort() {
1069        let mut m = "openai/gpt-4o:nitro".to_string();
1070        let mut p = None;
1071        apply_model_suffix(&mut m, &mut p);
1072        assert_eq!(m, "openai/gpt-4o");
1073        assert_eq!(p.unwrap().sort.as_deref(), Some("throughput"));
1074    }
1075
1076    #[test]
1077    fn floor_suffix_maps_to_price_sort() {
1078        let mut m = "anthropic/claude-3:floor".to_string();
1079        let mut p = None;
1080        apply_model_suffix(&mut m, &mut p);
1081        assert_eq!(m, "anthropic/claude-3");
1082        assert_eq!(p.unwrap().sort.as_deref(), Some("price"));
1083    }
1084
1085    #[test]
1086    fn caller_set_sort_wins_over_suffix() {
1087        let mut m = "openai/gpt-4o:nitro".to_string();
1088        let mut p = Some(Provider {
1089            sort: Some("latency".to_string()),
1090            ..Provider::default()
1091        });
1092        apply_model_suffix(&mut m, &mut p);
1093        assert_eq!(m, "openai/gpt-4o");
1094        assert_eq!(p.unwrap().sort.as_deref(), Some("latency"));
1095    }
1096
1097    #[test]
1098    fn unknown_suffix_passes_through() {
1099        let mut m = "openai/gpt-4o:exotic".to_string();
1100        let mut p = None;
1101        apply_model_suffix(&mut m, &mut p);
1102        assert_eq!(m, "openai/gpt-4o:exotic");
1103        assert!(p.is_none());
1104    }
1105}