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_enables_stream_options() {
1314 let payload =
1315 build_openai_prompt_payload("gpt-4.1-mini", "hello", Some(0.0), None, None, true);
1316 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1317
1318 assert_eq!(
1319 parsed.get("stream").and_then(JsonValue::as_bool),
1320 Some(true)
1321 );
1322 assert_eq!(
1323 parsed
1324 .get("stream_options")
1325 .and_then(|value| value.get("include_usage"))
1326 .and_then(JsonValue::as_bool),
1327 Some(true)
1328 );
1329 }
1330
1331 #[test]
1332 fn openai_streaming_prompt_response_collects_delta_chunks() {
1333 let body = concat!(
1334 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"login \"},\"finish_reason\":null}]}\n\n",
1335 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"failed\"},\"finish_reason\":null}]}\n\n",
1336 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"total_tokens\":14}}\n\n",
1337 "data: [DONE]\n\n",
1338 );
1339 let parsed = parse_openai_streaming_prompt_response(body, "fallback").unwrap();
1340
1341 assert_eq!(parsed.model, "gpt-test");
1342 assert_eq!(parsed.output_text, "login failed");
1343 assert_eq!(
1344 parsed.output_chunks.as_deref(),
1345 Some(["login ".to_string(), "failed".to_string()].as_slice())
1346 );
1347 assert_eq!(parsed.prompt_tokens, Some(12));
1348 assert_eq!(parsed.completion_tokens, Some(2));
1349 assert_eq!(parsed.total_tokens, Some(14));
1350 assert_eq!(parsed.stop_reason.as_deref(), Some("stop"));
1351 }
1352
1353 #[tokio::test]
1354 async fn openai_prompt_async_rejects_empty_model() {
1355 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1356 let request = OpenAiPromptRequest {
1357 api_key: "key".to_string(),
1358 model: " ".to_string(),
1359 prompt: "hello".to_string(),
1360 temperature: None,
1361 seed: None,
1362 max_output_tokens: None,
1363 api_base: "https://api.openai.com/v1".to_string(),
1364 stream: false,
1365 };
1366 let err = openai_prompt_async(&transport, request).await.unwrap_err();
1367 assert!(err.to_string().contains("model cannot be empty"));
1368 }
1369
1370 #[tokio::test]
1371 async fn openai_prompt_async_rejects_empty_prompt() {
1372 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1373 let request = OpenAiPromptRequest {
1374 api_key: "key".to_string(),
1375 model: "gpt-4.1-mini".to_string(),
1376 prompt: "".to_string(),
1377 temperature: None,
1378 seed: None,
1379 max_output_tokens: None,
1380 api_base: "https://api.openai.com/v1".to_string(),
1381 stream: false,
1382 };
1383 let err = openai_prompt_async(&transport, request).await.unwrap_err();
1384 assert!(err.to_string().contains("prompt cannot be empty"));
1385 }
1386
1387 use std::io::{Read as _, Write as _};
1397 use std::net::TcpListener;
1398 use std::sync::{Arc, Mutex};
1399 use std::thread;
1400
1401 struct CapturedRequest {
1402 method: String,
1403 path: String,
1404 headers: Vec<(String, String)>,
1405 body: String,
1406 }
1407
1408 fn parse_http_request(stream: &mut std::net::TcpStream) -> CapturedRequest {
1409 let mut buf = [0u8; 8192];
1410 let mut data = Vec::new();
1411 loop {
1412 let read = stream.read(&mut buf).unwrap_or(0);
1413 if read == 0 {
1414 break;
1415 }
1416 data.extend_from_slice(&buf[..read]);
1417 if let Some(idx) = data.windows(4).position(|w| w == b"\r\n\r\n") {
1418 let header_len = idx + 4;
1419 let header_str = String::from_utf8_lossy(&data[..idx]).to_string();
1420 let mut lines = header_str.split("\r\n");
1421 let request_line = lines.next().unwrap_or("");
1422 let mut parts = request_line.split_whitespace();
1423 let method = parts.next().unwrap_or("").to_string();
1424 let path = parts.next().unwrap_or("").to_string();
1425 let mut headers = Vec::new();
1426 let mut content_length: usize = 0;
1427 for line in lines {
1428 if let Some((k, v)) = line.split_once(':') {
1429 let k = k.trim().to_string();
1430 let v = v.trim().to_string();
1431 if k.eq_ignore_ascii_case("content-length") {
1432 content_length = v.parse().unwrap_or(0);
1433 }
1434 headers.push((k, v));
1435 }
1436 }
1437 while data.len() < header_len + content_length {
1438 let read = stream.read(&mut buf).unwrap_or(0);
1439 if read == 0 {
1440 break;
1441 }
1442 data.extend_from_slice(&buf[..read]);
1443 }
1444 let body = String::from_utf8_lossy(&data[header_len..header_len + content_length])
1445 .to_string();
1446 return CapturedRequest {
1447 method,
1448 path,
1449 headers,
1450 body,
1451 };
1452 }
1453 }
1454 CapturedRequest {
1455 method: String::new(),
1456 path: String::new(),
1457 headers: Vec::new(),
1458 body: String::new(),
1459 }
1460 }
1461
1462 fn spawn_mock(
1465 status: u16,
1466 response_body: &'static str,
1467 ) -> (String, Arc<Mutex<Option<CapturedRequest>>>) {
1468 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1469 let addr = listener.local_addr().expect("addr");
1470 let captured: Arc<Mutex<Option<CapturedRequest>>> = Arc::new(Mutex::new(None));
1471 let captured_clone = Arc::clone(&captured);
1472 thread::spawn(move || {
1473 if let Ok((mut stream, _)) = listener.accept() {
1474 let req = parse_http_request(&mut stream);
1475 *captured_clone.lock().unwrap() = Some(req);
1476 let status_line = match status {
1477 200 => "200 OK",
1478 400 => "400 Bad Request",
1479 401 => "401 Unauthorized",
1480 500 => "500 Internal Server Error",
1481 _ => "200 OK",
1482 };
1483 let resp = format!(
1484 "HTTP/1.1 {status_line}\r\n\
1485 Content-Type: application/json\r\n\
1486 Content-Length: {}\r\n\
1487 Connection: close\r\n\r\n{}",
1488 response_body.len(),
1489 response_body
1490 );
1491 let _ = stream.write_all(resp.as_bytes());
1492 }
1493 });
1494 (format!("http://{}", addr), captured)
1495 }
1496
1497 #[test]
1498 fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
1499 let body = r#"{
1500 "id":"chatcmpl_x",
1501 "model":"custom-model",
1502 "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
1503 "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
1504 }"#;
1505 let (base, captured) = spawn_mock(200, body);
1506
1507 let req = OpenAiCompatChatRequest {
1508 api_base: base.clone(),
1509 api_key: "sk-test".to_string(),
1510 model: "custom-model".to_string(),
1511 prompt: "say hi".to_string(),
1512 temperature: None,
1513 seed: None,
1514 max_output_tokens: None,
1515 extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
1516 };
1517 let resp = openai_compat_chat(req).expect("ok");
1518
1519 assert_eq!(resp.output_text, "hi");
1520 assert_eq!(resp.model, "custom-model");
1521 assert_eq!(resp.usage.input_tokens, Some(7));
1522 assert_eq!(resp.usage.output_tokens, Some(2));
1523 assert_eq!(resp.usage.total_tokens, Some(9));
1524 assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
1525
1526 let cap = captured.lock().unwrap().take().expect("captured");
1527 assert_eq!(cap.method, "POST");
1528 assert_eq!(cap.path, "/chat/completions");
1529 let has_auth = cap
1530 .headers
1531 .iter()
1532 .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
1533 assert!(has_auth, "Authorization header missing");
1534 let has_custom = cap
1535 .headers
1536 .iter()
1537 .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
1538 assert!(has_custom, "extra header missing");
1539 assert!(cap.body.contains("\"model\":\"custom-model\""));
1540 }
1541
1542 #[test]
1543 fn openai_compat_embeddings_roundtrip_with_dimensions() {
1544 let body = r#"{
1545 "object":"list",
1546 "model":"embed-model",
1547 "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
1548 "usage":{"prompt_tokens":4,"total_tokens":4}
1549 }"#;
1550 let (base, captured) = spawn_mock(200, body);
1551
1552 let req = OpenAiCompatEmbeddingsRequest {
1553 api_base: base,
1554 api_key: "sk-emb".to_string(),
1555 model: "embed-model".to_string(),
1556 inputs: vec!["hello".to_string()],
1557 dimensions: Some(2),
1558 extra_headers: vec![],
1559 };
1560 let resp = openai_compat_embeddings(req).expect("ok");
1561
1562 assert_eq!(resp.embeddings.len(), 1);
1563 assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
1564 assert_eq!(resp.usage.total_tokens, Some(4));
1565 assert_eq!(resp.usage.input_tokens, Some(4));
1566
1567 let cap = captured.lock().unwrap().take().expect("captured");
1568 assert_eq!(cap.path, "/embeddings");
1569 assert!(cap.body.contains("\"dimensions\":2"));
1570 }
1571
1572 #[test]
1573 fn openai_compat_chat_non_2xx_returns_structured_error() {
1574 let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
1575 let (base, _captured) = spawn_mock(401, body);
1576
1577 let req = OpenAiCompatChatRequest {
1578 api_base: base,
1579 api_key: "bad".to_string(),
1580 model: "m".to_string(),
1581 prompt: "hi".to_string(),
1582 temperature: None,
1583 seed: None,
1584 max_output_tokens: None,
1585 extra_headers: vec![],
1586 };
1587 let err = openai_compat_chat(req).unwrap_err().to_string();
1588 assert!(err.contains("status 401"), "got: {err}");
1589 assert!(err.contains("bad api key"), "got: {err}");
1590 }
1591
1592 #[test]
1593 fn openai_compat_chat_rejects_empty_model_and_prompt() {
1594 let req = OpenAiCompatChatRequest {
1595 api_base: "http://localhost:1".to_string(),
1596 api_key: "k".to_string(),
1597 model: " ".to_string(),
1598 prompt: "hi".to_string(),
1599 temperature: None,
1600 seed: None,
1601 max_output_tokens: None,
1602 extra_headers: vec![],
1603 };
1604 let err = openai_compat_chat(req).unwrap_err().to_string();
1605 assert!(err.contains("model cannot be empty"), "got: {err}");
1606
1607 let req = OpenAiCompatChatRequest {
1608 api_base: "http://localhost:1".to_string(),
1609 api_key: "k".to_string(),
1610 model: "m".to_string(),
1611 prompt: " ".to_string(),
1612 temperature: None,
1613 seed: None,
1614 max_output_tokens: None,
1615 extra_headers: vec![],
1616 };
1617 let err = openai_compat_chat(req).unwrap_err().to_string();
1618 assert!(err.contains("prompt cannot be empty"), "got: {err}");
1619 }
1620
1621 #[test]
1622 fn parse_provider_mode_recognizes_all_three_tokens() {
1623 assert_eq!(
1624 parse_provider_mode("openai-compat"),
1625 Some(AiProviderMode::OpenAiCompat)
1626 );
1627 assert_eq!(
1628 parse_provider_mode("OPENAI_NATIVE"),
1629 Some(AiProviderMode::OpenAiNative)
1630 );
1631 assert_eq!(
1632 parse_provider_mode("anthropic-native"),
1633 Some(AiProviderMode::AnthropicNative)
1634 );
1635 assert_eq!(parse_provider_mode("groq"), None);
1636 }
1637
1638 #[test]
1639 fn resolve_provider_mode_reads_kv_key() {
1640 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1641 if key == "red.config.ai.provider" {
1642 Ok(Some("anthropic-native".to_string()))
1643 } else {
1644 Ok(None)
1645 }
1646 };
1647 assert_eq!(
1648 resolve_provider_mode(&kv),
1649 Some(AiProviderMode::AnthropicNative)
1650 );
1651 }
1652
1653 #[test]
1654 fn resolve_default_provider_honors_mode_key() {
1655 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1657 match key {
1658 "red.config.ai.provider" => Ok(Some("anthropic-native".to_string())),
1659 "red.config.ai.inference.provider" => Ok(Some("groq".to_string())),
1660 _ => Ok(None),
1661 }
1662 };
1663 assert_eq!(resolve_default_provider(&kv), AiProvider::Anthropic);
1664 }
1665
1666 #[test]
1669 fn inference_provider_ask_specific_beats_task_pointer() {
1670 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1671 match key {
1672 "red.config.ai.ask.provider" => Ok(Some("groq".to_string())),
1673 "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1674 _ => Ok(None),
1675 }
1676 };
1677 assert_eq!(resolve_default_provider(&kv), AiProvider::Groq);
1678 }
1679
1680 #[test]
1681 fn inference_provider_falls_through_to_task_pointer_then_default() {
1682 let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
1683 match key {
1684 "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1685 _ => Ok(None),
1686 }
1687 };
1688 assert_eq!(resolve_default_provider(&pointer), AiProvider::DeepSeek);
1689
1690 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1691 assert_eq!(resolve_default_provider(&empty), AiProvider::OpenAi);
1692 }
1693
1694 #[test]
1695 fn inference_model_ask_specific_beats_models_block_beats_builtin() {
1696 let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
1697 match key {
1698 "red.config.ai.ask.model" => Ok(Some("gpt-ask".to_string())),
1699 "red.config.ai.providers.openai.models.inference" => {
1700 Ok(Some("gpt-block".to_string()))
1701 }
1702 _ => Ok(None),
1703 }
1704 };
1705 assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
1706
1707 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
1708 match key {
1709 "red.config.ai.providers.openai.models.inference" => {
1710 Ok(Some("gpt-block".to_string()))
1711 }
1712 _ => Ok(None),
1713 }
1714 };
1715 assert_eq!(
1716 resolve_default_model(&AiProvider::OpenAi, &block),
1717 "gpt-block"
1718 );
1719
1720 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1721 assert_eq!(
1722 resolve_default_model(&AiProvider::OpenAi, &empty),
1723 AiProvider::OpenAi.default_prompt_model()
1724 );
1725 }
1726
1727 #[test]
1728 fn embeddings_provider_follows_task_pointer() {
1729 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1730 match key {
1731 "red.config.ai.embeddings.provider" => Ok(Some("ollama".to_string())),
1732 _ => Ok(None),
1733 }
1734 };
1735 assert_eq!(
1736 resolve_embeddings_provider(&kv).unwrap(),
1737 AiProvider::Ollama
1738 );
1739
1740 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1741 assert_eq!(
1742 resolve_embeddings_provider(&empty).unwrap(),
1743 AiProvider::OpenAi
1744 );
1745 }
1746
1747 #[test]
1748 fn embeddings_provider_rejects_modality_incapable_pointer() {
1749 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1750 match key {
1751 "red.config.ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
1752 _ => Ok(None),
1753 }
1754 };
1755 let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
1756 assert!(
1757 err.contains("red.config.ai.embeddings.provider"),
1758 "error must name the pointer to fix: {err}"
1759 );
1760 assert!(
1761 err.contains("openai") && err.contains("no embeddings API"),
1762 "error must name capable alternatives: {err}"
1763 );
1764 }
1765
1766 #[test]
1767 fn embeddings_provider_falls_back_to_default_provider_chain() {
1768 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1774 match key {
1775 "red.config.ai.inference.provider" => Ok(Some("ollama".to_string())),
1776 _ => Ok(None),
1777 }
1778 };
1779 assert_eq!(
1780 resolve_embeddings_provider(&kv).unwrap(),
1781 AiProvider::Ollama
1782 );
1783 }
1784
1785 #[test]
1786 fn embeddings_model_block_beats_builtin() {
1787 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
1788 match key {
1789 "red.config.ai.providers.openai.models.embeddings" => {
1790 Ok(Some("text-embedding-custom".to_string()))
1791 }
1792 _ => Ok(None),
1793 }
1794 };
1795 assert_eq!(
1796 resolve_embeddings_model(&AiProvider::OpenAi, &block),
1797 "text-embedding-custom"
1798 );
1799
1800 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1801 assert_eq!(
1802 resolve_embeddings_model(&AiProvider::Ollama, &empty),
1803 AiProvider::Ollama.default_embedding_model()
1804 );
1805 }
1806
1807 #[test]
1808 fn base_url_reads_provider_block_key() {
1809 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1810 if key == "red.config.ai.providers.openai.base_url" {
1811 Ok(Some("https://proxy.example/v1".to_string()))
1812 } else {
1813 Ok(None)
1814 }
1815 };
1816 assert_eq!(
1817 AiProvider::OpenAi.resolve_api_base_with_kv("default", &kv),
1818 "https://proxy.example/v1"
1819 );
1820 }
1821
1822 #[test]
1823 fn removed_config_keys_rejected_naming_replacements() {
1824 let err = validate_ai_config_key_on_write("red.config.ai.default.provider")
1825 .unwrap_err()
1826 .to_string();
1827 assert!(err.contains("red.config.ai.inference.provider"), "{err}");
1828
1829 let err = validate_ai_config_key_on_write("red.config.ai.default.model")
1830 .unwrap_err()
1831 .to_string();
1832 assert!(
1833 err.contains("red.config.ai.providers.<provider>.models.inference"),
1834 "{err}"
1835 );
1836
1837 let err = validate_ai_config_key_on_write("red.config.ai.openai.default.base_url")
1838 .unwrap_err()
1839 .to_string();
1840 assert!(
1841 err.contains("red.config.ai.providers.<provider>.base_url"),
1842 "{err}"
1843 );
1844
1845 assert!(validate_ai_config_key_on_write("red.config.ai.inference.provider").is_ok());
1847 assert!(validate_ai_config_key_on_write("red.config.ai.providers.openai.base_url").is_ok());
1848 assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
1849 }
1850
1851 #[test]
1852 fn ask_planner_model_and_effort_resolve() {
1853 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1854 match key {
1855 "red.config.ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
1856 "red.config.ai.ask.effort" => Ok(Some("high".to_string())),
1857 _ => Ok(None),
1858 }
1859 };
1860 assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
1861 assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
1862
1863 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1864 assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
1865 assert_eq!(resolve_ask_effort(&empty), None);
1866 }
1867
1868 #[tokio::test]
1869 async fn anthropic_prompt_async_rejects_empty_api_key() {
1870 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1871 let request = AnthropicPromptRequest {
1872 api_key: " ".to_string(),
1873 model: "claude-3-5-haiku-latest".to_string(),
1874 prompt: "hello".to_string(),
1875 temperature: None,
1876 max_output_tokens: None,
1877 api_base: "https://api.anthropic.com/v1".to_string(),
1878 anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
1879 };
1880 let err = anthropic_prompt_async(&transport, request)
1881 .await
1882 .unwrap_err();
1883 assert!(err.to_string().contains("API key cannot be empty"));
1884 }
1885}
1886
1887#[derive(Debug, Clone, PartialEq, Eq)]
1893pub enum AiProvider {
1894 OpenAi,
1895 Anthropic,
1896 Groq,
1897 OpenRouter,
1898 Together,
1899 Venice,
1900 Ollama,
1901 DeepSeek,
1902 MiniMax,
1903 HuggingFace,
1904 Local,
1905 Custom(String),
1906}
1907
1908impl AiProvider {
1909 pub fn token(&self) -> &str {
1910 match self {
1911 Self::OpenAi => "openai",
1912 Self::Anthropic => "anthropic",
1913 Self::Groq => "groq",
1914 Self::OpenRouter => "openrouter",
1915 Self::Together => "together",
1916 Self::Venice => "venice",
1917 Self::Ollama => "ollama",
1918 Self::DeepSeek => "deepseek",
1919 Self::MiniMax => "minimax",
1920 Self::HuggingFace => "huggingface",
1921 Self::Local => "local",
1922 Self::Custom(name) => name.as_str(),
1923 }
1924 }
1925
1926 pub fn default_prompt_model(&self) -> &str {
1927 match self {
1928 Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
1929 Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
1930 Self::Groq => "llama-3.3-70b-versatile",
1931 Self::OpenRouter => "auto",
1932 Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
1933 Self::Venice => "llama-3.3-70b",
1934 Self::Ollama => "llama3",
1935 Self::DeepSeek => "deepseek-chat",
1936 Self::MiniMax => "abab6.5s-chat",
1937 Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
1938 Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
1939 Self::Custom(_) => DEFAULT_OPENAI_PROMPT_MODEL,
1940 }
1941 }
1942
1943 pub fn prompt_model_env_name(&self) -> String {
1944 format!("REDDB_{}_PROMPT_MODEL", self.token().to_ascii_uppercase())
1945 }
1946
1947 pub fn default_embedding_model(&self) -> &str {
1948 match self {
1949 Self::Ollama => "nomic-embed-text",
1950 Self::MiniMax => "embo-01",
1951 Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
1952 _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
1953 }
1954 }
1955
1956 pub fn default_api_base(&self) -> &str {
1957 match self {
1958 Self::OpenAi => DEFAULT_OPENAI_API_BASE,
1959 Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
1960 Self::Groq => "https://api.groq.com/openai/v1",
1961 Self::OpenRouter => "https://openrouter.ai/api/v1",
1962 Self::Together => "https://api.together.xyz/v1",
1963 Self::Venice => "https://api.venice.ai/api/v1",
1964 Self::Ollama => "http://localhost:11434/v1",
1965 Self::DeepSeek => "https://api.deepseek.com/v1",
1966 Self::MiniMax => "https://api.minimax.chat/v1",
1967 Self::HuggingFace => "https://api-inference.huggingface.co",
1968 Self::Local => "local",
1969 Self::Custom(base) => base.as_str(),
1970 }
1971 }
1972
1973 pub fn api_base_env_name(&self) -> String {
1974 format!("REDDB_{}_API_BASE", self.token().to_ascii_uppercase())
1975 }
1976
1977 pub fn default_key_env_name(&self) -> String {
1978 format!("REDDB_{}_API_KEY", self.token().to_ascii_uppercase())
1979 }
1980
1981 pub fn alias_key_env_name(&self, alias: &str) -> String {
1982 let normalized = normalize_alias_token(alias);
1983 format!(
1984 "REDDB_{}_API_KEY_{normalized}",
1985 self.token().to_ascii_uppercase()
1986 )
1987 }
1988
1989 pub fn resolve_api_base(&self) -> String {
1990 if let Ok(value) = std::env::var(self.api_base_env_name()) {
1991 let value = value.trim().to_string();
1992 if !value.is_empty() {
1993 return value;
1994 }
1995 }
1996 self.default_api_base().to_string()
1997 }
1998
1999 pub fn resolve_api_base_with_kv<F>(&self, _alias: &str, kv_getter: &F) -> String
2005 where
2006 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2007 {
2008 if let Ok(value) = std::env::var(self.api_base_env_name()) {
2010 let value = value.trim().to_string();
2011 if !value.is_empty() {
2012 return value;
2013 }
2014 }
2015 if let Ok(Some(value)) = kv_getter(&provider_base_url_key(self)) {
2017 let value = value.trim().to_string();
2018 if !value.is_empty() {
2019 return value;
2020 }
2021 }
2022 self.default_api_base().to_string()
2023 }
2024
2025 pub fn is_openai_compatible(&self) -> bool {
2027 matches!(
2028 self,
2029 Self::OpenAi
2030 | Self::Groq
2031 | Self::OpenRouter
2032 | Self::Together
2033 | Self::Venice
2034 | Self::Ollama
2035 | Self::DeepSeek
2036 | Self::MiniMax
2037 | Self::Custom(_)
2038 )
2039 }
2040
2041 pub fn requires_api_key(&self) -> bool {
2043 !matches!(self, Self::Ollama | Self::Local)
2044 }
2045
2046 pub fn supports_embeddings(&self) -> bool {
2051 !matches!(self, Self::Anthropic)
2052 }
2053}
2054
2055pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
2057 match name.trim().to_ascii_lowercase().as_str() {
2058 "openai" => Ok(AiProvider::OpenAi),
2059 "anthropic" => Ok(AiProvider::Anthropic),
2060 "groq" => Ok(AiProvider::Groq),
2061 "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
2062 "together" => Ok(AiProvider::Together),
2063 "venice" => Ok(AiProvider::Venice),
2064 "ollama" => Ok(AiProvider::Ollama),
2065 "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
2066 "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
2067 "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
2068 "local" => Ok(AiProvider::Local),
2069 other => {
2070 if other.starts_with("http://") || other.starts_with("https://") {
2072 Ok(AiProvider::Custom(other.to_string()))
2073 } else {
2074 Err(crate::RedDBError::Query(format!(
2075 "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
2076 openrouter, together, venice, ollama, deepseek, minimax, huggingface, local"
2077 )))
2078 }
2079 }
2080 }
2081}
2082
2083pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
2103 "openai, groq, ollama, openrouter, together, venice, deepseek, minimax, huggingface, local";
2104
2105pub fn provider_base_url_key(provider: &AiProvider) -> String {
2107 format!("red.config.ai.providers.{}.base_url", provider.token())
2108}
2109
2110pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
2113 format!(
2114 "red.config.ai.providers.{}.models.{modality}",
2115 provider.token()
2116 )
2117}
2118
2119pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
2124 let key = key.trim();
2125 if key == "red.config.ai.default.provider" {
2126 return Err(crate::RedDBError::Query(
2127 "AI config key 'red.config.ai.default.provider' was removed in the ADR-0068 \
2128 clean break; set the task pointer 'red.config.ai.inference.provider' (or \
2129 'red.config.ai.ask.provider' for the ASK planner) instead"
2130 .to_string(),
2131 ));
2132 }
2133 if key == "red.config.ai.default.model" {
2134 return Err(crate::RedDBError::Query(
2135 "AI config key 'red.config.ai.default.model' was removed in the ADR-0068 clean \
2136 break; set 'red.config.ai.ask.model' or \
2137 'red.config.ai.providers.<provider>.models.inference' instead"
2138 .to_string(),
2139 ));
2140 }
2141 if key.starts_with("red.config.ai.")
2145 && key.ends_with(".base_url")
2146 && !key.starts_with("red.config.ai.providers.")
2147 {
2148 return Err(crate::RedDBError::Query(format!(
2149 "AI config key '{key}' uses the removed per-credential base-URL shape; set \
2150 'red.config.ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
2151 )));
2152 }
2153 Ok(())
2154}
2155
2156pub fn resolve_default_provider<F>(kv_getter: &F) -> AiProvider
2163where
2164 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2165{
2166 if let Some(mode) = resolve_provider_mode(kv_getter) {
2168 return provider_mode_to_provider(mode);
2169 }
2170 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
2172 let value = value.trim().to_string();
2173 if !value.is_empty() {
2174 if let Ok(provider) = parse_provider(&value) {
2175 return provider;
2176 }
2177 }
2178 }
2179 for key in [
2181 "red.config.ai.ask.provider",
2182 "red.config.ai.inference.provider",
2183 ] {
2184 if let Ok(Some(value)) = kv_getter(key) {
2185 let value = value.trim().to_string();
2186 if !value.is_empty() {
2187 if let Ok(provider) = parse_provider(&value) {
2188 return provider;
2189 }
2190 }
2191 }
2192 }
2193 AiProvider::OpenAi
2194}
2195
2196pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2203where
2204 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2205{
2206 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2208 let value = value.trim().to_string();
2209 if !value.is_empty() {
2210 return value;
2211 }
2212 }
2213 if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
2215 let value = value.trim().to_string();
2216 if !value.is_empty() {
2217 return value;
2218 }
2219 }
2220 for key in [
2222 "red.config.ai.ask.model".to_string(),
2223 provider_models_key(provider, "inference"),
2224 ] {
2225 if let Ok(Some(value)) = kv_getter(&key) {
2226 let value = value.trim().to_string();
2227 if !value.is_empty() {
2228 return value;
2229 }
2230 }
2231 }
2232 provider.default_prompt_model().to_string()
2233}
2234
2235pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
2247where
2248 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2249{
2250 let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
2251 .ok()
2252 .map(|v| v.trim().to_string())
2253 .filter(|v| !v.is_empty())
2254 {
2255 parse_provider(&value)?
2256 } else if let Some(value) = kv_getter("red.config.ai.embeddings.provider")
2257 .ok()
2258 .flatten()
2259 .map(|v| v.trim().to_string())
2260 .filter(|v| !v.is_empty())
2261 {
2262 parse_provider(&value)?
2263 } else {
2264 resolve_default_provider(kv_getter)
2268 };
2269 ensure_provider_supports_embeddings(&provider)?;
2270 Ok(provider)
2271}
2272
2273pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
2275 if provider.supports_embeddings() {
2276 return Ok(());
2277 }
2278 Err(crate::RedDBError::Query(format!(
2279 "the embeddings task pointer 'red.config.ai.embeddings.provider' names '{}', which \
2280 has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
2281 re-routes embeddings to a different provider than the one you named.",
2282 provider.token(),
2283 EMBEDDING_CAPABLE_PROVIDERS
2284 )))
2285}
2286
2287pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2300where
2301 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2302{
2303 if let Ok(value) = std::env::var(format!(
2304 "REDDB_{}_EMBEDDING_MODEL",
2305 provider.token().to_ascii_uppercase()
2306 )) {
2307 let value = value.trim().to_string();
2308 if !value.is_empty() {
2309 return value;
2310 }
2311 }
2312 if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
2313 let value = value.trim().to_string();
2314 if !value.is_empty() {
2315 return value;
2316 }
2317 }
2318 if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
2319 let value = value.trim().to_string();
2320 if !value.is_empty() {
2321 return value;
2322 }
2323 }
2324 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2325 let value = value.trim().to_string();
2326 if !value.is_empty() {
2327 return value;
2328 }
2329 }
2330 provider.default_embedding_model().to_string()
2331}
2332
2333pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
2337where
2338 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2339{
2340 if let Ok(Some(value)) = kv_getter("red.config.ai.ask.planner_model") {
2341 let value = value.trim().to_string();
2342 if !value.is_empty() {
2343 return value;
2344 }
2345 }
2346 fallback_model.to_string()
2347}
2348
2349pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
2352where
2353 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2354{
2355 if let Ok(Some(value)) = kv_getter("red.config.ai.ask.effort") {
2356 let value = value.trim().to_string();
2357 if !value.is_empty() {
2358 return Some(value);
2359 }
2360 }
2361 None
2362}
2363
2364pub fn resolve_defaults_from_runtime(
2366 runtime: &crate::runtime::RedDBRuntime,
2367) -> (AiProvider, String) {
2368 use crate::application::ports::RuntimeEntityPort;
2369 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2370 match runtime.get_kv("red_config", key)? {
2371 Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2372 _ => Ok(None),
2373 }
2374 };
2375 let provider = resolve_default_provider(&kv_getter);
2376 let model = resolve_default_model(&provider, &kv_getter);
2377 (provider, model)
2378}
2379
2380pub fn resolve_ask_planner_model_from_runtime(
2385 runtime: &crate::runtime::RedDBRuntime,
2386 fallback_model: &str,
2387) -> String {
2388 use crate::application::ports::RuntimeEntityPort;
2389 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2390 match runtime.get_kv("red_config", key)? {
2391 Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2392 _ => Ok(None),
2393 }
2394 };
2395 resolve_ask_planner_model(&kv_getter, fallback_model)
2396}
2397
2398pub fn resolve_defaults_from_runtime_port<
2400 P: crate::application::ports::RuntimeEntityPort + ?Sized,
2401>(
2402 runtime: &P,
2403) -> (AiProvider, String) {
2404 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2405 match runtime.get_kv("red_config", key)? {
2406 Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2407 _ => Ok(None),
2408 }
2409 };
2410 let provider = resolve_default_provider(&kv_getter);
2411 let model = resolve_default_model(&provider, &kv_getter);
2412 (provider, model)
2413}
2414
2415pub fn resolve_embeddings_provider_from_runtime<
2420 P: crate::application::ports::RuntimeEntityPort + ?Sized,
2421>(
2422 runtime: &P,
2423 explicit: &str,
2424) -> crate::RedDBResult<AiProvider> {
2425 let explicit = explicit.trim();
2426 if !explicit.is_empty() {
2427 let provider = parse_provider(explicit)?;
2428 ensure_provider_supports_embeddings(&provider)?;
2429 return Ok(provider);
2430 }
2431 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2432 match runtime.get_kv("red_config", key)? {
2433 Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2434 _ => Ok(None),
2435 }
2436 };
2437 resolve_embeddings_provider(&kv_getter)
2438}
2439
2440pub fn resolve_embeddings_model_from_runtime<
2444 P: crate::application::ports::RuntimeEntityPort + ?Sized,
2445>(
2446 runtime: &P,
2447 provider: &AiProvider,
2448 explicit: Option<&str>,
2449) -> String {
2450 if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
2451 return model.to_string();
2452 }
2453 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2454 match runtime.get_kv("red_config", key)? {
2455 Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2456 _ => Ok(None),
2457 }
2458 };
2459 resolve_embeddings_model(provider, &kv_getter)
2460}
2461
2462pub fn resolve_api_key<F>(
2489 provider: &AiProvider,
2490 credential_alias: Option<&str>,
2491 kv_getter: F,
2492) -> crate::RedDBResult<String>
2493where
2494 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2495{
2496 if !provider.requires_api_key() {
2498 if let Ok(value) = std::env::var(provider.default_key_env_name()) {
2500 let value = value.trim().to_string();
2501 if !value.is_empty() {
2502 return Ok(value);
2503 }
2504 }
2505 return Ok(String::new());
2506 }
2507
2508 if let Some(alias) = credential_alias.map(str::trim).filter(|a| !a.is_empty()) {
2509 if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
2511 if !key.trim().is_empty() {
2512 return Ok(key);
2513 }
2514 }
2515 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
2517 if let Some(key) = kv_getter(secret_ref.trim())? {
2518 if !key.trim().is_empty() {
2519 return Ok(key);
2520 }
2521 }
2522 }
2523 if let Some(key) = resolve_key_from_env_alias(provider, alias) {
2525 return Ok(key);
2526 }
2527 reject_removed_credential_paths(provider, alias, &kv_getter)?;
2530 return Err(crate::RedDBError::Query(format!(
2531 "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
2532 provider.token(),
2533 provider.alias_key_env_name(alias),
2534 ai_api_secret_path(provider, alias),
2535 )));
2536 }
2537
2538 if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
2540 if !key.trim().is_empty() {
2541 return Ok(key);
2542 }
2543 }
2544 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
2546 if let Some(key) = kv_getter(secret_ref.trim())? {
2547 if !key.trim().is_empty() {
2548 return Ok(key);
2549 }
2550 }
2551 }
2552
2553 if let Ok(value) = std::env::var(provider.default_key_env_name()) {
2555 let value = value.trim().to_string();
2556 if !value.is_empty() {
2557 return Ok(value);
2558 }
2559 }
2560
2561 reject_removed_credential_paths(provider, "default", &kv_getter)?;
2564
2565 Err(crate::RedDBError::Query(format!(
2566 "missing {} API key. Set {} or store it in the vault at '{}'",
2567 provider.token(),
2568 provider.default_key_env_name(),
2569 ai_api_secret_path(provider, "default"),
2570 )))
2571}
2572
2573pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
2575 format!(
2576 "red.secret.ai.providers.{}.tokens.{}",
2577 provider.token(),
2578 normalize_credential_alias_path(alias)
2579 )
2580}
2581
2582pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
2584 format!(
2585 "red.config.ai.providers.{}.tokens.{}.secret_ref",
2586 provider.token(),
2587 normalize_credential_alias_path(alias)
2588 )
2589}
2590
2591fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
2595 format!(
2596 "red.secret.ai.{}.{}.api_key",
2597 provider.token(),
2598 normalize_credential_alias_path(alias)
2599 )
2600}
2601
2602fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
2605 format!(
2606 "red.config.ai.{}.{}.key",
2607 provider.token(),
2608 normalize_credential_alias_path(alias)
2609 )
2610}
2611
2612fn reject_removed_credential_paths<F>(
2616 provider: &AiProvider,
2617 alias: &str,
2618 kv_getter: &F,
2619) -> crate::RedDBResult<()>
2620where
2621 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2622{
2623 let new_path = ai_api_secret_path(provider, alias);
2624 for removed in [
2625 removed_vault_api_key_path(provider, alias),
2626 removed_plaintext_config_key(provider, alias),
2627 ] {
2628 if let Some(value) = kv_getter(&removed)? {
2629 if !value.trim().is_empty() {
2630 return Err(crate::RedDBError::Query(format!(
2631 "AI credential found at removed path '{removed}'. The AI credential vault \
2632 path changed (issue #1745): store the token at '{new_path}' instead. The \
2633 old vault path shape and the legacy plaintext config path are no longer read."
2634 )));
2635 }
2636 }
2637 }
2638 Ok(())
2639}
2640
2641fn normalize_credential_alias_path(alias: &str) -> String {
2642 let alias = alias.trim();
2643 if alias.is_empty() {
2644 "default".to_string()
2645 } else {
2646 alias.to_ascii_lowercase()
2647 }
2648}
2649
2650fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
2651 let env_name = provider.alias_key_env_name(alias);
2652 std::env::var(env_name)
2653 .ok()
2654 .map(|v| v.trim().to_string())
2655 .filter(|v| !v.is_empty())
2656}
2657
2658fn normalize_alias_token(alias: &str) -> String {
2659 let mut out = String::with_capacity(alias.len());
2660 for character in alias.chars() {
2661 if character.is_ascii_alphanumeric() {
2662 out.push(character.to_ascii_uppercase());
2663 } else {
2664 out.push('_');
2665 }
2666 }
2667 while out.contains("__") {
2668 out = out.replace("__", "_");
2669 }
2670 out.trim_matches('_').to_string()
2671}
2672
2673pub fn resolve_api_key_from_runtime(
2683 provider: &AiProvider,
2684 credential_alias: Option<&str>,
2685 runtime: &crate::runtime::RedDBRuntime,
2686) -> crate::RedDBResult<String> {
2687 use crate::application::ports::RuntimeEntityPort;
2688 let alias_for_audit = credential_alias.unwrap_or("default").to_string();
2689 let provider_token = provider.token().to_string();
2690 let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
2691 std::cell::RefCell::new(Vec::new());
2692 let result = resolve_api_key(provider, credential_alias, |kv_key| {
2693 if kv_key.starts_with("red.secret.") {
2694 let value = runtime.vault_kv_get(kv_key);
2695 audited_paths
2696 .borrow_mut()
2697 .push((kv_key.to_string(), value.is_some()));
2698 return Ok(value);
2699 }
2700 match runtime.get_kv("red_config", kv_key)? {
2701 Some((crate::storage::schema::Value::Text(secret), _)) => {
2702 audited_paths.borrow_mut().push((kv_key.to_string(), true));
2703 Ok(Some(secret.to_string()))
2704 }
2705 Some(_) => {
2706 audited_paths.borrow_mut().push((kv_key.to_string(), false));
2707 Ok(None)
2708 }
2709 None => {
2710 audited_paths.borrow_mut().push((kv_key.to_string(), false));
2711 Ok(None)
2712 }
2713 }
2714 });
2715 let audited_paths = audited_paths.into_inner();
2716
2717 let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
2718 .map(|(user, _role)| user)
2719 .unwrap_or_else(|| "system".to_string());
2720 let outcome = if result.is_ok() { "hit" } else { "miss" };
2721 let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
2722 let paths_json: Vec<crate::serde_json::Value> = audited_paths
2723 .iter()
2724 .map(|(p, hit)| {
2725 crate::serde_json::json!({
2726 "path": p,
2727 "hit": hit,
2728 })
2729 })
2730 .collect();
2731 let details = crate::serde_json::json!({
2732 "provider": provider_token,
2733 "alias": alias_for_audit,
2734 "paths_checked": paths_json,
2735 });
2736 runtime.audit_log().record(
2737 "ai.credential.resolve",
2738 &principal,
2739 &target,
2740 outcome,
2741 details,
2742 );
2743 result
2744}
2745
2746pub fn huggingface_embeddings(
2752 api_key: &str,
2753 model: &str,
2754 inputs: &[String],
2755 api_base: &str,
2756) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
2757 let url = format!("{api_base}/pipeline/feature-extraction/{model}");
2758 let mut embeddings = Vec::with_capacity(inputs.len());
2759
2760 for input in inputs {
2761 let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
2762 let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
2763 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
2764 if !(200..300).contains(&status) {
2765 return Err(crate::RedDBError::Query(format!(
2766 "HuggingFace API error (status {status}): {body_str}"
2767 )));
2768 }
2769 let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
2770 crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
2771 })?;
2772
2773 let vector: Vec<f32> = match &body {
2775 JsonValue::Array(outer) => outer
2776 .iter()
2777 .filter_map(|v| v.as_f64().map(|n| n as f32))
2778 .collect(),
2779 _ => {
2780 return Err(crate::RedDBError::Query(
2781 "unexpected HuggingFace embedding response format".to_string(),
2782 ))
2783 }
2784 };
2785 embeddings.push(vector);
2786 }
2787
2788 Ok(OpenAiEmbeddingResponse {
2789 provider: "huggingface",
2790 model: model.to_string(),
2791 embeddings,
2792 prompt_tokens: None,
2793 total_tokens: None,
2794 })
2795}
2796
2797pub fn huggingface_prompt(
2799 api_key: &str,
2800 model: &str,
2801 prompt: &str,
2802 temperature: Option<f32>,
2803 max_tokens: Option<usize>,
2804 api_base: &str,
2805) -> crate::RedDBResult<AiPromptResponse> {
2806 let url = format!("{api_base}/models/{model}");
2807 let mut params = Map::new();
2808 if let Some(t) = temperature {
2809 params.insert("temperature".into(), JsonValue::Number(t as f64));
2810 }
2811 params.insert(
2812 "max_new_tokens".into(),
2813 JsonValue::Number(max_tokens.unwrap_or(512) as f64),
2814 );
2815 let payload = crate::serde_json::json!({
2816 "inputs": prompt,
2817 "parameters": JsonValue::Object(params)
2818 });
2819
2820 let (status, body_str) =
2821 http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
2822 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
2823 if !(200..300).contains(&status) {
2824 return Err(crate::RedDBError::Query(format!(
2825 "HuggingFace API error (status {status}): {body_str}"
2826 )));
2827 }
2828 let body: JsonValue = crate::serde_json::from_str(&body_str)
2829 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
2830
2831 let output_text = match &body {
2832 JsonValue::Array(arr) => arr
2833 .first()
2834 .and_then(|v| v.get("generated_text"))
2835 .and_then(JsonValue::as_str)
2836 .unwrap_or("")
2837 .to_string(),
2838 _ => body
2839 .get("generated_text")
2840 .and_then(JsonValue::as_str)
2841 .unwrap_or("")
2842 .to_string(),
2843 };
2844
2845 Ok(AiPromptResponse {
2846 provider: "huggingface",
2847 model: model.to_string(),
2848 output_text,
2849 output_chunks: None,
2850 prompt_tokens: None,
2851 completion_tokens: None,
2852 total_tokens: None,
2853 stop_reason: None,
2854 })
2855}
2856
2857const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
2862flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
2863the 'ollama' provider with a local Ollama server.";
2864
2865const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
2866`local-models` feature, but local model artifact execution is not implemented in this slice. \
2867Alternatively, use the 'ollama' provider with a local Ollama server.";
2868
2869const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
2870the `local-models` feature; the local provider contract is embeddings-only for this slice.";
2871
2872pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
2873 if cfg!(feature = "local-models") {
2874 crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
2875 } else {
2876 crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
2877 }
2878}
2879
2880pub fn local_prompt_unavailable_error() -> crate::RedDBError {
2881 crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
2882}
2883
2884pub fn local_embeddings(
2886 _model_id: &str,
2887 _texts: &[String],
2888) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
2889 Err(local_embeddings_unavailable_error())
2890}
2891
2892pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
2894 Err(local_prompt_unavailable_error())
2895}
2896
2897fn grpc_collect_embedding_inputs(
2910 runtime: &crate::runtime::RedDBRuntime,
2911 payload: &JsonValue,
2912) -> crate::RedDBResult<Vec<String>> {
2913 if let Some(source_query) = payload
2914 .get("source_query")
2915 .and_then(|v| v.as_str())
2916 .map(str::trim)
2917 .filter(|s| !s.is_empty())
2918 {
2919 return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
2920 }
2921
2922 if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
2923 let mut out = Vec::with_capacity(arr.len());
2924 for (idx, v) in arr.iter().enumerate() {
2925 let text = v.as_str().ok_or_else(|| {
2926 crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
2927 })?;
2928 if text.trim().is_empty() {
2929 return Err(crate::RedDBError::Query(format!(
2930 "field 'inputs[{idx}]' cannot be empty"
2931 )));
2932 }
2933 out.push(text.to_string());
2934 }
2935 if out.is_empty() {
2936 return Err(crate::RedDBError::Query(
2937 "field 'inputs' must be a non-empty array of strings".to_string(),
2938 ));
2939 }
2940 return Ok(out);
2941 }
2942
2943 if let Some(single) = payload
2944 .get("input")
2945 .and_then(|v| v.as_str())
2946 .map(str::trim)
2947 .filter(|s| !s.is_empty())
2948 {
2949 return Ok(vec![single.to_string()]);
2950 }
2951
2952 Err(crate::RedDBError::Query(
2953 "provide either 'input', 'inputs', or 'source_query'".to_string(),
2954 ))
2955}
2956
2957fn grpc_collect_inputs_from_source_query(
2958 runtime: &crate::runtime::RedDBRuntime,
2959 payload: &JsonValue,
2960 source_query: &str,
2961) -> crate::RedDBResult<Vec<String>> {
2962 let result = runtime
2963 .execute_query(source_query)
2964 .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
2965
2966 let source_mode = payload
2967 .get("source_mode")
2968 .and_then(|v| v.as_str())
2969 .map(str::trim)
2970 .filter(|s| !s.is_empty())
2971 .unwrap_or("row")
2972 .to_ascii_lowercase();
2973
2974 let mut out: Vec<String> = Vec::new();
2975 match source_mode.as_str() {
2976 "row" => {
2977 let field = payload
2978 .get("source_field")
2979 .and_then(|v| v.as_str())
2980 .map(str::trim)
2981 .filter(|s| !s.is_empty())
2982 .ok_or_else(|| {
2983 crate::RedDBError::Query(
2984 "field 'source_field' is required when source_mode='row'".to_string(),
2985 )
2986 })?;
2987 for rec in &result.result.records {
2988 for (key, value) in rec.iter_fields() {
2989 if key.as_ref() == field {
2990 if let crate::storage::schema::Value::Text(text) = value {
2991 let trimmed = text.trim();
2992 if !trimmed.is_empty() {
2993 out.push(trimmed.to_string());
2994 }
2995 }
2996 }
2997 }
2998 }
2999 }
3000 "result" => {
3001 for rec in &result.result.records {
3002 for (_, value) in rec.iter_fields() {
3003 if let crate::storage::schema::Value::Text(text) = value {
3004 let trimmed = text.trim();
3005 if !trimmed.is_empty() {
3006 out.push(trimmed.to_string());
3007 }
3008 }
3009 }
3010 }
3011 }
3012 other => {
3013 return Err(crate::RedDBError::Query(format!(
3014 "field 'source_mode' must be 'row' or 'result' (got '{other}')"
3015 )));
3016 }
3017 }
3018
3019 if out.is_empty() {
3020 return Err(crate::RedDBError::Query(
3021 "source_query produced zero non-empty text inputs".to_string(),
3022 ));
3023 }
3024 Ok(out)
3025}
3026
3027pub fn grpc_embeddings(
3049 runtime: &crate::runtime::RedDBRuntime,
3050 payload: &JsonValue,
3051) -> crate::RedDBResult<JsonValue> {
3052 let provider = match payload
3055 .get("provider")
3056 .and_then(|v| v.as_str())
3057 .map(str::trim)
3058 .filter(|s| !s.is_empty())
3059 {
3060 Some(name) => parse_provider(name)?,
3061 None => resolve_embeddings_provider_from_runtime(runtime, "")?,
3062 };
3063 match &provider {
3068 AiProvider::Anthropic => {
3069 return Err(crate::RedDBError::Query(
3070 "Anthropic does not offer an embeddings API. \
3071 Re-issue the request against an OpenAI-compatible \
3072 provider (openai, groq, ollama, openrouter, together, \
3073 venice, deepseek), HuggingFace, or a custom base URL — \
3074 RedDB does not silently route embeddings to a \
3075 different provider than the one you named."
3076 .to_string(),
3077 ));
3078 }
3079 AiProvider::Local => {
3080 return grpc_embeddings_local(runtime, payload);
3081 }
3082 _ => {}
3083 }
3084
3085 let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
3086
3087 let explicit_model = payload.get("model").and_then(|v| v.as_str());
3088 let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
3089
3090 let credential = payload
3091 .get("credential")
3092 .and_then(|v| v.as_str())
3093 .map(str::to_string);
3094 let api_key = resolve_api_key_from_runtime(&provider, credential.as_deref(), runtime)?;
3095
3096 let dimensions = payload
3097 .get("dimensions")
3098 .and_then(|v| v.as_i64())
3099 .and_then(|v| usize::try_from(v).ok())
3100 .filter(|v| *v > 0);
3101
3102 let response = match &provider {
3103 AiProvider::HuggingFace => {
3104 huggingface_embeddings(&api_key, &model, &inputs, &provider.resolve_api_base())?
3105 }
3106 _ => {
3107 let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
3108 let request = OpenAiEmbeddingRequest {
3109 api_key,
3110 model,
3111 inputs,
3112 dimensions,
3113 api_base: provider.resolve_api_base(),
3114 };
3115 crate::runtime::ai::block_on_ai(async move {
3116 openai_embeddings_async(&transport, request).await
3117 })
3118 .and_then(|result| result)?
3119 }
3120 };
3121
3122 let embeddings_json: Vec<JsonValue> = response
3123 .embeddings
3124 .into_iter()
3125 .map(|vec| {
3126 JsonValue::Array(
3127 vec.into_iter()
3128 .map(|f| JsonValue::Number(f as f64))
3129 .collect(),
3130 )
3131 })
3132 .collect();
3133
3134 let mut obj = Map::new();
3135 obj.insert(
3136 "provider".to_string(),
3137 JsonValue::String(response.provider.to_string()),
3138 );
3139 obj.insert("model".to_string(), JsonValue::String(response.model));
3140 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3141 if let Some(pt) = response.prompt_tokens {
3142 obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
3143 }
3144 if let Some(tt) = response.total_tokens {
3145 obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
3146 }
3147 Ok(JsonValue::Object(obj))
3148}
3149
3150fn grpc_embeddings_local(
3159 runtime: &crate::runtime::RedDBRuntime,
3160 payload: &JsonValue,
3161) -> crate::RedDBResult<JsonValue> {
3162 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
3163
3164 let model_name = payload
3165 .get("model")
3166 .and_then(|v| v.as_str())
3167 .map(str::trim)
3168 .filter(|s| !s.is_empty())
3169 .ok_or_else(|| {
3170 crate::RedDBError::Query(
3171 "field 'model' is required for the local provider and must be the \
3172 registered local model name (see POST /ai/models)"
3173 .to_string(),
3174 )
3175 })?
3176 .to_string();
3177
3178 let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
3179 let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
3180
3181 let embeddings_json: Vec<JsonValue> = response
3182 .embeddings
3183 .into_iter()
3184 .map(|vec| {
3185 JsonValue::Array(
3186 vec.into_iter()
3187 .map(|f| JsonValue::Number(f as f64))
3188 .collect(),
3189 )
3190 })
3191 .collect();
3192
3193 let mut obj = Map::new();
3194 obj.insert(
3195 "provider".to_string(),
3196 JsonValue::String(response.provider.to_string()),
3197 );
3198 obj.insert("model".to_string(), JsonValue::String(response.name));
3199 obj.insert(
3200 "model_source".to_string(),
3201 JsonValue::String(response.source),
3202 );
3203 obj.insert(
3204 "model_revision".to_string(),
3205 JsonValue::String(response.revision),
3206 );
3207 obj.insert(
3208 "model_engine".to_string(),
3209 JsonValue::String(response.engine),
3210 );
3211 obj.insert(
3212 "dimensions".to_string(),
3213 JsonValue::Number(response.dimensions as f64),
3214 );
3215 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3216 Ok(JsonValue::Object(obj))
3217}
3218
3219pub fn grpc_prompt(
3221 _runtime: &crate::runtime::RedDBRuntime,
3222 _payload: &JsonValue,
3223) -> crate::RedDBResult<JsonValue> {
3224 Err(crate::RedDBError::FeatureNotEnabled(
3225 "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
3226 ))
3227}
3228
3229pub fn grpc_credentials(
3231 _runtime: &crate::runtime::RedDBRuntime,
3232 _payload: &JsonValue,
3233) -> crate::RedDBResult<JsonValue> {
3234 Err(crate::RedDBError::FeatureNotEnabled(
3235 "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
3236 ))
3237}
3238
3239#[derive(Debug, Clone, Default, PartialEq, Eq)]
3255pub struct OpenAiCompatUsage {
3256 pub input_tokens: Option<u64>,
3257 pub output_tokens: Option<u64>,
3258 pub total_tokens: Option<u64>,
3259}
3260
3261#[derive(Debug, Clone)]
3262pub struct OpenAiCompatChatRequest {
3263 pub api_base: String,
3264 pub api_key: String,
3265 pub model: String,
3266 pub prompt: String,
3267 pub temperature: Option<f32>,
3268 pub seed: Option<u64>,
3269 pub max_output_tokens: Option<usize>,
3270 pub extra_headers: Vec<(String, String)>,
3271}
3272
3273#[derive(Debug, Clone)]
3274pub struct OpenAiCompatChatResponse {
3275 pub model: String,
3276 pub output_text: String,
3277 pub stop_reason: Option<String>,
3278 pub usage: OpenAiCompatUsage,
3279}
3280
3281#[derive(Debug, Clone)]
3282pub struct OpenAiCompatEmbeddingsRequest {
3283 pub api_base: String,
3284 pub api_key: String,
3285 pub model: String,
3286 pub inputs: Vec<String>,
3287 pub dimensions: Option<usize>,
3288 pub extra_headers: Vec<(String, String)>,
3289}
3290
3291#[derive(Debug, Clone)]
3292pub struct OpenAiCompatEmbeddingsResponse {
3293 pub model: String,
3294 pub embeddings: Vec<Vec<f32>>,
3295 pub usage: OpenAiCompatUsage,
3296}
3297
3298fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
3299 headers
3300 .iter()
3301 .map(|(k, v)| (k.as_str(), v.as_str()))
3302 .collect()
3303}
3304
3305pub fn openai_compat_chat(
3313 request: OpenAiCompatChatRequest,
3314) -> RedDBResult<OpenAiCompatChatResponse> {
3315 if request.model.trim().is_empty() {
3316 return Err(RedDBError::Query(
3317 "openai-compat: model cannot be empty".to_string(),
3318 ));
3319 }
3320 if request.prompt.trim().is_empty() {
3321 return Err(RedDBError::Query(
3322 "openai-compat: prompt cannot be empty".to_string(),
3323 ));
3324 }
3325
3326 let url = format!(
3327 "{}/chat/completions",
3328 request.api_base.trim_end_matches('/')
3329 );
3330 let payload = build_openai_prompt_payload(
3331 &request.model,
3332 &request.prompt,
3333 request.temperature,
3334 request.seed,
3335 request.max_output_tokens,
3336 false,
3337 );
3338
3339 let extra = extra_header_refs(&request.extra_headers);
3340 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
3341 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3342
3343 if !(200..300).contains(&status) {
3344 let message = openai_error_message(&body).unwrap_or_else(|| {
3345 if body.trim().is_empty() {
3346 "openai-compat chat request failed".to_string()
3347 } else {
3348 body.clone()
3349 }
3350 });
3351 return Err(RedDBError::Query(format!(
3352 "openai-compat chat request failed (status {status}): {message}"
3353 )));
3354 }
3355
3356 let parsed = parse_openai_prompt_response(&body, &request.model)?;
3357 Ok(OpenAiCompatChatResponse {
3358 model: parsed.model,
3359 output_text: parsed.output_text,
3360 stop_reason: parsed.stop_reason,
3361 usage: OpenAiCompatUsage {
3362 input_tokens: parsed.prompt_tokens,
3363 output_tokens: parsed.completion_tokens,
3364 total_tokens: parsed.total_tokens,
3365 },
3366 })
3367}
3368
3369pub fn openai_compat_embeddings(
3371 request: OpenAiCompatEmbeddingsRequest,
3372) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
3373 if request.model.trim().is_empty() {
3374 return Err(RedDBError::Query(
3375 "openai-compat: embedding model cannot be empty".to_string(),
3376 ));
3377 }
3378 if request.inputs.is_empty() {
3379 return Err(RedDBError::Query(
3380 "openai-compat: at least one input is required".to_string(),
3381 ));
3382 }
3383
3384 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
3385 let payload =
3386 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
3387
3388 let extra = extra_header_refs(&request.extra_headers);
3389 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
3390 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3391
3392 if !(200..300).contains(&status) {
3393 let message = openai_error_message(&body).unwrap_or_else(|| {
3394 if body.trim().is_empty() {
3395 "openai-compat embeddings request failed".to_string()
3396 } else {
3397 body.clone()
3398 }
3399 });
3400 return Err(RedDBError::Query(format!(
3401 "openai-compat embeddings request failed (status {status}): {message}"
3402 )));
3403 }
3404
3405 let parsed = parse_openai_embedding_response(&body)?;
3406 Ok(OpenAiCompatEmbeddingsResponse {
3407 model: parsed.model,
3408 embeddings: parsed.embeddings,
3409 usage: OpenAiCompatUsage {
3410 input_tokens: parsed.prompt_tokens,
3411 output_tokens: None,
3412 total_tokens: parsed.total_tokens,
3413 },
3414 })
3415}
3416
3417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3429pub enum AiProviderMode {
3430 OpenAiCompat,
3432 OpenAiNative,
3434 AnthropicNative,
3436}
3437
3438impl AiProviderMode {
3439 pub fn token(&self) -> &'static str {
3440 match self {
3441 Self::OpenAiCompat => "openai-compat",
3442 Self::OpenAiNative => "openai-native",
3443 Self::AnthropicNative => "anthropic-native",
3444 }
3445 }
3446}
3447
3448pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
3450 match name.trim().to_ascii_lowercase().as_str() {
3451 "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
3452 "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
3453 "anthropic-native" | "anthropic_native" | "anthropicnative" => {
3454 Some(AiProviderMode::AnthropicNative)
3455 }
3456 _ => None,
3457 }
3458}
3459
3460pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
3466where
3467 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3468{
3469 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
3470 if let Some(mode) = parse_provider_mode(&value) {
3471 return Some(mode);
3472 }
3473 }
3474 if let Ok(Some(value)) = kv_getter("red.config.ai.provider") {
3475 if let Some(mode) = parse_provider_mode(&value) {
3476 return Some(mode);
3477 }
3478 }
3479 None
3480}
3481
3482pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
3486 match mode {
3487 AiProviderMode::OpenAiNative => AiProvider::OpenAi,
3488 AiProviderMode::AnthropicNative => AiProvider::Anthropic,
3489 AiProviderMode::OpenAiCompat => AiProvider::Custom(String::new()),
3490 }
3491}