Skip to main content

openrouter/
client.rs

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