1use serde::{Deserialize, Serialize};
9
10use crate::error::EmbeddingError;
11use crate::preprocessor::PreprocessorConfig;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct EmbeddingConfig {
16 pub base_url: String,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub api_key: Option<String>,
21 pub model: String,
23 #[serde(default = "default_timeout_secs")]
25 pub timeout_secs: u64,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub dimension: Option<usize>,
29 #[serde(default)]
31 pub preprocessor: PreprocessorConfig,
32}
33
34fn default_timeout_secs() -> u64 {
35 30
36}
37
38impl EmbeddingConfig {
39 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
41 Self {
42 base_url: base_url.into(),
43 api_key: None,
44 model: model.into(),
45 timeout_secs: default_timeout_secs(),
46 dimension: None,
47 preprocessor: PreprocessorConfig::default(),
48 }
49 }
50
51 pub fn openai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
53 Self::new("https://api.openai.com/v1/embeddings", model).with_api_key(api_key)
54 }
55
56 pub fn ollama(model: impl Into<String>) -> Self {
58 Self::new("http://localhost:11434/api/embed", model)
59 }
60
61 pub fn azure(
65 resource_url: impl Into<String>,
66 api_key: impl Into<String>,
67 model: impl Into<String>,
68 ) -> Self {
69 Self::new(resource_url, model).with_api_key(api_key)
70 }
71
72 pub fn gemini(api_key: impl Into<String>, model: impl Into<String>) -> Self {
75 Self::new(
76 "https://generativelanguage.googleapis.com/v1beta/openai/embeddings",
77 model,
78 )
79 .with_api_key(api_key)
80 }
81
82 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
84 self.api_key = Some(api_key.into());
85 self
86 }
87
88 pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
90 self.timeout_secs = timeout_secs;
91 self
92 }
93
94 pub fn with_dimension(mut self, dimension: usize) -> Self {
96 self.dimension = Some(dimension);
97 self
98 }
99
100 pub fn with_preprocessor(mut self, preprocessor: PreprocessorConfig) -> Self {
102 self.preprocessor = preprocessor;
103 self
104 }
105
106 pub fn validate(&self) -> crate::error::Result<()> {
108 if self.base_url.is_empty() {
109 return Err(EmbeddingError::Config("base_url is required".into()));
110 }
111 if self.model.is_empty() {
112 return Err(EmbeddingError::Config("model is required".into()));
113 }
114 url::Url::parse(&self.base_url)
115 .map_err(|err| EmbeddingError::Config(format!("invalid base_url: {err}")))?;
116 match self.dimension {
117 Some(dimension) if dimension > 0 => Ok(()),
118 _ => Err(EmbeddingError::Config(
119 "dimension is required for startup validation; set it with EmbeddingConfig::with_dimension()".into(),
120 )),
121 }
122 }
123}
124
125impl Default for EmbeddingConfig {
126 fn default() -> Self {
127 Self {
128 base_url: "http://localhost:11434/api/embed".into(),
129 api_key: None,
130 model: "all-minilm".into(),
131 timeout_secs: default_timeout_secs(),
132 dimension: None,
133 preprocessor: PreprocessorConfig::default(),
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::preprocessor::PreprocessorConfig;
142
143 #[test]
144 fn presets_point_at_expected_endpoints() {
145 assert!(EmbeddingConfig::openai("sk-test", "text-embedding-3-small")
146 .base_url
147 .contains("api.openai.com"));
148 assert!(EmbeddingConfig::ollama("nomic-embed-text")
149 .base_url
150 .contains("11434"));
151 assert!(EmbeddingConfig::gemini("key", "text-embedding-004")
152 .base_url
153 .contains("generativelanguage"));
154 let azure =
155 EmbeddingConfig::azure("https://example.openai.azure.com/x", "key", "deployment");
156 assert_eq!(azure.api_key.as_deref(), Some("key"));
157 }
158
159 #[test]
160 fn validate_accepts_complete_config() {
161 let config =
162 EmbeddingConfig::new("http://localhost:11434/api/embed", "model").with_dimension(384);
163 assert!(config.validate().is_ok());
164 }
165
166 #[test]
167 fn validate_rejects_missing_fields() {
168 let missing_url = EmbeddingConfig::new("", "model").with_dimension(384);
169 assert!(missing_url.validate().is_err());
170
171 let missing_model = EmbeddingConfig::new("http://example.com", "").with_dimension(384);
172 assert!(missing_model.validate().is_err());
173
174 let bad_url = EmbeddingConfig::new("not-a-url", "model").with_dimension(384);
175 assert!(bad_url
176 .validate()
177 .unwrap_err()
178 .to_string()
179 .contains("base_url"));
180
181 let missing_dimension = EmbeddingConfig::new("http://example.com", "model");
182 assert!(missing_dimension
183 .validate()
184 .unwrap_err()
185 .to_string()
186 .contains("dimension"));
187 }
188
189 #[test]
190 fn builder_sets_preprocessor() {
191 let config = EmbeddingConfig::new("http://example.com", "model").with_preprocessor(
192 PreprocessorConfig::Prefix {
193 prefix: "search_document: ".into(),
194 },
195 );
196 assert!(matches!(
197 config.preprocessor,
198 PreprocessorConfig::Prefix { .. }
199 ));
200 }
201}