1use 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#[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 pub fn builder() -> ClientBuilder {
58 ClientBuilder::default()
59 }
60
61 pub fn new(api_key: impl Into<String>) -> Result<Self> {
63 Self::builder().api_key(api_key).build()
64 }
65
66 pub fn api_key(&self) -> &str {
68 &self.inner.api_key
69 }
70
71 pub fn base_url(&self) -> &Url {
73 &self.inner.base_url
74 }
75
76 pub fn http(&self) -> &reqwest::Client {
78 &self.inner.http
79 }
80
81 pub fn retry(&self) -> &RetryConfig {
83 &self.inner.retry
84 }
85
86 pub fn stream_reconnects(&self) -> u32 {
88 self.inner.stream_reconnects
89 }
90
91 pub fn app_name(&self) -> Option<&str> {
93 self.inner.app_name.as_deref()
94 }
95
96 pub fn referer(&self) -> Option<&str> {
98 self.inner.referer.as_deref()
99 }
100
101 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 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 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 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 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 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 pub async fn list_providers(&self) -> Result<ProvidersResponse> {
198 request::execute_json_get(self, "providers", &[]).await
199 }
200
201 pub async fn get_credits(&self) -> Result<CreditsResponse> {
206 request::execute_json_get(self, "credits", &[]).await
207 }
208
209 pub async fn get_key(&self) -> Result<KeyResponse> {
215 request::execute_json_get(self, "key", &[]).await
216 }
217
218 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn list_video_models(&self) -> Result<VideoModelsResponse> {
574 request::execute_json_get(self, "videos/models", &[]).await
575 }
576
577 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 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 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 pub async fn list_zdr_endpoints(&self) -> Result<ZdrEndpointsResponse> {
645 request::execute_json_get(self, "endpoints/zdr", &[]).await
646 }
647
648 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 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 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 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 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 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 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 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 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#[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 pub fn api_key(mut self, key: impl Into<String>) -> Self {
834 self.api_key = Some(key.into());
835 self
836 }
837
838 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 pub fn http_client(mut self, client: reqwest::Client) -> Self {
853 self.http_client = Some(client);
854 self
855 }
856
857 pub fn timeout(mut self, d: Duration) -> Self {
859 self.timeout = Some(d);
860 self
861 }
862
863 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 pub fn retry_config(mut self, cfg: RetryConfig) -> Self {
876 self.retry = Some(cfg);
877 self
878 }
879
880 pub fn stream_reconnects(mut self, max: u32) -> Self {
885 self.stream_reconnects = Some(max);
886 self
887 }
888
889 pub fn app_name(mut self, name: impl Into<String>) -> Self {
891 self.app_name = Some(name.into());
892 self
893 }
894
895 pub fn referer(mut self, referer: impl Into<String>) -> Self {
897 self.referer = Some(referer.into());
898 self
899 }
900
901 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
943pub(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
962pub(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}