1use std::io::BufRead;
7use std::time::Duration;
8
9use crate::json::{parse_json, Map, Value as JsonValue};
10use crate::{RedDBError, RedDBResult};
11
12fn http_post_json(
18 url: &str,
19 api_key: &str,
20 extra_headers: &[(&str, &str)],
21 payload: String,
22 read_timeout_secs: u64,
23) -> Result<(u16, String), String> {
24 let agent: ureq::Agent = ureq::Agent::config_builder()
25 .timeout_connect(Some(Duration::from_secs(10)))
26 .timeout_send_request(Some(Duration::from_secs(30)))
27 .timeout_recv_response(Some(Duration::from_secs(read_timeout_secs)))
28 .timeout_recv_body(Some(Duration::from_secs(read_timeout_secs)))
29 .http_status_as_error(false)
30 .build()
31 .into();
32
33 let mut req = agent
34 .post(url)
35 .header("Content-Type", "application/json")
36 .header("Accept", "application/json");
37 for (k, v) in extra_headers {
38 req = req.header(*k, *v);
39 }
40 let trimmed_key = api_key.trim();
41 if !trimmed_key.is_empty() {
42 req = req.header("Authorization", &format!("Bearer {}", trimmed_key));
43 }
44
45 match req.send(payload) {
46 Ok(mut resp) => {
47 let status = resp.status().as_u16();
48 let body = resp
49 .body_mut()
50 .read_to_string()
51 .map_err(|err| format!("failed to read response body: {err}"))?;
52 Ok((status, body))
53 }
54 Err(err) => Err(format!("{err}")),
55 }
56}
57
58pub const DEFAULT_OPENAI_EMBEDDING_MODEL: &str = "text-embedding-3-small";
59pub const DEFAULT_OPENAI_API_BASE: &str = "https://api.openai.com/v1";
60pub const DEFAULT_OPENAI_PROMPT_MODEL: &str = "gpt-4.1-mini";
61pub const DEFAULT_ANTHROPIC_PROMPT_MODEL: &str = "claude-3-5-haiku-latest";
62pub const DEFAULT_ANTHROPIC_API_BASE: &str = "https://api.anthropic.com/v1";
63pub const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
64
65#[derive(Debug, Clone)]
66pub struct OpenAiEmbeddingRequest {
67 pub api_key: String,
68 pub model: String,
69 pub inputs: Vec<String>,
70 pub dimensions: Option<usize>,
71 pub api_base: String,
72}
73
74#[derive(Debug, Clone)]
75pub struct OpenAiEmbeddingResponse {
76 pub provider: &'static str,
77 pub model: String,
78 pub embeddings: Vec<Vec<f32>>,
79 pub prompt_tokens: Option<u64>,
80 pub total_tokens: Option<u64>,
81}
82
83#[derive(Debug, Clone)]
84pub struct OpenAiPromptRequest {
85 pub api_key: String,
86 pub model: String,
87 pub prompt: String,
88 pub temperature: Option<f32>,
89 pub seed: Option<u64>,
90 pub max_output_tokens: Option<usize>,
91 pub api_base: String,
92 pub stream: bool,
93}
94
95#[derive(Debug, Clone)]
96pub struct AnthropicPromptRequest {
97 pub api_key: String,
98 pub model: String,
99 pub prompt: String,
100 pub temperature: Option<f32>,
101 pub max_output_tokens: Option<usize>,
102 pub api_base: String,
103 pub anthropic_version: String,
104}
105
106#[derive(Debug, Clone)]
107pub struct AiPromptResponse {
108 pub provider: &'static str,
109 pub model: String,
110 pub output_text: String,
111 pub output_chunks: Option<Vec<String>>,
112 pub prompt_tokens: Option<u64>,
113 pub completion_tokens: Option<u64>,
114 pub total_tokens: Option<u64>,
115 pub stop_reason: Option<String>,
116}
117
118#[deprecated(
119 since = "1.0.0",
120 note = "use AiBatchClient::embed_batch for embeddings or openai_embeddings_async with AiTransport when token usage metadata is required"
121)]
122pub fn openai_embeddings(request: OpenAiEmbeddingRequest) -> RedDBResult<OpenAiEmbeddingResponse> {
123 if request.model.trim().is_empty() {
124 return Err(RedDBError::Query(
125 "OpenAI embedding model cannot be empty".to_string(),
126 ));
127 }
128 if request.inputs.is_empty() {
129 return Err(RedDBError::Query(
130 "at least one input is required for embeddings".to_string(),
131 ));
132 }
133
134 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
135 let payload =
136 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
137
138 let (status, body) = http_post_json(&url, &request.api_key, &[], payload, 90)
139 .map_err(|err| RedDBError::Query(format!("OpenAI transport error: {err}")))?;
140
141 if !(200..300).contains(&status) {
142 let message = openai_error_message(&body)
143 .unwrap_or_else(|| "OpenAI embeddings request failed".to_string());
144 return Err(RedDBError::Query(format!(
145 "OpenAI embeddings request failed (status {status}): {message}"
146 )));
147 }
148
149 parse_openai_embedding_response(&body)
150}
151
152#[deprecated(since = "1.0.0", note = "use openai_prompt_async with AiTransport")]
153pub fn openai_prompt(request: OpenAiPromptRequest) -> RedDBResult<AiPromptResponse> {
154 if request.model.trim().is_empty() {
155 return Err(RedDBError::Query(
156 "OpenAI prompt model cannot be empty".to_string(),
157 ));
158 }
159 if request.prompt.trim().is_empty() {
160 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
161 }
162
163 let url = format!(
164 "{}/chat/completions",
165 request.api_base.trim_end_matches('/')
166 );
167 let payload = build_openai_prompt_payload(
168 &request.model,
169 &request.prompt,
170 request.temperature,
171 request.seed,
172 request.max_output_tokens,
173 false,
174 );
175
176 let (status, body) = http_post_json(&url, &request.api_key, &[], payload, 120)
177 .map_err(|err| RedDBError::Query(format!("OpenAI transport error: {err}")))?;
178
179 if !(200..300).contains(&status) {
180 let message = openai_error_message(&body)
181 .unwrap_or_else(|| "OpenAI prompt request failed".to_string());
182 return Err(RedDBError::Query(format!(
183 "OpenAI prompt request failed (status {status}): {message}"
184 )));
185 }
186
187 parse_openai_prompt_response(&body, &request.model)
188}
189
190#[deprecated(since = "1.0.0", note = "use anthropic_prompt_async with AiTransport")]
191pub fn anthropic_prompt(request: AnthropicPromptRequest) -> RedDBResult<AiPromptResponse> {
192 if request.api_key.trim().is_empty() {
193 return Err(RedDBError::Query(
194 "Anthropic API key cannot be empty".to_string(),
195 ));
196 }
197 if request.model.trim().is_empty() {
198 return Err(RedDBError::Query(
199 "Anthropic model cannot be empty".to_string(),
200 ));
201 }
202 if request.prompt.trim().is_empty() {
203 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
204 }
205
206 let url = format!("{}/messages", request.api_base.trim_end_matches('/'));
207 let payload = build_anthropic_prompt_payload(
208 &request.model,
209 &request.prompt,
210 request.temperature,
211 request.max_output_tokens,
212 );
213
214 let extra = [
219 ("x-api-key", request.api_key.as_str()),
220 ("anthropic-version", request.anthropic_version.as_str()),
221 ];
222 let (status, body) = http_post_json(&url, "", &extra, payload, 120)
223 .map_err(|err| RedDBError::Query(format!("Anthropic transport error: {err}")))?;
224
225 if !(200..300).contains(&status) {
226 let message = anthropic_error_message(&body)
227 .unwrap_or_else(|| "Anthropic prompt request failed".to_string());
228 return Err(RedDBError::Query(format!(
229 "Anthropic prompt request failed (status {status}): {message}"
230 )));
231 }
232
233 parse_anthropic_prompt_response(&body, &request.model)
234}
235
236pub async fn openai_embeddings_async(
241 transport: &crate::runtime::ai::transport::AiTransport,
242 request: OpenAiEmbeddingRequest,
243) -> RedDBResult<OpenAiEmbeddingResponse> {
244 if request.model.trim().is_empty() {
245 return Err(RedDBError::Query(
246 "OpenAI embedding model cannot be empty".to_string(),
247 ));
248 }
249 if request.inputs.is_empty() {
250 return Err(RedDBError::Query(
251 "at least one input is required for embeddings".to_string(),
252 ));
253 }
254
255 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
256 let payload =
257 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
258 let mut http_req =
259 crate::runtime::ai::transport::AiHttpRequest::post_json("openai-compatible", url, payload);
260 let trimmed_key = request.api_key.trim();
261 if !trimmed_key.is_empty() {
262 http_req = http_req.header("authorization", format!("Bearer {}", trimmed_key));
263 }
264
265 let response = transport
266 .request(http_req)
267 .await
268 .map_err(|e| RedDBError::Query(e.to_string()))?;
269
270 parse_openai_embedding_response(&response.body)
271}
272
273pub async fn openai_prompt_async(
278 transport: &crate::runtime::ai::transport::AiTransport,
279 request: OpenAiPromptRequest,
280) -> RedDBResult<AiPromptResponse> {
281 if request.model.trim().is_empty() {
282 return Err(RedDBError::Query(
283 "OpenAI prompt model cannot be empty".to_string(),
284 ));
285 }
286 if request.prompt.trim().is_empty() {
287 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
288 }
289
290 let url = format!(
291 "{}/chat/completions",
292 request.api_base.trim_end_matches('/')
293 );
294 let payload = build_openai_prompt_payload(
295 &request.model,
296 &request.prompt,
297 request.temperature,
298 request.seed,
299 request.max_output_tokens,
300 request.stream,
301 );
302 let http_req = crate::runtime::ai::transport::AiHttpRequest::post_json("openai", url, payload)
303 .model(request.model.clone())
304 .header("authorization", format!("Bearer {}", request.api_key));
305
306 let response = transport
307 .request(http_req)
308 .await
309 .map_err(|e| RedDBError::Query(e.to_string()))?;
310
311 if request.stream {
312 parse_openai_streaming_prompt_response(&response.body, &request.model)
313 } else {
314 parse_openai_prompt_response(&response.body, &request.model)
315 }
316}
317
318pub fn openai_prompt_streaming(
324 request: OpenAiPromptRequest,
325 mut on_chunk: impl FnMut(&str) -> RedDBResult<()>,
326) -> RedDBResult<AiPromptResponse> {
327 if request.model.trim().is_empty() {
328 return Err(RedDBError::Query(
329 "OpenAI prompt model cannot be empty".to_string(),
330 ));
331 }
332 if request.prompt.trim().is_empty() {
333 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
334 }
335
336 let url = format!(
337 "{}/chat/completions",
338 request.api_base.trim_end_matches('/')
339 );
340 let payload = build_openai_prompt_payload(
341 &request.model,
342 &request.prompt,
343 request.temperature,
344 request.seed,
345 request.max_output_tokens,
346 true,
347 );
348
349 let agent: ureq::Agent = ureq::Agent::config_builder()
350 .timeout_connect(Some(Duration::from_secs(10)))
351 .timeout_send_request(Some(Duration::from_secs(30)))
352 .timeout_recv_response(Some(Duration::from_secs(120)))
353 .timeout_recv_body(Some(Duration::from_secs(120)))
354 .http_status_as_error(false)
355 .build()
356 .into();
357
358 let mut req = agent
359 .post(&url)
360 .header("content-type", "application/json")
361 .header("accept", "text/event-stream");
362 let trimmed_key = request.api_key.trim();
363 if !trimmed_key.is_empty() {
364 req = req.header("authorization", &format!("Bearer {}", trimmed_key));
365 }
366
367 let mut response = req
368 .send(payload)
369 .map_err(|err| RedDBError::Query(format!("OpenAI transport error: {err}")))?;
370 let status = response.status().as_u16();
371 if !(200..300).contains(&status) {
372 let body = response
373 .body_mut()
374 .read_to_string()
375 .unwrap_or_else(|err| format!("failed to read response body: {err}"));
376 let message = openai_error_message(&body)
377 .unwrap_or_else(|| "OpenAI prompt request failed".to_string());
378 return Err(RedDBError::Query(format!(
379 "OpenAI prompt request failed (status {status}): {message}"
380 )));
381 }
382
383 let mut model = request.model;
384 let mut chunks = Vec::new();
385 let mut prompt_tokens = None;
386 let mut completion_tokens = None;
387 let mut total_tokens = None;
388 let mut stop_reason = None;
389
390 let mut reader = std::io::BufReader::new(response.body_mut().as_reader());
391 let mut line = String::new();
392 loop {
393 line.clear();
394 let read = reader.read_line(&mut line).map_err(|err| {
395 RedDBError::Query(format!("failed to read OpenAI streaming response: {err}"))
396 })?;
397 if read == 0 {
398 break;
399 }
400
401 let trimmed = line.trim();
402 let Some(data) = trimmed.strip_prefix("data:") else {
403 continue;
404 };
405 let data = data.trim();
406 if data.is_empty() {
407 continue;
408 }
409 if data == "[DONE]" {
410 break;
411 }
412
413 let parsed = parse_json(data).map_err(|err| {
414 RedDBError::Query(format!(
415 "invalid OpenAI streaming prompt JSON response: {err}"
416 ))
417 })?;
418 let json = JsonValue::from(parsed);
419 if let Some(value) = json.get("model").and_then(JsonValue::as_str) {
420 model = value.to_string();
421 }
422 if let Some(usage) = json.get("usage") {
423 prompt_tokens = usage
424 .get("prompt_tokens")
425 .and_then(JsonValue::as_i64)
426 .and_then(|value| u64::try_from(value).ok())
427 .or(prompt_tokens);
428 completion_tokens = usage
429 .get("completion_tokens")
430 .and_then(JsonValue::as_i64)
431 .and_then(|value| u64::try_from(value).ok())
432 .or(completion_tokens);
433 total_tokens = usage
434 .get("total_tokens")
435 .and_then(JsonValue::as_i64)
436 .and_then(|value| u64::try_from(value).ok())
437 .or(total_tokens);
438 }
439
440 let Some(choices) = json.get("choices").and_then(JsonValue::as_array) else {
441 continue;
442 };
443 let Some(first_choice) = choices.first() else {
444 continue;
445 };
446 if let Some(reason) = first_choice
447 .get("finish_reason")
448 .and_then(JsonValue::as_str)
449 {
450 stop_reason = Some(reason.to_string());
451 }
452 if let Some(text) = first_choice
453 .get("delta")
454 .and_then(|delta| delta.get("content"))
455 .and_then(JsonValue::as_str)
456 {
457 if !text.is_empty() {
458 on_chunk(text)?;
459 chunks.push(text.to_string());
460 }
461 }
462 }
463
464 if chunks.is_empty() {
465 return Err(RedDBError::Query(
466 "OpenAI streaming prompt response missing text content".to_string(),
467 ));
468 }
469
470 let output_text = chunks.concat();
471 let total_tokens = total_tokens.or_else(|| match (prompt_tokens, completion_tokens) {
472 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
473 _ => None,
474 });
475
476 Ok(AiPromptResponse {
477 provider: "openai",
478 model,
479 output_text,
480 output_chunks: Some(chunks),
481 prompt_tokens,
482 completion_tokens,
483 total_tokens,
484 stop_reason,
485 })
486}
487
488pub async fn anthropic_prompt_async(
493 transport: &crate::runtime::ai::transport::AiTransport,
494 request: AnthropicPromptRequest,
495) -> RedDBResult<AiPromptResponse> {
496 if request.api_key.trim().is_empty() {
497 return Err(RedDBError::Query(
498 "Anthropic API key cannot be empty".to_string(),
499 ));
500 }
501 if request.model.trim().is_empty() {
502 return Err(RedDBError::Query(
503 "Anthropic model cannot be empty".to_string(),
504 ));
505 }
506 if request.prompt.trim().is_empty() {
507 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
508 }
509
510 let url = format!("{}/messages", request.api_base.trim_end_matches('/'));
511 let payload = build_anthropic_prompt_payload(
512 &request.model,
513 &request.prompt,
514 request.temperature,
515 request.max_output_tokens,
516 );
517 let http_req =
518 crate::runtime::ai::transport::AiHttpRequest::post_json("anthropic", url, payload)
519 .model(request.model.clone())
520 .header("x-api-key", request.api_key)
521 .header("anthropic-version", request.anthropic_version);
522
523 let response = transport
524 .request(http_req)
525 .await
526 .map_err(|e| RedDBError::Query(e.to_string()))?;
527
528 parse_anthropic_prompt_response(&response.body, &request.model)
529}
530
531pub(crate) fn build_embedding_payload(model: &str, inputs: &[String]) -> String {
533 build_openai_embedding_payload(model, inputs, None)
534}
535
536pub(crate) fn parse_embedding_vectors(body: &str) -> Result<Vec<Vec<f32>>, String> {
538 parse_openai_embedding_response(body)
539 .map(|r| r.embeddings)
540 .map_err(|e| e.to_string())
541}
542
543pub(crate) fn parse_embedding_response(body: &str) -> Result<OpenAiEmbeddingResponse, String> {
544 parse_openai_embedding_response(body).map_err(|e| e.to_string())
545}
546
547fn build_openai_embedding_payload(
548 model: &str,
549 inputs: &[String],
550 dimensions: Option<usize>,
551) -> String {
552 let mut object = Map::new();
553 object.insert("model".to_string(), JsonValue::String(model.to_string()));
554 if inputs.len() == 1 {
555 object.insert("input".to_string(), JsonValue::String(inputs[0].clone()));
556 } else {
557 object.insert(
558 "input".to_string(),
559 JsonValue::Array(inputs.iter().cloned().map(JsonValue::String).collect()),
560 );
561 }
562 if let Some(dimensions) = dimensions {
563 object.insert(
564 "dimensions".to_string(),
565 JsonValue::Number(dimensions as f64),
566 );
567 }
568 object.insert(
569 "encoding_format".to_string(),
570 JsonValue::String("float".to_string()),
571 );
572 JsonValue::Object(object).to_string_compact()
573}
574
575fn openai_error_message(body: &str) -> Option<String> {
576 provider_error_message(body)
577}
578
579fn anthropic_error_message(body: &str) -> Option<String> {
580 provider_error_message(body)
581}
582
583fn provider_error_message(body: &str) -> Option<String> {
584 let parsed = parse_json(body).ok().map(JsonValue::from)?;
585 let error = parsed.get("error")?;
586 if let Some(message) = error.get("message").and_then(JsonValue::as_str) {
587 let trimmed = message.trim();
588 if !trimmed.is_empty() {
589 return Some(trimmed.to_string());
590 }
591 }
592 None
593}
594
595fn build_openai_prompt_payload(
596 model: &str,
597 prompt: &str,
598 temperature: Option<f32>,
599 seed: Option<u64>,
600 max_output_tokens: Option<usize>,
601 stream: bool,
602) -> String {
603 let mut object = Map::new();
604 object.insert("model".to_string(), JsonValue::String(model.to_string()));
605
606 let mut message = Map::new();
607 message.insert("role".to_string(), JsonValue::String("user".to_string()));
608 message.insert("content".to_string(), JsonValue::String(prompt.to_string()));
609 object.insert(
610 "messages".to_string(),
611 JsonValue::Array(vec![JsonValue::Object(message)]),
612 );
613
614 if let Some(temperature) = temperature {
615 object.insert(
616 "temperature".to_string(),
617 JsonValue::Number(temperature as f64),
618 );
619 }
620
621 if let Some(seed) = seed {
622 object.insert("seed".to_string(), JsonValue::Number(seed as f64));
623 }
624
625 if let Some(max_output_tokens) = max_output_tokens {
626 object.insert(
627 "max_tokens".to_string(),
628 JsonValue::Number(max_output_tokens as f64),
629 );
630 }
631
632 if stream {
633 object.insert("stream".to_string(), JsonValue::Bool(true));
634 let mut options = Map::new();
635 options.insert("include_usage".to_string(), JsonValue::Bool(true));
636 object.insert("stream_options".to_string(), JsonValue::Object(options));
637 }
638
639 JsonValue::Object(object).to_string_compact()
640}
641
642fn build_anthropic_prompt_payload(
643 model: &str,
644 prompt: &str,
645 temperature: Option<f32>,
646 max_output_tokens: Option<usize>,
647) -> String {
648 let mut object = Map::new();
649 object.insert("model".to_string(), JsonValue::String(model.to_string()));
650 object.insert(
651 "max_tokens".to_string(),
652 JsonValue::Number(max_output_tokens.unwrap_or(512) as f64),
653 );
654
655 let mut message = Map::new();
656 message.insert("role".to_string(), JsonValue::String("user".to_string()));
657 message.insert("content".to_string(), JsonValue::String(prompt.to_string()));
658 object.insert(
659 "messages".to_string(),
660 JsonValue::Array(vec![JsonValue::Object(message)]),
661 );
662
663 if let Some(temperature) = temperature {
664 object.insert(
665 "temperature".to_string(),
666 JsonValue::Number(temperature as f64),
667 );
668 }
669
670 JsonValue::Object(object).to_string_compact()
671}
672
673fn extract_text_from_parts(parts: &[JsonValue]) -> Option<String> {
674 let mut chunks = Vec::new();
675 for part in parts {
676 if let Some(text) = part.as_str() {
677 let trimmed = text.trim();
678 if !trimmed.is_empty() {
679 chunks.push(trimmed.to_string());
680 }
681 continue;
682 }
683
684 let Some(object) = part.as_object() else {
685 continue;
686 };
687 let Some(text) = object.get("text").and_then(JsonValue::as_str) else {
688 continue;
689 };
690 let trimmed = text.trim();
691 if !trimmed.is_empty() {
692 chunks.push(trimmed.to_string());
693 }
694 }
695
696 if chunks.is_empty() {
697 None
698 } else {
699 Some(chunks.join("\n\n"))
700 }
701}
702
703fn parse_openai_prompt_response(
704 body: &str,
705 requested_model: &str,
706) -> RedDBResult<AiPromptResponse> {
707 let parsed = parse_json(body)
708 .map_err(|err| RedDBError::Query(format!("invalid OpenAI prompt JSON response: {err}")))?;
709 let json = JsonValue::from(parsed);
710
711 let model = json
712 .get("model")
713 .and_then(JsonValue::as_str)
714 .unwrap_or(requested_model)
715 .to_string();
716
717 let Some(choices) = json.get("choices").and_then(JsonValue::as_array) else {
718 return Err(RedDBError::Query(
719 "OpenAI prompt response missing 'choices' array".to_string(),
720 ));
721 };
722 let Some(first_choice) = choices.first() else {
723 return Err(RedDBError::Query(
724 "OpenAI prompt response contains no choices".to_string(),
725 ));
726 };
727
728 let output_text = first_choice
729 .get("message")
730 .and_then(|message| {
731 if let Some(text) = message.get("content").and_then(JsonValue::as_str) {
732 let trimmed = text.trim();
733 if !trimmed.is_empty() {
734 return Some(trimmed.to_string());
735 }
736 }
737 message
738 .get("content")
739 .and_then(JsonValue::as_array)
740 .and_then(extract_text_from_parts)
741 })
742 .ok_or_else(|| {
743 RedDBError::Query("OpenAI prompt response missing text content".to_string())
744 })?;
745
746 let prompt_tokens = json
747 .get("usage")
748 .and_then(|usage| usage.get("prompt_tokens"))
749 .and_then(JsonValue::as_i64)
750 .and_then(|value| u64::try_from(value).ok());
751 let completion_tokens = json
752 .get("usage")
753 .and_then(|usage| usage.get("completion_tokens"))
754 .and_then(JsonValue::as_i64)
755 .and_then(|value| u64::try_from(value).ok());
756 let total_tokens = json
757 .get("usage")
758 .and_then(|usage| usage.get("total_tokens"))
759 .and_then(JsonValue::as_i64)
760 .and_then(|value| u64::try_from(value).ok())
761 .or_else(|| match (prompt_tokens, completion_tokens) {
762 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
763 _ => None,
764 });
765
766 let stop_reason = first_choice
767 .get("finish_reason")
768 .and_then(JsonValue::as_str)
769 .map(str::to_string);
770
771 Ok(AiPromptResponse {
772 provider: "openai",
773 model,
774 output_text,
775 output_chunks: None,
776 prompt_tokens,
777 completion_tokens,
778 total_tokens,
779 stop_reason,
780 })
781}
782
783fn parse_openai_streaming_prompt_response(
784 body: &str,
785 requested_model: &str,
786) -> RedDBResult<AiPromptResponse> {
787 let mut model = requested_model.to_string();
788 let mut chunks = Vec::new();
789 let mut prompt_tokens = None;
790 let mut completion_tokens = None;
791 let mut total_tokens = None;
792 let mut stop_reason = None;
793
794 for line in body.lines() {
795 let line = line.trim();
796 let Some(data) = line.strip_prefix("data:") else {
797 continue;
798 };
799 let data = data.trim();
800 if data.is_empty() {
801 continue;
802 }
803 if data == "[DONE]" {
804 break;
805 }
806
807 let parsed = parse_json(data).map_err(|err| {
808 RedDBError::Query(format!(
809 "invalid OpenAI streaming prompt JSON response: {err}"
810 ))
811 })?;
812 let json = JsonValue::from(parsed);
813 if let Some(value) = json.get("model").and_then(JsonValue::as_str) {
814 model = value.to_string();
815 }
816 if let Some(usage) = json.get("usage") {
817 prompt_tokens = usage
818 .get("prompt_tokens")
819 .and_then(JsonValue::as_i64)
820 .and_then(|value| u64::try_from(value).ok())
821 .or(prompt_tokens);
822 completion_tokens = usage
823 .get("completion_tokens")
824 .and_then(JsonValue::as_i64)
825 .and_then(|value| u64::try_from(value).ok())
826 .or(completion_tokens);
827 total_tokens = usage
828 .get("total_tokens")
829 .and_then(JsonValue::as_i64)
830 .and_then(|value| u64::try_from(value).ok())
831 .or(total_tokens);
832 }
833
834 let Some(choices) = json.get("choices").and_then(JsonValue::as_array) else {
835 continue;
836 };
837 let Some(first_choice) = choices.first() else {
838 continue;
839 };
840 if let Some(reason) = first_choice
841 .get("finish_reason")
842 .and_then(JsonValue::as_str)
843 {
844 stop_reason = Some(reason.to_string());
845 }
846 if let Some(text) = first_choice
847 .get("delta")
848 .and_then(|delta| delta.get("content"))
849 .and_then(JsonValue::as_str)
850 {
851 if !text.is_empty() {
852 chunks.push(text.to_string());
853 }
854 }
855 }
856
857 if chunks.is_empty() {
858 return Err(RedDBError::Query(
859 "OpenAI streaming prompt response missing text content".to_string(),
860 ));
861 }
862
863 let output_text = chunks.concat();
864 let total_tokens = total_tokens.or_else(|| match (prompt_tokens, completion_tokens) {
865 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
866 _ => None,
867 });
868
869 Ok(AiPromptResponse {
870 provider: "openai",
871 model,
872 output_text,
873 output_chunks: Some(chunks),
874 prompt_tokens,
875 completion_tokens,
876 total_tokens,
877 stop_reason,
878 })
879}
880
881fn parse_anthropic_prompt_response(
882 body: &str,
883 requested_model: &str,
884) -> RedDBResult<AiPromptResponse> {
885 let parsed = parse_json(body).map_err(|err| {
886 RedDBError::Query(format!("invalid Anthropic prompt JSON response: {err}"))
887 })?;
888 let json = JsonValue::from(parsed);
889
890 let model = json
891 .get("model")
892 .and_then(JsonValue::as_str)
893 .unwrap_or(requested_model)
894 .to_string();
895
896 let Some(content_parts) = json.get("content").and_then(JsonValue::as_array) else {
897 return Err(RedDBError::Query(
898 "Anthropic prompt response missing 'content' array".to_string(),
899 ));
900 };
901
902 let output_text = extract_text_from_parts(content_parts).ok_or_else(|| {
903 RedDBError::Query("Anthropic prompt response missing text content".to_string())
904 })?;
905
906 let prompt_tokens = json
907 .get("usage")
908 .and_then(|usage| usage.get("input_tokens"))
909 .and_then(JsonValue::as_i64)
910 .and_then(|value| u64::try_from(value).ok());
911 let completion_tokens = json
912 .get("usage")
913 .and_then(|usage| usage.get("output_tokens"))
914 .and_then(JsonValue::as_i64)
915 .and_then(|value| u64::try_from(value).ok());
916 let total_tokens = match (prompt_tokens, completion_tokens) {
917 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
918 _ => None,
919 };
920
921 let stop_reason = json
922 .get("stop_reason")
923 .and_then(JsonValue::as_str)
924 .map(str::to_string);
925
926 Ok(AiPromptResponse {
927 provider: "anthropic",
928 model,
929 output_text,
930 output_chunks: None,
931 prompt_tokens,
932 completion_tokens,
933 total_tokens,
934 stop_reason,
935 })
936}
937
938fn parse_openai_embedding_response(body: &str) -> RedDBResult<OpenAiEmbeddingResponse> {
939 let parsed = parse_json(body).map_err(|err| {
940 RedDBError::Query(format!("invalid OpenAI embeddings JSON response: {err}"))
941 })?;
942 let json = JsonValue::from(parsed);
943
944 let model = json
945 .get("model")
946 .and_then(JsonValue::as_str)
947 .unwrap_or(DEFAULT_OPENAI_EMBEDDING_MODEL)
948 .to_string();
949
950 let Some(data) = json.get("data").and_then(JsonValue::as_array) else {
951 return Err(RedDBError::Query(
952 "OpenAI response missing 'data' array".to_string(),
953 ));
954 };
955
956 let mut rows: Vec<(usize, Vec<f32>)> = Vec::with_capacity(data.len());
957 for (position, item) in data.iter().enumerate() {
958 let index = item
959 .get("index")
960 .and_then(JsonValue::as_i64)
961 .and_then(|value| usize::try_from(value).ok())
962 .unwrap_or(position);
963
964 let Some(embedding_values) = item.get("embedding").and_then(JsonValue::as_array) else {
965 return Err(RedDBError::Query(
966 "OpenAI response contains item without 'embedding' array".to_string(),
967 ));
968 };
969 if embedding_values.is_empty() {
970 return Err(RedDBError::Query(
971 "OpenAI response contains empty embedding vector".to_string(),
972 ));
973 }
974
975 let mut embedding = Vec::with_capacity(embedding_values.len());
976 for value in embedding_values {
977 let Some(number) = value.as_f64() else {
978 return Err(RedDBError::Query(
979 "OpenAI response contains non-numeric embedding value".to_string(),
980 ));
981 };
982 embedding.push(number as f32);
983 }
984 rows.push((index, embedding));
985 }
986 rows.sort_by_key(|(index, _)| *index);
987 let embeddings = rows.into_iter().map(|(_, embedding)| embedding).collect();
988
989 let prompt_tokens = json
990 .get("usage")
991 .and_then(|usage| usage.get("prompt_tokens"))
992 .and_then(JsonValue::as_i64)
993 .and_then(|value| u64::try_from(value).ok());
994 let total_tokens = json
995 .get("usage")
996 .and_then(|usage| usage.get("total_tokens"))
997 .and_then(JsonValue::as_i64)
998 .and_then(|value| u64::try_from(value).ok());
999
1000 Ok(OpenAiEmbeddingResponse {
1001 provider: "openai",
1002 model,
1003 embeddings,
1004 prompt_tokens,
1005 total_tokens,
1006 })
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1014 fn parse_openai_embedding_response_extracts_vectors() {
1015 let body = r#"{
1016 "object":"list",
1017 "data":[
1018 {"object":"embedding","index":1,"embedding":[0.3,0.4]},
1019 {"object":"embedding","index":0,"embedding":[0.1,0.2]}
1020 ],
1021 "model":"text-embedding-3-small",
1022 "usage":{"prompt_tokens":12,"total_tokens":12}
1023 }"#;
1024
1025 let result = parse_openai_embedding_response(body).expect("response should parse");
1026 assert_eq!(result.provider, "openai");
1027 assert_eq!(result.model, "text-embedding-3-small");
1028 assert_eq!(result.embeddings.len(), 2);
1029 assert_eq!(result.embeddings[0], vec![0.1, 0.2]);
1030 assert_eq!(result.embeddings[1], vec![0.3, 0.4]);
1031 assert_eq!(result.prompt_tokens, Some(12));
1032 assert_eq!(result.total_tokens, Some(12));
1033 }
1034
1035 #[test]
1036 fn openai_error_message_extracts_nested_message() {
1037 let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
1038 assert_eq!(openai_error_message(body).as_deref(), Some("bad api key"));
1039 }
1040
1041 #[test]
1042 fn parse_openai_prompt_response_extracts_text_and_usage() {
1043 let body = r#"{
1044 "id":"chatcmpl_1",
1045 "object":"chat.completion",
1046 "model":"gpt-4.1-mini",
1047 "choices":[
1048 {
1049 "index":0,
1050 "finish_reason":"stop",
1051 "message":{"role":"assistant","content":"Resumo pronto."}
1052 }
1053 ],
1054 "usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}
1055 }"#;
1056
1057 let parsed =
1058 parse_openai_prompt_response(body, DEFAULT_OPENAI_PROMPT_MODEL).expect("parse");
1059 assert_eq!(parsed.provider, "openai");
1060 assert_eq!(parsed.model, "gpt-4.1-mini");
1061 assert_eq!(parsed.output_text, "Resumo pronto.");
1062 assert_eq!(parsed.prompt_tokens, Some(10));
1063 assert_eq!(parsed.completion_tokens, Some(4));
1064 assert_eq!(parsed.total_tokens, Some(14));
1065 assert_eq!(parsed.stop_reason.as_deref(), Some("stop"));
1066 }
1067
1068 #[test]
1069 fn parse_anthropic_prompt_response_extracts_text_and_usage() {
1070 let body = r#"{
1071 "id":"msg_1",
1072 "model":"claude-3-5-haiku-latest",
1073 "type":"message",
1074 "content":[{"type":"text","text":"Action complete."}],
1075 "usage":{"input_tokens":11,"output_tokens":5},
1076 "stop_reason":"end_turn"
1077 }"#;
1078
1079 let parsed =
1080 parse_anthropic_prompt_response(body, DEFAULT_ANTHROPIC_PROMPT_MODEL).expect("parse");
1081 assert_eq!(parsed.provider, "anthropic");
1082 assert_eq!(parsed.model, "claude-3-5-haiku-latest");
1083 assert_eq!(parsed.output_text, "Action complete.");
1084 assert_eq!(parsed.prompt_tokens, Some(11));
1085 assert_eq!(parsed.completion_tokens, Some(5));
1086 assert_eq!(parsed.total_tokens, Some(16));
1087 assert_eq!(parsed.stop_reason.as_deref(), Some("end_turn"));
1088 }
1089
1090 #[test]
1091 fn resolve_api_key_prefers_new_vault_path_over_removed_paths() {
1092 let provider = AiProvider::OpenAi;
1093 let alias = "vault_unit_alias";
1094 let secret_path = ai_api_secret_path(&provider, alias);
1095 let removed_legacy = removed_plaintext_config_key(&provider, alias);
1096 let removed_vault = removed_vault_api_key_path(&provider, alias);
1097
1098 let resolved = resolve_api_key(&provider, Some(alias), |key| {
1099 if key == secret_path {
1100 Ok(Some("vault-key".to_string()))
1101 } else if key == removed_legacy || key == removed_vault {
1102 Ok(Some("stale-key".to_string()))
1103 } else {
1104 Ok(None)
1105 }
1106 })
1107 .expect("resolve");
1108
1109 assert_eq!(resolved, "vault-key");
1110 }
1111
1112 #[test]
1113 fn resolve_api_key_rejects_removed_plaintext_config_path() {
1114 let provider = AiProvider::Custom("cred1745legacy".to_string());
1115 let alias = "prod";
1116 let removed_legacy = removed_plaintext_config_key(&provider, alias);
1117 let new_path = ai_api_secret_path(&provider, alias);
1118
1119 let err = resolve_api_key(&provider, Some(alias), |key| {
1122 if key == removed_legacy {
1123 Ok(Some("stale-plaintext-key".to_string()))
1124 } else {
1125 Ok(None)
1126 }
1127 })
1128 .expect_err("must reject removed path");
1129 let msg = err.to_string();
1130 assert!(msg.contains(&removed_legacy), "names removed path: {msg}");
1131 assert!(msg.contains(&new_path), "names new vault path: {msg}");
1132 }
1133
1134 #[test]
1135 fn resolve_api_key_rejects_removed_vault_api_key_path() {
1136 let provider = AiProvider::Custom("cred1745oldvault".to_string());
1137 let removed_vault = removed_vault_api_key_path(&provider, "default");
1138 let new_path = ai_api_secret_path(&provider, "default");
1139
1140 let err = resolve_api_key(&provider, None, |key| {
1141 if key == removed_vault {
1142 Ok(Some("stale-vault-key".to_string()))
1143 } else {
1144 Ok(None)
1145 }
1146 })
1147 .expect_err("must reject removed vault path");
1148 let msg = err.to_string();
1149 assert!(msg.contains(&removed_vault), "names removed path: {msg}");
1150 assert!(msg.contains(&new_path), "names new vault path: {msg}");
1151 }
1152
1153 #[test]
1154 fn resolve_api_key_alias_token_overrides_default_per_request() {
1155 let provider = AiProvider::OpenAi;
1156 let default_path = ai_api_secret_path(&provider, "default");
1157 let prod_path = ai_api_secret_path(&provider, "prod");
1158 let getter = |key: &str| {
1159 if key == default_path {
1160 Ok(Some("default-token".to_string()))
1161 } else if key == prod_path {
1162 Ok(Some("prod-token".to_string()))
1163 } else {
1164 Ok(None)
1165 }
1166 };
1167 assert_eq!(
1170 resolve_api_key(&provider, Some("prod"), getter).expect("prod"),
1171 "prod-token"
1172 );
1173 assert_eq!(
1174 resolve_api_key(&provider, None, getter).expect("default"),
1175 "default-token"
1176 );
1177 }
1178
1179 #[test]
1180 fn ai_api_secret_path_uses_providers_tokens_shape() {
1181 let path = ai_api_secret_path(&AiProvider::OpenAi, "default");
1182 assert_eq!(path, "red.secret.ai.providers.openai.tokens.default");
1183 let aliased = ai_api_secret_path(&AiProvider::OpenAi, "Prod");
1184 assert_eq!(aliased, "red.secret.ai.providers.openai.tokens.prod");
1185 }
1186
1187 #[test]
1188 fn resolve_api_key_uses_default_vault_secret_path() {
1189 let provider = AiProvider::OpenAi;
1190 let secret_path = ai_api_secret_path(&provider, "default");
1191
1192 let resolved = resolve_api_key(&provider, None, |key| {
1193 if key == secret_path {
1194 Ok(Some("default-vault-key".to_string()))
1195 } else {
1196 Ok(None)
1197 }
1198 })
1199 .expect("resolve");
1200
1201 assert_eq!(resolved, "default-vault-key");
1202 }
1203
1204 #[test]
1210 fn resolve_api_key_uses_env_when_no_vault_entry() {
1211 let provider = AiProvider::Custom("cred1270envonly".to_string());
1212 let env_name = provider.default_key_env_name();
1213 std::env::set_var(&env_name, "env-fallback-key");
1214
1215 let resolved = resolve_api_key(&provider, None, |_| Ok(None));
1217
1218 std::env::remove_var(&env_name);
1219 assert_eq!(resolved.expect("resolve"), "env-fallback-key");
1220 }
1221
1222 #[test]
1223 fn resolve_api_key_prefers_vault_over_env() {
1224 let provider = AiProvider::Custom("cred1270both".to_string());
1225 let env_name = provider.default_key_env_name();
1226 let secret_path = ai_api_secret_path(&provider, "default");
1227 std::env::set_var(&env_name, "env-fallback-key");
1228
1229 let resolved = resolve_api_key(&provider, None, |key| {
1231 if key == secret_path {
1232 Ok(Some("vault-managed-key".to_string()))
1233 } else {
1234 Ok(None)
1235 }
1236 });
1237
1238 std::env::remove_var(&env_name);
1239 assert_eq!(resolved.expect("resolve"), "vault-managed-key");
1240 }
1241
1242 #[test]
1243 fn resolve_api_key_alias_prefers_vault_over_env() {
1244 let provider = AiProvider::Custom("cred1270alias".to_string());
1245 let alias = "prod";
1246 let env_name = provider.alias_key_env_name(alias);
1247 let secret_path = ai_api_secret_path(&provider, alias);
1248 std::env::set_var(&env_name, "env-alias-key");
1249
1250 let resolved = resolve_api_key(&provider, Some(alias), |key| {
1251 if key == secret_path {
1252 Ok(Some("vault-alias-key".to_string()))
1253 } else {
1254 Ok(None)
1255 }
1256 });
1257
1258 std::env::remove_var(&env_name);
1259 assert_eq!(resolved.expect("resolve"), "vault-alias-key");
1260 }
1261
1262 #[test]
1263 fn resolve_api_key_alias_falls_back_to_env_without_vault() {
1264 let provider = AiProvider::Custom("cred1270aliasenv".to_string());
1265 let alias = "prod";
1266 let env_name = provider.alias_key_env_name(alias);
1267 std::env::set_var(&env_name, "env-alias-key");
1268
1269 let resolved = resolve_api_key(&provider, Some(alias), |_| Ok(None));
1270
1271 std::env::remove_var(&env_name);
1272 assert_eq!(resolved.expect("resolve"), "env-alias-key");
1273 }
1274
1275 #[test]
1276 fn openai_prompt_payload_includes_temperature_and_seed_when_present() {
1277 let payload = build_openai_prompt_payload(
1278 "gpt-4.1-mini",
1279 "hello",
1280 Some(0.0),
1281 Some(42),
1282 Some(128),
1283 false,
1284 );
1285 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1286
1287 assert_eq!(
1288 parsed.get("temperature").and_then(JsonValue::as_f64),
1289 Some(0.0)
1290 );
1291 assert_eq!(parsed.get("seed").and_then(JsonValue::as_u64), Some(42));
1292 assert_eq!(
1293 parsed.get("max_tokens").and_then(JsonValue::as_u64),
1294 Some(128)
1295 );
1296 }
1297
1298 #[test]
1299 fn openai_prompt_payload_omits_seed_when_none() {
1300 let payload =
1301 build_openai_prompt_payload("gpt-4.1-mini", "hello", Some(0.0), None, None, false);
1302 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1303
1304 assert!(parsed.get("seed").is_none());
1305 assert!(parsed.get("stream").is_none());
1306 assert_eq!(
1307 parsed.get("temperature").and_then(JsonValue::as_f64),
1308 Some(0.0)
1309 );
1310 }
1311
1312 #[test]
1313 fn openai_prompt_payload_default_seed_is_portable_and_matches_audit() {
1314 use crate::runtime::ai::{determinism_decider, provider_capabilities::Registry};
1315
1316 let applied = determinism_decider::decide(
1317 determinism_decider::Inputs {
1318 question: "what is the meaning of life?",
1319 sources_fingerprint: "abc123",
1320 },
1321 Registry::default().capabilities("openrouter"),
1322 determinism_decider::Overrides::default(),
1323 determinism_decider::Settings::default(),
1324 );
1325 let payload = build_openai_prompt_payload(
1326 "google/gemini-2.5-flash-lite",
1327 "hello",
1328 applied.temperature,
1329 applied.seed,
1330 None,
1331 false,
1332 );
1333 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1334 let seed = parsed
1335 .get("seed")
1336 .and_then(JsonValue::as_u64)
1337 .expect("default seed");
1338 assert!(seed <= i32::MAX as u64);
1339 assert_eq!(Some(seed), applied.seed);
1340 }
1341
1342 #[test]
1343 fn openai_prompt_payload_enables_stream_options() {
1344 let payload =
1345 build_openai_prompt_payload("gpt-4.1-mini", "hello", Some(0.0), None, None, true);
1346 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1347
1348 assert_eq!(
1349 parsed.get("stream").and_then(JsonValue::as_bool),
1350 Some(true)
1351 );
1352 assert_eq!(
1353 parsed
1354 .get("stream_options")
1355 .and_then(|value| value.get("include_usage"))
1356 .and_then(JsonValue::as_bool),
1357 Some(true)
1358 );
1359 }
1360
1361 #[test]
1362 fn openai_streaming_prompt_response_collects_delta_chunks() {
1363 let body = concat!(
1364 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"login \"},\"finish_reason\":null}]}\n\n",
1365 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"failed\"},\"finish_reason\":null}]}\n\n",
1366 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"total_tokens\":14}}\n\n",
1367 "data: [DONE]\n\n",
1368 );
1369 let parsed = parse_openai_streaming_prompt_response(body, "fallback").unwrap();
1370
1371 assert_eq!(parsed.model, "gpt-test");
1372 assert_eq!(parsed.output_text, "login failed");
1373 assert_eq!(
1374 parsed.output_chunks.as_deref(),
1375 Some(["login ".to_string(), "failed".to_string()].as_slice())
1376 );
1377 assert_eq!(parsed.prompt_tokens, Some(12));
1378 assert_eq!(parsed.completion_tokens, Some(2));
1379 assert_eq!(parsed.total_tokens, Some(14));
1380 assert_eq!(parsed.stop_reason.as_deref(), Some("stop"));
1381 }
1382
1383 #[tokio::test]
1384 async fn openai_prompt_async_rejects_empty_model() {
1385 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1386 let request = OpenAiPromptRequest {
1387 api_key: "key".to_string(),
1388 model: " ".to_string(),
1389 prompt: "hello".to_string(),
1390 temperature: None,
1391 seed: None,
1392 max_output_tokens: None,
1393 api_base: "https://api.openai.com/v1".to_string(),
1394 stream: false,
1395 };
1396 let err = openai_prompt_async(&transport, request).await.unwrap_err();
1397 assert!(err.to_string().contains("model cannot be empty"));
1398 }
1399
1400 #[tokio::test]
1401 async fn openai_prompt_async_rejects_empty_prompt() {
1402 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1403 let request = OpenAiPromptRequest {
1404 api_key: "key".to_string(),
1405 model: "gpt-4.1-mini".to_string(),
1406 prompt: "".to_string(),
1407 temperature: None,
1408 seed: None,
1409 max_output_tokens: None,
1410 api_base: "https://api.openai.com/v1".to_string(),
1411 stream: false,
1412 };
1413 let err = openai_prompt_async(&transport, request).await.unwrap_err();
1414 assert!(err.to_string().contains("prompt cannot be empty"));
1415 }
1416
1417 use std::io::{Read as _, Write as _};
1427 use std::net::TcpListener;
1428 use std::sync::{Arc, Mutex};
1429 use std::thread;
1430
1431 struct CapturedRequest {
1432 method: String,
1433 path: String,
1434 headers: Vec<(String, String)>,
1435 body: String,
1436 }
1437
1438 fn parse_http_request(stream: &mut std::net::TcpStream) -> CapturedRequest {
1439 let mut buf = [0u8; 8192];
1440 let mut data = Vec::new();
1441 loop {
1442 let read = stream.read(&mut buf).unwrap_or(0);
1443 if read == 0 {
1444 break;
1445 }
1446 data.extend_from_slice(&buf[..read]);
1447 if let Some(idx) = data.windows(4).position(|w| w == b"\r\n\r\n") {
1448 let header_len = idx + 4;
1449 let header_str = String::from_utf8_lossy(&data[..idx]).to_string();
1450 let mut lines = header_str.split("\r\n");
1451 let request_line = lines.next().unwrap_or("");
1452 let mut parts = request_line.split_whitespace();
1453 let method = parts.next().unwrap_or("").to_string();
1454 let path = parts.next().unwrap_or("").to_string();
1455 let mut headers = Vec::new();
1456 let mut content_length: usize = 0;
1457 for line in lines {
1458 if let Some((k, v)) = line.split_once(':') {
1459 let k = k.trim().to_string();
1460 let v = v.trim().to_string();
1461 if k.eq_ignore_ascii_case("content-length") {
1462 content_length = v.parse().unwrap_or(0);
1463 }
1464 headers.push((k, v));
1465 }
1466 }
1467 while data.len() < header_len + content_length {
1468 let read = stream.read(&mut buf).unwrap_or(0);
1469 if read == 0 {
1470 break;
1471 }
1472 data.extend_from_slice(&buf[..read]);
1473 }
1474 let body = String::from_utf8_lossy(&data[header_len..header_len + content_length])
1475 .to_string();
1476 return CapturedRequest {
1477 method,
1478 path,
1479 headers,
1480 body,
1481 };
1482 }
1483 }
1484 CapturedRequest {
1485 method: String::new(),
1486 path: String::new(),
1487 headers: Vec::new(),
1488 body: String::new(),
1489 }
1490 }
1491
1492 fn spawn_mock(
1495 status: u16,
1496 response_body: &'static str,
1497 ) -> (String, Arc<Mutex<Option<CapturedRequest>>>) {
1498 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1499 let addr = listener.local_addr().expect("addr");
1500 let captured: Arc<Mutex<Option<CapturedRequest>>> = Arc::new(Mutex::new(None));
1501 let captured_clone = Arc::clone(&captured);
1502 thread::spawn(move || {
1503 if let Ok((mut stream, _)) = listener.accept() {
1504 let req = parse_http_request(&mut stream);
1505 *captured_clone.lock().unwrap() = Some(req);
1506 let status_line = match status {
1507 200 => "200 OK",
1508 400 => "400 Bad Request",
1509 401 => "401 Unauthorized",
1510 500 => "500 Internal Server Error",
1511 _ => "200 OK",
1512 };
1513 let resp = format!(
1514 "HTTP/1.1 {status_line}\r\n\
1515 Content-Type: application/json\r\n\
1516 Content-Length: {}\r\n\
1517 Connection: close\r\n\r\n{}",
1518 response_body.len(),
1519 response_body
1520 );
1521 let _ = stream.write_all(resp.as_bytes());
1522 }
1523 });
1524 (format!("http://{}", addr), captured)
1525 }
1526
1527 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1528 async fn gateway_registration_drives_http_grpc_and_batch_with_saved_connection() {
1529 use crate::auth::{AuthConfig, AuthStore};
1530 use crate::{RedDBOptions, RedDBRuntime, StorageDeployPreset};
1531 let path = std::env::temp_dir().join(format!(
1532 "reddb_gateway_roundtrip_{}_{}.rdb",
1533 std::process::id(),
1534 std::time::SystemTime::now()
1535 .duration_since(std::time::UNIX_EPOCH)
1536 .expect("clock")
1537 .as_nanos(),
1538 ));
1539 let runtime = RedDBRuntime::with_options(
1540 RedDBOptions::persistent(&path)
1541 .with_storage_profile(StorageDeployPreset::Serverless.selection())
1542 .expect("profile"),
1543 )
1544 .expect("runtime");
1545 let db = runtime.db();
1546 let store = db.store();
1547 let pager = store.pager().expect("pager");
1548 let auth = Arc::new(
1549 AuthStore::with_vault_certificate(
1550 AuthConfig::default(),
1551 Arc::clone(pager),
1552 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
1553 )
1554 .expect("vault"),
1555 );
1556 let server = crate::server::RedDBServer::new(runtime.clone()).with_auth(auth);
1557 let embedding_body =
1558 r#"{"data":[{"index":0,"embedding":[0.1,0.2]}],"model":"Gateway/Embedding"}"#;
1559 for operation in [
1560 "prompt",
1561 "ask",
1562 "http-embeddings",
1563 "grpc-embeddings",
1564 "batch",
1565 "auto-embed",
1566 ] {
1567 let response_body = if matches!(operation, "prompt" | "ask") {
1568 r#"{"choices":[{"message":{"content":"hello"}}],"model":"Gateway/Chat"}"#
1569 } else {
1570 embedding_body
1571 };
1572 let (base, captured) = spawn_mock(200, response_body);
1573 let endpoint = format!("{base}/Gateway/v1");
1574 let registration = format!(
1575 r#"{{"provider":"red-router","alias":"mine","api_key":"synthetic-gateway-key","endpoint":"{endpoint}","default":true,"model":"Gateway/Chat"}}"#
1576 );
1577 let response = server.handle_ai_credentials(registration.into_bytes());
1578 assert_eq!(
1579 response.status,
1580 200,
1581 "{}",
1582 String::from_utf8_lossy(&response.body)
1583 );
1584 assert!(!String::from_utf8_lossy(&response.body).contains("synthetic-gateway-key"));
1585 match operation {
1586 "prompt" => {
1587 let response = server.handle_ai_prompt(br#"{"prompt":"hi"}"#.to_vec());
1588 assert_eq!(
1589 response.status,
1590 200,
1591 "{}",
1592 String::from_utf8_lossy(&response.body)
1593 );
1594 }
1595 "ask" => {
1596 let response = server.handle_ai_ask(br#"{"question":"hi"}"#.to_vec());
1597 assert_eq!(
1598 response.status,
1599 200,
1600 "{}",
1601 String::from_utf8_lossy(&response.body)
1602 );
1603 }
1604 "auto-embed" => {
1605 runtime.execute_query("INSERT INTO gateway_batch (body) VALUES ('hi') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'").expect("auto embed");
1606 let rows = runtime
1607 .execute_query("SELECT * FROM gateway_batch")
1608 .expect("rows");
1609 assert_eq!(
1610 rows.result.records.len(),
1611 2,
1612 "one row and its embedding vector"
1613 );
1614 }
1615 "http-embeddings" => {
1616 let response = server.handle_ai_embeddings(
1617 br#"{"model":"Gateway/Embedding","input":"hi"}"#.to_vec(),
1618 );
1619 assert_eq!(
1620 response.status,
1621 200,
1622 "{}",
1623 String::from_utf8_lossy(&response.body)
1624 );
1625 }
1626 "grpc-embeddings" => {
1627 let payload =
1628 crate::serde_json::json!({"model":"Gateway/Embedding","input":"hi"});
1629 grpc_embeddings(&runtime, &payload).expect("gRPC embedding");
1630 }
1631 "batch" => {
1632 let connection =
1633 resolve_connection_from_runtime(&AiProvider::RedRouter, None, &runtime)
1634 .expect("connection");
1635 let client =
1636 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(&runtime);
1637 let vectors = client
1638 .embed_batch_at(
1639 &AiProvider::RedRouter,
1640 "Gateway/Embedding",
1641 &connection.api_key,
1642 &connection.api_base,
1643 vec!["hi".to_string()],
1644 )
1645 .await
1646 .expect("batch");
1647 assert_eq!(vectors, vec![vec![0.1, 0.2]]);
1648 }
1649 _ => unreachable!("listed operation"),
1650 }
1651 let request = captured
1652 .lock()
1653 .expect("capture lock")
1654 .take()
1655 .expect("request");
1656 let suffix = if matches!(operation, "prompt" | "ask") {
1657 "chat/completions"
1658 } else {
1659 "embeddings"
1660 };
1661 assert_eq!(request.path, format!("/Gateway/v1/{suffix}"));
1662 assert!(request
1663 .headers
1664 .iter()
1665 .any(|(name, value)| name.eq_ignore_ascii_case("authorization")
1666 && value == "Bearer synthetic-gateway-key"));
1667 let model = if matches!(operation, "prompt" | "ask") {
1668 "Gateway/Chat"
1669 } else {
1670 "Gateway/Embedding"
1671 };
1672 assert!(request.body.contains(model), "{}", request.body);
1673 }
1674 drop(server);
1675 drop(runtime);
1676 drop(db);
1677 let _ = std::fs::remove_file(path);
1678 }
1679
1680 #[test]
1681 fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
1682 let body = r#"{
1683 "id":"chatcmpl_x",
1684 "model":"custom-model",
1685 "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
1686 "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
1687 }"#;
1688 let (base, captured) = spawn_mock(200, body);
1689
1690 let req = OpenAiCompatChatRequest {
1691 api_base: base.clone(),
1692 api_key: "sk-test".to_string(),
1693 model: "custom-model".to_string(),
1694 prompt: "say hi".to_string(),
1695 temperature: None,
1696 seed: None,
1697 max_output_tokens: None,
1698 extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
1699 };
1700 let resp = openai_compat_chat(req).expect("ok");
1701
1702 assert_eq!(resp.output_text, "hi");
1703 assert_eq!(resp.model, "custom-model");
1704 assert_eq!(resp.usage.input_tokens, Some(7));
1705 assert_eq!(resp.usage.output_tokens, Some(2));
1706 assert_eq!(resp.usage.total_tokens, Some(9));
1707 assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
1708
1709 let cap = captured.lock().unwrap().take().expect("captured");
1710 assert_eq!(cap.method, "POST");
1711 assert_eq!(cap.path, "/chat/completions");
1712 let has_auth = cap
1713 .headers
1714 .iter()
1715 .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
1716 assert!(has_auth, "Authorization header missing");
1717 let has_custom = cap
1718 .headers
1719 .iter()
1720 .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
1721 assert!(has_custom, "extra header missing");
1722 assert!(cap.body.contains("\"model\":\"custom-model\""));
1723 }
1724
1725 #[test]
1726 fn openai_compat_embeddings_roundtrip_with_dimensions() {
1727 let body = r#"{
1728 "object":"list",
1729 "model":"embed-model",
1730 "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
1731 "usage":{"prompt_tokens":4,"total_tokens":4}
1732 }"#;
1733 let (base, captured) = spawn_mock(200, body);
1734
1735 let req = OpenAiCompatEmbeddingsRequest {
1736 api_base: base,
1737 api_key: "sk-emb".to_string(),
1738 model: "embed-model".to_string(),
1739 inputs: vec!["hello".to_string()],
1740 dimensions: Some(2),
1741 extra_headers: vec![],
1742 };
1743 let resp = openai_compat_embeddings(req).expect("ok");
1744
1745 assert_eq!(resp.embeddings.len(), 1);
1746 assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
1747 assert_eq!(resp.usage.total_tokens, Some(4));
1748 assert_eq!(resp.usage.input_tokens, Some(4));
1749
1750 let cap = captured.lock().unwrap().take().expect("captured");
1751 assert_eq!(cap.path, "/embeddings");
1752 assert!(cap.body.contains("\"dimensions\":2"));
1753 }
1754
1755 #[test]
1756 fn openai_compat_chat_non_2xx_returns_structured_error() {
1757 let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
1758 let (base, _captured) = spawn_mock(401, body);
1759
1760 let req = OpenAiCompatChatRequest {
1761 api_base: base,
1762 api_key: "bad".to_string(),
1763 model: "m".to_string(),
1764 prompt: "hi".to_string(),
1765 temperature: None,
1766 seed: None,
1767 max_output_tokens: None,
1768 extra_headers: vec![],
1769 };
1770 let err = openai_compat_chat(req).unwrap_err().to_string();
1771 assert!(err.contains("status 401"), "got: {err}");
1772 assert!(err.contains("bad api key"), "got: {err}");
1773 }
1774
1775 #[test]
1776 fn openai_compat_chat_rejects_empty_model_and_prompt() {
1777 let req = OpenAiCompatChatRequest {
1778 api_base: "http://localhost:1".to_string(),
1779 api_key: "k".to_string(),
1780 model: " ".to_string(),
1781 prompt: "hi".to_string(),
1782 temperature: None,
1783 seed: None,
1784 max_output_tokens: None,
1785 extra_headers: vec![],
1786 };
1787 let err = openai_compat_chat(req).unwrap_err().to_string();
1788 assert!(err.contains("model cannot be empty"), "got: {err}");
1789
1790 let req = OpenAiCompatChatRequest {
1791 api_base: "http://localhost:1".to_string(),
1792 api_key: "k".to_string(),
1793 model: "m".to_string(),
1794 prompt: " ".to_string(),
1795 temperature: None,
1796 seed: None,
1797 max_output_tokens: None,
1798 extra_headers: vec![],
1799 };
1800 let err = openai_compat_chat(req).unwrap_err().to_string();
1801 assert!(err.contains("prompt cannot be empty"), "got: {err}");
1802 }
1803
1804 #[test]
1805 fn parse_provider_mode_recognizes_all_three_tokens() {
1806 assert_eq!(
1807 parse_provider_mode("openai-compat"),
1808 Some(AiProviderMode::OpenAiCompat)
1809 );
1810 assert_eq!(
1811 parse_provider_mode("OPENAI_NATIVE"),
1812 Some(AiProviderMode::OpenAiNative)
1813 );
1814 assert_eq!(
1815 parse_provider_mode("anthropic-native"),
1816 Some(AiProviderMode::AnthropicNative)
1817 );
1818 assert_eq!(parse_provider_mode("groq"), None);
1819 }
1820
1821 #[test]
1822 fn resolve_provider_mode_reads_kv_key() {
1823 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1824 if key == "red.config.ai.provider" {
1825 Ok(Some("anthropic-native".to_string()))
1826 } else {
1827 Ok(None)
1828 }
1829 };
1830 assert_eq!(
1831 resolve_provider_mode(&kv),
1832 Some(AiProviderMode::AnthropicNative)
1833 );
1834 }
1835
1836 #[test]
1837 fn resolve_default_provider_keeps_vendor_when_mode_is_configured() {
1838 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1840 match key {
1841 "red.config.ai.provider" => Ok(Some("anthropic-native".to_string())),
1842 "red.config.ai.inference.provider" => Ok(Some("groq".to_string())),
1843 _ => Ok(None),
1844 }
1845 };
1846 assert_eq!(
1847 resolve_default_provider(&kv).expect("provider"),
1848 AiProvider::Groq
1849 );
1850 }
1851
1852 #[test]
1853 fn auto_embed_missing_gateway_connection_fails_before_row_write() {
1854 let runtime = crate::RedDBRuntime::in_memory().expect("runtime");
1855 runtime
1856 .execute_query("CREATE TABLE gateway_docs (id INT, body TEXT)")
1857 .expect("table");
1858 let error = runtime.execute_query(
1859 "INSERT INTO gateway_docs (id, body) VALUES (1, 'hello') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'",
1860 ).expect_err("missing connection");
1861 assert!(error.to_string().contains("endpoint"), "{error}");
1862 let result = runtime
1863 .execute_query("SELECT * FROM gateway_docs")
1864 .expect("rows");
1865 assert!(
1866 result.result.records.is_empty(),
1867 "invalid connection must leave no rows"
1868 );
1869 }
1870
1871 #[test]
1872 fn gateway_connections_fail_closed_when_either_part_is_missing() {
1873 let provider = parse_provider("red-router").expect("router");
1874 let no_endpoint = |key: &str| -> crate::RedDBResult<Option<String>> {
1875 assert!(
1876 !key.starts_with("red.secret."),
1877 "validate endpoint before reading secrets"
1878 );
1879 Ok(None)
1880 };
1881 let err = resolve_connection(&provider, None, &no_endpoint)
1882 .err()
1883 .expect("endpoint required");
1884 assert!(err.to_string().contains("endpoint"));
1885 let endpoint_only = |key: &str| -> crate::RedDBResult<Option<String>> {
1886 Ok((key == "red.config.ai.providers.red-router.base_url")
1887 .then(|| "https://example.com/v1".to_string()))
1888 };
1889 let err = resolve_connection(&provider, None, &endpoint_only)
1890 .err()
1891 .expect("key required");
1892 assert!(err.to_string().contains("API key"));
1893 }
1894
1895 #[test]
1896 fn gateway_connections_preserve_endpoint_and_selected_credential() {
1897 let provider = parse_provider("openai-compat").expect("gateway");
1898 let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
1899 Ok(match key {
1900 "red.config.ai.providers.openai-compat.base_url" => {
1901 Some("https://example.com/Gateway/v1".to_string())
1902 }
1903 "red.config.ai.providers.openai-compat.credential" => Some("my-key".to_string()),
1904 "red.secret.ai.providers.openai-compat.tokens.my-key" => {
1905 Some("synthetic-key".to_string())
1906 }
1907 _ => None,
1908 })
1909 };
1910 let connection = resolve_connection(&provider, None, &getter).expect("connection");
1911 assert_eq!(connection.api_base, "https://example.com/Gateway/v1");
1912 assert_eq!(connection.api_key, "synthetic-key");
1913 }
1914
1915 #[test]
1916 fn endpoint_validation_rejects_malformed_and_credential_bearing_urls() {
1917 for endpoint in [
1918 "local",
1919 "ftp://example.com/v1",
1920 "https://",
1921 "https://user:pass@example.com/v1",
1922 "https://example.com/v1?key=secret",
1923 "https://example.com/v1#fragment",
1924 ] {
1925 assert!(
1926 validate_custom_provider_url(endpoint).is_err(),
1927 "{endpoint}"
1928 );
1929 }
1930 assert!(validate_custom_provider_url("http://127.0.0.1:8000/MyGateway/v1").is_ok());
1931 }
1932
1933 #[test]
1934 fn gateway_provider_names_and_url_paths_keep_their_identity() {
1935 for name in ["openai-compat", "red-router"] {
1936 let provider = parse_provider(name).expect("named gateway");
1937 assert_eq!(provider.token(), name);
1938 assert!(provider.is_openai_compatible());
1939 assert!(provider.requires_api_key());
1940 assert_eq!(provider.default_api_base(), "");
1941 assert!(!provider.default_key_env_name().contains('-'));
1942 }
1943 let url = "https://example.com/MyGateway/v1";
1944 let provider = parse_provider(url).expect("URL provider");
1945 assert_eq!(provider.default_api_base(), url);
1946 }
1947
1948 #[test]
1949 fn implicit_credentials_reject_non_ai_secret_refs_before_reading_secret() {
1950 let provider = AiProvider::Custom("invalid_secret_ref_test".to_string());
1951 let key = ai_api_secret_ref_config_key(&provider, "default");
1952 let err = resolve_api_key(&provider, None, |path| {
1953 if path == key {
1954 return Ok(Some("red.secret.aes_key".to_string()));
1955 }
1956 assert_ne!(path, "red.secret.aes_key", "must not read engine secret");
1957 Ok(None)
1958 })
1959 .expect_err("non-AI secret ref must fail");
1960 assert!(err.to_string().contains("must reference"));
1961 }
1962
1963 #[test]
1966 fn invalid_provider_selection_does_not_fall_back_to_vendor_credentials() {
1967 let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
1968 Ok((key == "red.config.ai.inference.provider").then(|| "typo-provider".to_string()))
1969 };
1970 assert!(resolve_default_provider(&getter).is_err());
1971 assert!(resolve_embeddings_provider(&getter).is_err());
1972 }
1973
1974 #[test]
1975 fn inference_provider_ask_specific_beats_task_pointer() {
1976 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1977 match key {
1978 "red.config.ai.ask.provider" => Ok(Some("groq".to_string())),
1979 "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1980 _ => Ok(None),
1981 }
1982 };
1983 assert_eq!(
1984 resolve_default_provider(&kv).expect("provider"),
1985 AiProvider::Groq
1986 );
1987 }
1988
1989 #[test]
1990 fn inference_provider_falls_through_to_task_pointer_then_default() {
1991 let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
1992 match key {
1993 "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1994 _ => Ok(None),
1995 }
1996 };
1997 assert_eq!(
1998 resolve_default_provider(&pointer).expect("provider"),
1999 AiProvider::DeepSeek
2000 );
2001
2002 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2003 assert_eq!(
2004 resolve_default_provider(&empty).expect("provider"),
2005 AiProvider::OpenAi
2006 );
2007 }
2008
2009 #[test]
2010 fn inference_model_ask_specific_beats_models_block_beats_builtin() {
2011 let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
2012 match key {
2013 "red.config.ai.ask.model" => Ok(Some("gpt-ask".to_string())),
2014 "red.config.ai.providers.openai.models.inference" => {
2015 Ok(Some("gpt-block".to_string()))
2016 }
2017 _ => Ok(None),
2018 }
2019 };
2020 assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
2021
2022 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2023 match key {
2024 "red.config.ai.providers.openai.models.inference" => {
2025 Ok(Some("gpt-block".to_string()))
2026 }
2027 _ => Ok(None),
2028 }
2029 };
2030 assert_eq!(
2031 resolve_default_model(&AiProvider::OpenAi, &block),
2032 "gpt-block"
2033 );
2034
2035 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2036 assert_eq!(
2037 resolve_default_model(&AiProvider::OpenAi, &empty),
2038 AiProvider::OpenAi.default_prompt_model()
2039 );
2040 }
2041
2042 #[test]
2043 fn embeddings_provider_follows_task_pointer() {
2044 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2045 match key {
2046 "red.config.ai.embeddings.provider" => Ok(Some("ollama".to_string())),
2047 _ => Ok(None),
2048 }
2049 };
2050 assert_eq!(
2051 resolve_embeddings_provider(&kv).unwrap(),
2052 AiProvider::Ollama
2053 );
2054
2055 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2056 assert_eq!(
2057 resolve_embeddings_provider(&empty).unwrap(),
2058 AiProvider::OpenAi
2059 );
2060 }
2061
2062 #[test]
2063 fn embeddings_selection_rejects_chat_only_providers_and_accepts_routers() {
2064 for token in ["anthropic", "groq", "deepseek"] {
2065 let provider = parse_provider(token).expect("provider");
2066 assert!(!provider.supports_embeddings());
2067 assert!(ensure_provider_supports_embeddings(&provider).is_err());
2068 }
2069 for token in ["openrouter", "venice", "openai-compat", "red-router"] {
2070 let provider = parse_provider(token).expect("provider");
2071 assert!(ensure_provider_supports_embeddings(&provider).is_ok());
2072 }
2073 }
2074
2075 #[test]
2076 fn embeddings_provider_rejects_modality_incapable_pointer() {
2077 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2078 match key {
2079 "red.config.ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
2080 _ => Ok(None),
2081 }
2082 };
2083 let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
2084 assert!(
2085 err.contains("red.config.ai.embeddings.provider"),
2086 "error must name the pointer to fix: {err}"
2087 );
2088 assert!(
2089 err.contains("openai") && err.contains("no embeddings API"),
2090 "error must name capable alternatives: {err}"
2091 );
2092 }
2093
2094 #[test]
2095 fn embeddings_provider_falls_back_to_default_provider_chain() {
2096 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2102 match key {
2103 "red.config.ai.inference.provider" => Ok(Some("ollama".to_string())),
2104 _ => Ok(None),
2105 }
2106 };
2107 assert_eq!(
2108 resolve_embeddings_provider(&kv).unwrap(),
2109 AiProvider::Ollama
2110 );
2111 }
2112
2113 #[test]
2114 fn embeddings_model_block_beats_builtin() {
2115 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2116 match key {
2117 "red.config.ai.providers.openai.models.embeddings" => {
2118 Ok(Some("text-embedding-custom".to_string()))
2119 }
2120 _ => Ok(None),
2121 }
2122 };
2123 assert_eq!(
2124 resolve_embeddings_model(&AiProvider::OpenAi, &block),
2125 "text-embedding-custom"
2126 );
2127
2128 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2129 assert_eq!(
2130 resolve_embeddings_model(&AiProvider::Ollama, &empty),
2131 AiProvider::Ollama.default_embedding_model()
2132 );
2133 }
2134
2135 #[test]
2136 fn base_url_reads_provider_block_key() {
2137 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2138 if key == "red.config.ai.providers.openai.base_url" {
2139 Ok(Some("https://proxy.example/v1".to_string()))
2140 } else {
2141 Ok(None)
2142 }
2143 };
2144 assert_eq!(
2145 AiProvider::OpenAi
2146 .resolve_api_base_checked(&kv)
2147 .expect("endpoint"),
2148 "https://proxy.example/v1"
2149 );
2150 }
2151
2152 #[test]
2153 fn removed_config_keys_rejected_naming_replacements() {
2154 let err = validate_ai_config_key_on_write("red.config.ai.default.provider")
2155 .unwrap_err()
2156 .to_string();
2157 assert!(err.contains("red.config.ai.inference.provider"), "{err}");
2158
2159 let err = validate_ai_config_key_on_write("red.config.ai.default.model")
2160 .unwrap_err()
2161 .to_string();
2162 assert!(
2163 err.contains("red.config.ai.providers.<provider>.models.inference"),
2164 "{err}"
2165 );
2166
2167 let err = validate_ai_config_key_on_write("red.config.ai.openai.default.base_url")
2168 .unwrap_err()
2169 .to_string();
2170 assert!(
2171 err.contains("red.config.ai.providers.<provider>.base_url"),
2172 "{err}"
2173 );
2174
2175 assert!(validate_ai_config_key_on_write("red.config.ai.inference.provider").is_ok());
2177 assert!(validate_ai_config_key_on_write("red.config.ai.providers.openai.base_url").is_ok());
2178 assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
2179 }
2180
2181 #[test]
2182 fn ask_planner_model_and_effort_resolve() {
2183 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2184 match key {
2185 "red.config.ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
2186 "red.config.ai.ask.effort" => Ok(Some("high".to_string())),
2187 _ => Ok(None),
2188 }
2189 };
2190 assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
2191 assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
2192
2193 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2194 assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
2195 assert_eq!(resolve_ask_effort(&empty), None);
2196 }
2197
2198 #[tokio::test]
2199 async fn anthropic_prompt_async_rejects_empty_api_key() {
2200 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2201 let request = AnthropicPromptRequest {
2202 api_key: " ".to_string(),
2203 model: "claude-3-5-haiku-latest".to_string(),
2204 prompt: "hello".to_string(),
2205 temperature: None,
2206 max_output_tokens: None,
2207 api_base: "https://api.anthropic.com/v1".to_string(),
2208 anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
2209 };
2210 let err = anthropic_prompt_async(&transport, request)
2211 .await
2212 .unwrap_err();
2213 assert!(err.to_string().contains("API key cannot be empty"));
2214 }
2215}
2216
2217#[derive(Debug, Clone, PartialEq, Eq)]
2223pub enum AiProvider {
2224 OpenAi,
2225 Anthropic,
2226 Groq,
2227 OpenRouter,
2228 OpenAiCompat,
2229 RedRouter,
2230 Together,
2231 Venice,
2232 Ollama,
2233 DeepSeek,
2234 MiniMax,
2235 HuggingFace,
2236 Local,
2237 Custom(String),
2238}
2239
2240impl AiProvider {
2241 pub fn token(&self) -> &str {
2242 match self {
2243 Self::OpenAi => "openai",
2244 Self::Anthropic => "anthropic",
2245 Self::Groq => "groq",
2246 Self::OpenRouter => "openrouter",
2247 Self::OpenAiCompat => "openai-compat",
2248 Self::RedRouter => "red-router",
2249 Self::Together => "together",
2250 Self::Venice => "venice",
2251 Self::Ollama => "ollama",
2252 Self::DeepSeek => "deepseek",
2253 Self::MiniMax => "minimax",
2254 Self::HuggingFace => "huggingface",
2255 Self::Local => "local",
2256 Self::Custom(name) => name.as_str(),
2257 }
2258 }
2259
2260 pub fn default_prompt_model(&self) -> &str {
2261 match self {
2262 Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
2263 Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
2264 Self::Groq => "llama-3.3-70b-versatile",
2265 Self::OpenRouter => "auto",
2266 Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
2267 Self::Venice => "llama-3.3-70b",
2268 Self::Ollama => "llama3",
2269 Self::DeepSeek => "deepseek-chat",
2270 Self::MiniMax => "abab6.5s-chat",
2271 Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
2272 Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2273 Self::Custom(_) | Self::OpenAiCompat | Self::RedRouter => DEFAULT_OPENAI_PROMPT_MODEL,
2274 }
2275 }
2276
2277 pub fn prompt_model_env_name(&self) -> String {
2278 format!(
2279 "REDDB_{}_PROMPT_MODEL",
2280 self.token().replace('-', "_").to_ascii_uppercase()
2281 )
2282 }
2283
2284 pub fn default_embedding_model(&self) -> &str {
2285 match self {
2286 Self::Ollama => "nomic-embed-text",
2287 Self::OpenRouter => "openai/text-embedding-3-small",
2288 Self::Venice => "text-embedding-bge-m3",
2289 Self::MiniMax => "embo-01",
2290 Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2291 _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
2292 }
2293 }
2294
2295 pub fn default_api_base(&self) -> &str {
2296 match self {
2297 Self::OpenAi => DEFAULT_OPENAI_API_BASE,
2298 Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
2299 Self::Groq => "https://api.groq.com/openai/v1",
2300 Self::OpenRouter => "https://openrouter.ai/api/v1",
2301 Self::Together => "https://api.together.xyz/v1",
2302 Self::Venice => "https://api.venice.ai/api/v1",
2303 Self::Ollama => "http://localhost:11434/v1",
2304 Self::DeepSeek => "https://api.deepseek.com/v1",
2305 Self::MiniMax => "https://api.minimax.chat/v1",
2306 Self::HuggingFace => "https://api-inference.huggingface.co",
2307 Self::Local => "local",
2308 Self::Custom(base) => base.as_str(),
2309 Self::OpenAiCompat | Self::RedRouter => "",
2310 }
2311 }
2312
2313 pub fn api_base_env_name(&self) -> String {
2314 format!(
2315 "REDDB_{}_API_BASE",
2316 self.token().replace('-', "_").to_ascii_uppercase()
2317 )
2318 }
2319
2320 pub fn default_key_env_name(&self) -> String {
2321 format!(
2322 "REDDB_{}_API_KEY",
2323 self.token().replace('-', "_").to_ascii_uppercase()
2324 )
2325 }
2326
2327 pub fn alias_key_env_name(&self, alias: &str) -> String {
2328 let normalized = normalize_alias_token(alias);
2329 format!(
2330 "REDDB_{}_API_KEY_{normalized}",
2331 self.token().replace('-', "_").to_ascii_uppercase()
2332 )
2333 }
2334
2335 pub fn resolve_api_base(&self) -> String {
2336 if let Ok(value) = std::env::var(self.api_base_env_name()) {
2337 let value = value.trim().to_string();
2338 if !value.is_empty() {
2339 return value;
2340 }
2341 }
2342 self.default_api_base().to_string()
2343 }
2344
2345 pub fn resolve_api_base_checked<F>(&self, kv_getter: &F) -> crate::RedDBResult<String>
2347 where
2348 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2349 {
2350 let endpoint = match std::env::var(self.api_base_env_name()) {
2351 Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
2352 _ => kv_getter(&provider_base_url_key(self))?
2353 .filter(|value| !value.trim().is_empty())
2354 .map(|value| value.trim().to_string())
2355 .unwrap_or_else(|| self.default_api_base().to_string()),
2356 };
2357 if !matches!(self, Self::Local) {
2358 if endpoint.is_empty() {
2359 return Err(crate::RedDBError::Query(format!(
2360 "missing {} endpoint; set {} or '{}' alongside the API key",
2361 self.token(),
2362 self.api_base_env_name(),
2363 provider_base_url_key(self),
2364 )));
2365 }
2366 validate_custom_provider_url(&endpoint)?;
2367 }
2368 Ok(endpoint)
2369 }
2370
2371 pub fn requires_explicit_endpoint(&self) -> bool {
2373 matches!(self, Self::OpenAiCompat | Self::RedRouter | Self::Custom(_))
2374 }
2375
2376 pub fn is_openai_compatible(&self) -> bool {
2378 matches!(
2379 self,
2380 Self::OpenAi
2381 | Self::Groq
2382 | Self::OpenRouter
2383 | Self::OpenAiCompat
2384 | Self::RedRouter
2385 | Self::Together
2386 | Self::Venice
2387 | Self::Ollama
2388 | Self::DeepSeek
2389 | Self::MiniMax
2390 | Self::Custom(_)
2391 )
2392 }
2393
2394 pub fn requires_api_key(&self) -> bool {
2396 !matches!(self, Self::Ollama | Self::Local)
2397 }
2398
2399 pub fn supports_embeddings(&self) -> bool {
2401 crate::runtime::ai::provider_capabilities::Modalities::for_provider(self.token()).embed
2402 }
2403}
2404
2405pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
2407 let name = name.trim();
2408 match name.to_ascii_lowercase().as_str() {
2409 "openai" => Ok(AiProvider::OpenAi),
2410 "anthropic" => Ok(AiProvider::Anthropic),
2411 "groq" => Ok(AiProvider::Groq),
2412 "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
2413 "openai-compat" | "openai_compat" => Ok(AiProvider::OpenAiCompat),
2414 "red-router" | "red_router" => Ok(AiProvider::RedRouter),
2415 "together" => Ok(AiProvider::Together),
2416 "venice" => Ok(AiProvider::Venice),
2417 "ollama" => Ok(AiProvider::Ollama),
2418 "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
2419 "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
2420 "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
2421 "local" => Ok(AiProvider::Local),
2422 other => {
2423 if other.starts_with("http://") || other.starts_with("https://") {
2425 validate_custom_provider_url(name)?;
2426 Ok(AiProvider::Custom(name.to_string()))
2427 } else {
2428 Err(crate::RedDBError::Query(format!(
2429 "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
2430 openrouter, openai-compat, red-router, together, venice, ollama, deepseek, minimax, huggingface, local"
2431 )))
2432 }
2433 }
2434 }
2435}
2436
2437pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
2457 "openai, ollama, openrouter, openai-compat, red-router, together, venice, minimax, huggingface, local";
2458
2459pub fn provider_base_url_key(provider: &AiProvider) -> String {
2461 format!("red.config.ai.providers.{}.base_url", provider.token())
2462}
2463
2464pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
2467 format!(
2468 "red.config.ai.providers.{}.models.{modality}",
2469 provider.token()
2470 )
2471}
2472
2473pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
2478 let key = key.trim();
2479 if key == "red.config.ai.default.provider" {
2480 return Err(crate::RedDBError::Query(
2481 "AI config key 'red.config.ai.default.provider' was removed in the ADR-0068 \
2482 clean break; set the task pointer 'red.config.ai.inference.provider' (or \
2483 'red.config.ai.ask.provider' for the ASK planner) instead"
2484 .to_string(),
2485 ));
2486 }
2487 if key == "red.config.ai.default.model" {
2488 return Err(crate::RedDBError::Query(
2489 "AI config key 'red.config.ai.default.model' was removed in the ADR-0068 clean \
2490 break; set 'red.config.ai.ask.model' or \
2491 'red.config.ai.providers.<provider>.models.inference' instead"
2492 .to_string(),
2493 ));
2494 }
2495 if key.starts_with("red.config.ai.")
2499 && key.ends_with(".base_url")
2500 && !key.starts_with("red.config.ai.providers.")
2501 {
2502 return Err(crate::RedDBError::Query(format!(
2503 "AI config key '{key}' uses the removed per-credential base-URL shape; set \
2504 'red.config.ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
2505 )));
2506 }
2507 Ok(())
2508}
2509
2510pub fn resolve_default_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
2517where
2518 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2519{
2520 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
2521 if !value.trim().is_empty() {
2522 return parse_provider(&value);
2523 }
2524 }
2525 for key in [
2526 "red.config.ai.ask.provider",
2527 "red.config.ai.inference.provider",
2528 ] {
2529 if let Some(value) = kv_getter(key)? {
2530 if !value.trim().is_empty() {
2531 return parse_provider(&value);
2532 }
2533 }
2534 }
2535 Ok(resolve_provider_mode(kv_getter)
2536 .map(provider_mode_to_provider)
2537 .unwrap_or(AiProvider::OpenAi))
2538}
2539
2540pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2547where
2548 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2549{
2550 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2552 let value = value.trim().to_string();
2553 if !value.is_empty() {
2554 return value;
2555 }
2556 }
2557 if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
2559 let value = value.trim().to_string();
2560 if !value.is_empty() {
2561 return value;
2562 }
2563 }
2564 for key in [
2566 "red.config.ai.ask.model".to_string(),
2567 provider_models_key(provider, "inference"),
2568 ] {
2569 if let Ok(Some(value)) = kv_getter(&key) {
2570 let value = value.trim().to_string();
2571 if !value.is_empty() {
2572 return value;
2573 }
2574 }
2575 }
2576 provider.default_prompt_model().to_string()
2577}
2578
2579pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
2591where
2592 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2593{
2594 let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
2595 .ok()
2596 .map(|v| v.trim().to_string())
2597 .filter(|v| !v.is_empty())
2598 {
2599 parse_provider(&value)?
2600 } else if let Some(value) = kv_getter("red.config.ai.embeddings.provider")?
2601 .map(|v| v.trim().to_string())
2602 .filter(|v| !v.is_empty())
2603 {
2604 parse_provider(&value)?
2605 } else {
2606 resolve_default_provider(kv_getter)?
2610 };
2611 ensure_provider_supports_embeddings(&provider)?;
2612 Ok(provider)
2613}
2614
2615pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
2617 if provider.supports_embeddings() {
2618 return Ok(());
2619 }
2620 Err(crate::RedDBError::Query(format!(
2621 "the embeddings task pointer 'red.config.ai.embeddings.provider' names '{}', which \
2622 has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
2623 re-routes embeddings to a different provider than the one you named.",
2624 provider.token(),
2625 EMBEDDING_CAPABLE_PROVIDERS
2626 )))
2627}
2628
2629pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2642where
2643 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2644{
2645 if let Ok(value) = std::env::var(format!(
2646 "REDDB_{}_EMBEDDING_MODEL",
2647 provider.token().replace('-', "_").to_ascii_uppercase()
2648 )) {
2649 let value = value.trim().to_string();
2650 if !value.is_empty() {
2651 return value;
2652 }
2653 }
2654 if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
2655 let value = value.trim().to_string();
2656 if !value.is_empty() {
2657 return value;
2658 }
2659 }
2660 if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
2661 let value = value.trim().to_string();
2662 if !value.is_empty() {
2663 return value;
2664 }
2665 }
2666 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2667 let value = value.trim().to_string();
2668 if !value.is_empty() {
2669 return value;
2670 }
2671 }
2672 provider.default_embedding_model().to_string()
2673}
2674
2675pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
2679where
2680 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2681{
2682 if let Ok(Some(value)) = kv_getter("red.config.ai.ask.planner_model") {
2683 let value = value.trim().to_string();
2684 if !value.is_empty() {
2685 return value;
2686 }
2687 }
2688 fallback_model.to_string()
2689}
2690
2691pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
2694where
2695 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2696{
2697 if let Ok(Some(value)) = kv_getter("red.config.ai.ask.effort") {
2698 let value = value.trim().to_string();
2699 if !value.is_empty() {
2700 return Some(value);
2701 }
2702 }
2703 None
2704}
2705
2706pub fn resolve_defaults_from_runtime(
2708 runtime: &crate::runtime::RedDBRuntime,
2709) -> crate::RedDBResult<(AiProvider, String)> {
2710 use crate::application::ports::RuntimeEntityPort;
2711 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2712 match runtime.get_kv("red_config", key)? {
2713 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2714 _ => Ok(None),
2715 }
2716 };
2717 let provider = resolve_default_provider(&kv_getter)?;
2718 let model = resolve_default_model(&provider, &kv_getter);
2719 Ok((provider, model))
2720}
2721
2722pub fn resolve_prompt_model_from_runtime(
2723 runtime: &crate::runtime::RedDBRuntime,
2724 provider: &AiProvider,
2725 explicit: Option<&str>,
2726) -> String {
2727 use crate::application::ports::RuntimeEntityPort;
2728 if let Some(model) = explicit {
2729 return model.to_string();
2730 }
2731 resolve_default_model(provider, &|key| match runtime.get_kv("red_config", key)? {
2732 Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
2733 _ => Ok(None),
2734 })
2735}
2736
2737pub fn resolve_ask_planner_model_from_runtime(
2742 runtime: &crate::runtime::RedDBRuntime,
2743 fallback_model: &str,
2744) -> String {
2745 use crate::application::ports::RuntimeEntityPort;
2746 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2747 match runtime.get_kv("red_config", key)? {
2748 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2749 _ => Ok(None),
2750 }
2751 };
2752 resolve_ask_planner_model(&kv_getter, fallback_model)
2753}
2754
2755pub fn resolve_defaults_from_runtime_port<
2757 P: crate::application::ports::RuntimeEntityPort + ?Sized,
2758>(
2759 runtime: &P,
2760) -> crate::RedDBResult<(AiProvider, String)> {
2761 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2762 match runtime.get_kv("red_config", key)? {
2763 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2764 _ => Ok(None),
2765 }
2766 };
2767 let provider = resolve_default_provider(&kv_getter)?;
2768 let model = resolve_default_model(&provider, &kv_getter);
2769 Ok((provider, model))
2770}
2771
2772pub fn resolve_embeddings_provider_from_runtime<
2777 P: crate::application::ports::RuntimeEntityPort + ?Sized,
2778>(
2779 runtime: &P,
2780 explicit: &str,
2781) -> crate::RedDBResult<AiProvider> {
2782 let explicit = explicit.trim();
2783 if !explicit.is_empty() {
2784 let provider = parse_provider(explicit)?;
2785 ensure_provider_supports_embeddings(&provider)?;
2786 return Ok(provider);
2787 }
2788 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2789 match runtime.get_kv("red_config", key)? {
2790 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2791 _ => Ok(None),
2792 }
2793 };
2794 resolve_embeddings_provider(&kv_getter)
2795}
2796
2797pub fn resolve_embeddings_model_from_runtime<
2801 P: crate::application::ports::RuntimeEntityPort + ?Sized,
2802>(
2803 runtime: &P,
2804 provider: &AiProvider,
2805 explicit: Option<&str>,
2806) -> String {
2807 if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
2808 return model.to_string();
2809 }
2810 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2811 match runtime.get_kv("red_config", key)? {
2812 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2813 _ => Ok(None),
2814 }
2815 };
2816 resolve_embeddings_model(provider, &kv_getter)
2817}
2818
2819pub fn resolve_api_key<F>(
2846 provider: &AiProvider,
2847 credential_alias: Option<&str>,
2848 kv_getter: F,
2849) -> crate::RedDBResult<String>
2850where
2851 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2852{
2853 let configured_alias = if credential_alias.is_none() {
2854 kv_getter(&format!(
2855 "red.config.ai.providers.{}.credential",
2856 provider.token()
2857 ))?
2858 } else {
2859 None
2860 };
2861 let credential_alias = credential_alias.or(configured_alias.as_deref());
2862 if let Some(alias) = credential_alias
2863 .map(str::trim)
2864 .filter(|a| !a.is_empty() && *a != "default")
2865 {
2866 if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
2868 if !key.trim().is_empty() {
2869 return Ok(key);
2870 }
2871 }
2872 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
2874 let secret_ref = secret_ref.trim();
2875 validate_ai_secret_ref(secret_ref)?;
2879 if let Some(key) = kv_getter(secret_ref)? {
2880 if !key.trim().is_empty() {
2881 return Ok(key);
2882 }
2883 }
2884 }
2885 if let Some(key) = resolve_key_from_env_alias(provider, alias) {
2887 return Ok(key);
2888 }
2889 reject_removed_credential_paths(provider, alias, &kv_getter)?;
2892 return Err(crate::RedDBError::Query(format!(
2893 "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
2894 provider.token(),
2895 provider.alias_key_env_name(alias),
2896 ai_api_secret_path(provider, alias),
2897 )));
2898 }
2899
2900 if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
2902 if !key.trim().is_empty() {
2903 return Ok(key);
2904 }
2905 }
2906 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
2908 let secret_ref = secret_ref.trim();
2909 validate_ai_secret_ref(secret_ref)?;
2910 if let Some(key) = kv_getter(secret_ref)? {
2911 if !key.trim().is_empty() {
2912 return Ok(key);
2913 }
2914 }
2915 }
2916
2917 if let Ok(value) = std::env::var(provider.default_key_env_name()) {
2919 let value = value.trim().to_string();
2920 if !value.is_empty() {
2921 return Ok(value);
2922 }
2923 }
2924
2925 reject_removed_credential_paths(provider, "default", &kv_getter)?;
2928 if !provider.requires_api_key() {
2929 return Ok(String::new());
2930 }
2931
2932 Err(crate::RedDBError::Query(format!(
2933 "missing {} API key. Set {} or store it in the vault at '{}'",
2934 provider.token(),
2935 provider.default_key_env_name(),
2936 ai_api_secret_path(provider, "default"),
2937 )))
2938}
2939
2940fn validate_ai_secret_ref(secret_ref: &str) -> crate::RedDBResult<()> {
2941 if !secret_ref
2942 .to_ascii_lowercase()
2943 .starts_with("red.secret.ai.")
2944 {
2945 return Err(crate::RedDBError::Query(format!(
2946 "secret_ref '{secret_ref}' must reference a vault key under red.secret.ai.*"
2947 )));
2948 }
2949 Ok(())
2950}
2951
2952pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
2954 format!(
2955 "red.secret.ai.providers.{}.tokens.{}",
2956 provider.token(),
2957 normalize_credential_alias_path(alias)
2958 )
2959}
2960
2961pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
2963 format!(
2964 "red.config.ai.providers.{}.tokens.{}.secret_ref",
2965 provider.token(),
2966 normalize_credential_alias_path(alias)
2967 )
2968}
2969
2970fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
2974 format!(
2975 "red.secret.ai.{}.{}.api_key",
2976 provider.token(),
2977 normalize_credential_alias_path(alias)
2978 )
2979}
2980
2981fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
2984 format!(
2985 "red.config.ai.{}.{}.key",
2986 provider.token(),
2987 normalize_credential_alias_path(alias)
2988 )
2989}
2990
2991fn reject_removed_credential_paths<F>(
2995 provider: &AiProvider,
2996 alias: &str,
2997 kv_getter: &F,
2998) -> crate::RedDBResult<()>
2999where
3000 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3001{
3002 let new_path = ai_api_secret_path(provider, alias);
3003 for removed in [
3004 removed_vault_api_key_path(provider, alias),
3005 removed_plaintext_config_key(provider, alias),
3006 ] {
3007 if let Some(value) = kv_getter(&removed)? {
3008 if !value.trim().is_empty() {
3009 return Err(crate::RedDBError::Query(format!(
3010 "AI credential found at removed path '{removed}'. The AI credential vault \
3011 path changed (issue #1745): store the token at '{new_path}' instead. The \
3012 old vault path shape and the legacy plaintext config path are no longer read."
3013 )));
3014 }
3015 }
3016 }
3017 Ok(())
3018}
3019
3020fn normalize_credential_alias_path(alias: &str) -> String {
3021 let alias = alias.trim();
3022 if alias.is_empty() {
3023 "default".to_string()
3024 } else {
3025 alias.to_ascii_lowercase()
3026 }
3027}
3028
3029fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
3030 let env_name = provider.alias_key_env_name(alias);
3031 std::env::var(env_name)
3032 .ok()
3033 .map(|v| v.trim().to_string())
3034 .filter(|v| !v.is_empty())
3035}
3036
3037fn normalize_alias_token(alias: &str) -> String {
3038 let mut out = String::with_capacity(alias.len());
3039 for character in alias.chars() {
3040 if character.is_ascii_alphanumeric() {
3041 out.push(character.to_ascii_uppercase());
3042 } else {
3043 out.push('_');
3044 }
3045 }
3046 while out.contains("__") {
3047 out = out.replace("__", "_");
3048 }
3049 out.trim_matches('_').to_string()
3050}
3051
3052pub struct AiConnection {
3055 pub api_base: String,
3056 pub api_key: String,
3057}
3058
3059pub fn resolve_connection<F>(
3060 provider: &AiProvider,
3061 credential_alias: Option<&str>,
3062 kv_getter: &F,
3063) -> crate::RedDBResult<AiConnection>
3064where
3065 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3066{
3067 let api_base = provider.resolve_api_base_checked(kv_getter)?;
3068 let api_key = resolve_api_key(provider, credential_alias, kv_getter)?;
3069 Ok(AiConnection { api_base, api_key })
3070}
3071
3072pub fn resolve_connection_from_runtime(
3073 provider: &AiProvider,
3074 credential_alias: Option<&str>,
3075 runtime: &crate::runtime::RedDBRuntime,
3076) -> crate::RedDBResult<AiConnection> {
3077 use crate::application::ports::RuntimeEntityPort;
3078 let api_base =
3079 provider.resolve_api_base_checked(&|key| match runtime.get_kv("red_config", key)? {
3080 Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3081 _ => Ok(None),
3082 })?;
3083 let api_key = resolve_api_key_from_runtime(provider, credential_alias, runtime)?;
3084 Ok(AiConnection { api_base, api_key })
3085}
3086
3087pub fn resolve_api_key_from_runtime(
3097 provider: &AiProvider,
3098 credential_alias: Option<&str>,
3099 runtime: &crate::runtime::RedDBRuntime,
3100) -> crate::RedDBResult<String> {
3101 use crate::application::ports::RuntimeEntityPort;
3102 let configured_alias = if credential_alias.is_none() {
3103 match runtime.get_kv(
3104 "red_config",
3105 &format!("red.config.ai.providers.{}.credential", provider.token()),
3106 )? {
3107 Some((reddb_types::Value::Text(value), _)) => Some(value.to_string()),
3108 _ => None,
3109 }
3110 } else {
3111 None
3112 };
3113 let credential_alias = credential_alias.or(configured_alias.as_deref());
3114 let alias_for_audit = credential_alias.unwrap_or("default").to_string();
3115 let provider_token = provider.token().to_string();
3116 let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
3117 std::cell::RefCell::new(Vec::new());
3118 let result = resolve_api_key(provider, credential_alias, |kv_key| {
3119 if kv_key.starts_with("red.secret.") {
3120 let value = runtime.vault_kv_get(kv_key);
3121 audited_paths
3122 .borrow_mut()
3123 .push((kv_key.to_string(), value.is_some()));
3124 return Ok(value);
3125 }
3126 match runtime.get_kv("red_config", kv_key)? {
3127 Some((reddb_types::Value::Text(secret), _)) => {
3128 audited_paths.borrow_mut().push((kv_key.to_string(), true));
3129 Ok(Some(secret.to_string()))
3130 }
3131 Some(_) => {
3132 audited_paths.borrow_mut().push((kv_key.to_string(), false));
3133 Ok(None)
3134 }
3135 None => {
3136 audited_paths.borrow_mut().push((kv_key.to_string(), false));
3137 Ok(None)
3138 }
3139 }
3140 });
3141 let audited_paths = audited_paths.into_inner();
3142
3143 let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
3144 .map(|(user, _role)| user)
3145 .unwrap_or_else(|| "system".to_string());
3146 let outcome = if result.is_ok() { "hit" } else { "miss" };
3147 let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
3148 let paths_json: Vec<crate::serde_json::Value> = audited_paths
3149 .iter()
3150 .map(|(p, hit)| {
3151 crate::serde_json::json!({
3152 "path": p,
3153 "hit": hit,
3154 })
3155 })
3156 .collect();
3157 let details = crate::serde_json::json!({
3158 "provider": provider_token,
3159 "alias": alias_for_audit,
3160 "paths_checked": paths_json,
3161 });
3162 runtime.audit_log().record(
3163 "ai.credential.resolve",
3164 &principal,
3165 &target,
3166 outcome,
3167 details,
3168 );
3169 result
3170}
3171
3172pub fn huggingface_embeddings(
3178 api_key: &str,
3179 model: &str,
3180 inputs: &[String],
3181 api_base: &str,
3182) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3183 let url = format!("{api_base}/pipeline/feature-extraction/{model}");
3184 let mut embeddings = Vec::with_capacity(inputs.len());
3185
3186 for input in inputs {
3187 let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
3188 let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
3189 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3190 if !(200..300).contains(&status) {
3191 return Err(crate::RedDBError::Query(format!(
3192 "HuggingFace API error (status {status}): {body_str}"
3193 )));
3194 }
3195 let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
3196 crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
3197 })?;
3198
3199 let vector: Vec<f32> = match &body {
3201 JsonValue::Array(outer) => outer
3202 .iter()
3203 .filter_map(|v| v.as_f64().map(|n| n as f32))
3204 .collect(),
3205 _ => {
3206 return Err(crate::RedDBError::Query(
3207 "unexpected HuggingFace embedding response format".to_string(),
3208 ))
3209 }
3210 };
3211 embeddings.push(vector);
3212 }
3213
3214 Ok(OpenAiEmbeddingResponse {
3215 provider: "huggingface",
3216 model: model.to_string(),
3217 embeddings,
3218 prompt_tokens: None,
3219 total_tokens: None,
3220 })
3221}
3222
3223pub fn huggingface_prompt(
3225 api_key: &str,
3226 model: &str,
3227 prompt: &str,
3228 temperature: Option<f32>,
3229 max_tokens: Option<usize>,
3230 api_base: &str,
3231) -> crate::RedDBResult<AiPromptResponse> {
3232 let url = format!("{api_base}/models/{model}");
3233 let mut params = Map::new();
3234 if let Some(t) = temperature {
3235 params.insert("temperature".into(), JsonValue::Number(t as f64));
3236 }
3237 params.insert(
3238 "max_new_tokens".into(),
3239 JsonValue::Number(max_tokens.unwrap_or(512) as f64),
3240 );
3241 let payload = crate::serde_json::json!({
3242 "inputs": prompt,
3243 "parameters": JsonValue::Object(params)
3244 });
3245
3246 let (status, body_str) =
3247 http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
3248 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3249 if !(200..300).contains(&status) {
3250 return Err(crate::RedDBError::Query(format!(
3251 "HuggingFace API error (status {status}): {body_str}"
3252 )));
3253 }
3254 let body: JsonValue = crate::serde_json::from_str(&body_str)
3255 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
3256
3257 let output_text = match &body {
3258 JsonValue::Array(arr) => arr
3259 .first()
3260 .and_then(|v| v.get("generated_text"))
3261 .and_then(JsonValue::as_str)
3262 .unwrap_or("")
3263 .to_string(),
3264 _ => body
3265 .get("generated_text")
3266 .and_then(JsonValue::as_str)
3267 .unwrap_or("")
3268 .to_string(),
3269 };
3270
3271 Ok(AiPromptResponse {
3272 provider: "huggingface",
3273 model: model.to_string(),
3274 output_text,
3275 output_chunks: None,
3276 prompt_tokens: None,
3277 completion_tokens: None,
3278 total_tokens: None,
3279 stop_reason: None,
3280 })
3281}
3282
3283const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
3288flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
3289the 'ollama' provider with a local Ollama server.";
3290
3291const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
3292`local-models` feature, but local model artifact execution is not implemented in this slice. \
3293Alternatively, use the 'ollama' provider with a local Ollama server.";
3294
3295const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
3296the `local-models` feature; the local provider contract is embeddings-only for this slice.";
3297
3298pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
3299 if cfg!(feature = "local-models") {
3300 crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
3301 } else {
3302 crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
3303 }
3304}
3305
3306pub fn local_prompt_unavailable_error() -> crate::RedDBError {
3307 crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
3308}
3309
3310pub fn local_embeddings(
3312 _model_id: &str,
3313 _texts: &[String],
3314) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3315 Err(local_embeddings_unavailable_error())
3316}
3317
3318pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
3320 Err(local_prompt_unavailable_error())
3321}
3322
3323fn grpc_collect_embedding_inputs(
3336 runtime: &crate::runtime::RedDBRuntime,
3337 payload: &JsonValue,
3338) -> crate::RedDBResult<Vec<String>> {
3339 if let Some(source_query) = payload
3340 .get("source_query")
3341 .and_then(|v| v.as_str())
3342 .map(str::trim)
3343 .filter(|s| !s.is_empty())
3344 {
3345 return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
3346 }
3347
3348 if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
3349 let mut out = Vec::with_capacity(arr.len());
3350 for (idx, v) in arr.iter().enumerate() {
3351 let text = v.as_str().ok_or_else(|| {
3352 crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
3353 })?;
3354 if text.trim().is_empty() {
3355 return Err(crate::RedDBError::Query(format!(
3356 "field 'inputs[{idx}]' cannot be empty"
3357 )));
3358 }
3359 out.push(text.to_string());
3360 }
3361 if out.is_empty() {
3362 return Err(crate::RedDBError::Query(
3363 "field 'inputs' must be a non-empty array of strings".to_string(),
3364 ));
3365 }
3366 return Ok(out);
3367 }
3368
3369 if let Some(single) = payload
3370 .get("input")
3371 .and_then(|v| v.as_str())
3372 .map(str::trim)
3373 .filter(|s| !s.is_empty())
3374 {
3375 return Ok(vec![single.to_string()]);
3376 }
3377
3378 Err(crate::RedDBError::Query(
3379 "provide either 'input', 'inputs', or 'source_query'".to_string(),
3380 ))
3381}
3382
3383fn grpc_collect_inputs_from_source_query(
3384 runtime: &crate::runtime::RedDBRuntime,
3385 payload: &JsonValue,
3386 source_query: &str,
3387) -> crate::RedDBResult<Vec<String>> {
3388 let result = runtime
3389 .execute_query(source_query)
3390 .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
3391
3392 let source_mode = payload
3393 .get("source_mode")
3394 .and_then(|v| v.as_str())
3395 .map(str::trim)
3396 .filter(|s| !s.is_empty())
3397 .unwrap_or("row")
3398 .to_ascii_lowercase();
3399
3400 let mut out: Vec<String> = Vec::new();
3401 match source_mode.as_str() {
3402 "row" => {
3403 let field = payload
3404 .get("source_field")
3405 .and_then(|v| v.as_str())
3406 .map(str::trim)
3407 .filter(|s| !s.is_empty())
3408 .ok_or_else(|| {
3409 crate::RedDBError::Query(
3410 "field 'source_field' is required when source_mode='row'".to_string(),
3411 )
3412 })?;
3413 for rec in &result.result.records {
3414 for (key, value) in rec.iter_fields() {
3415 if key.as_ref() == field {
3416 if let reddb_types::Value::Text(text) = value {
3417 let trimmed = text.trim();
3418 if !trimmed.is_empty() {
3419 out.push(trimmed.to_string());
3420 }
3421 }
3422 }
3423 }
3424 }
3425 }
3426 "result" => {
3427 for rec in &result.result.records {
3428 for (_, value) in rec.iter_fields() {
3429 if let reddb_types::Value::Text(text) = value {
3430 let trimmed = text.trim();
3431 if !trimmed.is_empty() {
3432 out.push(trimmed.to_string());
3433 }
3434 }
3435 }
3436 }
3437 }
3438 other => {
3439 return Err(crate::RedDBError::Query(format!(
3440 "field 'source_mode' must be 'row' or 'result' (got '{other}')"
3441 )));
3442 }
3443 }
3444
3445 if out.is_empty() {
3446 return Err(crate::RedDBError::Query(
3447 "source_query produced zero non-empty text inputs".to_string(),
3448 ));
3449 }
3450 Ok(out)
3451}
3452
3453pub fn grpc_embeddings(
3475 runtime: &crate::runtime::RedDBRuntime,
3476 payload: &JsonValue,
3477) -> crate::RedDBResult<JsonValue> {
3478 let provider = match payload
3481 .get("provider")
3482 .and_then(|v| v.as_str())
3483 .map(str::trim)
3484 .filter(|s| !s.is_empty())
3485 {
3486 Some(name) => parse_provider(name)?,
3487 None => resolve_embeddings_provider_from_runtime(runtime, "")?,
3488 };
3489 if matches!(provider, AiProvider::Groq | AiProvider::DeepSeek) {
3490 ensure_provider_supports_embeddings(&provider)?;
3491 }
3492 match &provider {
3497 AiProvider::Anthropic => {
3498 return Err(crate::RedDBError::Query(
3499 "Anthropic does not offer an embeddings API. \
3500 Re-issue the request against an OpenAI-compatible \
3501 provider (openai, ollama, openrouter, together, \
3502 venice, red-router), HuggingFace, or a custom base URL — \
3503 RedDB does not silently route embeddings to a \
3504 different provider than the one you named."
3505 .to_string(),
3506 ));
3507 }
3508 AiProvider::Local => {
3509 return grpc_embeddings_local(runtime, payload);
3510 }
3511 _ => {}
3512 }
3513
3514 let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
3515
3516 let explicit_model = payload.get("model").and_then(|v| v.as_str());
3517 let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
3518
3519 let credential = payload
3520 .get("credential")
3521 .and_then(|v| v.as_str())
3522 .map(str::to_string);
3523 let AiConnection { api_key, api_base } =
3524 resolve_connection_from_runtime(&provider, credential.as_deref(), runtime)?;
3525
3526 let dimensions = payload
3527 .get("dimensions")
3528 .and_then(|v| v.as_i64())
3529 .and_then(|v| usize::try_from(v).ok())
3530 .filter(|v| *v > 0);
3531
3532 let response = match &provider {
3533 AiProvider::HuggingFace => huggingface_embeddings(&api_key, &model, &inputs, &api_base)?,
3534 _ => {
3535 let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
3536 let request = OpenAiEmbeddingRequest {
3537 api_key,
3538 model,
3539 inputs,
3540 dimensions,
3541 api_base,
3542 };
3543 crate::runtime::ai::block_on_ai(async move {
3544 openai_embeddings_async(&transport, request).await
3545 })
3546 .and_then(|result| result)?
3547 }
3548 };
3549
3550 let embeddings_json: Vec<JsonValue> = response
3551 .embeddings
3552 .into_iter()
3553 .map(|vec| {
3554 JsonValue::Array(
3555 vec.into_iter()
3556 .map(|f| JsonValue::Number(f as f64))
3557 .collect(),
3558 )
3559 })
3560 .collect();
3561
3562 let mut obj = Map::new();
3563 obj.insert(
3564 "provider".to_string(),
3565 JsonValue::String(response.provider.to_string()),
3566 );
3567 obj.insert("model".to_string(), JsonValue::String(response.model));
3568 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3569 if let Some(pt) = response.prompt_tokens {
3570 obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
3571 }
3572 if let Some(tt) = response.total_tokens {
3573 obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
3574 }
3575 Ok(JsonValue::Object(obj))
3576}
3577
3578fn grpc_embeddings_local(
3587 runtime: &crate::runtime::RedDBRuntime,
3588 payload: &JsonValue,
3589) -> crate::RedDBResult<JsonValue> {
3590 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
3591
3592 let model_name = payload
3593 .get("model")
3594 .and_then(|v| v.as_str())
3595 .map(str::trim)
3596 .filter(|s| !s.is_empty())
3597 .ok_or_else(|| {
3598 crate::RedDBError::Query(
3599 "field 'model' is required for the local provider and must be the \
3600 registered local model name (see POST /ai/models)"
3601 .to_string(),
3602 )
3603 })?
3604 .to_string();
3605
3606 let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
3607 let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
3608
3609 let embeddings_json: Vec<JsonValue> = response
3610 .embeddings
3611 .into_iter()
3612 .map(|vec| {
3613 JsonValue::Array(
3614 vec.into_iter()
3615 .map(|f| JsonValue::Number(f as f64))
3616 .collect(),
3617 )
3618 })
3619 .collect();
3620
3621 let mut obj = Map::new();
3622 obj.insert(
3623 "provider".to_string(),
3624 JsonValue::String(response.provider.to_string()),
3625 );
3626 obj.insert("model".to_string(), JsonValue::String(response.name));
3627 obj.insert(
3628 "model_source".to_string(),
3629 JsonValue::String(response.source),
3630 );
3631 obj.insert(
3632 "model_revision".to_string(),
3633 JsonValue::String(response.revision),
3634 );
3635 obj.insert(
3636 "model_engine".to_string(),
3637 JsonValue::String(response.engine),
3638 );
3639 obj.insert(
3640 "dimensions".to_string(),
3641 JsonValue::Number(response.dimensions as f64),
3642 );
3643 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3644 Ok(JsonValue::Object(obj))
3645}
3646
3647pub fn grpc_prompt(
3649 _runtime: &crate::runtime::RedDBRuntime,
3650 _payload: &JsonValue,
3651) -> crate::RedDBResult<JsonValue> {
3652 Err(crate::RedDBError::FeatureNotEnabled(
3653 "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
3654 ))
3655}
3656
3657pub fn grpc_credentials(
3659 _runtime: &crate::runtime::RedDBRuntime,
3660 _payload: &JsonValue,
3661) -> crate::RedDBResult<JsonValue> {
3662 Err(crate::RedDBError::FeatureNotEnabled(
3663 "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
3664 ))
3665}
3666
3667#[derive(Debug, Clone, Default, PartialEq, Eq)]
3683pub struct OpenAiCompatUsage {
3684 pub input_tokens: Option<u64>,
3685 pub output_tokens: Option<u64>,
3686 pub total_tokens: Option<u64>,
3687}
3688
3689#[derive(Debug, Clone)]
3690pub struct OpenAiCompatChatRequest {
3691 pub api_base: String,
3692 pub api_key: String,
3693 pub model: String,
3694 pub prompt: String,
3695 pub temperature: Option<f32>,
3696 pub seed: Option<u64>,
3697 pub max_output_tokens: Option<usize>,
3698 pub extra_headers: Vec<(String, String)>,
3699}
3700
3701#[derive(Debug, Clone)]
3702pub struct OpenAiCompatChatResponse {
3703 pub model: String,
3704 pub output_text: String,
3705 pub stop_reason: Option<String>,
3706 pub usage: OpenAiCompatUsage,
3707}
3708
3709#[derive(Debug, Clone)]
3710pub struct OpenAiCompatEmbeddingsRequest {
3711 pub api_base: String,
3712 pub api_key: String,
3713 pub model: String,
3714 pub inputs: Vec<String>,
3715 pub dimensions: Option<usize>,
3716 pub extra_headers: Vec<(String, String)>,
3717}
3718
3719#[derive(Debug, Clone)]
3720pub struct OpenAiCompatEmbeddingsResponse {
3721 pub model: String,
3722 pub embeddings: Vec<Vec<f32>>,
3723 pub usage: OpenAiCompatUsage,
3724}
3725
3726fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
3727 headers
3728 .iter()
3729 .map(|(k, v)| (k.as_str(), v.as_str()))
3730 .collect()
3731}
3732
3733pub fn openai_compat_chat(
3741 request: OpenAiCompatChatRequest,
3742) -> RedDBResult<OpenAiCompatChatResponse> {
3743 if request.model.trim().is_empty() {
3744 return Err(RedDBError::Query(
3745 "openai-compat: model cannot be empty".to_string(),
3746 ));
3747 }
3748 if request.prompt.trim().is_empty() {
3749 return Err(RedDBError::Query(
3750 "openai-compat: prompt cannot be empty".to_string(),
3751 ));
3752 }
3753
3754 let url = format!(
3755 "{}/chat/completions",
3756 request.api_base.trim_end_matches('/')
3757 );
3758 let payload = build_openai_prompt_payload(
3759 &request.model,
3760 &request.prompt,
3761 request.temperature,
3762 request.seed,
3763 request.max_output_tokens,
3764 false,
3765 );
3766
3767 let extra = extra_header_refs(&request.extra_headers);
3768 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
3769 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3770
3771 if !(200..300).contains(&status) {
3772 let message = openai_error_message(&body).unwrap_or_else(|| {
3773 if body.trim().is_empty() {
3774 "openai-compat chat request failed".to_string()
3775 } else {
3776 body.clone()
3777 }
3778 });
3779 return Err(RedDBError::Query(format!(
3780 "openai-compat chat request failed (status {status}): {message}"
3781 )));
3782 }
3783
3784 let parsed = parse_openai_prompt_response(&body, &request.model)?;
3785 Ok(OpenAiCompatChatResponse {
3786 model: parsed.model,
3787 output_text: parsed.output_text,
3788 stop_reason: parsed.stop_reason,
3789 usage: OpenAiCompatUsage {
3790 input_tokens: parsed.prompt_tokens,
3791 output_tokens: parsed.completion_tokens,
3792 total_tokens: parsed.total_tokens,
3793 },
3794 })
3795}
3796
3797pub fn openai_compat_embeddings(
3799 request: OpenAiCompatEmbeddingsRequest,
3800) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
3801 if request.model.trim().is_empty() {
3802 return Err(RedDBError::Query(
3803 "openai-compat: embedding model cannot be empty".to_string(),
3804 ));
3805 }
3806 if request.inputs.is_empty() {
3807 return Err(RedDBError::Query(
3808 "openai-compat: at least one input is required".to_string(),
3809 ));
3810 }
3811
3812 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
3813 let payload =
3814 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
3815
3816 let extra = extra_header_refs(&request.extra_headers);
3817 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
3818 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3819
3820 if !(200..300).contains(&status) {
3821 let message = openai_error_message(&body).unwrap_or_else(|| {
3822 if body.trim().is_empty() {
3823 "openai-compat embeddings request failed".to_string()
3824 } else {
3825 body.clone()
3826 }
3827 });
3828 return Err(RedDBError::Query(format!(
3829 "openai-compat embeddings request failed (status {status}): {message}"
3830 )));
3831 }
3832
3833 let parsed = parse_openai_embedding_response(&body)?;
3834 Ok(OpenAiCompatEmbeddingsResponse {
3835 model: parsed.model,
3836 embeddings: parsed.embeddings,
3837 usage: OpenAiCompatUsage {
3838 input_tokens: parsed.prompt_tokens,
3839 output_tokens: None,
3840 total_tokens: parsed.total_tokens,
3841 },
3842 })
3843}
3844
3845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3857pub enum AiProviderMode {
3858 OpenAiCompat,
3860 OpenAiNative,
3862 AnthropicNative,
3864}
3865
3866impl AiProviderMode {
3867 pub fn token(&self) -> &'static str {
3868 match self {
3869 Self::OpenAiCompat => "openai-compat",
3870 Self::OpenAiNative => "openai-native",
3871 Self::AnthropicNative => "anthropic-native",
3872 }
3873 }
3874}
3875
3876pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
3878 match name.trim().to_ascii_lowercase().as_str() {
3879 "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
3880 "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
3881 "anthropic-native" | "anthropic_native" | "anthropicnative" => {
3882 Some(AiProviderMode::AnthropicNative)
3883 }
3884 _ => None,
3885 }
3886}
3887
3888pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
3894where
3895 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3896{
3897 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
3898 if let Some(mode) = parse_provider_mode(&value) {
3899 return Some(mode);
3900 }
3901 }
3902 if let Ok(Some(value)) = kv_getter("red.config.ai.provider") {
3903 if let Some(mode) = parse_provider_mode(&value) {
3904 return Some(mode);
3905 }
3906 }
3907 None
3908}
3909
3910pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
3913 match mode {
3914 AiProviderMode::OpenAiNative => AiProvider::OpenAi,
3915 AiProviderMode::AnthropicNative => AiProvider::Anthropic,
3916 AiProviderMode::OpenAiCompat => AiProvider::OpenAiCompat,
3917 }
3918}
3919
3920pub(crate) fn validate_custom_provider_url(url: &str) -> crate::RedDBResult<()> {
3930 let uri = url.parse::<http::Uri>().map_err(|_| {
3931 crate::RedDBError::Query("AI endpoint must be an absolute http(s) URL".to_string())
3932 })?;
3933 if !matches!(uri.scheme_str(), Some("http" | "https"))
3934 || uri.host().is_none_or(str::is_empty)
3935 || uri
3936 .authority()
3937 .is_some_and(|authority| authority.as_str().contains('@'))
3938 || uri.query().is_some()
3939 || url.contains('#')
3940 {
3941 return Err(crate::RedDBError::Query(
3942 "AI endpoint must be an absolute http(s) URL without userinfo, query or fragment"
3943 .to_string(),
3944 ));
3945 }
3946 if std::env::var("REDDB_AI_ALLOW_PRIVATE_PROVIDERS")
3947 .map(|v| matches!(v.trim(), "1" | "true" | "yes"))
3948 .unwrap_or(false)
3949 {
3950 return Ok(());
3951 }
3952 let (plaintext, rest) = match url.strip_prefix("https://") {
3953 Some(rest) => (false, rest),
3954 None => (true, url.strip_prefix("http://").unwrap_or(url)),
3955 };
3956 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
3957 let host_port = authority.rsplit('@').next().unwrap_or(authority);
3958 let host = if let Some(stripped) = host_port.strip_prefix('[') {
3959 stripped.split(']').next().unwrap_or(stripped)
3960 } else {
3961 host_port.split(':').next().unwrap_or(host_port)
3962 };
3963 let host_lower = host.to_ascii_lowercase();
3964 let literal_ip = host.parse::<std::net::IpAddr>().ok();
3965 let loopback = host_lower == "localhost"
3966 || host_lower.ends_with(".localhost")
3967 || literal_ip.is_some_and(|ip| ip.is_loopback());
3968 if plaintext && !loopback {
3969 return Err(crate::RedDBError::Query(format!(
3970 "custom AI provider '{url}' must use https:// (plaintext http is only allowed for loopback hosts)"
3971 )));
3972 }
3973 if let Some(ip) = literal_ip {
3974 let private = match ip {
3975 std::net::IpAddr::V4(v4) => {
3976 v4.is_private()
3977 || v4.is_link_local()
3978 || v4.is_broadcast()
3979 || v4.is_multicast()
3980 || v4.is_unspecified()
3981 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
3982 }
3983 std::net::IpAddr::V6(v6) => {
3984 v6.is_multicast()
3985 || v6.is_unspecified()
3986 || (v6.segments()[0] & 0xfe00) == 0xfc00
3987 || (v6.segments()[0] & 0xffc0) == 0xfe80
3988 }
3989 };
3990 if private && !loopback {
3991 return Err(crate::RedDBError::Query(format!(
3992 "custom AI provider '{url}' targets a private or link-local address; set REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1 to allow"
3993 )));
3994 }
3995 }
3996 Ok(())
3997}