1use crate::errors::AppError;
10use crate::retry::AttemptOutcome;
11use secrecy::{ExposeSecret, SecretBox};
12use serde::{Deserialize, Serialize};
13use std::time::Duration;
14
15use crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL;
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
19const MAX_BATCH_SIZE: usize = 32;
20
21#[derive(Serialize)]
22struct EmbeddingRequest<'a> {
23 model: &'a str,
24 input: EmbeddingInput<'a>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 dimensions: Option<usize>,
27 encoding_format: &'a str,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 input_type: Option<&'a str>,
30}
31
32#[derive(Serialize)]
33#[serde(untagged)]
34enum EmbeddingInput<'a> {
35 Single(&'a str),
36 Batch(Vec<&'a str>),
37}
38
39#[derive(Deserialize)]
40struct EmbeddingResponse {
41 data: Vec<EmbeddingData>,
42}
43
44#[derive(Deserialize)]
45struct EmbeddingData {
46 embedding: Vec<f32>,
47 index: usize,
48}
49
50#[derive(Deserialize)]
58struct EmbeddingEnvelope {
59 #[serde(default)]
60 data: Option<Vec<EmbeddingData>>,
61 #[serde(default)]
62 error: Option<ApiError>,
63}
64
65use crate::openrouter_http::ApiError;
70
71#[derive(Debug)]
83pub struct EmbedError {
84 pub source: AppError,
86 pub retry_class: AttemptOutcome,
89}
90
91impl EmbedError {
92 fn new(source: AppError, retry_class: AttemptOutcome) -> Self {
93 Self {
94 source,
95 retry_class,
96 }
97 }
98}
99
100impl std::fmt::Display for EmbedError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 std::fmt::Display::fmt(&self.source, f)
103 }
104}
105
106impl std::error::Error for EmbedError {
107 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
108 Some(&self.source)
109 }
110}
111
112impl From<AppError> for EmbedError {
121 fn from(source: AppError) -> Self {
122 Self::new(source, AttemptOutcome::HardFailure)
123 }
124}
125
126impl From<EmbedError> for AppError {
133 fn from(err: EmbedError) -> Self {
134 err.source
135 }
136}
137
138pub struct OpenRouterClient {
139 client: reqwest::Client,
140 api_key: SecretBox<String>,
141 model: String,
142 dim: usize,
143 supports_mrl: bool,
144 default_input_type: Option<&'static str>,
145 base_url: String,
148}
149
150fn model_supports_mrl(model: &str) -> bool {
151 model.contains("qwen3-embedding")
152 || model.contains("text-embedding-3")
153 || model.contains("gemini-embedding")
154 || model.contains("llama-nemotron-embed")
155 || model.contains("bge-m3")
156}
157
158fn model_default_input_type(model: &str) -> Option<&'static str> {
159 if model.contains("llama-nemotron-embed") {
160 Some("passage")
161 } else if model.contains("mistral-embed") {
162 None
163 } else {
164 Some("search_document")
165 }
166}
167
168impl OpenRouterClient {
169 pub fn new(api_key: SecretBox<String>, model: String, dim: usize) -> Result<Self, AppError> {
170 let base_url = crate::runtime_config::openrouter_embeddings_url(
171 DEFAULT_OPENROUTER_EMBEDDINGS_URL,
172 );
173 Self::new_with_base_url(api_key, model, dim, base_url)
174 }
175
176 pub fn new_with_base_url(
178 api_key: SecretBox<String>,
179 model: String,
180 dim: usize,
181 base_url: String,
182 ) -> Result<Self, AppError> {
183 let client = reqwest::Client::builder()
184 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
185 .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
186 .user_agent(concat!("sqlite-graphrag/", env!("CARGO_PKG_VERSION")))
187 .build()
188 .map_err(|e| AppError::Embedding(format!("failed to build HTTP client: {e}")))?;
189
190 let supports_mrl = model_supports_mrl(&model);
191 let default_input_type = model_default_input_type(&model);
192
193 Ok(Self {
194 client,
195 api_key,
196 model,
197 dim,
198 supports_mrl,
199 default_input_type,
200 base_url,
201 })
202 }
203
204 #[cfg(test)]
208 fn new_with_url(
209 api_key: SecretBox<String>,
210 model: String,
211 dim: usize,
212 base_url: String,
213 ) -> Result<Self, AppError> {
214 Self::new_with_base_url(api_key, model, dim, base_url)
215 }
216
217 pub fn default_input_type(&self) -> Option<&'static str> {
218 self.default_input_type
219 }
220
221 pub async fn embed_single(
222 &self,
223 text: &str,
224 input_type: Option<&str>,
225 ) -> Result<Vec<f32>, EmbedError> {
226 crate::memory_guard::check_embedding_input_size(text)?;
230
231 let request = EmbeddingRequest {
232 model: &self.model,
233 input: EmbeddingInput::Single(text),
234 dimensions: if self.supports_mrl {
235 Some(self.dim)
236 } else {
237 None
238 },
239 encoding_format: "float",
240 input_type,
241 };
242
243 let response = self.execute_with_retry(&request).await?;
244
245 let embedding = response
246 .data
247 .into_iter()
248 .next()
249 .ok_or_else(|| AppError::Embedding("empty response from OpenRouter".into()))?
250 .embedding;
251
252 Ok(self.truncate_embedding(embedding)?)
253 }
254
255 pub async fn embed_batch(
256 &self,
257 texts: &[&str],
258 input_type: Option<&str>,
259 ) -> Result<Vec<Vec<f32>>, EmbedError> {
260 if texts.is_empty() {
261 return Ok(Vec::new());
262 }
263
264 for text in texts {
268 crate::memory_guard::check_embedding_input_size(text)?;
269 }
270
271 let mut all = Vec::with_capacity(texts.len());
272
273 for chunk in texts.chunks(MAX_BATCH_SIZE) {
274 let request = EmbeddingRequest {
275 model: &self.model,
276 input: EmbeddingInput::Batch(chunk.to_vec()),
277 dimensions: if self.supports_mrl {
278 Some(self.dim)
279 } else {
280 None
281 },
282 encoding_format: "float",
283 input_type,
284 };
285
286 let response = self.execute_with_retry(&request).await?;
287
288 if response.data.len() != chunk.len() {
289 return Err(AppError::Embedding(format!(
290 "expected {} embeddings, got {}",
291 chunk.len(),
292 response.data.len()
293 ))
294 .into());
295 }
296
297 let mut sorted = response.data;
298 sorted.sort_by_key(|d| d.index);
299
300 for d in sorted {
301 all.push(self.truncate_embedding(d.embedding)?);
302 }
303 }
304
305 Ok(all)
306 }
307
308 fn truncate_embedding(&self, embedding: Vec<f32>) -> Result<Vec<f32>, AppError> {
309 if embedding.len() < self.dim {
310 return Err(AppError::Embedding(format!(
311 "embedding dimension {} < requested {}",
312 embedding.len(),
313 self.dim
314 )));
315 }
316 if embedding.len() == self.dim {
317 Ok(embedding)
318 } else {
319 Ok(embedding[..self.dim].to_vec())
320 }
321 }
322
323 async fn execute_with_retry(
330 &self,
331 request: &EmbeddingRequest<'_>,
332 ) -> Result<EmbeddingResponse, EmbedError> {
333 let mut last_err: Option<EmbedError> = None;
334
335 for attempt in 0..crate::openrouter_http::MAX_RETRIES {
336 let result = self
337 .client
338 .post(&self.base_url)
339 .header(
340 "Authorization",
341 format!("Bearer {}", self.api_key.expose_secret()),
342 )
343 .json(request)
344 .send()
345 .await;
346
347 let resp = match result {
348 Ok(r) => r,
349 Err(e) if e.is_timeout() => {
350 return Err(EmbedError::new(
351 AppError::Embedding("OpenRouter request timed out".into()),
352 AttemptOutcome::Transient,
353 ));
354 }
355 Err(e) => {
356 last_err = Some(EmbedError::new(
357 AppError::Embedding(format!("HTTP request failed: {e}")),
358 AttemptOutcome::Transient,
359 ));
360 crate::openrouter_http::backoff(attempt).await;
361 continue;
362 }
363 };
364
365 let status = resp.status();
366
367 if status.is_success() {
368 let body = resp.text().await.map_err(|e| {
369 EmbedError::new(
370 AppError::Embedding(format!("failed to read response body: {e}")),
371 AttemptOutcome::Transient,
372 )
373 })?;
374 match serde_json::from_str::<EmbeddingEnvelope>(&body) {
375 Ok(env) => {
376 if let Some(api_err) = env.error {
381 let retry_class =
382 crate::openrouter_http::provider_error_retry_class(&api_err);
383 return Err(EmbedError::new(
384 AppError::ProviderError {
385 code: api_err.code_string(),
386 message: api_err.message,
387 },
388 retry_class,
389 ));
390 }
391 match env.data {
392 Some(data) => return Ok(EmbeddingResponse { data }),
393 None => {
394 tracing::warn!(
395 attempt,
396 body_len = body.len(),
397 "HTTP 200 with neither data nor error (retrying)"
398 );
399 last_err = Some(EmbedError::new(
400 AppError::Embedding(
401 "OpenRouter 200 response had neither data nor error".into(),
402 ),
403 AttemptOutcome::Transient,
404 ));
405 crate::openrouter_http::backoff(attempt).await;
406 continue;
407 }
408 }
409 }
410 Err(e) => {
411 tracing::warn!(
412 attempt,
413 body_len = body.len(),
414 "HTTP 200 but JSON unparseable (retrying): {e}"
415 );
416 last_err = Some(EmbedError::new(
417 AppError::Embedding(format!("failed to parse embedding response: {e}")),
418 AttemptOutcome::Transient,
419 ));
420 crate::openrouter_http::backoff(attempt).await;
421 continue;
422 }
423 }
424 }
425
426 if status.as_u16() == 401 {
427 return Err(EmbedError::new(
428 AppError::Embedding("invalid OpenRouter API key (HTTP 401)".into()),
429 AttemptOutcome::HardFailure,
430 ));
431 }
432
433 if status.as_u16() == 400 || status.as_u16() == 404 {
434 let body = resp.text().await.unwrap_or_default();
435 return Err(EmbedError::new(
436 AppError::Embedding(format!("OpenRouter returned {status}: {body}")),
437 AttemptOutcome::HardFailure,
438 ));
439 }
440
441 if status.as_u16() == 429 {
442 let retry_after = resp
443 .headers()
444 .get("retry-after")
445 .and_then(|v| v.to_str().ok())
446 .and_then(|v| v.parse::<u64>().ok())
447 .unwrap_or(2);
448 tracing::warn!(
449 attempt,
450 retry_after_secs = retry_after,
451 "OpenRouter rate limited, waiting"
452 );
453 last_err = Some(EmbedError::new(
458 AppError::RateLimited {
459 detail: format!("OpenRouter HTTP 429 (retry-after {retry_after}s)"),
460 },
461 AttemptOutcome::Transient,
462 ));
463 tokio::time::sleep(Duration::from_secs(retry_after)).await;
464 continue;
465 }
466
467 if status.is_server_error() {
468 tracing::warn!(attempt, status = %status, "OpenRouter server error, retrying");
469 last_err = Some(EmbedError::new(
470 AppError::Embedding(format!("OpenRouter server error: {status}")),
471 AttemptOutcome::Transient,
472 ));
473 crate::openrouter_http::backoff(attempt).await;
474 continue;
475 }
476
477 let body = resp.text().await.unwrap_or_default();
478 return Err(EmbedError::new(
479 AppError::Embedding(format!("unexpected HTTP {status}: {body}")),
480 crate::openrouter_http::status_retry_class(status),
481 ));
482 }
483
484 Err(last_err.unwrap_or_else(|| {
489 EmbedError::new(
490 AppError::Embedding("max retries exceeded for OpenRouter request".into()),
491 AttemptOutcome::Transient,
492 )
493 }))
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn test_supports_mrl_detection() {
503 assert!(model_supports_mrl("qwen/qwen3-embedding-8b"));
504 assert!(model_supports_mrl("qwen/qwen3-embedding-4b"));
505 assert!(model_supports_mrl("openai/text-embedding-3-small"));
506 assert!(model_supports_mrl("openai/text-embedding-3-large"));
507 assert!(model_supports_mrl("google/gemini-embedding-001"));
508 assert!(model_supports_mrl("google/gemini-embedding-2"));
509 assert!(model_supports_mrl(
510 "nvidia/llama-nemotron-embed-vl-1b-v2:free"
511 ));
512 assert!(model_supports_mrl("baai/bge-m3"));
513
514 assert!(!model_supports_mrl("perplexity/pplx-embed-v1-0.6b"));
515 assert!(!model_supports_mrl("mistralai/mistral-embed-2312"));
516 assert!(!model_supports_mrl("some-random-model"));
517 }
518
519 #[test]
520 fn test_model_default_input_type() {
521 assert_eq!(
522 model_default_input_type("nvidia/llama-nemotron-embed-vl-1b-v2:free"),
523 Some("passage")
524 );
525 assert_eq!(
526 model_default_input_type("mistralai/mistral-embed-2312"),
527 None
528 );
529 assert_eq!(
530 model_default_input_type("qwen/qwen3-embedding-8b"),
531 Some("search_document")
532 );
533 assert_eq!(
534 model_default_input_type("openai/text-embedding-3-small"),
535 Some("search_document")
536 );
537 assert_eq!(
538 model_default_input_type("baai/bge-m3"),
539 Some("search_document")
540 );
541 }
542
543 #[test]
544 fn test_truncate_embedding() {
545 let api_key = SecretBox::new(Box::new("test-key".to_string()));
546 let client = OpenRouterClient::new(api_key, "test-model".into(), 3).unwrap();
547
548 let full = vec![1.0, 2.0, 3.0, 4.0, 5.0];
549 let truncated = client.truncate_embedding(full).unwrap();
550 assert_eq!(truncated, vec![1.0, 2.0, 3.0]);
551
552 let exact = vec![1.0, 2.0, 3.0];
553 let kept = client.truncate_embedding(exact).unwrap();
554 assert_eq!(kept, vec![1.0, 2.0, 3.0]);
555
556 let short = vec![1.0, 2.0];
557 let err = client.truncate_embedding(short);
558 assert!(err.is_err());
559 }
560
561 #[test]
562 fn embedding_envelope_surfaces_provider_error_not_missing_field() {
563 let body = r#"{"error":{"code":400,"message":"context length exceeded"}}"#;
566
567 let legacy_err = match serde_json::from_str::<EmbeddingResponse>(body) {
570 Ok(_) => panic!("legacy parse should have failed on an error body"),
571 Err(e) => e.to_string(),
572 };
573 assert!(
574 legacy_err.contains("missing field"),
575 "precondition: legacy parse masks the cause as a missing field: {legacy_err}"
576 );
577
578 let env: EmbeddingEnvelope =
580 serde_json::from_str(body).expect("envelope parses an error body");
581 assert!(env.data.is_none());
582 let api_err = env.error.expect("error object captured");
583 assert_eq!(api_err.message, "context length exceeded");
584 assert_eq!(api_err.code_string(), "400");
585 }
586
587 #[test]
588 fn embedding_envelope_parses_success_body() {
589 let body = r#"{"data":[{"embedding":[1.0,2.0,3.0],"index":0}]}"#;
590 let env: EmbeddingEnvelope =
591 serde_json::from_str(body).expect("envelope parses a success body");
592 assert!(env.error.is_none());
593 let data = env.data.expect("data present");
594 assert_eq!(data.len(), 1);
595 assert_eq!(data[0].embedding, vec![1.0, 2.0, 3.0]);
596 }
597
598 #[test]
599 fn api_error_code_string_handles_number_string_and_missing() {
600 let num: ApiError = serde_json::from_str(r#"{"code":429,"message":"slow down"}"#).unwrap();
601 assert_eq!(num.code_string(), "429");
602
603 let s: ApiError =
604 serde_json::from_str(r#"{"code":"rate_limited","message":"slow down"}"#).unwrap();
605 assert_eq!(s.code_string(), "rate_limited");
606
607 let missing: ApiError = serde_json::from_str(r#"{"message":"oops"}"#).unwrap();
608 assert_eq!(missing.code_string(), "unknown");
609 }
610
611 #[tokio::test]
612 async fn embed_single_rejects_oversized_input_before_request() {
613 let api_key = SecretBox::new(Box::new("test-key".to_string()));
618 let client = OpenRouterClient::new(api_key, "qwen/qwen3-embedding-8b".into(), 384).unwrap();
619 let big = "word ".repeat(crate::constants::EMBEDDING_REQUEST_MAX_TOKENS + 5_000);
620 match client.embed_single(&big, None).await {
621 Err(EmbedError {
622 source: AppError::TooManyTokens { tokens, limit },
623 retry_class,
624 }) => {
625 assert!(tokens > limit, "tokens={tokens} limit={limit}");
626 assert_eq!(limit, crate::constants::EMBEDDING_REQUEST_MAX_TOKENS as u64);
627 assert_eq!(
628 retry_class,
629 AttemptOutcome::HardFailure,
630 "an oversized input is a permanent client error"
631 );
632 }
633 other => unreachable!("expected TooManyTokens before request, got: {other:?}"),
634 }
635 }
636
637 async fn client_for(server: &wiremock::MockServer, model: &str) -> OpenRouterClient {
638 OpenRouterClient::new_with_url(
639 SecretBox::new(Box::new("test-key".to_string())),
640 model.to_string(),
641 384,
642 format!("{}/embeddings", server.uri()),
643 )
644 .expect("test client builds")
645 }
646
647 #[tokio::test]
648 async fn embed_single_401_is_hard_failure() {
649 use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
652 let server = MockServer::start().await;
653 Mock::given(method("POST"))
654 .respond_with(ResponseTemplate::new(401))
655 .mount(&server)
656 .await;
657
658 let client = client_for(&server, "qwen/qwen3-embedding-8b").await;
659 let err = client
660 .embed_single("hello", None)
661 .await
662 .expect_err("401 is an error");
663 assert_eq!(err.retry_class, AttemptOutcome::HardFailure);
664 }
665
666 #[tokio::test]
667 async fn embed_single_exhausted_5xx_is_transient() {
668 use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
672 let server = MockServer::start().await;
673 Mock::given(method("POST"))
674 .respond_with(ResponseTemplate::new(503))
675 .mount(&server)
676 .await;
677
678 let client = client_for(&server, "qwen/qwen3-embedding-8b").await;
679 let err = client
680 .embed_single("hello", None)
681 .await
682 .expect_err("persistent 5xx exhausts retries");
683 assert_eq!(err.retry_class, AttemptOutcome::Transient);
684 }
685
686 #[tokio::test]
687 async fn embed_single_provider_error_code_classifies_by_code_not_message() {
688 use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
692 let server = MockServer::start().await;
693 Mock::given(method("POST"))
694 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
695 "error": { "code": "context_length_exceeded", "message": "too many tokens" }
696 })))
697 .mount(&server)
698 .await;
699
700 let client = client_for(&server, "qwen/qwen3-embedding-8b").await;
701 let err = client
702 .embed_single("hello", None)
703 .await
704 .expect_err("provider error must surface");
705 assert_eq!(err.retry_class, AttemptOutcome::HardFailure);
706 }
707}