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#[derive(Debug, Clone, PartialEq)]
124pub enum AiChatMessage {
125 System(String),
126 User(String),
127 Assistant {
128 content: Option<String>,
129 tool_calls: Vec<AiToolCall>,
130 },
131 ToolResult {
132 call_id: String,
133 name: String,
134 content: String,
135 },
136}
137
138#[derive(Debug, Clone, PartialEq)]
139pub struct AiToolDefinition {
140 pub name: String,
141 pub description: String,
142 pub input_schema: JsonValue,
143}
144
145#[derive(Debug, Clone, PartialEq)]
146pub struct AiToolCall {
147 pub id: String,
148 pub name: String,
149 pub arguments: JsonValue,
150}
151
152#[derive(Debug, Clone, PartialEq)]
154pub struct AiChatRequest {
155 pub messages: Vec<AiChatMessage>,
156 pub tools: Vec<AiToolDefinition>,
157 pub temperature: Option<f32>,
158 pub seed: Option<u64>,
159 pub max_output_tokens: Option<usize>,
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub struct AiChatResponse {
164 pub provider: &'static str,
165 pub model: String,
166 pub output_text: Option<String>,
167 pub tool_calls: Vec<AiToolCall>,
168 pub prompt_tokens: Option<u64>,
169 pub completion_tokens: Option<u64>,
170 pub total_tokens: Option<u64>,
171 pub stop_reason: Option<String>,
172}
173
174#[derive(Debug, Clone)]
175pub struct OpenAiChatRequest {
176 pub api_key: String,
177 pub model: String,
178 pub api_base: String,
179 pub chat: AiChatRequest,
180}
181
182#[derive(Debug, Clone)]
183pub struct AnthropicChatRequest {
184 pub api_key: String,
185 pub model: String,
186 pub api_base: String,
187 pub anthropic_version: String,
188 pub chat: AiChatRequest,
189}
190
191#[deprecated(
192 since = "1.0.0",
193 note = "use AiBatchClient::embed_batch for embeddings or openai_embeddings_async with AiTransport when token usage metadata is required"
194)]
195pub fn openai_embeddings(request: OpenAiEmbeddingRequest) -> RedDBResult<OpenAiEmbeddingResponse> {
196 if request.model.trim().is_empty() {
197 return Err(RedDBError::Query(
198 "OpenAI embedding model cannot be empty".to_string(),
199 ));
200 }
201 if request.inputs.is_empty() {
202 return Err(RedDBError::Query(
203 "at least one input is required for embeddings".to_string(),
204 ));
205 }
206
207 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
208 let payload =
209 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
210
211 let (status, body) = http_post_json(&url, &request.api_key, &[], payload, 90)
212 .map_err(|err| RedDBError::Query(format!("OpenAI transport error: {err}")))?;
213
214 if !(200..300).contains(&status) {
215 let message = openai_error_message(&body)
216 .unwrap_or_else(|| "OpenAI embeddings request failed".to_string());
217 return Err(RedDBError::Query(format!(
218 "OpenAI embeddings request failed (status {status}): {message}"
219 )));
220 }
221
222 parse_openai_embedding_response(&body)
223}
224
225#[deprecated(since = "1.0.0", note = "use openai_prompt_async with AiTransport")]
226pub fn openai_prompt(request: OpenAiPromptRequest) -> RedDBResult<AiPromptResponse> {
227 if request.model.trim().is_empty() {
228 return Err(RedDBError::Query(
229 "OpenAI prompt model cannot be empty".to_string(),
230 ));
231 }
232 if request.prompt.trim().is_empty() {
233 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
234 }
235
236 let url = format!(
237 "{}/chat/completions",
238 request.api_base.trim_end_matches('/')
239 );
240 let payload = build_openai_prompt_payload(
241 &request.model,
242 &request.prompt,
243 request.temperature,
244 request.seed,
245 request.max_output_tokens,
246 false,
247 );
248
249 let (status, body) = http_post_json(&url, &request.api_key, &[], payload, 120)
250 .map_err(|err| RedDBError::Query(format!("OpenAI transport error: {err}")))?;
251
252 if !(200..300).contains(&status) {
253 let message = openai_error_message(&body)
254 .unwrap_or_else(|| "OpenAI prompt request failed".to_string());
255 return Err(RedDBError::Query(format!(
256 "OpenAI prompt request failed (status {status}): {message}"
257 )));
258 }
259
260 parse_openai_prompt_response(&body, &request.model)
261}
262
263#[deprecated(since = "1.0.0", note = "use anthropic_prompt_async with AiTransport")]
264pub fn anthropic_prompt(request: AnthropicPromptRequest) -> RedDBResult<AiPromptResponse> {
265 if request.api_key.trim().is_empty() {
266 return Err(RedDBError::Query(
267 "Anthropic API key cannot be empty".to_string(),
268 ));
269 }
270 if request.model.trim().is_empty() {
271 return Err(RedDBError::Query(
272 "Anthropic model cannot be empty".to_string(),
273 ));
274 }
275 if request.prompt.trim().is_empty() {
276 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
277 }
278
279 let url = format!("{}/messages", request.api_base.trim_end_matches('/'));
280 let payload = build_anthropic_prompt_payload(
281 &request.model,
282 &request.prompt,
283 request.temperature,
284 request.max_output_tokens,
285 );
286
287 let extra = [
292 ("x-api-key", request.api_key.as_str()),
293 ("anthropic-version", request.anthropic_version.as_str()),
294 ];
295 let (status, body) = http_post_json(&url, "", &extra, payload, 120)
296 .map_err(|err| RedDBError::Query(format!("Anthropic transport error: {err}")))?;
297
298 if !(200..300).contains(&status) {
299 let message = anthropic_error_message(&body)
300 .unwrap_or_else(|| "Anthropic prompt request failed".to_string());
301 return Err(RedDBError::Query(format!(
302 "Anthropic prompt request failed (status {status}): {message}"
303 )));
304 }
305
306 parse_anthropic_prompt_response(&body, &request.model)
307}
308
309pub async fn openai_embeddings_async(
314 transport: &crate::runtime::ai::transport::AiTransport,
315 request: OpenAiEmbeddingRequest,
316) -> RedDBResult<OpenAiEmbeddingResponse> {
317 if request.model.trim().is_empty() {
318 return Err(RedDBError::Query(
319 "OpenAI embedding model cannot be empty".to_string(),
320 ));
321 }
322 if request.inputs.is_empty() {
323 return Err(RedDBError::Query(
324 "at least one input is required for embeddings".to_string(),
325 ));
326 }
327
328 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
329 let payload =
330 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
331 let mut http_req =
332 crate::runtime::ai::transport::AiHttpRequest::post_json("openai-compatible", url, payload);
333 let trimmed_key = request.api_key.trim();
334 if !trimmed_key.is_empty() {
335 http_req = http_req.header("authorization", format!("Bearer {}", trimmed_key));
336 }
337
338 let response = transport
339 .request(http_req)
340 .await
341 .map_err(|e| RedDBError::Query(e.to_string()))?;
342
343 parse_openai_embedding_response(&response.body)
344}
345
346pub async fn openai_prompt_async(
351 transport: &crate::runtime::ai::transport::AiTransport,
352 request: OpenAiPromptRequest,
353) -> RedDBResult<AiPromptResponse> {
354 if request.model.trim().is_empty() {
355 return Err(RedDBError::Query(
356 "OpenAI prompt model cannot be empty".to_string(),
357 ));
358 }
359 if request.prompt.trim().is_empty() {
360 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
361 }
362
363 let url = format!(
364 "{}/chat/completions",
365 request.api_base.trim_end_matches('/')
366 );
367 let payload = build_openai_prompt_payload(
368 &request.model,
369 &request.prompt,
370 request.temperature,
371 request.seed,
372 request.max_output_tokens,
373 request.stream,
374 );
375 let http_req = crate::runtime::ai::transport::AiHttpRequest::post_json("openai", url, payload)
376 .model(request.model.clone())
377 .header("authorization", format!("Bearer {}", request.api_key));
378
379 let response = transport
380 .request(http_req)
381 .await
382 .map_err(|e| RedDBError::Query(e.to_string()))?;
383
384 if request.stream {
385 parse_openai_streaming_prompt_response(&response.body, &request.model)
386 } else {
387 parse_openai_prompt_response(&response.body, &request.model)
388 }
389}
390
391pub async fn openai_chat_async(
393 transport: &crate::runtime::ai::transport::AiTransport,
394 request: OpenAiChatRequest,
395) -> RedDBResult<AiChatResponse> {
396 validate_chat_request(&request.model, &request.chat)?;
397 let url = format!(
398 "{}/chat/completions",
399 request.api_base.trim_end_matches('/')
400 );
401 let payload = build_openai_chat_payload(&request.model, &request.chat);
402 let http_req = crate::runtime::ai::transport::AiHttpRequest::post_json("openai", url, payload)
403 .model(request.model.clone())
404 .header("authorization", format!("Bearer {}", request.api_key));
405
406 let response = transport
407 .request(http_req)
408 .await
409 .map_err(|err| RedDBError::Query(err.to_string()))?;
410 parse_openai_chat_response(&response.body, &request.model)
411}
412
413pub fn openai_prompt_streaming(
419 request: OpenAiPromptRequest,
420 mut on_chunk: impl FnMut(&str) -> RedDBResult<()>,
421) -> RedDBResult<AiPromptResponse> {
422 if request.model.trim().is_empty() {
423 return Err(RedDBError::Query(
424 "OpenAI prompt model cannot be empty".to_string(),
425 ));
426 }
427 if request.prompt.trim().is_empty() {
428 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
429 }
430
431 let url = format!(
432 "{}/chat/completions",
433 request.api_base.trim_end_matches('/')
434 );
435 let payload = build_openai_prompt_payload(
436 &request.model,
437 &request.prompt,
438 request.temperature,
439 request.seed,
440 request.max_output_tokens,
441 true,
442 );
443
444 let agent: ureq::Agent = ureq::Agent::config_builder()
445 .timeout_connect(Some(Duration::from_secs(10)))
446 .timeout_send_request(Some(Duration::from_secs(30)))
447 .timeout_recv_response(Some(Duration::from_secs(120)))
448 .timeout_recv_body(Some(Duration::from_secs(120)))
449 .http_status_as_error(false)
450 .build()
451 .into();
452
453 let mut req = agent
454 .post(&url)
455 .header("content-type", "application/json")
456 .header("accept", "text/event-stream");
457 let trimmed_key = request.api_key.trim();
458 if !trimmed_key.is_empty() {
459 req = req.header("authorization", &format!("Bearer {}", trimmed_key));
460 }
461
462 let mut response = req
463 .send(payload)
464 .map_err(|err| RedDBError::Query(format!("OpenAI transport error: {err}")))?;
465 let status = response.status().as_u16();
466 if !(200..300).contains(&status) {
467 let body = response
468 .body_mut()
469 .read_to_string()
470 .unwrap_or_else(|err| format!("failed to read response body: {err}"));
471 let message = openai_error_message(&body)
472 .unwrap_or_else(|| "OpenAI prompt request failed".to_string());
473 return Err(RedDBError::Query(format!(
474 "OpenAI prompt request failed (status {status}): {message}"
475 )));
476 }
477
478 let mut model = request.model;
479 let mut chunks = Vec::new();
480 let mut prompt_tokens = None;
481 let mut completion_tokens = None;
482 let mut total_tokens = None;
483 let mut stop_reason = None;
484
485 let mut reader = std::io::BufReader::new(response.body_mut().as_reader());
486 let mut line = String::new();
487 loop {
488 line.clear();
489 let read = reader.read_line(&mut line).map_err(|err| {
490 RedDBError::Query(format!("failed to read OpenAI streaming response: {err}"))
491 })?;
492 if read == 0 {
493 break;
494 }
495
496 let trimmed = line.trim();
497 let Some(data) = trimmed.strip_prefix("data:") else {
498 continue;
499 };
500 let data = data.trim();
501 if data.is_empty() {
502 continue;
503 }
504 if data == "[DONE]" {
505 break;
506 }
507
508 let parsed = parse_json(data).map_err(|err| {
509 RedDBError::Query(format!(
510 "invalid OpenAI streaming prompt JSON response: {err}"
511 ))
512 })?;
513 let json = JsonValue::from(parsed);
514 if let Some(value) = json.get("model").and_then(JsonValue::as_str) {
515 model = value.to_string();
516 }
517 if let Some(usage) = json.get("usage") {
518 prompt_tokens = usage
519 .get("prompt_tokens")
520 .and_then(JsonValue::as_i64)
521 .and_then(|value| u64::try_from(value).ok())
522 .or(prompt_tokens);
523 completion_tokens = usage
524 .get("completion_tokens")
525 .and_then(JsonValue::as_i64)
526 .and_then(|value| u64::try_from(value).ok())
527 .or(completion_tokens);
528 total_tokens = usage
529 .get("total_tokens")
530 .and_then(JsonValue::as_i64)
531 .and_then(|value| u64::try_from(value).ok())
532 .or(total_tokens);
533 }
534
535 let Some(choices) = json.get("choices").and_then(JsonValue::as_array) else {
536 continue;
537 };
538 let Some(first_choice) = choices.first() else {
539 continue;
540 };
541 if let Some(reason) = first_choice
542 .get("finish_reason")
543 .and_then(JsonValue::as_str)
544 {
545 stop_reason = Some(reason.to_string());
546 }
547 if let Some(text) = first_choice
548 .get("delta")
549 .and_then(|delta| delta.get("content"))
550 .and_then(JsonValue::as_str)
551 {
552 if !text.is_empty() {
553 on_chunk(text)?;
554 chunks.push(text.to_string());
555 }
556 }
557 }
558
559 if chunks.is_empty() {
560 return Err(RedDBError::Query(
561 "OpenAI streaming prompt response missing text content".to_string(),
562 ));
563 }
564
565 let output_text = chunks.concat();
566 let total_tokens = total_tokens.or_else(|| match (prompt_tokens, completion_tokens) {
567 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
568 _ => None,
569 });
570
571 Ok(AiPromptResponse {
572 provider: "openai",
573 model,
574 output_text,
575 output_chunks: Some(chunks),
576 prompt_tokens,
577 completion_tokens,
578 total_tokens,
579 stop_reason,
580 })
581}
582
583pub async fn anthropic_prompt_async(
588 transport: &crate::runtime::ai::transport::AiTransport,
589 request: AnthropicPromptRequest,
590) -> RedDBResult<AiPromptResponse> {
591 if request.api_key.trim().is_empty() {
592 return Err(RedDBError::Query(
593 "Anthropic API key cannot be empty".to_string(),
594 ));
595 }
596 if request.model.trim().is_empty() {
597 return Err(RedDBError::Query(
598 "Anthropic model cannot be empty".to_string(),
599 ));
600 }
601 if request.prompt.trim().is_empty() {
602 return Err(RedDBError::Query("prompt cannot be empty".to_string()));
603 }
604
605 let url = format!("{}/messages", request.api_base.trim_end_matches('/'));
606 let payload = build_anthropic_prompt_payload(
607 &request.model,
608 &request.prompt,
609 request.temperature,
610 request.max_output_tokens,
611 );
612 let http_req =
613 crate::runtime::ai::transport::AiHttpRequest::post_json("anthropic", url, payload)
614 .model(request.model.clone())
615 .header("x-api-key", request.api_key)
616 .header("anthropic-version", request.anthropic_version);
617
618 let response = transport
619 .request(http_req)
620 .await
621 .map_err(|e| RedDBError::Query(e.to_string()))?;
622
623 parse_anthropic_prompt_response(&response.body, &request.model)
624}
625
626pub async fn anthropic_chat_async(
628 transport: &crate::runtime::ai::transport::AiTransport,
629 request: AnthropicChatRequest,
630) -> RedDBResult<AiChatResponse> {
631 if request.api_key.trim().is_empty() {
632 return Err(RedDBError::Query(
633 "Anthropic API key cannot be empty".to_string(),
634 ));
635 }
636 validate_chat_request(&request.model, &request.chat)?;
637 let url = format!("{}/messages", request.api_base.trim_end_matches('/'));
638 let payload = build_anthropic_chat_payload(&request.model, &request.chat);
639 let http_req =
640 crate::runtime::ai::transport::AiHttpRequest::post_json("anthropic", url, payload)
641 .model(request.model.clone())
642 .header("x-api-key", request.api_key)
643 .header("anthropic-version", request.anthropic_version);
644
645 let response = transport
646 .request(http_req)
647 .await
648 .map_err(|err| RedDBError::Query(err.to_string()))?;
649 parse_anthropic_chat_response(&response.body, &request.model)
650}
651
652pub(crate) fn build_embedding_payload(model: &str, inputs: &[String]) -> String {
654 build_openai_embedding_payload(model, inputs, None)
655}
656
657pub(crate) fn parse_embedding_vectors(body: &str) -> Result<Vec<Vec<f32>>, String> {
659 parse_openai_embedding_response(body)
660 .map(|r| r.embeddings)
661 .map_err(|e| e.to_string())
662}
663
664pub(crate) fn parse_embedding_response(body: &str) -> Result<OpenAiEmbeddingResponse, String> {
665 parse_openai_embedding_response(body).map_err(|e| e.to_string())
666}
667
668fn build_openai_embedding_payload(
669 model: &str,
670 inputs: &[String],
671 dimensions: Option<usize>,
672) -> String {
673 let mut object = Map::new();
674 object.insert("model".to_string(), JsonValue::String(model.to_string()));
675 if inputs.len() == 1 {
676 object.insert("input".to_string(), JsonValue::String(inputs[0].clone()));
677 } else {
678 object.insert(
679 "input".to_string(),
680 JsonValue::Array(inputs.iter().cloned().map(JsonValue::String).collect()),
681 );
682 }
683 if let Some(dimensions) = dimensions {
684 object.insert(
685 "dimensions".to_string(),
686 JsonValue::Number(dimensions as f64),
687 );
688 }
689 object.insert(
690 "encoding_format".to_string(),
691 JsonValue::String("float".to_string()),
692 );
693 JsonValue::Object(object).to_string_compact()
694}
695
696fn openai_error_message(body: &str) -> Option<String> {
697 provider_error_message(body)
698}
699
700fn anthropic_error_message(body: &str) -> Option<String> {
701 provider_error_message(body)
702}
703
704fn provider_error_message(body: &str) -> Option<String> {
705 let parsed = parse_json(body).ok().map(JsonValue::from)?;
706 let error = parsed.get("error")?;
707 if let Some(message) = error.get("message").and_then(JsonValue::as_str) {
708 let trimmed = message.trim();
709 if !trimmed.is_empty() {
710 return Some(trimmed.to_string());
711 }
712 }
713 None
714}
715
716fn validate_chat_request(model: &str, request: &AiChatRequest) -> RedDBResult<()> {
717 if model.trim().is_empty() {
718 return Err(RedDBError::Query("chat model cannot be empty".to_string()));
719 }
720 if request.messages.is_empty() {
721 return Err(RedDBError::Query(
722 "chat requires at least one message".to_string(),
723 ));
724 }
725 for tool in &request.tools {
726 if tool.name.trim().is_empty() {
727 return Err(RedDBError::Query(
728 "chat tool name cannot be empty".to_string(),
729 ));
730 }
731 if tool.input_schema.as_object().is_none() {
732 return Err(RedDBError::Query(format!(
733 "chat tool `{}` input schema must be an object",
734 tool.name
735 )));
736 }
737 }
738 Ok(())
739}
740
741fn openai_tool_call_json(call: &AiToolCall) -> JsonValue {
742 let mut function = Map::new();
743 function.insert("name".to_string(), JsonValue::String(call.name.clone()));
744 function.insert(
745 "arguments".to_string(),
746 JsonValue::String(call.arguments.to_string_compact()),
747 );
748
749 let mut object = Map::new();
750 object.insert("id".to_string(), JsonValue::String(call.id.clone()));
751 object.insert(
752 "type".to_string(),
753 JsonValue::String("function".to_string()),
754 );
755 object.insert("function".to_string(), JsonValue::Object(function));
756 JsonValue::Object(object)
757}
758
759fn build_openai_chat_payload(model: &str, request: &AiChatRequest) -> String {
760 let mut object = Map::new();
761 object.insert("model".to_string(), JsonValue::String(model.to_string()));
762
763 let messages = request
764 .messages
765 .iter()
766 .map(|message| {
767 let mut item = Map::new();
768 match message {
769 AiChatMessage::System(content) => {
770 item.insert("role".to_string(), JsonValue::String("system".to_string()));
771 item.insert("content".to_string(), JsonValue::String(content.clone()));
772 }
773 AiChatMessage::User(content) => {
774 item.insert("role".to_string(), JsonValue::String("user".to_string()));
775 item.insert("content".to_string(), JsonValue::String(content.clone()));
776 }
777 AiChatMessage::Assistant {
778 content,
779 tool_calls,
780 } => {
781 item.insert(
782 "role".to_string(),
783 JsonValue::String("assistant".to_string()),
784 );
785 item.insert(
786 "content".to_string(),
787 content
788 .as_ref()
789 .map(|value| JsonValue::String(value.clone()))
790 .unwrap_or(JsonValue::Null),
791 );
792 if !tool_calls.is_empty() {
793 item.insert(
794 "tool_calls".to_string(),
795 JsonValue::Array(
796 tool_calls.iter().map(openai_tool_call_json).collect(),
797 ),
798 );
799 }
800 }
801 AiChatMessage::ToolResult {
802 call_id, content, ..
803 } => {
804 item.insert("role".to_string(), JsonValue::String("tool".to_string()));
805 item.insert(
806 "tool_call_id".to_string(),
807 JsonValue::String(call_id.clone()),
808 );
809 item.insert("content".to_string(), JsonValue::String(content.clone()));
810 }
811 }
812 JsonValue::Object(item)
813 })
814 .collect();
815 object.insert("messages".to_string(), JsonValue::Array(messages));
816
817 if !request.tools.is_empty() {
818 let tools = request
819 .tools
820 .iter()
821 .map(|tool| {
822 let mut function = Map::new();
823 function.insert("name".to_string(), JsonValue::String(tool.name.clone()));
824 function.insert(
825 "description".to_string(),
826 JsonValue::String(tool.description.clone()),
827 );
828 function.insert("parameters".to_string(), tool.input_schema.clone());
829 let mut item = Map::new();
830 item.insert(
831 "type".to_string(),
832 JsonValue::String("function".to_string()),
833 );
834 item.insert("function".to_string(), JsonValue::Object(function));
835 JsonValue::Object(item)
836 })
837 .collect();
838 object.insert("tools".to_string(), JsonValue::Array(tools));
839 object.insert(
840 "tool_choice".to_string(),
841 JsonValue::String("auto".to_string()),
842 );
843 }
844 if let Some(temperature) = request.temperature {
845 object.insert(
846 "temperature".to_string(),
847 JsonValue::Number(temperature as f64),
848 );
849 }
850 if let Some(seed) = request.seed {
851 object.insert("seed".to_string(), JsonValue::Number(seed as f64));
852 }
853 if let Some(max_output_tokens) = request.max_output_tokens {
854 object.insert(
855 "max_tokens".to_string(),
856 JsonValue::Number(max_output_tokens as f64),
857 );
858 }
859 JsonValue::Object(object).to_string_compact()
860}
861
862fn anthropic_text_block(text: &str) -> JsonValue {
863 let mut block = Map::new();
864 block.insert("type".to_string(), JsonValue::String("text".to_string()));
865 block.insert("text".to_string(), JsonValue::String(text.to_string()));
866 JsonValue::Object(block)
867}
868
869fn build_anthropic_chat_payload(model: &str, request: &AiChatRequest) -> String {
870 let mut object = Map::new();
871 object.insert("model".to_string(), JsonValue::String(model.to_string()));
872 object.insert(
873 "max_tokens".to_string(),
874 JsonValue::Number(request.max_output_tokens.unwrap_or(512) as f64),
875 );
876
877 let systems = request
878 .messages
879 .iter()
880 .filter_map(|message| match message {
881 AiChatMessage::System(content) => Some(content.as_str()),
882 _ => None,
883 })
884 .collect::<Vec<_>>();
885 if !systems.is_empty() {
886 object.insert(
887 "system".to_string(),
888 JsonValue::String(systems.join("\n\n")),
889 );
890 }
891
892 let messages = request
893 .messages
894 .iter()
895 .filter_map(|message| {
896 let mut item = Map::new();
897 match message {
898 AiChatMessage::System(_) => return None,
899 AiChatMessage::User(content) => {
900 item.insert("role".to_string(), JsonValue::String("user".to_string()));
901 item.insert("content".to_string(), JsonValue::String(content.clone()));
902 }
903 AiChatMessage::Assistant {
904 content,
905 tool_calls,
906 } => {
907 item.insert(
908 "role".to_string(),
909 JsonValue::String("assistant".to_string()),
910 );
911 let mut blocks = Vec::new();
912 if let Some(content) = content.as_deref().filter(|value| !value.is_empty()) {
913 blocks.push(anthropic_text_block(content));
914 }
915 for call in tool_calls {
916 let mut block = Map::new();
917 block.insert(
918 "type".to_string(),
919 JsonValue::String("tool_use".to_string()),
920 );
921 block.insert("id".to_string(), JsonValue::String(call.id.clone()));
922 block.insert("name".to_string(), JsonValue::String(call.name.clone()));
923 block.insert("input".to_string(), call.arguments.clone());
924 blocks.push(JsonValue::Object(block));
925 }
926 item.insert("content".to_string(), JsonValue::Array(blocks));
927 }
928 AiChatMessage::ToolResult {
929 call_id, content, ..
930 } => {
931 item.insert("role".to_string(), JsonValue::String("user".to_string()));
932 let mut block = Map::new();
933 block.insert(
934 "type".to_string(),
935 JsonValue::String("tool_result".to_string()),
936 );
937 block.insert(
938 "tool_use_id".to_string(),
939 JsonValue::String(call_id.clone()),
940 );
941 block.insert("content".to_string(), JsonValue::String(content.clone()));
942 item.insert(
943 "content".to_string(),
944 JsonValue::Array(vec![JsonValue::Object(block)]),
945 );
946 }
947 }
948 Some(JsonValue::Object(item))
949 })
950 .collect();
951 object.insert("messages".to_string(), JsonValue::Array(messages));
952
953 if !request.tools.is_empty() {
954 let tools = request
955 .tools
956 .iter()
957 .map(|tool| {
958 let mut item = Map::new();
959 item.insert("name".to_string(), JsonValue::String(tool.name.clone()));
960 item.insert(
961 "description".to_string(),
962 JsonValue::String(tool.description.clone()),
963 );
964 item.insert("input_schema".to_string(), tool.input_schema.clone());
965 JsonValue::Object(item)
966 })
967 .collect();
968 object.insert("tools".to_string(), JsonValue::Array(tools));
969 }
970 if let Some(temperature) = request.temperature {
971 object.insert(
972 "temperature".to_string(),
973 JsonValue::Number(temperature as f64),
974 );
975 }
976 JsonValue::Object(object).to_string_compact()
977}
978
979fn build_openai_prompt_payload(
980 model: &str,
981 prompt: &str,
982 temperature: Option<f32>,
983 seed: Option<u64>,
984 max_output_tokens: Option<usize>,
985 stream: bool,
986) -> String {
987 let mut object = Map::new();
988 object.insert("model".to_string(), JsonValue::String(model.to_string()));
989
990 let mut message = Map::new();
991 message.insert("role".to_string(), JsonValue::String("user".to_string()));
992 message.insert("content".to_string(), JsonValue::String(prompt.to_string()));
993 object.insert(
994 "messages".to_string(),
995 JsonValue::Array(vec![JsonValue::Object(message)]),
996 );
997
998 if let Some(temperature) = temperature {
999 object.insert(
1000 "temperature".to_string(),
1001 JsonValue::Number(temperature as f64),
1002 );
1003 }
1004
1005 if let Some(seed) = seed {
1006 object.insert("seed".to_string(), JsonValue::Number(seed as f64));
1007 }
1008
1009 if let Some(max_output_tokens) = max_output_tokens {
1010 object.insert(
1011 "max_tokens".to_string(),
1012 JsonValue::Number(max_output_tokens as f64),
1013 );
1014 }
1015
1016 if stream {
1017 object.insert("stream".to_string(), JsonValue::Bool(true));
1018 let mut options = Map::new();
1019 options.insert("include_usage".to_string(), JsonValue::Bool(true));
1020 object.insert("stream_options".to_string(), JsonValue::Object(options));
1021 }
1022
1023 JsonValue::Object(object).to_string_compact()
1024}
1025
1026fn build_anthropic_prompt_payload(
1027 model: &str,
1028 prompt: &str,
1029 temperature: Option<f32>,
1030 max_output_tokens: Option<usize>,
1031) -> String {
1032 let mut object = Map::new();
1033 object.insert("model".to_string(), JsonValue::String(model.to_string()));
1034 object.insert(
1035 "max_tokens".to_string(),
1036 JsonValue::Number(max_output_tokens.unwrap_or(512) as f64),
1037 );
1038
1039 let mut message = Map::new();
1040 message.insert("role".to_string(), JsonValue::String("user".to_string()));
1041 message.insert("content".to_string(), JsonValue::String(prompt.to_string()));
1042 object.insert(
1043 "messages".to_string(),
1044 JsonValue::Array(vec![JsonValue::Object(message)]),
1045 );
1046
1047 if let Some(temperature) = temperature {
1048 object.insert(
1049 "temperature".to_string(),
1050 JsonValue::Number(temperature as f64),
1051 );
1052 }
1053
1054 JsonValue::Object(object).to_string_compact()
1055}
1056
1057fn extract_text_from_parts(parts: &[JsonValue]) -> Option<String> {
1058 let mut chunks = Vec::new();
1059 for part in parts {
1060 if let Some(text) = part.as_str() {
1061 let trimmed = text.trim();
1062 if !trimmed.is_empty() {
1063 chunks.push(trimmed.to_string());
1064 }
1065 continue;
1066 }
1067
1068 let Some(object) = part.as_object() else {
1069 continue;
1070 };
1071 let Some(text) = object.get("text").and_then(JsonValue::as_str) else {
1072 continue;
1073 };
1074 let trimmed = text.trim();
1075 if !trimmed.is_empty() {
1076 chunks.push(trimmed.to_string());
1077 }
1078 }
1079
1080 if chunks.is_empty() {
1081 None
1082 } else {
1083 Some(chunks.join("\n\n"))
1084 }
1085}
1086
1087fn parse_openai_prompt_response(
1088 body: &str,
1089 requested_model: &str,
1090) -> RedDBResult<AiPromptResponse> {
1091 let parsed = parse_json(body)
1092 .map_err(|err| RedDBError::Query(format!("invalid OpenAI prompt JSON response: {err}")))?;
1093 let json = JsonValue::from(parsed);
1094
1095 let model = json
1096 .get("model")
1097 .and_then(JsonValue::as_str)
1098 .unwrap_or(requested_model)
1099 .to_string();
1100
1101 let Some(choices) = json.get("choices").and_then(JsonValue::as_array) else {
1102 return Err(RedDBError::Query(
1103 "OpenAI prompt response missing 'choices' array".to_string(),
1104 ));
1105 };
1106 let Some(first_choice) = choices.first() else {
1107 return Err(RedDBError::Query(
1108 "OpenAI prompt response contains no choices".to_string(),
1109 ));
1110 };
1111
1112 let output_text = first_choice
1113 .get("message")
1114 .and_then(|message| {
1115 if let Some(text) = message.get("content").and_then(JsonValue::as_str) {
1116 let trimmed = text.trim();
1117 if !trimmed.is_empty() {
1118 return Some(trimmed.to_string());
1119 }
1120 }
1121 message
1122 .get("content")
1123 .and_then(JsonValue::as_array)
1124 .and_then(extract_text_from_parts)
1125 })
1126 .ok_or_else(|| {
1127 RedDBError::Query("OpenAI prompt response missing text content".to_string())
1128 })?;
1129
1130 let prompt_tokens = json
1131 .get("usage")
1132 .and_then(|usage| usage.get("prompt_tokens"))
1133 .and_then(JsonValue::as_i64)
1134 .and_then(|value| u64::try_from(value).ok());
1135 let completion_tokens = json
1136 .get("usage")
1137 .and_then(|usage| usage.get("completion_tokens"))
1138 .and_then(JsonValue::as_i64)
1139 .and_then(|value| u64::try_from(value).ok());
1140 let total_tokens = json
1141 .get("usage")
1142 .and_then(|usage| usage.get("total_tokens"))
1143 .and_then(JsonValue::as_i64)
1144 .and_then(|value| u64::try_from(value).ok())
1145 .or_else(|| match (prompt_tokens, completion_tokens) {
1146 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
1147 _ => None,
1148 });
1149
1150 let stop_reason = first_choice
1151 .get("finish_reason")
1152 .and_then(JsonValue::as_str)
1153 .map(str::to_string);
1154
1155 Ok(AiPromptResponse {
1156 provider: "openai",
1157 model,
1158 output_text,
1159 output_chunks: None,
1160 prompt_tokens,
1161 completion_tokens,
1162 total_tokens,
1163 stop_reason,
1164 })
1165}
1166
1167fn parse_openai_streaming_prompt_response(
1168 body: &str,
1169 requested_model: &str,
1170) -> RedDBResult<AiPromptResponse> {
1171 let mut model = requested_model.to_string();
1172 let mut chunks = Vec::new();
1173 let mut prompt_tokens = None;
1174 let mut completion_tokens = None;
1175 let mut total_tokens = None;
1176 let mut stop_reason = None;
1177
1178 for line in body.lines() {
1179 let line = line.trim();
1180 let Some(data) = line.strip_prefix("data:") else {
1181 continue;
1182 };
1183 let data = data.trim();
1184 if data.is_empty() {
1185 continue;
1186 }
1187 if data == "[DONE]" {
1188 break;
1189 }
1190
1191 let parsed = parse_json(data).map_err(|err| {
1192 RedDBError::Query(format!(
1193 "invalid OpenAI streaming prompt JSON response: {err}"
1194 ))
1195 })?;
1196 let json = JsonValue::from(parsed);
1197 if let Some(value) = json.get("model").and_then(JsonValue::as_str) {
1198 model = value.to_string();
1199 }
1200 if let Some(usage) = json.get("usage") {
1201 prompt_tokens = usage
1202 .get("prompt_tokens")
1203 .and_then(JsonValue::as_i64)
1204 .and_then(|value| u64::try_from(value).ok())
1205 .or(prompt_tokens);
1206 completion_tokens = usage
1207 .get("completion_tokens")
1208 .and_then(JsonValue::as_i64)
1209 .and_then(|value| u64::try_from(value).ok())
1210 .or(completion_tokens);
1211 total_tokens = usage
1212 .get("total_tokens")
1213 .and_then(JsonValue::as_i64)
1214 .and_then(|value| u64::try_from(value).ok())
1215 .or(total_tokens);
1216 }
1217
1218 let Some(choices) = json.get("choices").and_then(JsonValue::as_array) else {
1219 continue;
1220 };
1221 let Some(first_choice) = choices.first() else {
1222 continue;
1223 };
1224 if let Some(reason) = first_choice
1225 .get("finish_reason")
1226 .and_then(JsonValue::as_str)
1227 {
1228 stop_reason = Some(reason.to_string());
1229 }
1230 if let Some(text) = first_choice
1231 .get("delta")
1232 .and_then(|delta| delta.get("content"))
1233 .and_then(JsonValue::as_str)
1234 {
1235 if !text.is_empty() {
1236 chunks.push(text.to_string());
1237 }
1238 }
1239 }
1240
1241 if chunks.is_empty() {
1242 return Err(RedDBError::Query(
1243 "OpenAI streaming prompt response missing text content".to_string(),
1244 ));
1245 }
1246
1247 let output_text = chunks.concat();
1248 let total_tokens = total_tokens.or_else(|| match (prompt_tokens, completion_tokens) {
1249 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
1250 _ => None,
1251 });
1252
1253 Ok(AiPromptResponse {
1254 provider: "openai",
1255 model,
1256 output_text,
1257 output_chunks: Some(chunks),
1258 prompt_tokens,
1259 completion_tokens,
1260 total_tokens,
1261 stop_reason,
1262 })
1263}
1264
1265fn parse_anthropic_prompt_response(
1266 body: &str,
1267 requested_model: &str,
1268) -> RedDBResult<AiPromptResponse> {
1269 let parsed = parse_json(body).map_err(|err| {
1270 RedDBError::Query(format!("invalid Anthropic prompt JSON response: {err}"))
1271 })?;
1272 let json = JsonValue::from(parsed);
1273
1274 let model = json
1275 .get("model")
1276 .and_then(JsonValue::as_str)
1277 .unwrap_or(requested_model)
1278 .to_string();
1279
1280 let Some(content_parts) = json.get("content").and_then(JsonValue::as_array) else {
1281 return Err(RedDBError::Query(
1282 "Anthropic prompt response missing 'content' array".to_string(),
1283 ));
1284 };
1285
1286 let output_text = extract_text_from_parts(content_parts).ok_or_else(|| {
1287 RedDBError::Query("Anthropic prompt response missing text content".to_string())
1288 })?;
1289
1290 let prompt_tokens = json
1291 .get("usage")
1292 .and_then(|usage| usage.get("input_tokens"))
1293 .and_then(JsonValue::as_i64)
1294 .and_then(|value| u64::try_from(value).ok());
1295 let completion_tokens = json
1296 .get("usage")
1297 .and_then(|usage| usage.get("output_tokens"))
1298 .and_then(JsonValue::as_i64)
1299 .and_then(|value| u64::try_from(value).ok());
1300 let total_tokens = match (prompt_tokens, completion_tokens) {
1301 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
1302 _ => None,
1303 };
1304
1305 let stop_reason = json
1306 .get("stop_reason")
1307 .and_then(JsonValue::as_str)
1308 .map(str::to_string);
1309
1310 Ok(AiPromptResponse {
1311 provider: "anthropic",
1312 model,
1313 output_text,
1314 output_chunks: None,
1315 prompt_tokens,
1316 completion_tokens,
1317 total_tokens,
1318 stop_reason,
1319 })
1320}
1321
1322fn parse_tool_arguments(value: &JsonValue, provider: &str) -> RedDBResult<JsonValue> {
1323 if let Some(raw) = value.as_str() {
1324 let parsed = parse_json(raw).map_err(|err| {
1325 RedDBError::Query(format!(
1326 "invalid {provider} tool arguments JSON response: {err}"
1327 ))
1328 })?;
1329 return Ok(JsonValue::from(parsed));
1330 }
1331 if value.as_object().is_some() {
1332 return Ok(value.clone());
1333 }
1334 Err(RedDBError::Query(format!(
1335 "{provider} tool arguments must be a JSON object"
1336 )))
1337}
1338
1339fn parse_openai_chat_response(body: &str, requested_model: &str) -> RedDBResult<AiChatResponse> {
1340 let parsed = parse_json(body)
1341 .map_err(|err| RedDBError::Query(format!("invalid OpenAI chat JSON response: {err}")))?;
1342 let json = JsonValue::from(parsed);
1343 let model = json
1344 .get("model")
1345 .and_then(JsonValue::as_str)
1346 .unwrap_or(requested_model)
1347 .to_string();
1348 let first_choice = json
1349 .get("choices")
1350 .and_then(JsonValue::as_array)
1351 .and_then(|choices| choices.first())
1352 .ok_or_else(|| RedDBError::Query("OpenAI chat response contains no choices".to_string()))?;
1353 let message = first_choice.get("message").ok_or_else(|| {
1354 RedDBError::Query("OpenAI chat response missing assistant message".to_string())
1355 })?;
1356 let output_text = message.get("content").and_then(|content| {
1357 content
1358 .as_str()
1359 .map(str::trim)
1360 .filter(|value| !value.is_empty())
1361 .map(str::to_string)
1362 .or_else(|| content.as_array().and_then(extract_text_from_parts))
1363 });
1364 let mut tool_calls = Vec::new();
1365 for item in message
1366 .get("tool_calls")
1367 .and_then(JsonValue::as_array)
1368 .unwrap_or(&[])
1369 {
1370 let id = item
1371 .get("id")
1372 .and_then(JsonValue::as_str)
1373 .ok_or_else(|| RedDBError::Query("OpenAI tool call is missing `id`".to_string()))?;
1374 let function = item.get("function").ok_or_else(|| {
1375 RedDBError::Query("OpenAI tool call is missing `function`".to_string())
1376 })?;
1377 let name = function
1378 .get("name")
1379 .and_then(JsonValue::as_str)
1380 .ok_or_else(|| RedDBError::Query("OpenAI tool call is missing `name`".to_string()))?;
1381 let arguments = function.get("arguments").ok_or_else(|| {
1382 RedDBError::Query("OpenAI tool call is missing `arguments`".to_string())
1383 })?;
1384 tool_calls.push(AiToolCall {
1385 id: id.to_string(),
1386 name: name.to_string(),
1387 arguments: parse_tool_arguments(arguments, "OpenAI")?,
1388 });
1389 }
1390 if output_text.is_none() && tool_calls.is_empty() {
1391 return Err(RedDBError::Query(
1392 "OpenAI chat response contains neither text nor tool calls".to_string(),
1393 ));
1394 }
1395
1396 let prompt_tokens = json
1397 .get("usage")
1398 .and_then(|usage| usage.get("prompt_tokens"))
1399 .and_then(JsonValue::as_i64)
1400 .and_then(|value| u64::try_from(value).ok());
1401 let completion_tokens = json
1402 .get("usage")
1403 .and_then(|usage| usage.get("completion_tokens"))
1404 .and_then(JsonValue::as_i64)
1405 .and_then(|value| u64::try_from(value).ok());
1406 let total_tokens = json
1407 .get("usage")
1408 .and_then(|usage| usage.get("total_tokens"))
1409 .and_then(JsonValue::as_i64)
1410 .and_then(|value| u64::try_from(value).ok())
1411 .or_else(|| match (prompt_tokens, completion_tokens) {
1412 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
1413 _ => None,
1414 });
1415 Ok(AiChatResponse {
1416 provider: "openai",
1417 model,
1418 output_text,
1419 tool_calls,
1420 prompt_tokens,
1421 completion_tokens,
1422 total_tokens,
1423 stop_reason: first_choice
1424 .get("finish_reason")
1425 .and_then(JsonValue::as_str)
1426 .map(str::to_string),
1427 })
1428}
1429
1430fn parse_anthropic_chat_response(body: &str, requested_model: &str) -> RedDBResult<AiChatResponse> {
1431 let parsed = parse_json(body)
1432 .map_err(|err| RedDBError::Query(format!("invalid Anthropic chat JSON response: {err}")))?;
1433 let json = JsonValue::from(parsed);
1434 let content = json
1435 .get("content")
1436 .and_then(JsonValue::as_array)
1437 .ok_or_else(|| {
1438 RedDBError::Query("Anthropic chat response missing 'content' array".to_string())
1439 })?;
1440 let output_text = extract_text_from_parts(content);
1441 let mut tool_calls = Vec::new();
1442 for block in content {
1443 if block.get("type").and_then(JsonValue::as_str) != Some("tool_use") {
1444 continue;
1445 }
1446 let id = block
1447 .get("id")
1448 .and_then(JsonValue::as_str)
1449 .ok_or_else(|| RedDBError::Query("Anthropic tool call is missing `id`".to_string()))?;
1450 let name = block
1451 .get("name")
1452 .and_then(JsonValue::as_str)
1453 .ok_or_else(|| {
1454 RedDBError::Query("Anthropic tool call is missing `name`".to_string())
1455 })?;
1456 let arguments = block.get("input").ok_or_else(|| {
1457 RedDBError::Query("Anthropic tool call is missing `input`".to_string())
1458 })?;
1459 tool_calls.push(AiToolCall {
1460 id: id.to_string(),
1461 name: name.to_string(),
1462 arguments: parse_tool_arguments(arguments, "Anthropic")?,
1463 });
1464 }
1465 if output_text.is_none() && tool_calls.is_empty() {
1466 return Err(RedDBError::Query(
1467 "Anthropic chat response contains neither text nor tool calls".to_string(),
1468 ));
1469 }
1470 let prompt_tokens = json
1471 .get("usage")
1472 .and_then(|usage| usage.get("input_tokens"))
1473 .and_then(JsonValue::as_i64)
1474 .and_then(|value| u64::try_from(value).ok());
1475 let completion_tokens = json
1476 .get("usage")
1477 .and_then(|usage| usage.get("output_tokens"))
1478 .and_then(JsonValue::as_i64)
1479 .and_then(|value| u64::try_from(value).ok());
1480 Ok(AiChatResponse {
1481 provider: "anthropic",
1482 model: json
1483 .get("model")
1484 .and_then(JsonValue::as_str)
1485 .unwrap_or(requested_model)
1486 .to_string(),
1487 output_text,
1488 tool_calls,
1489 prompt_tokens,
1490 completion_tokens,
1491 total_tokens: match (prompt_tokens, completion_tokens) {
1492 (Some(prompt), Some(completion)) => Some(prompt.saturating_add(completion)),
1493 _ => None,
1494 },
1495 stop_reason: json
1496 .get("stop_reason")
1497 .and_then(JsonValue::as_str)
1498 .map(str::to_string),
1499 })
1500}
1501
1502fn parse_openai_embedding_response(body: &str) -> RedDBResult<OpenAiEmbeddingResponse> {
1503 let parsed = parse_json(body).map_err(|err| {
1504 RedDBError::Query(format!("invalid OpenAI embeddings JSON response: {err}"))
1505 })?;
1506 let json = JsonValue::from(parsed);
1507
1508 let model = json
1509 .get("model")
1510 .and_then(JsonValue::as_str)
1511 .unwrap_or(DEFAULT_OPENAI_EMBEDDING_MODEL)
1512 .to_string();
1513
1514 let Some(data) = json.get("data").and_then(JsonValue::as_array) else {
1515 return Err(RedDBError::Query(
1516 "OpenAI response missing 'data' array".to_string(),
1517 ));
1518 };
1519
1520 let mut rows: Vec<(usize, Vec<f32>)> = Vec::with_capacity(data.len());
1521 for (position, item) in data.iter().enumerate() {
1522 let index = item
1523 .get("index")
1524 .and_then(JsonValue::as_i64)
1525 .and_then(|value| usize::try_from(value).ok())
1526 .unwrap_or(position);
1527
1528 let Some(embedding_values) = item.get("embedding").and_then(JsonValue::as_array) else {
1529 return Err(RedDBError::Query(
1530 "OpenAI response contains item without 'embedding' array".to_string(),
1531 ));
1532 };
1533 if embedding_values.is_empty() {
1534 return Err(RedDBError::Query(
1535 "OpenAI response contains empty embedding vector".to_string(),
1536 ));
1537 }
1538
1539 let mut embedding = Vec::with_capacity(embedding_values.len());
1540 for value in embedding_values {
1541 let Some(number) = value.as_f64() else {
1542 return Err(RedDBError::Query(
1543 "OpenAI response contains non-numeric embedding value".to_string(),
1544 ));
1545 };
1546 embedding.push(number as f32);
1547 }
1548 rows.push((index, embedding));
1549 }
1550 rows.sort_by_key(|(index, _)| *index);
1551 let embeddings = rows.into_iter().map(|(_, embedding)| embedding).collect();
1552
1553 let prompt_tokens = json
1554 .get("usage")
1555 .and_then(|usage| usage.get("prompt_tokens"))
1556 .and_then(JsonValue::as_i64)
1557 .and_then(|value| u64::try_from(value).ok());
1558 let total_tokens = json
1559 .get("usage")
1560 .and_then(|usage| usage.get("total_tokens"))
1561 .and_then(JsonValue::as_i64)
1562 .and_then(|value| u64::try_from(value).ok());
1563
1564 Ok(OpenAiEmbeddingResponse {
1565 provider: "openai",
1566 model,
1567 embeddings,
1568 prompt_tokens,
1569 total_tokens,
1570 })
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575 use super::*;
1576
1577 fn object(entries: Vec<(&str, JsonValue)>) -> JsonValue {
1578 let mut map = Map::new();
1579 for (key, value) in entries {
1580 map.insert(key.to_string(), value);
1581 }
1582 JsonValue::Object(map)
1583 }
1584
1585 fn chat_fixture() -> AiChatRequest {
1586 AiChatRequest {
1587 messages: vec![
1588 AiChatMessage::System("system policy".to_string()),
1589 AiChatMessage::User("find passport 123".to_string()),
1590 AiChatMessage::Assistant {
1591 content: None,
1592 tool_calls: vec![AiToolCall {
1593 id: "call_1".to_string(),
1594 name: "reddb_find_field".to_string(),
1595 arguments: object(vec![(
1596 "field",
1597 JsonValue::String("passport_number".to_string()),
1598 )]),
1599 }],
1600 },
1601 AiChatMessage::ToolResult {
1602 call_id: "call_1".to_string(),
1603 name: "reddb_find_field".to_string(),
1604 content: "authorized result".to_string(),
1605 },
1606 ],
1607 tools: vec![AiToolDefinition {
1608 name: "reddb_find_field".to_string(),
1609 description: "Find a visible field".to_string(),
1610 input_schema: object(vec![("type", JsonValue::String("object".to_string()))]),
1611 }],
1612 temperature: Some(0.0),
1613 seed: Some(7),
1614 max_output_tokens: Some(256),
1615 }
1616 }
1617
1618 #[test]
1619 fn openai_chat_uses_native_tool_wire_contract() {
1620 let payload = build_openai_chat_payload("gpt-test", &chat_fixture());
1621 let json = JsonValue::from(parse_json(&payload).expect("payload JSON"));
1622
1623 assert_eq!(
1624 json.get("messages")
1625 .and_then(JsonValue::as_array)
1626 .and_then(|messages| messages.get(2))
1627 .and_then(|message| message.get("tool_calls"))
1628 .and_then(JsonValue::as_array)
1629 .and_then(|calls| calls.first())
1630 .and_then(|call| call.get("function"))
1631 .and_then(|function| function.get("name"))
1632 .and_then(JsonValue::as_str),
1633 Some("reddb_find_field")
1634 );
1635 assert_eq!(
1636 json.get("messages")
1637 .and_then(JsonValue::as_array)
1638 .and_then(|messages| messages.get(3))
1639 .and_then(|message| message.get("tool_call_id"))
1640 .and_then(JsonValue::as_str),
1641 Some("call_1")
1642 );
1643 assert!(json.get("tools").and_then(JsonValue::as_array).is_some());
1644 }
1645
1646 #[test]
1647 fn anthropic_chat_uses_native_tool_wire_contract() {
1648 let payload = build_anthropic_chat_payload("claude-test", &chat_fixture());
1649 let json = JsonValue::from(parse_json(&payload).expect("payload JSON"));
1650
1651 assert_eq!(
1652 json.get("system").and_then(JsonValue::as_str),
1653 Some("system policy")
1654 );
1655 let messages = json
1656 .get("messages")
1657 .and_then(JsonValue::as_array)
1658 .expect("messages");
1659 assert_eq!(
1660 messages[1]
1661 .get("content")
1662 .and_then(JsonValue::as_array)
1663 .and_then(|content| content.first())
1664 .and_then(|block| block.get("type"))
1665 .and_then(JsonValue::as_str),
1666 Some("tool_use")
1667 );
1668 assert_eq!(
1669 messages[2]
1670 .get("content")
1671 .and_then(JsonValue::as_array)
1672 .and_then(|content| content.first())
1673 .and_then(|block| block.get("tool_use_id"))
1674 .and_then(JsonValue::as_str),
1675 Some("call_1")
1676 );
1677 }
1678
1679 #[test]
1680 fn native_provider_responses_preserve_structured_tool_calls() {
1681 let openai = parse_openai_chat_response(
1682 r#"{"model":"gpt-test","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_9","type":"function","function":{"name":"reddb_final_query","arguments":"{\"sql\":\"SELECT 1\",\"params\":[],\"purpose\":\"answer\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":4}}"#,
1683 "fallback",
1684 )
1685 .expect("OpenAI response");
1686 assert_eq!(openai.tool_calls[0].name, "reddb_final_query");
1687 assert_eq!(openai.total_tokens, Some(14));
1688
1689 let anthropic = parse_anthropic_chat_response(
1690 r#"{"model":"claude-test","content":[{"type":"tool_use","id":"tool_2","name":"reddb_query","input":{"sql":"SELECT 1","params":[],"purpose":"inspect"}}],"stop_reason":"tool_use","usage":{"input_tokens":8,"output_tokens":3}}"#,
1691 "fallback",
1692 )
1693 .expect("Anthropic response");
1694 assert_eq!(anthropic.tool_calls[0].id, "tool_2");
1695 assert_eq!(anthropic.total_tokens, Some(11));
1696 }
1697
1698 #[test]
1699 fn parse_openai_embedding_response_extracts_vectors() {
1700 let body = r#"{
1701 "object":"list",
1702 "data":[
1703 {"object":"embedding","index":1,"embedding":[0.3,0.4]},
1704 {"object":"embedding","index":0,"embedding":[0.1,0.2]}
1705 ],
1706 "model":"text-embedding-3-small",
1707 "usage":{"prompt_tokens":12,"total_tokens":12}
1708 }"#;
1709
1710 let result = parse_openai_embedding_response(body).expect("response should parse");
1711 assert_eq!(result.provider, "openai");
1712 assert_eq!(result.model, "text-embedding-3-small");
1713 assert_eq!(result.embeddings.len(), 2);
1714 assert_eq!(result.embeddings[0], vec![0.1, 0.2]);
1715 assert_eq!(result.embeddings[1], vec![0.3, 0.4]);
1716 assert_eq!(result.prompt_tokens, Some(12));
1717 assert_eq!(result.total_tokens, Some(12));
1718 }
1719
1720 #[test]
1721 fn openai_error_message_extracts_nested_message() {
1722 let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
1723 assert_eq!(openai_error_message(body).as_deref(), Some("bad api key"));
1724 }
1725
1726 #[test]
1727 fn parse_openai_prompt_response_extracts_text_and_usage() {
1728 let body = r#"{
1729 "id":"chatcmpl_1",
1730 "object":"chat.completion",
1731 "model":"gpt-4.1-mini",
1732 "choices":[
1733 {
1734 "index":0,
1735 "finish_reason":"stop",
1736 "message":{"role":"assistant","content":"Resumo pronto."}
1737 }
1738 ],
1739 "usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}
1740 }"#;
1741
1742 let parsed =
1743 parse_openai_prompt_response(body, DEFAULT_OPENAI_PROMPT_MODEL).expect("parse");
1744 assert_eq!(parsed.provider, "openai");
1745 assert_eq!(parsed.model, "gpt-4.1-mini");
1746 assert_eq!(parsed.output_text, "Resumo pronto.");
1747 assert_eq!(parsed.prompt_tokens, Some(10));
1748 assert_eq!(parsed.completion_tokens, Some(4));
1749 assert_eq!(parsed.total_tokens, Some(14));
1750 assert_eq!(parsed.stop_reason.as_deref(), Some("stop"));
1751 }
1752
1753 #[test]
1754 fn parse_anthropic_prompt_response_extracts_text_and_usage() {
1755 let body = r#"{
1756 "id":"msg_1",
1757 "model":"claude-3-5-haiku-latest",
1758 "type":"message",
1759 "content":[{"type":"text","text":"Action complete."}],
1760 "usage":{"input_tokens":11,"output_tokens":5},
1761 "stop_reason":"end_turn"
1762 }"#;
1763
1764 let parsed =
1765 parse_anthropic_prompt_response(body, DEFAULT_ANTHROPIC_PROMPT_MODEL).expect("parse");
1766 assert_eq!(parsed.provider, "anthropic");
1767 assert_eq!(parsed.model, "claude-3-5-haiku-latest");
1768 assert_eq!(parsed.output_text, "Action complete.");
1769 assert_eq!(parsed.prompt_tokens, Some(11));
1770 assert_eq!(parsed.completion_tokens, Some(5));
1771 assert_eq!(parsed.total_tokens, Some(16));
1772 assert_eq!(parsed.stop_reason.as_deref(), Some("end_turn"));
1773 }
1774
1775 #[test]
1776 fn resolve_api_key_prefers_new_vault_path_over_removed_paths() {
1777 let provider = AiProvider::OpenAi;
1778 let alias = "vault_unit_alias";
1779 let secret_path = ai_api_secret_path(&provider, alias);
1780 let removed_legacy = removed_plaintext_config_key(&provider, alias);
1781 let removed_vault = removed_vault_api_key_path(&provider, alias);
1782
1783 let resolved = resolve_api_key(&provider, Some(alias), |key| {
1784 if key == secret_path {
1785 Ok(Some("vault-key".to_string()))
1786 } else if key == removed_legacy || key == removed_vault {
1787 Ok(Some("stale-key".to_string()))
1788 } else {
1789 Ok(None)
1790 }
1791 })
1792 .expect("resolve");
1793
1794 assert_eq!(resolved, "vault-key");
1795 }
1796
1797 #[test]
1798 fn resolve_api_key_rejects_removed_plaintext_config_path() {
1799 let provider = AiProvider::Custom("cred1745legacy".to_string());
1800 let alias = "prod";
1801 let removed_legacy = removed_plaintext_config_key(&provider, alias);
1802 let new_path = ai_api_secret_path(&provider, alias);
1803
1804 let err = resolve_api_key(&provider, Some(alias), |key| {
1807 if key == removed_legacy {
1808 Ok(Some("stale-plaintext-key".to_string()))
1809 } else {
1810 Ok(None)
1811 }
1812 })
1813 .expect_err("must reject removed path");
1814 let msg = err.to_string();
1815 assert!(msg.contains(&removed_legacy), "names removed path: {msg}");
1816 assert!(msg.contains(&new_path), "names new vault path: {msg}");
1817 }
1818
1819 #[test]
1820 fn resolve_api_key_rejects_removed_vault_api_key_path() {
1821 let provider = AiProvider::Custom("cred1745oldvault".to_string());
1822 let removed_vault = removed_vault_api_key_path(&provider, "default");
1823 let new_path = ai_api_secret_path(&provider, "default");
1824
1825 let err = resolve_api_key(&provider, None, |key| {
1826 if key == removed_vault {
1827 Ok(Some("stale-vault-key".to_string()))
1828 } else {
1829 Ok(None)
1830 }
1831 })
1832 .expect_err("must reject removed vault path");
1833 let msg = err.to_string();
1834 assert!(msg.contains(&removed_vault), "names removed path: {msg}");
1835 assert!(msg.contains(&new_path), "names new vault path: {msg}");
1836 }
1837
1838 #[test]
1839 fn resolve_api_key_alias_token_overrides_default_per_request() {
1840 let provider = AiProvider::OpenAi;
1841 let default_path = ai_api_secret_path(&provider, "default");
1842 let prod_path = ai_api_secret_path(&provider, "prod");
1843 let getter = |key: &str| {
1844 if key == default_path {
1845 Ok(Some("default-token".to_string()))
1846 } else if key == prod_path {
1847 Ok(Some("prod-token".to_string()))
1848 } else {
1849 Ok(None)
1850 }
1851 };
1852 assert_eq!(
1855 resolve_api_key(&provider, Some("prod"), getter).expect("prod"),
1856 "prod-token"
1857 );
1858 assert_eq!(
1859 resolve_api_key(&provider, None, getter).expect("default"),
1860 "default-token"
1861 );
1862 }
1863
1864 #[test]
1865 fn ai_api_secret_path_uses_providers_tokens_shape() {
1866 let path = ai_api_secret_path(&AiProvider::OpenAi, "default");
1867 assert_eq!(path, "red.secret.ai.providers.openai.tokens.default");
1868 let aliased = ai_api_secret_path(&AiProvider::OpenAi, "Prod");
1869 assert_eq!(aliased, "red.secret.ai.providers.openai.tokens.prod");
1870 }
1871
1872 #[test]
1873 fn resolve_api_key_uses_default_vault_secret_path() {
1874 let provider = AiProvider::OpenAi;
1875 let secret_path = ai_api_secret_path(&provider, "default");
1876
1877 let resolved = resolve_api_key(&provider, None, |key| {
1878 if key == secret_path {
1879 Ok(Some("default-vault-key".to_string()))
1880 } else {
1881 Ok(None)
1882 }
1883 })
1884 .expect("resolve");
1885
1886 assert_eq!(resolved, "default-vault-key");
1887 }
1888
1889 #[test]
1895 fn resolve_api_key_uses_env_when_no_vault_entry() {
1896 let provider = AiProvider::Custom("cred1270envonly".to_string());
1897 let env_name = provider.default_key_env_name();
1898 std::env::set_var(&env_name, "env-fallback-key");
1899
1900 let resolved = resolve_api_key(&provider, None, |_| Ok(None));
1902
1903 std::env::remove_var(&env_name);
1904 assert_eq!(resolved.expect("resolve"), "env-fallback-key");
1905 }
1906
1907 #[test]
1908 fn resolve_api_key_prefers_vault_over_env() {
1909 let provider = AiProvider::Custom("cred1270both".to_string());
1910 let env_name = provider.default_key_env_name();
1911 let secret_path = ai_api_secret_path(&provider, "default");
1912 std::env::set_var(&env_name, "env-fallback-key");
1913
1914 let resolved = resolve_api_key(&provider, None, |key| {
1916 if key == secret_path {
1917 Ok(Some("vault-managed-key".to_string()))
1918 } else {
1919 Ok(None)
1920 }
1921 });
1922
1923 std::env::remove_var(&env_name);
1924 assert_eq!(resolved.expect("resolve"), "vault-managed-key");
1925 }
1926
1927 #[test]
1928 fn resolve_api_key_alias_prefers_vault_over_env() {
1929 let provider = AiProvider::Custom("cred1270alias".to_string());
1930 let alias = "prod";
1931 let env_name = provider.alias_key_env_name(alias);
1932 let secret_path = ai_api_secret_path(&provider, alias);
1933 std::env::set_var(&env_name, "env-alias-key");
1934
1935 let resolved = resolve_api_key(&provider, Some(alias), |key| {
1936 if key == secret_path {
1937 Ok(Some("vault-alias-key".to_string()))
1938 } else {
1939 Ok(None)
1940 }
1941 });
1942
1943 std::env::remove_var(&env_name);
1944 assert_eq!(resolved.expect("resolve"), "vault-alias-key");
1945 }
1946
1947 #[test]
1948 fn resolve_api_key_alias_falls_back_to_env_without_vault() {
1949 let provider = AiProvider::Custom("cred1270aliasenv".to_string());
1950 let alias = "prod";
1951 let env_name = provider.alias_key_env_name(alias);
1952 std::env::set_var(&env_name, "env-alias-key");
1953
1954 let resolved = resolve_api_key(&provider, Some(alias), |_| Ok(None));
1955
1956 std::env::remove_var(&env_name);
1957 assert_eq!(resolved.expect("resolve"), "env-alias-key");
1958 }
1959
1960 #[test]
1961 fn openai_prompt_payload_includes_temperature_and_seed_when_present() {
1962 let payload = build_openai_prompt_payload(
1963 "gpt-4.1-mini",
1964 "hello",
1965 Some(0.0),
1966 Some(42),
1967 Some(128),
1968 false,
1969 );
1970 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1971
1972 assert_eq!(
1973 parsed.get("temperature").and_then(JsonValue::as_f64),
1974 Some(0.0)
1975 );
1976 assert_eq!(parsed.get("seed").and_then(JsonValue::as_u64), Some(42));
1977 assert_eq!(
1978 parsed.get("max_tokens").and_then(JsonValue::as_u64),
1979 Some(128)
1980 );
1981 }
1982
1983 #[test]
1984 fn openai_prompt_payload_omits_seed_when_none() {
1985 let payload =
1986 build_openai_prompt_payload("gpt-4.1-mini", "hello", Some(0.0), None, None, false);
1987 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1988
1989 assert!(parsed.get("seed").is_none());
1990 assert!(parsed.get("stream").is_none());
1991 assert_eq!(
1992 parsed.get("temperature").and_then(JsonValue::as_f64),
1993 Some(0.0)
1994 );
1995 }
1996
1997 #[test]
1998 fn openai_prompt_payload_default_seed_is_portable_and_matches_audit() {
1999 use crate::runtime::ai::{determinism_decider, provider_capabilities::Registry};
2000
2001 let applied = determinism_decider::decide(
2002 determinism_decider::Inputs {
2003 question: "what is the meaning of life?",
2004 sources_fingerprint: "abc123",
2005 },
2006 Registry::default().capabilities("openrouter"),
2007 determinism_decider::Overrides::default(),
2008 determinism_decider::Settings::default(),
2009 );
2010 let payload = build_openai_prompt_payload(
2011 "google/gemini-2.5-flash-lite",
2012 "hello",
2013 applied.temperature,
2014 applied.seed,
2015 None,
2016 false,
2017 );
2018 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
2019 let seed = parsed
2020 .get("seed")
2021 .and_then(JsonValue::as_u64)
2022 .expect("default seed");
2023 assert!(seed <= i32::MAX as u64);
2024 assert_eq!(Some(seed), applied.seed);
2025 }
2026
2027 #[test]
2028 fn openai_prompt_payload_enables_stream_options() {
2029 let payload =
2030 build_openai_prompt_payload("gpt-4.1-mini", "hello", Some(0.0), None, None, true);
2031 let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
2032
2033 assert_eq!(
2034 parsed.get("stream").and_then(JsonValue::as_bool),
2035 Some(true)
2036 );
2037 assert_eq!(
2038 parsed
2039 .get("stream_options")
2040 .and_then(|value| value.get("include_usage"))
2041 .and_then(JsonValue::as_bool),
2042 Some(true)
2043 );
2044 }
2045
2046 #[test]
2047 fn openai_streaming_prompt_response_collects_delta_chunks() {
2048 let body = concat!(
2049 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"login \"},\"finish_reason\":null}]}\n\n",
2050 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"failed\"},\"finish_reason\":null}]}\n\n",
2051 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"total_tokens\":14}}\n\n",
2052 "data: [DONE]\n\n",
2053 );
2054 let parsed = parse_openai_streaming_prompt_response(body, "fallback").unwrap();
2055
2056 assert_eq!(parsed.model, "gpt-test");
2057 assert_eq!(parsed.output_text, "login failed");
2058 assert_eq!(
2059 parsed.output_chunks.as_deref(),
2060 Some(["login ".to_string(), "failed".to_string()].as_slice())
2061 );
2062 assert_eq!(parsed.prompt_tokens, Some(12));
2063 assert_eq!(parsed.completion_tokens, Some(2));
2064 assert_eq!(parsed.total_tokens, Some(14));
2065 assert_eq!(parsed.stop_reason.as_deref(), Some("stop"));
2066 }
2067
2068 #[tokio::test]
2069 async fn openai_prompt_async_rejects_empty_model() {
2070 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2071 let request = OpenAiPromptRequest {
2072 api_key: "key".to_string(),
2073 model: " ".to_string(),
2074 prompt: "hello".to_string(),
2075 temperature: None,
2076 seed: None,
2077 max_output_tokens: None,
2078 api_base: "https://api.openai.com/v1".to_string(),
2079 stream: false,
2080 };
2081 let err = openai_prompt_async(&transport, request).await.unwrap_err();
2082 assert!(err.to_string().contains("model cannot be empty"));
2083 }
2084
2085 #[tokio::test]
2086 async fn openai_prompt_async_rejects_empty_prompt() {
2087 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2088 let request = OpenAiPromptRequest {
2089 api_key: "key".to_string(),
2090 model: "gpt-4.1-mini".to_string(),
2091 prompt: "".to_string(),
2092 temperature: None,
2093 seed: None,
2094 max_output_tokens: None,
2095 api_base: "https://api.openai.com/v1".to_string(),
2096 stream: false,
2097 };
2098 let err = openai_prompt_async(&transport, request).await.unwrap_err();
2099 assert!(err.to_string().contains("prompt cannot be empty"));
2100 }
2101
2102 use std::io::{Read as _, Write as _};
2112 use std::net::TcpListener;
2113 use std::sync::{Arc, Mutex};
2114 use std::thread;
2115
2116 struct CapturedRequest {
2117 method: String,
2118 path: String,
2119 headers: Vec<(String, String)>,
2120 body: String,
2121 }
2122
2123 fn parse_http_request(stream: &mut std::net::TcpStream) -> CapturedRequest {
2124 let mut buf = [0u8; 8192];
2125 let mut data = Vec::new();
2126 loop {
2127 let read = stream.read(&mut buf).unwrap_or(0);
2128 if read == 0 {
2129 break;
2130 }
2131 data.extend_from_slice(&buf[..read]);
2132 if let Some(idx) = data.windows(4).position(|w| w == b"\r\n\r\n") {
2133 let header_len = idx + 4;
2134 let header_str = String::from_utf8_lossy(&data[..idx]).to_string();
2135 let mut lines = header_str.split("\r\n");
2136 let request_line = lines.next().unwrap_or("");
2137 let mut parts = request_line.split_whitespace();
2138 let method = parts.next().unwrap_or("").to_string();
2139 let path = parts.next().unwrap_or("").to_string();
2140 let mut headers = Vec::new();
2141 let mut content_length: usize = 0;
2142 for line in lines {
2143 if let Some((k, v)) = line.split_once(':') {
2144 let k = k.trim().to_string();
2145 let v = v.trim().to_string();
2146 if k.eq_ignore_ascii_case("content-length") {
2147 content_length = v.parse().unwrap_or(0);
2148 }
2149 headers.push((k, v));
2150 }
2151 }
2152 while data.len() < header_len + content_length {
2153 let read = stream.read(&mut buf).unwrap_or(0);
2154 if read == 0 {
2155 break;
2156 }
2157 data.extend_from_slice(&buf[..read]);
2158 }
2159 let body = String::from_utf8_lossy(&data[header_len..header_len + content_length])
2160 .to_string();
2161 return CapturedRequest {
2162 method,
2163 path,
2164 headers,
2165 body,
2166 };
2167 }
2168 }
2169 CapturedRequest {
2170 method: String::new(),
2171 path: String::new(),
2172 headers: Vec::new(),
2173 body: String::new(),
2174 }
2175 }
2176
2177 fn spawn_mock(
2180 status: u16,
2181 response_body: &'static str,
2182 ) -> (String, Arc<Mutex<Option<CapturedRequest>>>) {
2183 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2184 let addr = listener.local_addr().expect("addr");
2185 let captured: Arc<Mutex<Option<CapturedRequest>>> = Arc::new(Mutex::new(None));
2186 let captured_clone = Arc::clone(&captured);
2187 thread::spawn(move || {
2188 if let Ok((mut stream, _)) = listener.accept() {
2189 let req = parse_http_request(&mut stream);
2190 *captured_clone.lock().unwrap() = Some(req);
2191 let status_line = match status {
2192 200 => "200 OK",
2193 400 => "400 Bad Request",
2194 401 => "401 Unauthorized",
2195 500 => "500 Internal Server Error",
2196 _ => "200 OK",
2197 };
2198 let resp = format!(
2199 "HTTP/1.1 {status_line}\r\n\
2200 Content-Type: application/json\r\n\
2201 Content-Length: {}\r\n\
2202 Connection: close\r\n\r\n{}",
2203 response_body.len(),
2204 response_body
2205 );
2206 let _ = stream.write_all(resp.as_bytes());
2207 }
2208 });
2209 (format!("http://{}", addr), captured)
2210 }
2211
2212 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2213 async fn gateway_registration_drives_http_grpc_and_batch_with_saved_connection() {
2214 use crate::application::ports::RuntimeEntityPort;
2215 use crate::auth::{AuthConfig, AuthStore};
2216 use crate::{RedDBOptions, RedDBRuntime, StorageDeployPreset};
2217 let path = std::env::temp_dir().join(format!(
2218 "reddb_gateway_roundtrip_{}_{}.rdb",
2219 std::process::id(),
2220 std::time::SystemTime::now()
2221 .duration_since(std::time::UNIX_EPOCH)
2222 .expect("clock")
2223 .as_nanos(),
2224 ));
2225 let runtime = RedDBRuntime::with_options(
2226 RedDBOptions::persistent(&path)
2227 .with_storage_profile(StorageDeployPreset::Serverless.selection())
2228 .expect("profile"),
2229 )
2230 .expect("runtime");
2231 let db = runtime.db();
2232 let store = db.store();
2233 let pager = store.pager().expect("pager");
2234 let auth = Arc::new(
2235 AuthStore::with_vault_certificate(
2236 AuthConfig::default(),
2237 Arc::clone(pager),
2238 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
2239 )
2240 .expect("vault"),
2241 );
2242 let server = crate::server::RedDBServer::new(runtime.clone()).with_auth(auth);
2243 let embedding_body =
2244 r#"{"data":[{"index":0,"embedding":[0.1,0.2]}],"model":"Gateway/Embedding"}"#;
2245 for operation in [
2246 "prompt",
2247 "ask",
2248 "http-embeddings",
2249 "grpc-embeddings",
2250 "batch",
2251 "auto-embed",
2252 ] {
2253 let response_body = if matches!(operation, "prompt" | "ask") {
2254 r#"{"choices":[{"message":{"content":"hello"}}],"model":"Gateway/Chat"}"#
2255 } else {
2256 embedding_body
2257 };
2258 let (base, captured) = spawn_mock(200, response_body);
2259 let endpoint = format!("{base}/Gateway/v1");
2260 let registration = format!(
2261 r#"{{"provider":"red-router","alias":"mine","api_key":"synthetic-gateway-key","endpoint":"{endpoint}","default":true,"model":"Gateway/Chat"}}"#
2262 );
2263 let response = server.handle_ai_credentials(registration.into_bytes());
2264 assert_eq!(
2265 response.status,
2266 200,
2267 "{}",
2268 String::from_utf8_lossy(&response.body)
2269 );
2270 assert!(!String::from_utf8_lossy(&response.body).contains("synthetic-gateway-key"));
2271 assert_eq!(
2272 runtime
2273 .db()
2274 .store()
2275 .get_config("ai.providers.red-router.base_url"),
2276 Some(reddb_types::Value::text(endpoint.clone())),
2277 "registration must replace the endpoint in red.config"
2278 );
2279 assert_eq!(
2280 runtime
2281 .get_kv("red.config", "ai.providers.red-router.base_url")
2282 .expect("read registered gateway endpoint")
2283 .map(|(value, _)| value),
2284 Some(reddb_types::Value::text(endpoint.clone())),
2285 "registration must replace the endpoint visible to runtime readers"
2286 );
2287 assert_eq!(
2288 runtime
2289 .get_kv("red.config", "ai.inference.provider")
2290 .expect("read registered gateway provider")
2291 .map(|(value, _)| value),
2292 Some(reddb_types::Value::text("red-router")),
2293 "registration must select the canonical inference provider key"
2294 );
2295 match operation {
2296 "prompt" => {
2297 let response = server.handle_ai_prompt(br#"{"prompt":"hi"}"#.to_vec());
2298 assert_eq!(
2299 response.status,
2300 200,
2301 "{}",
2302 String::from_utf8_lossy(&response.body)
2303 );
2304 }
2305 "ask" => {
2306 let response = server.handle_ai_ask(br#"{"question":"hi"}"#.to_vec());
2307 assert_eq!(
2308 response.status,
2309 200,
2310 "{}",
2311 String::from_utf8_lossy(&response.body)
2312 );
2313 }
2314 "auto-embed" => {
2315 runtime.execute_query("INSERT INTO gateway_batch (body) VALUES ('hi') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'").expect("auto embed");
2316 let rows = runtime
2317 .execute_query("SELECT * FROM gateway_batch")
2318 .expect("rows");
2319 assert_eq!(
2320 rows.result.records.len(),
2321 2,
2322 "one row and its embedding vector"
2323 );
2324 }
2325 "http-embeddings" => {
2326 let response = server.handle_ai_embeddings(
2327 br#"{"model":"Gateway/Embedding","input":"hi"}"#.to_vec(),
2328 );
2329 assert_eq!(
2330 response.status,
2331 200,
2332 "{}",
2333 String::from_utf8_lossy(&response.body)
2334 );
2335 }
2336 "grpc-embeddings" => {
2337 let payload =
2338 crate::serde_json::json!({"model":"Gateway/Embedding","input":"hi"});
2339 grpc_embeddings(&runtime, &payload).expect("gRPC embedding");
2340 }
2341 "batch" => {
2342 let connection =
2343 resolve_connection_from_runtime(&AiProvider::RedRouter, None, &runtime)
2344 .expect("connection");
2345 let client =
2346 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(&runtime);
2347 let vectors = client
2348 .embed_batch_at(
2349 &AiProvider::RedRouter,
2350 "Gateway/Embedding",
2351 &connection.api_key,
2352 &connection.api_base,
2353 vec!["hi".to_string()],
2354 )
2355 .await
2356 .expect("batch");
2357 assert_eq!(vectors, vec![vec![0.1, 0.2]]);
2358 }
2359 _ => unreachable!("listed operation"),
2360 }
2361 let request = captured
2362 .lock()
2363 .expect("capture lock")
2364 .take()
2365 .expect("request");
2366 let suffix = if matches!(operation, "prompt" | "ask") {
2367 "chat/completions"
2368 } else {
2369 "embeddings"
2370 };
2371 assert_eq!(request.path, format!("/Gateway/v1/{suffix}"));
2372 assert!(request
2373 .headers
2374 .iter()
2375 .any(|(name, value)| name.eq_ignore_ascii_case("authorization")
2376 && value == "Bearer synthetic-gateway-key"));
2377 let model = if matches!(operation, "prompt" | "ask") {
2378 "Gateway/Chat"
2379 } else {
2380 "Gateway/Embedding"
2381 };
2382 assert!(request.body.contains(model), "{}", request.body);
2383 }
2384 drop(server);
2385 drop(runtime);
2386 drop(db);
2387 let _ = std::fs::remove_file(path);
2388 }
2389
2390 #[test]
2391 fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
2392 let body = r#"{
2393 "id":"chatcmpl_x",
2394 "model":"custom-model",
2395 "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
2396 "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
2397 }"#;
2398 let (base, captured) = spawn_mock(200, body);
2399
2400 let req = OpenAiCompatChatRequest {
2401 api_base: base.clone(),
2402 api_key: "sk-test".to_string(),
2403 model: "custom-model".to_string(),
2404 prompt: "say hi".to_string(),
2405 temperature: None,
2406 seed: None,
2407 max_output_tokens: None,
2408 extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
2409 };
2410 let resp = openai_compat_chat(req).expect("ok");
2411
2412 assert_eq!(resp.output_text, "hi");
2413 assert_eq!(resp.model, "custom-model");
2414 assert_eq!(resp.usage.input_tokens, Some(7));
2415 assert_eq!(resp.usage.output_tokens, Some(2));
2416 assert_eq!(resp.usage.total_tokens, Some(9));
2417 assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
2418
2419 let cap = captured.lock().unwrap().take().expect("captured");
2420 assert_eq!(cap.method, "POST");
2421 assert_eq!(cap.path, "/chat/completions");
2422 let has_auth = cap
2423 .headers
2424 .iter()
2425 .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
2426 assert!(has_auth, "Authorization header missing");
2427 let has_custom = cap
2428 .headers
2429 .iter()
2430 .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
2431 assert!(has_custom, "extra header missing");
2432 assert!(cap.body.contains("\"model\":\"custom-model\""));
2433 }
2434
2435 #[test]
2436 fn openai_compat_embeddings_roundtrip_with_dimensions() {
2437 let body = r#"{
2438 "object":"list",
2439 "model":"embed-model",
2440 "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
2441 "usage":{"prompt_tokens":4,"total_tokens":4}
2442 }"#;
2443 let (base, captured) = spawn_mock(200, body);
2444
2445 let req = OpenAiCompatEmbeddingsRequest {
2446 api_base: base,
2447 api_key: "sk-emb".to_string(),
2448 model: "embed-model".to_string(),
2449 inputs: vec!["hello".to_string()],
2450 dimensions: Some(2),
2451 extra_headers: vec![],
2452 };
2453 let resp = openai_compat_embeddings(req).expect("ok");
2454
2455 assert_eq!(resp.embeddings.len(), 1);
2456 assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
2457 assert_eq!(resp.usage.total_tokens, Some(4));
2458 assert_eq!(resp.usage.input_tokens, Some(4));
2459
2460 let cap = captured.lock().unwrap().take().expect("captured");
2461 assert_eq!(cap.path, "/embeddings");
2462 assert!(cap.body.contains("\"dimensions\":2"));
2463 }
2464
2465 #[test]
2466 fn openai_compat_chat_non_2xx_returns_structured_error() {
2467 let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
2468 let (base, _captured) = spawn_mock(401, body);
2469
2470 let req = OpenAiCompatChatRequest {
2471 api_base: base,
2472 api_key: "bad".to_string(),
2473 model: "m".to_string(),
2474 prompt: "hi".to_string(),
2475 temperature: None,
2476 seed: None,
2477 max_output_tokens: None,
2478 extra_headers: vec![],
2479 };
2480 let err = openai_compat_chat(req).unwrap_err().to_string();
2481 assert!(err.contains("status 401"), "got: {err}");
2482 assert!(err.contains("bad api key"), "got: {err}");
2483 }
2484
2485 #[test]
2486 fn openai_compat_chat_rejects_empty_model_and_prompt() {
2487 let req = OpenAiCompatChatRequest {
2488 api_base: "http://localhost:1".to_string(),
2489 api_key: "k".to_string(),
2490 model: " ".to_string(),
2491 prompt: "hi".to_string(),
2492 temperature: None,
2493 seed: None,
2494 max_output_tokens: None,
2495 extra_headers: vec![],
2496 };
2497 let err = openai_compat_chat(req).unwrap_err().to_string();
2498 assert!(err.contains("model cannot be empty"), "got: {err}");
2499
2500 let req = OpenAiCompatChatRequest {
2501 api_base: "http://localhost:1".to_string(),
2502 api_key: "k".to_string(),
2503 model: "m".to_string(),
2504 prompt: " ".to_string(),
2505 temperature: None,
2506 seed: None,
2507 max_output_tokens: None,
2508 extra_headers: vec![],
2509 };
2510 let err = openai_compat_chat(req).unwrap_err().to_string();
2511 assert!(err.contains("prompt cannot be empty"), "got: {err}");
2512 }
2513
2514 #[test]
2515 fn parse_provider_mode_recognizes_all_three_tokens() {
2516 assert_eq!(
2517 parse_provider_mode("openai-compat"),
2518 Some(AiProviderMode::OpenAiCompat)
2519 );
2520 assert_eq!(
2521 parse_provider_mode("OPENAI_NATIVE"),
2522 Some(AiProviderMode::OpenAiNative)
2523 );
2524 assert_eq!(
2525 parse_provider_mode("anthropic-native"),
2526 Some(AiProviderMode::AnthropicNative)
2527 );
2528 assert_eq!(parse_provider_mode("groq"), None);
2529 }
2530
2531 #[test]
2532 fn resolve_provider_mode_reads_kv_key() {
2533 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2534 if key == "ai.provider" {
2535 Ok(Some("anthropic-native".to_string()))
2536 } else {
2537 Ok(None)
2538 }
2539 };
2540 assert_eq!(
2541 resolve_provider_mode(&kv),
2542 Some(AiProviderMode::AnthropicNative)
2543 );
2544 }
2545
2546 #[test]
2547 fn resolve_default_provider_keeps_vendor_when_mode_is_configured() {
2548 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2550 match key {
2551 "ai.provider" => Ok(Some("anthropic-native".to_string())),
2552 "ai.inference.provider" => Ok(Some("groq".to_string())),
2553 _ => Ok(None),
2554 }
2555 };
2556 assert_eq!(
2557 resolve_default_provider(&kv).expect("provider"),
2558 AiProvider::Groq
2559 );
2560 }
2561
2562 #[test]
2563 fn auto_embed_missing_gateway_connection_fails_before_row_write() {
2564 let runtime = crate::RedDBRuntime::in_memory().expect("runtime");
2565 runtime
2566 .execute_query("CREATE TABLE gateway_docs (id INT, body TEXT)")
2567 .expect("table");
2568 let error = runtime.execute_query(
2569 "INSERT INTO gateway_docs (id, body) VALUES (1, 'hello') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'",
2570 ).expect_err("missing connection");
2571 assert!(error.to_string().contains("endpoint"), "{error}");
2572 let result = runtime
2573 .execute_query("SELECT * FROM gateway_docs")
2574 .expect("rows");
2575 assert!(
2576 result.result.records.is_empty(),
2577 "invalid connection must leave no rows"
2578 );
2579 }
2580
2581 #[test]
2582 fn gateway_connections_fail_closed_when_either_part_is_missing() {
2583 let provider = parse_provider("red-router").expect("router");
2584 let no_endpoint = |key: &str| -> crate::RedDBResult<Option<String>> {
2585 assert!(
2586 !key.starts_with("red.secret."),
2587 "validate endpoint before reading secrets"
2588 );
2589 Ok(None)
2590 };
2591 let err = resolve_connection(&provider, None, &no_endpoint)
2592 .err()
2593 .expect("endpoint required");
2594 assert!(err.to_string().contains("endpoint"));
2595 let endpoint_only = |key: &str| -> crate::RedDBResult<Option<String>> {
2596 Ok((key == "ai.providers.red-router.base_url")
2597 .then(|| "https://example.com/v1".to_string()))
2598 };
2599 let err = resolve_connection(&provider, None, &endpoint_only)
2600 .err()
2601 .expect("key required");
2602 assert!(err.to_string().contains("API key"));
2603 }
2604
2605 #[test]
2606 fn gateway_connections_preserve_endpoint_and_selected_credential() {
2607 let provider = parse_provider("openai-compat").expect("gateway");
2608 let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2609 Ok(match key {
2610 "ai.providers.openai-compat.base_url" => {
2611 Some("https://example.com/Gateway/v1".to_string())
2612 }
2613 "ai.providers.openai-compat.credential" => Some("my-key".to_string()),
2614 "red.secret.ai.providers.openai-compat.tokens.my-key" => {
2615 Some("synthetic-key".to_string())
2616 }
2617 _ => None,
2618 })
2619 };
2620 let connection = resolve_connection(&provider, None, &getter).expect("connection");
2621 assert_eq!(connection.api_base, "https://example.com/Gateway/v1");
2622 assert_eq!(connection.api_key, "synthetic-key");
2623 }
2624
2625 #[test]
2626 fn endpoint_validation_rejects_malformed_and_credential_bearing_urls() {
2627 for endpoint in [
2628 "local",
2629 "ftp://example.com/v1",
2630 "https://",
2631 "https://user:pass@example.com/v1",
2632 "https://example.com/v1?key=secret",
2633 "https://example.com/v1#fragment",
2634 ] {
2635 assert!(
2636 validate_custom_provider_url(endpoint).is_err(),
2637 "{endpoint}"
2638 );
2639 }
2640 assert!(validate_custom_provider_url("http://127.0.0.1:8000/MyGateway/v1").is_ok());
2641 }
2642
2643 #[test]
2644 fn gateway_provider_names_and_url_paths_keep_their_identity() {
2645 for name in ["openai-compat", "red-router"] {
2646 let provider = parse_provider(name).expect("named gateway");
2647 assert_eq!(provider.token(), name);
2648 assert!(provider.is_openai_compatible());
2649 assert!(provider.requires_api_key());
2650 assert_eq!(provider.default_api_base(), "");
2651 assert!(!provider.default_key_env_name().contains('-'));
2652 }
2653 let url = "https://example.com/MyGateway/v1";
2654 let provider = parse_provider(url).expect("URL provider");
2655 assert_eq!(provider.default_api_base(), url);
2656 }
2657
2658 #[test]
2659 fn implicit_credentials_reject_non_ai_secret_refs_before_reading_secret() {
2660 let provider = AiProvider::Custom("invalid_secret_ref_test".to_string());
2661 let key = ai_api_secret_ref_config_key(&provider, "default");
2662 let err = resolve_api_key(&provider, None, |path| {
2663 if path == key {
2664 return Ok(Some("red.secret.aes_key".to_string()));
2665 }
2666 assert_ne!(path, "red.secret.aes_key", "must not read engine secret");
2667 Ok(None)
2668 })
2669 .expect_err("non-AI secret ref must fail");
2670 assert!(err.to_string().contains("must reference"));
2671 }
2672
2673 #[test]
2676 fn invalid_provider_selection_does_not_fall_back_to_vendor_credentials() {
2677 let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2678 Ok((key == "ai.inference.provider").then(|| "typo-provider".to_string()))
2679 };
2680 assert!(resolve_default_provider(&getter).is_err());
2681 assert!(resolve_embeddings_provider(&getter).is_err());
2682 }
2683
2684 #[test]
2685 fn inference_provider_ask_specific_beats_task_pointer() {
2686 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2687 match key {
2688 "ai.ask.provider" => Ok(Some("groq".to_string())),
2689 "ai.inference.provider" => Ok(Some("deepseek".to_string())),
2690 _ => Ok(None),
2691 }
2692 };
2693 assert_eq!(
2694 resolve_default_provider(&kv).expect("provider"),
2695 AiProvider::Groq
2696 );
2697 }
2698
2699 #[test]
2700 fn inference_provider_falls_through_task_pointer_then_global_default() {
2701 let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
2702 match key {
2703 "ai.inference.provider" => Ok(Some("deepseek".to_string())),
2704 _ => Ok(None),
2705 }
2706 };
2707 assert_eq!(
2708 resolve_default_provider(&pointer).expect("provider"),
2709 AiProvider::DeepSeek
2710 );
2711
2712 let global = |key: &str| -> crate::RedDBResult<Option<String>> {
2713 Ok((key == "ai.default.provider").then(|| "groq".to_string()))
2714 };
2715 assert_eq!(
2716 resolve_default_provider(&global).expect("provider"),
2717 AiProvider::Groq
2718 );
2719
2720 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2721 assert_eq!(
2722 resolve_default_provider(&empty).expect("provider"),
2723 AiProvider::OpenAi
2724 );
2725 }
2726
2727 #[test]
2728 fn inference_model_ask_specific_beats_models_block_then_global_default() {
2729 let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
2730 match key {
2731 "ai.ask.model" => Ok(Some("gpt-ask".to_string())),
2732 "ai.providers.openai.models.inference" => Ok(Some("gpt-block".to_string())),
2733 _ => Ok(None),
2734 }
2735 };
2736 assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
2737
2738 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2739 match key {
2740 "ai.providers.openai.models.inference" => Ok(Some("gpt-block".to_string())),
2741 _ => Ok(None),
2742 }
2743 };
2744 assert_eq!(
2745 resolve_default_model(&AiProvider::OpenAi, &block),
2746 "gpt-block"
2747 );
2748
2749 let global = |key: &str| -> crate::RedDBResult<Option<String>> {
2750 Ok((key == "ai.default.model").then(|| "gpt-global".to_string()))
2751 };
2752 assert_eq!(
2753 resolve_default_model(&AiProvider::OpenAi, &global),
2754 "gpt-global"
2755 );
2756
2757 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2758 assert_eq!(
2759 resolve_default_model(&AiProvider::OpenAi, &empty),
2760 AiProvider::OpenAi.default_prompt_model()
2761 );
2762 }
2763
2764 #[test]
2765 fn embeddings_provider_follows_task_pointer() {
2766 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2767 match key {
2768 "ai.embeddings.provider" => Ok(Some("ollama".to_string())),
2769 _ => Ok(None),
2770 }
2771 };
2772 assert_eq!(
2773 resolve_embeddings_provider(&kv).unwrap(),
2774 AiProvider::Ollama
2775 );
2776
2777 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2778 assert_eq!(
2779 resolve_embeddings_provider(&empty).unwrap(),
2780 AiProvider::OpenAi
2781 );
2782 }
2783
2784 #[test]
2785 fn embeddings_selection_rejects_chat_only_providers_and_accepts_routers() {
2786 for token in ["anthropic", "groq", "deepseek"] {
2787 let provider = parse_provider(token).expect("provider");
2788 assert!(!provider.supports_embeddings());
2789 assert!(ensure_provider_supports_embeddings(&provider).is_err());
2790 }
2791 for token in ["openrouter", "venice", "openai-compat", "red-router"] {
2792 let provider = parse_provider(token).expect("provider");
2793 assert!(ensure_provider_supports_embeddings(&provider).is_ok());
2794 }
2795 }
2796
2797 #[test]
2798 fn embeddings_provider_rejects_modality_incapable_pointer() {
2799 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2800 match key {
2801 "ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
2802 _ => Ok(None),
2803 }
2804 };
2805 let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
2806 assert!(
2807 err.contains("ai.embeddings.provider"),
2808 "error must name the pointer to fix: {err}"
2809 );
2810 assert!(
2811 err.contains("openai") && err.contains("no embeddings API"),
2812 "error must name capable alternatives: {err}"
2813 );
2814 }
2815
2816 #[test]
2817 fn embeddings_provider_falls_back_to_default_provider_chain() {
2818 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2824 match key {
2825 "ai.inference.provider" => Ok(Some("ollama".to_string())),
2826 _ => Ok(None),
2827 }
2828 };
2829 assert_eq!(
2830 resolve_embeddings_provider(&kv).unwrap(),
2831 AiProvider::Ollama
2832 );
2833 }
2834
2835 #[test]
2836 fn embeddings_model_block_beats_builtin() {
2837 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2838 match key {
2839 "ai.providers.openai.models.embeddings" => {
2840 Ok(Some("text-embedding-custom".to_string()))
2841 }
2842 _ => Ok(None),
2843 }
2844 };
2845 assert_eq!(
2846 resolve_embeddings_model(&AiProvider::OpenAi, &block),
2847 "text-embedding-custom"
2848 );
2849
2850 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2851 assert_eq!(
2852 resolve_embeddings_model(&AiProvider::Ollama, &empty),
2853 AiProvider::Ollama.default_embedding_model()
2854 );
2855 }
2856
2857 #[test]
2858 fn base_url_reads_provider_block_key() {
2859 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2860 if key == "ai.providers.openai.base_url" {
2861 Ok(Some("https://proxy.example/v1".to_string()))
2862 } else {
2863 Ok(None)
2864 }
2865 };
2866 assert_eq!(
2867 AiProvider::OpenAi
2868 .resolve_api_base_checked(&kv)
2869 .expect("endpoint"),
2870 "https://proxy.example/v1"
2871 );
2872 }
2873
2874 #[test]
2875 fn removed_config_keys_rejected_naming_replacements() {
2876 let err = validate_ai_config_key_on_write("ai.openai.default.base_url")
2877 .unwrap_err()
2878 .to_string();
2879 assert!(err.contains("ai.providers.<provider>.base_url"), "{err}");
2880
2881 assert!(validate_ai_config_key_on_write("ai.default.provider").is_ok());
2884 assert!(validate_ai_config_key_on_write("ai.default.model").is_ok());
2885 assert!(validate_ai_config_key_on_write("ai.inference.provider").is_ok());
2886 assert!(validate_ai_config_key_on_write("ai.providers.openai.base_url").is_ok());
2887 assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
2888 }
2889
2890 #[test]
2891 fn ask_planner_model_and_effort_resolve() {
2892 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2893 match key {
2894 "ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
2895 "ai.ask.effort" => Ok(Some("high".to_string())),
2896 _ => Ok(None),
2897 }
2898 };
2899 assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
2900 assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
2901
2902 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2903 assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
2904 assert_eq!(resolve_ask_effort(&empty), None);
2905 }
2906
2907 #[tokio::test]
2908 async fn anthropic_prompt_async_rejects_empty_api_key() {
2909 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2910 let request = AnthropicPromptRequest {
2911 api_key: " ".to_string(),
2912 model: "claude-3-5-haiku-latest".to_string(),
2913 prompt: "hello".to_string(),
2914 temperature: None,
2915 max_output_tokens: None,
2916 api_base: "https://api.anthropic.com/v1".to_string(),
2917 anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
2918 };
2919 let err = anthropic_prompt_async(&transport, request)
2920 .await
2921 .unwrap_err();
2922 assert!(err.to_string().contains("API key cannot be empty"));
2923 }
2924}
2925
2926#[derive(Debug, Clone, PartialEq, Eq)]
2932pub enum AiProvider {
2933 OpenAi,
2934 Anthropic,
2935 Groq,
2936 OpenRouter,
2937 OpenAiCompat,
2938 RedRouter,
2939 Together,
2940 Venice,
2941 Ollama,
2942 DeepSeek,
2943 MiniMax,
2944 HuggingFace,
2945 Local,
2946 Custom(String),
2947}
2948
2949impl AiProvider {
2950 pub fn token(&self) -> &str {
2951 match self {
2952 Self::OpenAi => "openai",
2953 Self::Anthropic => "anthropic",
2954 Self::Groq => "groq",
2955 Self::OpenRouter => "openrouter",
2956 Self::OpenAiCompat => "openai-compat",
2957 Self::RedRouter => "red-router",
2958 Self::Together => "together",
2959 Self::Venice => "venice",
2960 Self::Ollama => "ollama",
2961 Self::DeepSeek => "deepseek",
2962 Self::MiniMax => "minimax",
2963 Self::HuggingFace => "huggingface",
2964 Self::Local => "local",
2965 Self::Custom(name) => name.as_str(),
2966 }
2967 }
2968
2969 pub fn default_prompt_model(&self) -> &str {
2970 match self {
2971 Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
2972 Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
2973 Self::Groq => "llama-3.3-70b-versatile",
2974 Self::OpenRouter => "auto",
2975 Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
2976 Self::Venice => "llama-3.3-70b",
2977 Self::Ollama => "llama3",
2978 Self::DeepSeek => "deepseek-chat",
2979 Self::MiniMax => "abab6.5s-chat",
2980 Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
2981 Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2982 Self::Custom(_) | Self::OpenAiCompat | Self::RedRouter => DEFAULT_OPENAI_PROMPT_MODEL,
2983 }
2984 }
2985
2986 pub fn prompt_model_env_name(&self) -> String {
2987 format!(
2988 "REDDB_{}_PROMPT_MODEL",
2989 self.token().replace('-', "_").to_ascii_uppercase()
2990 )
2991 }
2992
2993 pub fn default_embedding_model(&self) -> &str {
2994 match self {
2995 Self::Ollama => "nomic-embed-text",
2996 Self::OpenRouter => "openai/text-embedding-3-small",
2997 Self::Venice => "text-embedding-bge-m3",
2998 Self::MiniMax => "embo-01",
2999 Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
3000 _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
3001 }
3002 }
3003
3004 pub fn default_api_base(&self) -> &str {
3005 match self {
3006 Self::OpenAi => DEFAULT_OPENAI_API_BASE,
3007 Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
3008 Self::Groq => "https://api.groq.com/openai/v1",
3009 Self::OpenRouter => "https://openrouter.ai/api/v1",
3010 Self::Together => "https://api.together.xyz/v1",
3011 Self::Venice => "https://api.venice.ai/api/v1",
3012 Self::Ollama => "http://localhost:11434/v1",
3013 Self::DeepSeek => "https://api.deepseek.com/v1",
3014 Self::MiniMax => "https://api.minimax.chat/v1",
3015 Self::HuggingFace => "https://api-inference.huggingface.co",
3016 Self::Local => "local",
3017 Self::Custom(base) => base.as_str(),
3018 Self::OpenAiCompat | Self::RedRouter => "",
3019 }
3020 }
3021
3022 pub fn api_base_env_name(&self) -> String {
3023 format!(
3024 "REDDB_{}_API_BASE",
3025 self.token().replace('-', "_").to_ascii_uppercase()
3026 )
3027 }
3028
3029 pub fn default_key_env_name(&self) -> String {
3030 format!(
3031 "REDDB_{}_API_KEY",
3032 self.token().replace('-', "_").to_ascii_uppercase()
3033 )
3034 }
3035
3036 pub fn alias_key_env_name(&self, alias: &str) -> String {
3037 let normalized = normalize_alias_token(alias);
3038 format!(
3039 "REDDB_{}_API_KEY_{normalized}",
3040 self.token().replace('-', "_").to_ascii_uppercase()
3041 )
3042 }
3043
3044 pub fn resolve_api_base(&self) -> String {
3045 if let Ok(value) = std::env::var(self.api_base_env_name()) {
3046 let value = value.trim().to_string();
3047 if !value.is_empty() {
3048 return value;
3049 }
3050 }
3051 self.default_api_base().to_string()
3052 }
3053
3054 pub fn resolve_api_base_checked<F>(&self, kv_getter: &F) -> crate::RedDBResult<String>
3056 where
3057 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3058 {
3059 let endpoint = match std::env::var(self.api_base_env_name()) {
3060 Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
3061 _ => kv_getter(&provider_base_url_key(self))?
3062 .filter(|value| !value.trim().is_empty())
3063 .map(|value| value.trim().to_string())
3064 .unwrap_or_else(|| self.default_api_base().to_string()),
3065 };
3066 if !matches!(self, Self::Local) {
3067 if endpoint.is_empty() {
3068 return Err(crate::RedDBError::Query(format!(
3069 "missing {} endpoint; set {} or '{}' alongside the API key",
3070 self.token(),
3071 self.api_base_env_name(),
3072 provider_base_url_key(self),
3073 )));
3074 }
3075 validate_custom_provider_url(&endpoint)?;
3076 }
3077 Ok(endpoint)
3078 }
3079
3080 pub fn requires_explicit_endpoint(&self) -> bool {
3082 matches!(self, Self::OpenAiCompat | Self::RedRouter | Self::Custom(_))
3083 }
3084
3085 pub fn is_openai_compatible(&self) -> bool {
3087 matches!(
3088 self,
3089 Self::OpenAi
3090 | Self::Groq
3091 | Self::OpenRouter
3092 | Self::OpenAiCompat
3093 | Self::RedRouter
3094 | Self::Together
3095 | Self::Venice
3096 | Self::Ollama
3097 | Self::DeepSeek
3098 | Self::MiniMax
3099 | Self::Custom(_)
3100 )
3101 }
3102
3103 pub fn requires_api_key(&self) -> bool {
3105 !matches!(self, Self::Ollama | Self::Local)
3106 }
3107
3108 pub fn supports_embeddings(&self) -> bool {
3110 crate::runtime::ai::provider_capabilities::Modalities::for_provider(self.token()).embed
3111 }
3112}
3113
3114pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
3116 let name = name.trim();
3117 match name.to_ascii_lowercase().as_str() {
3118 "openai" => Ok(AiProvider::OpenAi),
3119 "anthropic" => Ok(AiProvider::Anthropic),
3120 "groq" => Ok(AiProvider::Groq),
3121 "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
3122 "openai-compat" | "openai_compat" => Ok(AiProvider::OpenAiCompat),
3123 "red-router" | "red_router" => Ok(AiProvider::RedRouter),
3124 "together" => Ok(AiProvider::Together),
3125 "venice" => Ok(AiProvider::Venice),
3126 "ollama" => Ok(AiProvider::Ollama),
3127 "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
3128 "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
3129 "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
3130 "local" => Ok(AiProvider::Local),
3131 other => {
3132 if other.starts_with("http://") || other.starts_with("https://") {
3134 validate_custom_provider_url(name)?;
3135 Ok(AiProvider::Custom(name.to_string()))
3136 } else {
3137 Err(crate::RedDBError::Query(format!(
3138 "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
3139 openrouter, openai-compat, red-router, together, venice, ollama, deepseek, minimax, huggingface, local"
3140 )))
3141 }
3142 }
3143 }
3144}
3145
3146pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
3166 "openai, ollama, openrouter, openai-compat, red-router, together, venice, minimax, huggingface, local";
3167
3168pub fn provider_base_url_key(provider: &AiProvider) -> String {
3170 format!("ai.providers.{}.base_url", provider.token())
3171}
3172
3173pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
3176 format!("ai.providers.{}.models.{modality}", provider.token())
3177}
3178
3179pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
3184 let key = key.trim();
3185 if key.starts_with("ai.") && key.ends_with(".base_url") && !key.starts_with("ai.providers.") {
3189 return Err(crate::RedDBError::Query(format!(
3190 "AI config key '{key}' uses the removed per-credential base-URL shape; set \
3191 'ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
3192 )));
3193 }
3194 Ok(())
3195}
3196
3197pub fn resolve_default_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
3205where
3206 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3207{
3208 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
3209 if !value.trim().is_empty() {
3210 return parse_provider(&value);
3211 }
3212 }
3213 for key in [
3214 "ai.ask.provider",
3215 "ai.inference.provider",
3216 "ai.default.provider",
3217 ] {
3218 if let Some(value) = kv_getter(key)? {
3219 if !value.trim().is_empty() {
3220 return parse_provider(&value);
3221 }
3222 }
3223 }
3224 Ok(resolve_provider_mode(kv_getter)
3225 .map(provider_mode_to_provider)
3226 .unwrap_or(AiProvider::OpenAi))
3227}
3228
3229pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
3237where
3238 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3239{
3240 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
3242 let value = value.trim().to_string();
3243 if !value.is_empty() {
3244 return value;
3245 }
3246 }
3247 if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
3249 let value = value.trim().to_string();
3250 if !value.is_empty() {
3251 return value;
3252 }
3253 }
3254 for key in [
3256 "ai.ask.model".to_string(),
3257 provider_models_key(provider, "inference"),
3258 "ai.default.model".to_string(),
3259 ] {
3260 if let Ok(Some(value)) = kv_getter(&key) {
3261 let value = value.trim().to_string();
3262 if !value.is_empty() {
3263 return value;
3264 }
3265 }
3266 }
3267 provider.default_prompt_model().to_string()
3268}
3269
3270pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
3282where
3283 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3284{
3285 let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
3286 .ok()
3287 .map(|v| v.trim().to_string())
3288 .filter(|v| !v.is_empty())
3289 {
3290 parse_provider(&value)?
3291 } else if let Some(value) = kv_getter("ai.embeddings.provider")?
3292 .map(|v| v.trim().to_string())
3293 .filter(|v| !v.is_empty())
3294 {
3295 parse_provider(&value)?
3296 } else {
3297 resolve_default_provider(kv_getter)?
3301 };
3302 ensure_provider_supports_embeddings(&provider)?;
3303 Ok(provider)
3304}
3305
3306pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
3308 if provider.supports_embeddings() {
3309 return Ok(());
3310 }
3311 Err(crate::RedDBError::Query(format!(
3312 "the embeddings task pointer 'ai.embeddings.provider' names '{}', which \
3313 has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
3314 re-routes embeddings to a different provider than the one you named.",
3315 provider.token(),
3316 EMBEDDING_CAPABLE_PROVIDERS
3317 )))
3318}
3319
3320pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
3333where
3334 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3335{
3336 if let Ok(value) = std::env::var(format!(
3337 "REDDB_{}_EMBEDDING_MODEL",
3338 provider.token().replace('-', "_").to_ascii_uppercase()
3339 )) {
3340 let value = value.trim().to_string();
3341 if !value.is_empty() {
3342 return value;
3343 }
3344 }
3345 if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
3346 let value = value.trim().to_string();
3347 if !value.is_empty() {
3348 return value;
3349 }
3350 }
3351 if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
3352 let value = value.trim().to_string();
3353 if !value.is_empty() {
3354 return value;
3355 }
3356 }
3357 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
3358 let value = value.trim().to_string();
3359 if !value.is_empty() {
3360 return value;
3361 }
3362 }
3363 provider.default_embedding_model().to_string()
3364}
3365
3366pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
3370where
3371 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3372{
3373 if let Ok(Some(value)) = kv_getter("ai.ask.planner_model") {
3374 let value = value.trim().to_string();
3375 if !value.is_empty() {
3376 return value;
3377 }
3378 }
3379 fallback_model.to_string()
3380}
3381
3382pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
3385where
3386 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3387{
3388 if let Ok(Some(value)) = kv_getter("ai.ask.effort") {
3389 let value = value.trim().to_string();
3390 if !value.is_empty() {
3391 return Some(value);
3392 }
3393 }
3394 None
3395}
3396
3397pub fn resolve_defaults_from_runtime(
3399 runtime: &crate::runtime::RedDBRuntime,
3400) -> crate::RedDBResult<(AiProvider, String)> {
3401 use crate::application::ports::RuntimeEntityPort;
3402 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3403 match runtime.get_kv("red.config", key)? {
3404 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3405 _ => Ok(None),
3406 }
3407 };
3408 let provider = resolve_default_provider(&kv_getter)?;
3409 let model = resolve_default_model(&provider, &kv_getter);
3410 Ok((provider, model))
3411}
3412
3413pub fn resolve_prompt_model_from_runtime(
3414 runtime: &crate::runtime::RedDBRuntime,
3415 provider: &AiProvider,
3416 explicit: Option<&str>,
3417) -> String {
3418 use crate::application::ports::RuntimeEntityPort;
3419 if let Some(model) = explicit {
3420 return model.to_string();
3421 }
3422 resolve_default_model(provider, &|key| match runtime.get_kv("red.config", key)? {
3423 Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3424 _ => Ok(None),
3425 })
3426}
3427
3428pub fn resolve_ask_planner_model_from_runtime(
3433 runtime: &crate::runtime::RedDBRuntime,
3434 fallback_model: &str,
3435) -> String {
3436 use crate::application::ports::RuntimeEntityPort;
3437 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3438 match runtime.get_kv("red.config", key)? {
3439 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3440 _ => Ok(None),
3441 }
3442 };
3443 resolve_ask_planner_model(&kv_getter, fallback_model)
3444}
3445
3446pub fn resolve_defaults_from_runtime_port<
3448 P: crate::application::ports::RuntimeEntityPort + ?Sized,
3449>(
3450 runtime: &P,
3451) -> crate::RedDBResult<(AiProvider, String)> {
3452 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3453 match runtime.get_kv("red.config", key)? {
3454 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3455 _ => Ok(None),
3456 }
3457 };
3458 let provider = resolve_default_provider(&kv_getter)?;
3459 let model = resolve_default_model(&provider, &kv_getter);
3460 Ok((provider, model))
3461}
3462
3463pub fn resolve_embeddings_provider_from_runtime<
3468 P: crate::application::ports::RuntimeEntityPort + ?Sized,
3469>(
3470 runtime: &P,
3471 explicit: &str,
3472) -> crate::RedDBResult<AiProvider> {
3473 let explicit = explicit.trim();
3474 if !explicit.is_empty() {
3475 let provider = parse_provider(explicit)?;
3476 ensure_provider_supports_embeddings(&provider)?;
3477 return Ok(provider);
3478 }
3479 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3480 match runtime.get_kv("red.config", key)? {
3481 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3482 _ => Ok(None),
3483 }
3484 };
3485 resolve_embeddings_provider(&kv_getter)
3486}
3487
3488pub fn resolve_embeddings_model_from_runtime<
3492 P: crate::application::ports::RuntimeEntityPort + ?Sized,
3493>(
3494 runtime: &P,
3495 provider: &AiProvider,
3496 explicit: Option<&str>,
3497) -> String {
3498 if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
3499 return model.to_string();
3500 }
3501 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3502 match runtime.get_kv("red.config", key)? {
3503 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3504 _ => Ok(None),
3505 }
3506 };
3507 resolve_embeddings_model(provider, &kv_getter)
3508}
3509
3510pub fn resolve_api_key<F>(
3537 provider: &AiProvider,
3538 credential_alias: Option<&str>,
3539 kv_getter: F,
3540) -> crate::RedDBResult<String>
3541where
3542 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3543{
3544 let configured_alias = if credential_alias.is_none() {
3545 kv_getter(&format!("ai.providers.{}.credential", provider.token()))?
3546 } else {
3547 None
3548 };
3549 let credential_alias = credential_alias.or(configured_alias.as_deref());
3550 if let Some(alias) = credential_alias
3551 .map(str::trim)
3552 .filter(|a| !a.is_empty() && *a != "default")
3553 {
3554 if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
3556 if !key.trim().is_empty() {
3557 return Ok(key);
3558 }
3559 }
3560 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
3562 let secret_ref = secret_ref.trim();
3563 validate_ai_secret_ref(secret_ref)?;
3567 if let Some(key) = kv_getter(secret_ref)? {
3568 if !key.trim().is_empty() {
3569 return Ok(key);
3570 }
3571 }
3572 }
3573 if let Some(key) = resolve_key_from_env_alias(provider, alias) {
3575 return Ok(key);
3576 }
3577 reject_removed_credential_paths(provider, alias, &kv_getter)?;
3580 return Err(crate::RedDBError::Query(format!(
3581 "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
3582 provider.token(),
3583 provider.alias_key_env_name(alias),
3584 ai_api_secret_path(provider, alias),
3585 )));
3586 }
3587
3588 if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
3590 if !key.trim().is_empty() {
3591 return Ok(key);
3592 }
3593 }
3594 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
3596 let secret_ref = secret_ref.trim();
3597 validate_ai_secret_ref(secret_ref)?;
3598 if let Some(key) = kv_getter(secret_ref)? {
3599 if !key.trim().is_empty() {
3600 return Ok(key);
3601 }
3602 }
3603 }
3604
3605 if let Ok(value) = std::env::var(provider.default_key_env_name()) {
3607 let value = value.trim().to_string();
3608 if !value.is_empty() {
3609 return Ok(value);
3610 }
3611 }
3612
3613 reject_removed_credential_paths(provider, "default", &kv_getter)?;
3616 if !provider.requires_api_key() {
3617 return Ok(String::new());
3618 }
3619
3620 Err(crate::RedDBError::Query(format!(
3621 "missing {} API key. Set {} or store it in the vault at '{}'",
3622 provider.token(),
3623 provider.default_key_env_name(),
3624 ai_api_secret_path(provider, "default"),
3625 )))
3626}
3627
3628fn validate_ai_secret_ref(secret_ref: &str) -> crate::RedDBResult<()> {
3629 if !secret_ref
3630 .to_ascii_lowercase()
3631 .starts_with("red.secret.ai.")
3632 {
3633 return Err(crate::RedDBError::Query(format!(
3634 "secret_ref '{secret_ref}' must reference a vault key under red.secret.ai.*"
3635 )));
3636 }
3637 Ok(())
3638}
3639
3640pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
3642 format!(
3643 "red.secret.ai.providers.{}.tokens.{}",
3644 provider.token(),
3645 normalize_credential_alias_path(alias)
3646 )
3647}
3648
3649pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
3651 format!(
3652 "ai.providers.{}.tokens.{}.secret_ref",
3653 provider.token(),
3654 normalize_credential_alias_path(alias)
3655 )
3656}
3657
3658fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
3662 format!(
3663 "red.secret.ai.{}.{}.api_key",
3664 provider.token(),
3665 normalize_credential_alias_path(alias)
3666 )
3667}
3668
3669fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
3672 format!(
3673 "ai.{}.{}.key",
3674 provider.token(),
3675 normalize_credential_alias_path(alias)
3676 )
3677}
3678
3679fn reject_removed_credential_paths<F>(
3683 provider: &AiProvider,
3684 alias: &str,
3685 kv_getter: &F,
3686) -> crate::RedDBResult<()>
3687where
3688 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3689{
3690 let new_path = ai_api_secret_path(provider, alias);
3691 for removed in [
3692 removed_vault_api_key_path(provider, alias),
3693 removed_plaintext_config_key(provider, alias),
3694 ] {
3695 if let Some(value) = kv_getter(&removed)? {
3696 if !value.trim().is_empty() {
3697 return Err(crate::RedDBError::Query(format!(
3698 "AI credential found at removed path '{removed}'. The AI credential vault \
3699 path changed (issue #1745): store the token at '{new_path}' instead. The \
3700 old vault path shape and the legacy plaintext config path are no longer read."
3701 )));
3702 }
3703 }
3704 }
3705 Ok(())
3706}
3707
3708fn normalize_credential_alias_path(alias: &str) -> String {
3709 let alias = alias.trim();
3710 if alias.is_empty() {
3711 "default".to_string()
3712 } else {
3713 alias.to_ascii_lowercase()
3714 }
3715}
3716
3717fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
3718 let env_name = provider.alias_key_env_name(alias);
3719 std::env::var(env_name)
3720 .ok()
3721 .map(|v| v.trim().to_string())
3722 .filter(|v| !v.is_empty())
3723}
3724
3725fn normalize_alias_token(alias: &str) -> String {
3726 let mut out = String::with_capacity(alias.len());
3727 for character in alias.chars() {
3728 if character.is_ascii_alphanumeric() {
3729 out.push(character.to_ascii_uppercase());
3730 } else {
3731 out.push('_');
3732 }
3733 }
3734 while out.contains("__") {
3735 out = out.replace("__", "_");
3736 }
3737 out.trim_matches('_').to_string()
3738}
3739
3740pub struct AiConnection {
3743 pub api_base: String,
3744 pub api_key: String,
3745}
3746
3747pub fn resolve_connection<F>(
3748 provider: &AiProvider,
3749 credential_alias: Option<&str>,
3750 kv_getter: &F,
3751) -> crate::RedDBResult<AiConnection>
3752where
3753 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3754{
3755 let api_base = provider.resolve_api_base_checked(kv_getter)?;
3756 let api_key = resolve_api_key(provider, credential_alias, kv_getter)?;
3757 Ok(AiConnection { api_base, api_key })
3758}
3759
3760pub fn resolve_connection_from_runtime(
3761 provider: &AiProvider,
3762 credential_alias: Option<&str>,
3763 runtime: &crate::runtime::RedDBRuntime,
3764) -> crate::RedDBResult<AiConnection> {
3765 use crate::application::ports::RuntimeEntityPort;
3766 let api_base =
3767 provider.resolve_api_base_checked(&|key| match runtime.get_kv("red.config", key)? {
3768 Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3769 _ => Ok(None),
3770 })?;
3771 let api_key = resolve_api_key_from_runtime(provider, credential_alias, runtime)?;
3772 Ok(AiConnection { api_base, api_key })
3773}
3774
3775pub fn resolve_api_key_from_runtime(
3785 provider: &AiProvider,
3786 credential_alias: Option<&str>,
3787 runtime: &crate::runtime::RedDBRuntime,
3788) -> crate::RedDBResult<String> {
3789 use crate::application::ports::RuntimeEntityPort;
3790 let configured_alias = if credential_alias.is_none() {
3791 match runtime.get_kv(
3792 "red.config",
3793 &format!("ai.providers.{}.credential", provider.token()),
3794 )? {
3795 Some((reddb_types::Value::Text(value), _)) => Some(value.to_string()),
3796 _ => None,
3797 }
3798 } else {
3799 None
3800 };
3801 let credential_alias = credential_alias.or(configured_alias.as_deref());
3802 let alias_for_audit = credential_alias.unwrap_or("default").to_string();
3803 let provider_token = provider.token().to_string();
3804 let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
3805 std::cell::RefCell::new(Vec::new());
3806 let result = resolve_api_key(provider, credential_alias, |kv_key| {
3807 if kv_key.starts_with("red.secret.") {
3808 let value = runtime.vault_kv_get(kv_key);
3809 audited_paths
3810 .borrow_mut()
3811 .push((kv_key.to_string(), value.is_some()));
3812 return Ok(value);
3813 }
3814 match runtime.get_kv("red.config", kv_key)? {
3815 Some((reddb_types::Value::Text(secret), _)) => {
3816 audited_paths.borrow_mut().push((kv_key.to_string(), true));
3817 Ok(Some(secret.to_string()))
3818 }
3819 Some(_) => {
3820 audited_paths.borrow_mut().push((kv_key.to_string(), false));
3821 Ok(None)
3822 }
3823 None => {
3824 audited_paths.borrow_mut().push((kv_key.to_string(), false));
3825 Ok(None)
3826 }
3827 }
3828 });
3829 let audited_paths = audited_paths.into_inner();
3830
3831 let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
3832 .map(|(user, _role)| user)
3833 .unwrap_or_else(|| "system".to_string());
3834 let outcome = if result.is_ok() { "hit" } else { "miss" };
3835 let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
3836 let paths_json: Vec<crate::serde_json::Value> = audited_paths
3837 .iter()
3838 .map(|(p, hit)| {
3839 crate::serde_json::json!({
3840 "path": p,
3841 "hit": hit,
3842 })
3843 })
3844 .collect();
3845 let details = crate::serde_json::json!({
3846 "provider": provider_token,
3847 "alias": alias_for_audit,
3848 "paths_checked": paths_json,
3849 });
3850 runtime.audit_log().record(
3851 "ai.credential.resolve",
3852 &principal,
3853 &target,
3854 outcome,
3855 details,
3856 );
3857 result
3858}
3859
3860pub fn huggingface_embeddings(
3866 api_key: &str,
3867 model: &str,
3868 inputs: &[String],
3869 api_base: &str,
3870) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3871 let url = format!("{api_base}/pipeline/feature-extraction/{model}");
3872 let mut embeddings = Vec::with_capacity(inputs.len());
3873
3874 for input in inputs {
3875 let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
3876 let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
3877 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3878 if !(200..300).contains(&status) {
3879 return Err(crate::RedDBError::Query(format!(
3880 "HuggingFace API error (status {status}): {body_str}"
3881 )));
3882 }
3883 let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
3884 crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
3885 })?;
3886
3887 let vector: Vec<f32> = match &body {
3889 JsonValue::Array(outer) => outer
3890 .iter()
3891 .filter_map(|v| v.as_f64().map(|n| n as f32))
3892 .collect(),
3893 _ => {
3894 return Err(crate::RedDBError::Query(
3895 "unexpected HuggingFace embedding response format".to_string(),
3896 ))
3897 }
3898 };
3899 embeddings.push(vector);
3900 }
3901
3902 Ok(OpenAiEmbeddingResponse {
3903 provider: "huggingface",
3904 model: model.to_string(),
3905 embeddings,
3906 prompt_tokens: None,
3907 total_tokens: None,
3908 })
3909}
3910
3911pub fn huggingface_prompt(
3913 api_key: &str,
3914 model: &str,
3915 prompt: &str,
3916 temperature: Option<f32>,
3917 max_tokens: Option<usize>,
3918 api_base: &str,
3919) -> crate::RedDBResult<AiPromptResponse> {
3920 let url = format!("{api_base}/models/{model}");
3921 let mut params = Map::new();
3922 if let Some(t) = temperature {
3923 params.insert("temperature".into(), JsonValue::Number(t as f64));
3924 }
3925 params.insert(
3926 "max_new_tokens".into(),
3927 JsonValue::Number(max_tokens.unwrap_or(512) as f64),
3928 );
3929 let payload = crate::serde_json::json!({
3930 "inputs": prompt,
3931 "parameters": JsonValue::Object(params)
3932 });
3933
3934 let (status, body_str) =
3935 http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
3936 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3937 if !(200..300).contains(&status) {
3938 return Err(crate::RedDBError::Query(format!(
3939 "HuggingFace API error (status {status}): {body_str}"
3940 )));
3941 }
3942 let body: JsonValue = crate::serde_json::from_str(&body_str)
3943 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
3944
3945 let output_text = match &body {
3946 JsonValue::Array(arr) => arr
3947 .first()
3948 .and_then(|v| v.get("generated_text"))
3949 .and_then(JsonValue::as_str)
3950 .unwrap_or("")
3951 .to_string(),
3952 _ => body
3953 .get("generated_text")
3954 .and_then(JsonValue::as_str)
3955 .unwrap_or("")
3956 .to_string(),
3957 };
3958
3959 Ok(AiPromptResponse {
3960 provider: "huggingface",
3961 model: model.to_string(),
3962 output_text,
3963 output_chunks: None,
3964 prompt_tokens: None,
3965 completion_tokens: None,
3966 total_tokens: None,
3967 stop_reason: None,
3968 })
3969}
3970
3971const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
3976flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
3977the 'ollama' provider with a local Ollama server.";
3978
3979const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
3980`local-models` feature, but local model artifact execution is not implemented in this slice. \
3981Alternatively, use the 'ollama' provider with a local Ollama server.";
3982
3983const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
3984the `local-models` feature; the local provider contract is embeddings-only for this slice.";
3985
3986pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
3987 if cfg!(feature = "local-models") {
3988 crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
3989 } else {
3990 crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
3991 }
3992}
3993
3994pub fn local_prompt_unavailable_error() -> crate::RedDBError {
3995 crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
3996}
3997
3998pub fn local_embeddings(
4000 _model_id: &str,
4001 _texts: &[String],
4002) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
4003 Err(local_embeddings_unavailable_error())
4004}
4005
4006pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
4008 Err(local_prompt_unavailable_error())
4009}
4010
4011fn grpc_collect_embedding_inputs(
4024 runtime: &crate::runtime::RedDBRuntime,
4025 payload: &JsonValue,
4026) -> crate::RedDBResult<Vec<String>> {
4027 if let Some(source_query) = payload
4028 .get("source_query")
4029 .and_then(|v| v.as_str())
4030 .map(str::trim)
4031 .filter(|s| !s.is_empty())
4032 {
4033 return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
4034 }
4035
4036 if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
4037 let mut out = Vec::with_capacity(arr.len());
4038 for (idx, v) in arr.iter().enumerate() {
4039 let text = v.as_str().ok_or_else(|| {
4040 crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
4041 })?;
4042 if text.trim().is_empty() {
4043 return Err(crate::RedDBError::Query(format!(
4044 "field 'inputs[{idx}]' cannot be empty"
4045 )));
4046 }
4047 out.push(text.to_string());
4048 }
4049 if out.is_empty() {
4050 return Err(crate::RedDBError::Query(
4051 "field 'inputs' must be a non-empty array of strings".to_string(),
4052 ));
4053 }
4054 return Ok(out);
4055 }
4056
4057 if let Some(single) = payload
4058 .get("input")
4059 .and_then(|v| v.as_str())
4060 .map(str::trim)
4061 .filter(|s| !s.is_empty())
4062 {
4063 return Ok(vec![single.to_string()]);
4064 }
4065
4066 Err(crate::RedDBError::Query(
4067 "provide either 'input', 'inputs', or 'source_query'".to_string(),
4068 ))
4069}
4070
4071fn grpc_collect_inputs_from_source_query(
4072 runtime: &crate::runtime::RedDBRuntime,
4073 payload: &JsonValue,
4074 source_query: &str,
4075) -> crate::RedDBResult<Vec<String>> {
4076 let result = runtime
4077 .execute_query(source_query)
4078 .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
4079
4080 let source_mode = payload
4081 .get("source_mode")
4082 .and_then(|v| v.as_str())
4083 .map(str::trim)
4084 .filter(|s| !s.is_empty())
4085 .unwrap_or("row")
4086 .to_ascii_lowercase();
4087
4088 let mut out: Vec<String> = Vec::new();
4089 match source_mode.as_str() {
4090 "row" => {
4091 let field = payload
4092 .get("source_field")
4093 .and_then(|v| v.as_str())
4094 .map(str::trim)
4095 .filter(|s| !s.is_empty())
4096 .ok_or_else(|| {
4097 crate::RedDBError::Query(
4098 "field 'source_field' is required when source_mode='row'".to_string(),
4099 )
4100 })?;
4101 for rec in &result.result.records {
4102 for (key, value) in rec.iter_fields() {
4103 if key.as_ref() == field {
4104 if let reddb_types::Value::Text(text) = value {
4105 let trimmed = text.trim();
4106 if !trimmed.is_empty() {
4107 out.push(trimmed.to_string());
4108 }
4109 }
4110 }
4111 }
4112 }
4113 }
4114 "result" => {
4115 for rec in &result.result.records {
4116 for (_, value) in rec.iter_fields() {
4117 if let reddb_types::Value::Text(text) = value {
4118 let trimmed = text.trim();
4119 if !trimmed.is_empty() {
4120 out.push(trimmed.to_string());
4121 }
4122 }
4123 }
4124 }
4125 }
4126 other => {
4127 return Err(crate::RedDBError::Query(format!(
4128 "field 'source_mode' must be 'row' or 'result' (got '{other}')"
4129 )));
4130 }
4131 }
4132
4133 if out.is_empty() {
4134 return Err(crate::RedDBError::Query(
4135 "source_query produced zero non-empty text inputs".to_string(),
4136 ));
4137 }
4138 Ok(out)
4139}
4140
4141pub fn grpc_embeddings(
4163 runtime: &crate::runtime::RedDBRuntime,
4164 payload: &JsonValue,
4165) -> crate::RedDBResult<JsonValue> {
4166 let provider = match payload
4169 .get("provider")
4170 .and_then(|v| v.as_str())
4171 .map(str::trim)
4172 .filter(|s| !s.is_empty())
4173 {
4174 Some(name) => parse_provider(name)?,
4175 None => resolve_embeddings_provider_from_runtime(runtime, "")?,
4176 };
4177 if matches!(provider, AiProvider::Groq | AiProvider::DeepSeek) {
4178 ensure_provider_supports_embeddings(&provider)?;
4179 }
4180 match &provider {
4185 AiProvider::Anthropic => {
4186 return Err(crate::RedDBError::Query(
4187 "Anthropic does not offer an embeddings API. \
4188 Re-issue the request against an OpenAI-compatible \
4189 provider (openai, ollama, openrouter, together, \
4190 venice, red-router), HuggingFace, or a custom base URL — \
4191 RedDB does not silently route embeddings to a \
4192 different provider than the one you named."
4193 .to_string(),
4194 ));
4195 }
4196 AiProvider::Local => {
4197 return grpc_embeddings_local(runtime, payload);
4198 }
4199 _ => {}
4200 }
4201
4202 let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
4203
4204 let explicit_model = payload.get("model").and_then(|v| v.as_str());
4205 let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
4206
4207 let credential = payload
4208 .get("credential")
4209 .and_then(|v| v.as_str())
4210 .map(str::to_string);
4211 let AiConnection { api_key, api_base } =
4212 resolve_connection_from_runtime(&provider, credential.as_deref(), runtime)?;
4213
4214 let dimensions = payload
4215 .get("dimensions")
4216 .and_then(|v| v.as_i64())
4217 .and_then(|v| usize::try_from(v).ok())
4218 .filter(|v| *v > 0);
4219
4220 let response = match &provider {
4221 AiProvider::HuggingFace => huggingface_embeddings(&api_key, &model, &inputs, &api_base)?,
4222 _ => {
4223 let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
4224 let request = OpenAiEmbeddingRequest {
4225 api_key,
4226 model,
4227 inputs,
4228 dimensions,
4229 api_base,
4230 };
4231 crate::runtime::ai::block_on_ai(async move {
4232 openai_embeddings_async(&transport, request).await
4233 })
4234 .and_then(|result| result)?
4235 }
4236 };
4237
4238 let embeddings_json: Vec<JsonValue> = response
4239 .embeddings
4240 .into_iter()
4241 .map(|vec| {
4242 JsonValue::Array(
4243 vec.into_iter()
4244 .map(|f| JsonValue::Number(f as f64))
4245 .collect(),
4246 )
4247 })
4248 .collect();
4249
4250 let mut obj = Map::new();
4251 obj.insert(
4252 "provider".to_string(),
4253 JsonValue::String(response.provider.to_string()),
4254 );
4255 obj.insert("model".to_string(), JsonValue::String(response.model));
4256 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
4257 if let Some(pt) = response.prompt_tokens {
4258 obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
4259 }
4260 if let Some(tt) = response.total_tokens {
4261 obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
4262 }
4263 Ok(JsonValue::Object(obj))
4264}
4265
4266fn grpc_embeddings_local(
4275 runtime: &crate::runtime::RedDBRuntime,
4276 payload: &JsonValue,
4277) -> crate::RedDBResult<JsonValue> {
4278 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
4279
4280 let model_name = payload
4281 .get("model")
4282 .and_then(|v| v.as_str())
4283 .map(str::trim)
4284 .filter(|s| !s.is_empty())
4285 .ok_or_else(|| {
4286 crate::RedDBError::Query(
4287 "field 'model' is required for the local provider and must be the \
4288 registered local model name (see POST /ai/models)"
4289 .to_string(),
4290 )
4291 })?
4292 .to_string();
4293
4294 let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
4295 let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
4296
4297 let embeddings_json: Vec<JsonValue> = response
4298 .embeddings
4299 .into_iter()
4300 .map(|vec| {
4301 JsonValue::Array(
4302 vec.into_iter()
4303 .map(|f| JsonValue::Number(f as f64))
4304 .collect(),
4305 )
4306 })
4307 .collect();
4308
4309 let mut obj = Map::new();
4310 obj.insert(
4311 "provider".to_string(),
4312 JsonValue::String(response.provider.to_string()),
4313 );
4314 obj.insert("model".to_string(), JsonValue::String(response.name));
4315 obj.insert(
4316 "model_source".to_string(),
4317 JsonValue::String(response.source),
4318 );
4319 obj.insert(
4320 "model_revision".to_string(),
4321 JsonValue::String(response.revision),
4322 );
4323 obj.insert(
4324 "model_engine".to_string(),
4325 JsonValue::String(response.engine),
4326 );
4327 obj.insert(
4328 "dimensions".to_string(),
4329 JsonValue::Number(response.dimensions as f64),
4330 );
4331 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
4332 Ok(JsonValue::Object(obj))
4333}
4334
4335pub fn grpc_prompt(
4337 _runtime: &crate::runtime::RedDBRuntime,
4338 _payload: &JsonValue,
4339) -> crate::RedDBResult<JsonValue> {
4340 Err(crate::RedDBError::FeatureNotEnabled(
4341 "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
4342 ))
4343}
4344
4345pub fn grpc_credentials(
4347 _runtime: &crate::runtime::RedDBRuntime,
4348 _payload: &JsonValue,
4349) -> crate::RedDBResult<JsonValue> {
4350 Err(crate::RedDBError::FeatureNotEnabled(
4351 "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
4352 ))
4353}
4354
4355#[derive(Debug, Clone, Default, PartialEq, Eq)]
4371pub struct OpenAiCompatUsage {
4372 pub input_tokens: Option<u64>,
4373 pub output_tokens: Option<u64>,
4374 pub total_tokens: Option<u64>,
4375}
4376
4377#[derive(Debug, Clone)]
4378pub struct OpenAiCompatChatRequest {
4379 pub api_base: String,
4380 pub api_key: String,
4381 pub model: String,
4382 pub prompt: String,
4383 pub temperature: Option<f32>,
4384 pub seed: Option<u64>,
4385 pub max_output_tokens: Option<usize>,
4386 pub extra_headers: Vec<(String, String)>,
4387}
4388
4389#[derive(Debug, Clone)]
4390pub struct OpenAiCompatChatResponse {
4391 pub model: String,
4392 pub output_text: String,
4393 pub stop_reason: Option<String>,
4394 pub usage: OpenAiCompatUsage,
4395}
4396
4397#[derive(Debug, Clone)]
4398pub struct OpenAiCompatEmbeddingsRequest {
4399 pub api_base: String,
4400 pub api_key: String,
4401 pub model: String,
4402 pub inputs: Vec<String>,
4403 pub dimensions: Option<usize>,
4404 pub extra_headers: Vec<(String, String)>,
4405}
4406
4407#[derive(Debug, Clone)]
4408pub struct OpenAiCompatEmbeddingsResponse {
4409 pub model: String,
4410 pub embeddings: Vec<Vec<f32>>,
4411 pub usage: OpenAiCompatUsage,
4412}
4413
4414fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
4415 headers
4416 .iter()
4417 .map(|(k, v)| (k.as_str(), v.as_str()))
4418 .collect()
4419}
4420
4421pub fn openai_compat_chat(
4429 request: OpenAiCompatChatRequest,
4430) -> RedDBResult<OpenAiCompatChatResponse> {
4431 if request.model.trim().is_empty() {
4432 return Err(RedDBError::Query(
4433 "openai-compat: model cannot be empty".to_string(),
4434 ));
4435 }
4436 if request.prompt.trim().is_empty() {
4437 return Err(RedDBError::Query(
4438 "openai-compat: prompt cannot be empty".to_string(),
4439 ));
4440 }
4441
4442 let url = format!(
4443 "{}/chat/completions",
4444 request.api_base.trim_end_matches('/')
4445 );
4446 let payload = build_openai_prompt_payload(
4447 &request.model,
4448 &request.prompt,
4449 request.temperature,
4450 request.seed,
4451 request.max_output_tokens,
4452 false,
4453 );
4454
4455 let extra = extra_header_refs(&request.extra_headers);
4456 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
4457 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
4458
4459 if !(200..300).contains(&status) {
4460 let message = openai_error_message(&body).unwrap_or_else(|| {
4461 if body.trim().is_empty() {
4462 "openai-compat chat request failed".to_string()
4463 } else {
4464 body.clone()
4465 }
4466 });
4467 return Err(RedDBError::Query(format!(
4468 "openai-compat chat request failed (status {status}): {message}"
4469 )));
4470 }
4471
4472 let parsed = parse_openai_prompt_response(&body, &request.model)?;
4473 Ok(OpenAiCompatChatResponse {
4474 model: parsed.model,
4475 output_text: parsed.output_text,
4476 stop_reason: parsed.stop_reason,
4477 usage: OpenAiCompatUsage {
4478 input_tokens: parsed.prompt_tokens,
4479 output_tokens: parsed.completion_tokens,
4480 total_tokens: parsed.total_tokens,
4481 },
4482 })
4483}
4484
4485pub fn openai_compat_embeddings(
4487 request: OpenAiCompatEmbeddingsRequest,
4488) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
4489 if request.model.trim().is_empty() {
4490 return Err(RedDBError::Query(
4491 "openai-compat: embedding model cannot be empty".to_string(),
4492 ));
4493 }
4494 if request.inputs.is_empty() {
4495 return Err(RedDBError::Query(
4496 "openai-compat: at least one input is required".to_string(),
4497 ));
4498 }
4499
4500 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
4501 let payload =
4502 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
4503
4504 let extra = extra_header_refs(&request.extra_headers);
4505 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
4506 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
4507
4508 if !(200..300).contains(&status) {
4509 let message = openai_error_message(&body).unwrap_or_else(|| {
4510 if body.trim().is_empty() {
4511 "openai-compat embeddings request failed".to_string()
4512 } else {
4513 body.clone()
4514 }
4515 });
4516 return Err(RedDBError::Query(format!(
4517 "openai-compat embeddings request failed (status {status}): {message}"
4518 )));
4519 }
4520
4521 let parsed = parse_openai_embedding_response(&body)?;
4522 Ok(OpenAiCompatEmbeddingsResponse {
4523 model: parsed.model,
4524 embeddings: parsed.embeddings,
4525 usage: OpenAiCompatUsage {
4526 input_tokens: parsed.prompt_tokens,
4527 output_tokens: None,
4528 total_tokens: parsed.total_tokens,
4529 },
4530 })
4531}
4532
4533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4545pub enum AiProviderMode {
4546 OpenAiCompat,
4548 OpenAiNative,
4550 AnthropicNative,
4552}
4553
4554impl AiProviderMode {
4555 pub fn token(&self) -> &'static str {
4556 match self {
4557 Self::OpenAiCompat => "openai-compat",
4558 Self::OpenAiNative => "openai-native",
4559 Self::AnthropicNative => "anthropic-native",
4560 }
4561 }
4562}
4563
4564pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
4566 match name.trim().to_ascii_lowercase().as_str() {
4567 "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
4568 "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
4569 "anthropic-native" | "anthropic_native" | "anthropicnative" => {
4570 Some(AiProviderMode::AnthropicNative)
4571 }
4572 _ => None,
4573 }
4574}
4575
4576pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
4582where
4583 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
4584{
4585 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
4586 if let Some(mode) = parse_provider_mode(&value) {
4587 return Some(mode);
4588 }
4589 }
4590 if let Ok(Some(value)) = kv_getter("ai.provider") {
4591 if let Some(mode) = parse_provider_mode(&value) {
4592 return Some(mode);
4593 }
4594 }
4595 None
4596}
4597
4598pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
4601 match mode {
4602 AiProviderMode::OpenAiNative => AiProvider::OpenAi,
4603 AiProviderMode::AnthropicNative => AiProvider::Anthropic,
4604 AiProviderMode::OpenAiCompat => AiProvider::OpenAiCompat,
4605 }
4606}
4607
4608pub(crate) fn validate_custom_provider_url(url: &str) -> crate::RedDBResult<()> {
4618 let uri = url.parse::<http::Uri>().map_err(|_| {
4619 crate::RedDBError::Query("AI endpoint must be an absolute http(s) URL".to_string())
4620 })?;
4621 if !matches!(uri.scheme_str(), Some("http" | "https"))
4622 || uri.host().is_none_or(str::is_empty)
4623 || uri
4624 .authority()
4625 .is_some_and(|authority| authority.as_str().contains('@'))
4626 || uri.query().is_some()
4627 || url.contains('#')
4628 {
4629 return Err(crate::RedDBError::Query(
4630 "AI endpoint must be an absolute http(s) URL without userinfo, query or fragment"
4631 .to_string(),
4632 ));
4633 }
4634 if std::env::var("REDDB_AI_ALLOW_PRIVATE_PROVIDERS")
4635 .map(|v| matches!(v.trim(), "1" | "true" | "yes"))
4636 .unwrap_or(false)
4637 {
4638 return Ok(());
4639 }
4640 let (plaintext, rest) = match url.strip_prefix("https://") {
4641 Some(rest) => (false, rest),
4642 None => (true, url.strip_prefix("http://").unwrap_or(url)),
4643 };
4644 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
4645 let host_port = authority.rsplit('@').next().unwrap_or(authority);
4646 let host = if let Some(stripped) = host_port.strip_prefix('[') {
4647 stripped.split(']').next().unwrap_or(stripped)
4648 } else {
4649 host_port.split(':').next().unwrap_or(host_port)
4650 };
4651 let host_lower = host.to_ascii_lowercase();
4652 let literal_ip = host.parse::<std::net::IpAddr>().ok();
4653 let loopback = host_lower == "localhost"
4654 || host_lower.ends_with(".localhost")
4655 || literal_ip.is_some_and(|ip| ip.is_loopback());
4656 if plaintext && !loopback {
4657 return Err(crate::RedDBError::Query(format!(
4658 "custom AI provider '{url}' must use https:// (plaintext http is only allowed for loopback hosts)"
4659 )));
4660 }
4661 if let Some(ip) = literal_ip {
4662 let private = match ip {
4663 std::net::IpAddr::V4(v4) => {
4664 v4.is_private()
4665 || v4.is_link_local()
4666 || v4.is_broadcast()
4667 || v4.is_multicast()
4668 || v4.is_unspecified()
4669 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
4670 }
4671 std::net::IpAddr::V6(v6) => {
4672 v6.is_multicast()
4673 || v6.is_unspecified()
4674 || (v6.segments()[0] & 0xfe00) == 0xfc00
4675 || (v6.segments()[0] & 0xffc0) == 0xfe80
4676 }
4677 };
4678 if private && !loopback {
4679 return Err(crate::RedDBError::Query(format!(
4680 "custom AI provider '{url}' targets a private or link-local address; set REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1 to allow"
4681 )));
4682 }
4683 }
4684 Ok(())
4685}