1use crate::provider::Provider;
6
7pub const COHERE_URL: &str = "https://api.cohere.com/v2/audio/transcriptions";
9pub const DEFAULT_COHERE_MODEL: &str = "cohere-transcribe-arabic-07-2026";
13
14#[derive(Clone)]
19pub struct Endpoint {
20 pub url: String,
22 pub api_key: Option<String>,
24 pub model: String,
26 pub provider: Provider,
28}
29
30impl std::fmt::Debug for Endpoint {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("Endpoint")
39 .field("url", &crate::error::redact_url(&self.url))
40 .field("api_key", &self.api_key.as_ref().map(|_| "***"))
41 .field("model", &self.model)
42 .field("provider", &self.provider)
43 .finish()
44 }
45}
46
47impl Endpoint {
48 pub fn cohere(api_key: impl Into<String>, model: Option<String>) -> Self {
50 Endpoint {
51 url: COHERE_URL.to_string(),
52 api_key: Some(api_key.into()),
53 model: model.unwrap_or_else(|| DEFAULT_COHERE_MODEL.to_string()),
54 provider: Provider::Cohere,
55 }
56 }
57
58 pub fn openai_compatible(
65 url: impl Into<String>,
66 model: impl Into<String>,
67 api_key: Option<String>,
68 ) -> crate::Result<Self> {
69 let url = url.into();
70 if url.trim().is_empty() {
71 return Err(crate::Error::InvalidEndpoint {
72 url,
73 reason: "it is empty".to_string(),
74 });
75 }
76 let parsed = url::Url::parse(&url).map_err(|e| crate::Error::InvalidEndpoint {
77 url: crate::error::redact_url(&url),
78 reason: e.to_string(),
79 })?;
80 if !matches!(parsed.scheme(), "http" | "https") {
81 return Err(crate::Error::InvalidEndpoint {
82 url: crate::error::redact_url(&url),
83 reason: format!("scheme {:?} is not http or https", parsed.scheme()),
84 });
85 }
86 Ok(Endpoint {
87 url,
88 api_key,
89 model: model.into(),
90 provider: Provider::OpenAiCompatible,
91 })
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::Endpoint;
98
99 #[test]
100 fn debug_never_prints_the_api_key() {
101 let ep = Endpoint::cohere("sk-SECRET-DO-NOT-PRINT", None);
102 let shown = format!("{ep:?}");
103 assert!(!shown.contains("sk-SECRET-DO-NOT-PRINT"), "{shown}");
104 assert!(
105 shown.contains("***"),
106 "presence should still be visible: {shown}"
107 );
108 }
109
110 #[test]
111 fn debug_never_prints_url_credentials() {
112 let ep = Endpoint::openai_compatible("https://bob:hunter2@host/v1", "m", None).unwrap();
113 let shown = format!("{ep:?}");
114 assert!(!shown.contains("hunter2"), "{shown}");
115 }
116
117 #[test]
118 fn a_valid_url_is_accepted() {
119 let ep = Endpoint::openai_compatible("http://localhost:8000/v1", "m", None).unwrap();
120 assert_eq!(ep.url, "http://localhost:8000/v1");
121 }
122
123 #[test]
124 fn a_bad_url_is_rejected_by_name_not_by_builder_error() {
125 for bad in ["", " ", "not-a-url", "ftp://host/v1", "/just/a/path"] {
126 let err = Endpoint::openai_compatible(bad, "m", None)
127 .expect_err("{bad:?} should be rejected");
128 assert!(
129 err.to_string().contains("endpoint"),
130 "error should name the endpoint for {bad:?}: {err}"
131 );
132 }
133 }
134}