1use 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#[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 pub fn builder() -> ClientBuilder {
53 ClientBuilder::default()
54 }
55
56 pub fn new(api_key: impl Into<String>) -> Result<Self> {
58 Self::builder().api_key(api_key).build()
59 }
60
61 pub fn api_key(&self) -> &str {
63 &self.inner.api_key
64 }
65
66 pub fn base_url(&self) -> &Url {
68 &self.inner.base_url
69 }
70
71 pub fn http(&self) -> &reqwest::Client {
73 &self.inner.http
74 }
75
76 pub fn retry(&self) -> &RetryConfig {
78 &self.inner.retry
79 }
80
81 pub fn app_name(&self) -> Option<&str> {
83 self.inner.app_name.as_deref()
84 }
85
86 pub fn referer(&self) -> Option<&str> {
88 self.inner.referer.as_deref()
89 }
90
91 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 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 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 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 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 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 pub async fn list_providers(&self) -> Result<ProvidersResponse> {
188 request::execute_json_get(self, "providers", &[]).await
189 }
190
191 pub async fn get_credits(&self) -> Result<CreditsResponse> {
196 request::execute_json_get(self, "credits", &[]).await
197 }
198
199 pub async fn get_key(&self) -> Result<KeyResponse> {
205 request::execute_json_get(self, "key", &[]).await
206 }
207
208 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn list_video_models(&self) -> Result<VideoModelsResponse> {
564 request::execute_json_get(self, "videos/models", &[]).await
565 }
566
567 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 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 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 pub async fn list_zdr_endpoints(&self) -> Result<ZdrEndpointsResponse> {
635 request::execute_json_get(self, "endpoints/zdr", &[]).await
636 }
637
638 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 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 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 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 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 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 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 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 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#[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 pub fn api_key(mut self, key: impl Into<String>) -> Self {
812 self.api_key = Some(key.into());
813 self
814 }
815
816 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 pub fn http_client(mut self, client: reqwest::Client) -> Self {
831 self.http_client = Some(client);
832 self
833 }
834
835 pub fn timeout(mut self, d: Duration) -> Self {
837 self.timeout = Some(d);
838 self
839 }
840
841 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 pub fn retry_config(mut self, cfg: RetryConfig) -> Self {
854 self.retry = Some(cfg);
855 self
856 }
857
858 pub fn app_name(mut self, name: impl Into<String>) -> Self {
860 self.app_name = Some(name.into());
861 self
862 }
863
864 pub fn referer(mut self, referer: impl Into<String>) -> Self {
866 self.referer = Some(referer.into());
867 self
868 }
869
870 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
904pub(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
923pub(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}