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::auth::{AuthConfig, AuthStore};
2215 use crate::{RedDBOptions, RedDBRuntime, StorageDeployPreset};
2216 let path = std::env::temp_dir().join(format!(
2217 "reddb_gateway_roundtrip_{}_{}.rdb",
2218 std::process::id(),
2219 std::time::SystemTime::now()
2220 .duration_since(std::time::UNIX_EPOCH)
2221 .expect("clock")
2222 .as_nanos(),
2223 ));
2224 let runtime = RedDBRuntime::with_options(
2225 RedDBOptions::persistent(&path)
2226 .with_storage_profile(StorageDeployPreset::Serverless.selection())
2227 .expect("profile"),
2228 )
2229 .expect("runtime");
2230 let db = runtime.db();
2231 let store = db.store();
2232 let pager = store.pager().expect("pager");
2233 let auth = Arc::new(
2234 AuthStore::with_vault_certificate(
2235 AuthConfig::default(),
2236 Arc::clone(pager),
2237 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
2238 )
2239 .expect("vault"),
2240 );
2241 let server = crate::server::RedDBServer::new(runtime.clone()).with_auth(auth);
2242 let embedding_body =
2243 r#"{"data":[{"index":0,"embedding":[0.1,0.2]}],"model":"Gateway/Embedding"}"#;
2244 for operation in [
2245 "prompt",
2246 "ask",
2247 "http-embeddings",
2248 "grpc-embeddings",
2249 "batch",
2250 "auto-embed",
2251 ] {
2252 let response_body = if matches!(operation, "prompt" | "ask") {
2253 r#"{"choices":[{"message":{"content":"hello"}}],"model":"Gateway/Chat"}"#
2254 } else {
2255 embedding_body
2256 };
2257 let (base, captured) = spawn_mock(200, response_body);
2258 let endpoint = format!("{base}/Gateway/v1");
2259 let registration = format!(
2260 r#"{{"provider":"red-router","alias":"mine","api_key":"synthetic-gateway-key","endpoint":"{endpoint}","default":true,"model":"Gateway/Chat"}}"#
2261 );
2262 let response = server.handle_ai_credentials(registration.into_bytes());
2263 assert_eq!(
2264 response.status,
2265 200,
2266 "{}",
2267 String::from_utf8_lossy(&response.body)
2268 );
2269 assert!(!String::from_utf8_lossy(&response.body).contains("synthetic-gateway-key"));
2270 match operation {
2271 "prompt" => {
2272 let response = server.handle_ai_prompt(br#"{"prompt":"hi"}"#.to_vec());
2273 assert_eq!(
2274 response.status,
2275 200,
2276 "{}",
2277 String::from_utf8_lossy(&response.body)
2278 );
2279 }
2280 "ask" => {
2281 let response = server.handle_ai_ask(br#"{"question":"hi"}"#.to_vec());
2282 assert_eq!(
2283 response.status,
2284 200,
2285 "{}",
2286 String::from_utf8_lossy(&response.body)
2287 );
2288 }
2289 "auto-embed" => {
2290 runtime.execute_query("INSERT INTO gateway_batch (body) VALUES ('hi') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'").expect("auto embed");
2291 let rows = runtime
2292 .execute_query("SELECT * FROM gateway_batch")
2293 .expect("rows");
2294 assert_eq!(
2295 rows.result.records.len(),
2296 2,
2297 "one row and its embedding vector"
2298 );
2299 }
2300 "http-embeddings" => {
2301 let response = server.handle_ai_embeddings(
2302 br#"{"model":"Gateway/Embedding","input":"hi"}"#.to_vec(),
2303 );
2304 assert_eq!(
2305 response.status,
2306 200,
2307 "{}",
2308 String::from_utf8_lossy(&response.body)
2309 );
2310 }
2311 "grpc-embeddings" => {
2312 let payload =
2313 crate::serde_json::json!({"model":"Gateway/Embedding","input":"hi"});
2314 grpc_embeddings(&runtime, &payload).expect("gRPC embedding");
2315 }
2316 "batch" => {
2317 let connection =
2318 resolve_connection_from_runtime(&AiProvider::RedRouter, None, &runtime)
2319 .expect("connection");
2320 let client =
2321 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(&runtime);
2322 let vectors = client
2323 .embed_batch_at(
2324 &AiProvider::RedRouter,
2325 "Gateway/Embedding",
2326 &connection.api_key,
2327 &connection.api_base,
2328 vec!["hi".to_string()],
2329 )
2330 .await
2331 .expect("batch");
2332 assert_eq!(vectors, vec![vec![0.1, 0.2]]);
2333 }
2334 _ => unreachable!("listed operation"),
2335 }
2336 let request = captured
2337 .lock()
2338 .expect("capture lock")
2339 .take()
2340 .expect("request");
2341 let suffix = if matches!(operation, "prompt" | "ask") {
2342 "chat/completions"
2343 } else {
2344 "embeddings"
2345 };
2346 assert_eq!(request.path, format!("/Gateway/v1/{suffix}"));
2347 assert!(request
2348 .headers
2349 .iter()
2350 .any(|(name, value)| name.eq_ignore_ascii_case("authorization")
2351 && value == "Bearer synthetic-gateway-key"));
2352 let model = if matches!(operation, "prompt" | "ask") {
2353 "Gateway/Chat"
2354 } else {
2355 "Gateway/Embedding"
2356 };
2357 assert!(request.body.contains(model), "{}", request.body);
2358 }
2359 drop(server);
2360 drop(runtime);
2361 drop(db);
2362 let _ = std::fs::remove_file(path);
2363 }
2364
2365 #[test]
2366 fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
2367 let body = r#"{
2368 "id":"chatcmpl_x",
2369 "model":"custom-model",
2370 "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
2371 "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
2372 }"#;
2373 let (base, captured) = spawn_mock(200, body);
2374
2375 let req = OpenAiCompatChatRequest {
2376 api_base: base.clone(),
2377 api_key: "sk-test".to_string(),
2378 model: "custom-model".to_string(),
2379 prompt: "say hi".to_string(),
2380 temperature: None,
2381 seed: None,
2382 max_output_tokens: None,
2383 extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
2384 };
2385 let resp = openai_compat_chat(req).expect("ok");
2386
2387 assert_eq!(resp.output_text, "hi");
2388 assert_eq!(resp.model, "custom-model");
2389 assert_eq!(resp.usage.input_tokens, Some(7));
2390 assert_eq!(resp.usage.output_tokens, Some(2));
2391 assert_eq!(resp.usage.total_tokens, Some(9));
2392 assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
2393
2394 let cap = captured.lock().unwrap().take().expect("captured");
2395 assert_eq!(cap.method, "POST");
2396 assert_eq!(cap.path, "/chat/completions");
2397 let has_auth = cap
2398 .headers
2399 .iter()
2400 .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
2401 assert!(has_auth, "Authorization header missing");
2402 let has_custom = cap
2403 .headers
2404 .iter()
2405 .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
2406 assert!(has_custom, "extra header missing");
2407 assert!(cap.body.contains("\"model\":\"custom-model\""));
2408 }
2409
2410 #[test]
2411 fn openai_compat_embeddings_roundtrip_with_dimensions() {
2412 let body = r#"{
2413 "object":"list",
2414 "model":"embed-model",
2415 "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
2416 "usage":{"prompt_tokens":4,"total_tokens":4}
2417 }"#;
2418 let (base, captured) = spawn_mock(200, body);
2419
2420 let req = OpenAiCompatEmbeddingsRequest {
2421 api_base: base,
2422 api_key: "sk-emb".to_string(),
2423 model: "embed-model".to_string(),
2424 inputs: vec!["hello".to_string()],
2425 dimensions: Some(2),
2426 extra_headers: vec![],
2427 };
2428 let resp = openai_compat_embeddings(req).expect("ok");
2429
2430 assert_eq!(resp.embeddings.len(), 1);
2431 assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
2432 assert_eq!(resp.usage.total_tokens, Some(4));
2433 assert_eq!(resp.usage.input_tokens, Some(4));
2434
2435 let cap = captured.lock().unwrap().take().expect("captured");
2436 assert_eq!(cap.path, "/embeddings");
2437 assert!(cap.body.contains("\"dimensions\":2"));
2438 }
2439
2440 #[test]
2441 fn openai_compat_chat_non_2xx_returns_structured_error() {
2442 let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
2443 let (base, _captured) = spawn_mock(401, body);
2444
2445 let req = OpenAiCompatChatRequest {
2446 api_base: base,
2447 api_key: "bad".to_string(),
2448 model: "m".to_string(),
2449 prompt: "hi".to_string(),
2450 temperature: None,
2451 seed: None,
2452 max_output_tokens: None,
2453 extra_headers: vec![],
2454 };
2455 let err = openai_compat_chat(req).unwrap_err().to_string();
2456 assert!(err.contains("status 401"), "got: {err}");
2457 assert!(err.contains("bad api key"), "got: {err}");
2458 }
2459
2460 #[test]
2461 fn openai_compat_chat_rejects_empty_model_and_prompt() {
2462 let req = OpenAiCompatChatRequest {
2463 api_base: "http://localhost:1".to_string(),
2464 api_key: "k".to_string(),
2465 model: " ".to_string(),
2466 prompt: "hi".to_string(),
2467 temperature: None,
2468 seed: None,
2469 max_output_tokens: None,
2470 extra_headers: vec![],
2471 };
2472 let err = openai_compat_chat(req).unwrap_err().to_string();
2473 assert!(err.contains("model cannot be empty"), "got: {err}");
2474
2475 let req = OpenAiCompatChatRequest {
2476 api_base: "http://localhost:1".to_string(),
2477 api_key: "k".to_string(),
2478 model: "m".to_string(),
2479 prompt: " ".to_string(),
2480 temperature: None,
2481 seed: None,
2482 max_output_tokens: None,
2483 extra_headers: vec![],
2484 };
2485 let err = openai_compat_chat(req).unwrap_err().to_string();
2486 assert!(err.contains("prompt cannot be empty"), "got: {err}");
2487 }
2488
2489 #[test]
2490 fn parse_provider_mode_recognizes_all_three_tokens() {
2491 assert_eq!(
2492 parse_provider_mode("openai-compat"),
2493 Some(AiProviderMode::OpenAiCompat)
2494 );
2495 assert_eq!(
2496 parse_provider_mode("OPENAI_NATIVE"),
2497 Some(AiProviderMode::OpenAiNative)
2498 );
2499 assert_eq!(
2500 parse_provider_mode("anthropic-native"),
2501 Some(AiProviderMode::AnthropicNative)
2502 );
2503 assert_eq!(parse_provider_mode("groq"), None);
2504 }
2505
2506 #[test]
2507 fn resolve_provider_mode_reads_kv_key() {
2508 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2509 if key == "red.config.ai.provider" {
2510 Ok(Some("anthropic-native".to_string()))
2511 } else {
2512 Ok(None)
2513 }
2514 };
2515 assert_eq!(
2516 resolve_provider_mode(&kv),
2517 Some(AiProviderMode::AnthropicNative)
2518 );
2519 }
2520
2521 #[test]
2522 fn resolve_default_provider_keeps_vendor_when_mode_is_configured() {
2523 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2525 match key {
2526 "red.config.ai.provider" => Ok(Some("anthropic-native".to_string())),
2527 "red.config.ai.inference.provider" => Ok(Some("groq".to_string())),
2528 _ => Ok(None),
2529 }
2530 };
2531 assert_eq!(
2532 resolve_default_provider(&kv).expect("provider"),
2533 AiProvider::Groq
2534 );
2535 }
2536
2537 #[test]
2538 fn auto_embed_missing_gateway_connection_fails_before_row_write() {
2539 let runtime = crate::RedDBRuntime::in_memory().expect("runtime");
2540 runtime
2541 .execute_query("CREATE TABLE gateway_docs (id INT, body TEXT)")
2542 .expect("table");
2543 let error = runtime.execute_query(
2544 "INSERT INTO gateway_docs (id, body) VALUES (1, 'hello') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'",
2545 ).expect_err("missing connection");
2546 assert!(error.to_string().contains("endpoint"), "{error}");
2547 let result = runtime
2548 .execute_query("SELECT * FROM gateway_docs")
2549 .expect("rows");
2550 assert!(
2551 result.result.records.is_empty(),
2552 "invalid connection must leave no rows"
2553 );
2554 }
2555
2556 #[test]
2557 fn gateway_connections_fail_closed_when_either_part_is_missing() {
2558 let provider = parse_provider("red-router").expect("router");
2559 let no_endpoint = |key: &str| -> crate::RedDBResult<Option<String>> {
2560 assert!(
2561 !key.starts_with("red.secret."),
2562 "validate endpoint before reading secrets"
2563 );
2564 Ok(None)
2565 };
2566 let err = resolve_connection(&provider, None, &no_endpoint)
2567 .err()
2568 .expect("endpoint required");
2569 assert!(err.to_string().contains("endpoint"));
2570 let endpoint_only = |key: &str| -> crate::RedDBResult<Option<String>> {
2571 Ok((key == "red.config.ai.providers.red-router.base_url")
2572 .then(|| "https://example.com/v1".to_string()))
2573 };
2574 let err = resolve_connection(&provider, None, &endpoint_only)
2575 .err()
2576 .expect("key required");
2577 assert!(err.to_string().contains("API key"));
2578 }
2579
2580 #[test]
2581 fn gateway_connections_preserve_endpoint_and_selected_credential() {
2582 let provider = parse_provider("openai-compat").expect("gateway");
2583 let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2584 Ok(match key {
2585 "red.config.ai.providers.openai-compat.base_url" => {
2586 Some("https://example.com/Gateway/v1".to_string())
2587 }
2588 "red.config.ai.providers.openai-compat.credential" => Some("my-key".to_string()),
2589 "red.secret.ai.providers.openai-compat.tokens.my-key" => {
2590 Some("synthetic-key".to_string())
2591 }
2592 _ => None,
2593 })
2594 };
2595 let connection = resolve_connection(&provider, None, &getter).expect("connection");
2596 assert_eq!(connection.api_base, "https://example.com/Gateway/v1");
2597 assert_eq!(connection.api_key, "synthetic-key");
2598 }
2599
2600 #[test]
2601 fn endpoint_validation_rejects_malformed_and_credential_bearing_urls() {
2602 for endpoint in [
2603 "local",
2604 "ftp://example.com/v1",
2605 "https://",
2606 "https://user:pass@example.com/v1",
2607 "https://example.com/v1?key=secret",
2608 "https://example.com/v1#fragment",
2609 ] {
2610 assert!(
2611 validate_custom_provider_url(endpoint).is_err(),
2612 "{endpoint}"
2613 );
2614 }
2615 assert!(validate_custom_provider_url("http://127.0.0.1:8000/MyGateway/v1").is_ok());
2616 }
2617
2618 #[test]
2619 fn gateway_provider_names_and_url_paths_keep_their_identity() {
2620 for name in ["openai-compat", "red-router"] {
2621 let provider = parse_provider(name).expect("named gateway");
2622 assert_eq!(provider.token(), name);
2623 assert!(provider.is_openai_compatible());
2624 assert!(provider.requires_api_key());
2625 assert_eq!(provider.default_api_base(), "");
2626 assert!(!provider.default_key_env_name().contains('-'));
2627 }
2628 let url = "https://example.com/MyGateway/v1";
2629 let provider = parse_provider(url).expect("URL provider");
2630 assert_eq!(provider.default_api_base(), url);
2631 }
2632
2633 #[test]
2634 fn implicit_credentials_reject_non_ai_secret_refs_before_reading_secret() {
2635 let provider = AiProvider::Custom("invalid_secret_ref_test".to_string());
2636 let key = ai_api_secret_ref_config_key(&provider, "default");
2637 let err = resolve_api_key(&provider, None, |path| {
2638 if path == key {
2639 return Ok(Some("red.secret.aes_key".to_string()));
2640 }
2641 assert_ne!(path, "red.secret.aes_key", "must not read engine secret");
2642 Ok(None)
2643 })
2644 .expect_err("non-AI secret ref must fail");
2645 assert!(err.to_string().contains("must reference"));
2646 }
2647
2648 #[test]
2651 fn invalid_provider_selection_does_not_fall_back_to_vendor_credentials() {
2652 let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2653 Ok((key == "red.config.ai.inference.provider").then(|| "typo-provider".to_string()))
2654 };
2655 assert!(resolve_default_provider(&getter).is_err());
2656 assert!(resolve_embeddings_provider(&getter).is_err());
2657 }
2658
2659 #[test]
2660 fn inference_provider_ask_specific_beats_task_pointer() {
2661 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2662 match key {
2663 "red.config.ai.ask.provider" => Ok(Some("groq".to_string())),
2664 "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
2665 _ => Ok(None),
2666 }
2667 };
2668 assert_eq!(
2669 resolve_default_provider(&kv).expect("provider"),
2670 AiProvider::Groq
2671 );
2672 }
2673
2674 #[test]
2675 fn inference_provider_falls_through_to_task_pointer_then_default() {
2676 let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
2677 match key {
2678 "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
2679 _ => Ok(None),
2680 }
2681 };
2682 assert_eq!(
2683 resolve_default_provider(&pointer).expect("provider"),
2684 AiProvider::DeepSeek
2685 );
2686
2687 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2688 assert_eq!(
2689 resolve_default_provider(&empty).expect("provider"),
2690 AiProvider::OpenAi
2691 );
2692 }
2693
2694 #[test]
2695 fn inference_model_ask_specific_beats_models_block_beats_builtin() {
2696 let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
2697 match key {
2698 "red.config.ai.ask.model" => Ok(Some("gpt-ask".to_string())),
2699 "red.config.ai.providers.openai.models.inference" => {
2700 Ok(Some("gpt-block".to_string()))
2701 }
2702 _ => Ok(None),
2703 }
2704 };
2705 assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
2706
2707 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2708 match key {
2709 "red.config.ai.providers.openai.models.inference" => {
2710 Ok(Some("gpt-block".to_string()))
2711 }
2712 _ => Ok(None),
2713 }
2714 };
2715 assert_eq!(
2716 resolve_default_model(&AiProvider::OpenAi, &block),
2717 "gpt-block"
2718 );
2719
2720 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2721 assert_eq!(
2722 resolve_default_model(&AiProvider::OpenAi, &empty),
2723 AiProvider::OpenAi.default_prompt_model()
2724 );
2725 }
2726
2727 #[test]
2728 fn embeddings_provider_follows_task_pointer() {
2729 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2730 match key {
2731 "red.config.ai.embeddings.provider" => Ok(Some("ollama".to_string())),
2732 _ => Ok(None),
2733 }
2734 };
2735 assert_eq!(
2736 resolve_embeddings_provider(&kv).unwrap(),
2737 AiProvider::Ollama
2738 );
2739
2740 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2741 assert_eq!(
2742 resolve_embeddings_provider(&empty).unwrap(),
2743 AiProvider::OpenAi
2744 );
2745 }
2746
2747 #[test]
2748 fn embeddings_selection_rejects_chat_only_providers_and_accepts_routers() {
2749 for token in ["anthropic", "groq", "deepseek"] {
2750 let provider = parse_provider(token).expect("provider");
2751 assert!(!provider.supports_embeddings());
2752 assert!(ensure_provider_supports_embeddings(&provider).is_err());
2753 }
2754 for token in ["openrouter", "venice", "openai-compat", "red-router"] {
2755 let provider = parse_provider(token).expect("provider");
2756 assert!(ensure_provider_supports_embeddings(&provider).is_ok());
2757 }
2758 }
2759
2760 #[test]
2761 fn embeddings_provider_rejects_modality_incapable_pointer() {
2762 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2763 match key {
2764 "red.config.ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
2765 _ => Ok(None),
2766 }
2767 };
2768 let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
2769 assert!(
2770 err.contains("red.config.ai.embeddings.provider"),
2771 "error must name the pointer to fix: {err}"
2772 );
2773 assert!(
2774 err.contains("openai") && err.contains("no embeddings API"),
2775 "error must name capable alternatives: {err}"
2776 );
2777 }
2778
2779 #[test]
2780 fn embeddings_provider_falls_back_to_default_provider_chain() {
2781 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2787 match key {
2788 "red.config.ai.inference.provider" => Ok(Some("ollama".to_string())),
2789 _ => Ok(None),
2790 }
2791 };
2792 assert_eq!(
2793 resolve_embeddings_provider(&kv).unwrap(),
2794 AiProvider::Ollama
2795 );
2796 }
2797
2798 #[test]
2799 fn embeddings_model_block_beats_builtin() {
2800 let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2801 match key {
2802 "red.config.ai.providers.openai.models.embeddings" => {
2803 Ok(Some("text-embedding-custom".to_string()))
2804 }
2805 _ => Ok(None),
2806 }
2807 };
2808 assert_eq!(
2809 resolve_embeddings_model(&AiProvider::OpenAi, &block),
2810 "text-embedding-custom"
2811 );
2812
2813 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2814 assert_eq!(
2815 resolve_embeddings_model(&AiProvider::Ollama, &empty),
2816 AiProvider::Ollama.default_embedding_model()
2817 );
2818 }
2819
2820 #[test]
2821 fn base_url_reads_provider_block_key() {
2822 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2823 if key == "red.config.ai.providers.openai.base_url" {
2824 Ok(Some("https://proxy.example/v1".to_string()))
2825 } else {
2826 Ok(None)
2827 }
2828 };
2829 assert_eq!(
2830 AiProvider::OpenAi
2831 .resolve_api_base_checked(&kv)
2832 .expect("endpoint"),
2833 "https://proxy.example/v1"
2834 );
2835 }
2836
2837 #[test]
2838 fn removed_config_keys_rejected_naming_replacements() {
2839 let err = validate_ai_config_key_on_write("red.config.ai.default.provider")
2840 .unwrap_err()
2841 .to_string();
2842 assert!(err.contains("red.config.ai.inference.provider"), "{err}");
2843
2844 let err = validate_ai_config_key_on_write("red.config.ai.default.model")
2845 .unwrap_err()
2846 .to_string();
2847 assert!(
2848 err.contains("red.config.ai.providers.<provider>.models.inference"),
2849 "{err}"
2850 );
2851
2852 let err = validate_ai_config_key_on_write("red.config.ai.openai.default.base_url")
2853 .unwrap_err()
2854 .to_string();
2855 assert!(
2856 err.contains("red.config.ai.providers.<provider>.base_url"),
2857 "{err}"
2858 );
2859
2860 assert!(validate_ai_config_key_on_write("red.config.ai.inference.provider").is_ok());
2862 assert!(validate_ai_config_key_on_write("red.config.ai.providers.openai.base_url").is_ok());
2863 assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
2864 }
2865
2866 #[test]
2867 fn ask_planner_model_and_effort_resolve() {
2868 let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2869 match key {
2870 "red.config.ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
2871 "red.config.ai.ask.effort" => Ok(Some("high".to_string())),
2872 _ => Ok(None),
2873 }
2874 };
2875 assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
2876 assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
2877
2878 let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2879 assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
2880 assert_eq!(resolve_ask_effort(&empty), None);
2881 }
2882
2883 #[tokio::test]
2884 async fn anthropic_prompt_async_rejects_empty_api_key() {
2885 let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2886 let request = AnthropicPromptRequest {
2887 api_key: " ".to_string(),
2888 model: "claude-3-5-haiku-latest".to_string(),
2889 prompt: "hello".to_string(),
2890 temperature: None,
2891 max_output_tokens: None,
2892 api_base: "https://api.anthropic.com/v1".to_string(),
2893 anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
2894 };
2895 let err = anthropic_prompt_async(&transport, request)
2896 .await
2897 .unwrap_err();
2898 assert!(err.to_string().contains("API key cannot be empty"));
2899 }
2900}
2901
2902#[derive(Debug, Clone, PartialEq, Eq)]
2908pub enum AiProvider {
2909 OpenAi,
2910 Anthropic,
2911 Groq,
2912 OpenRouter,
2913 OpenAiCompat,
2914 RedRouter,
2915 Together,
2916 Venice,
2917 Ollama,
2918 DeepSeek,
2919 MiniMax,
2920 HuggingFace,
2921 Local,
2922 Custom(String),
2923}
2924
2925impl AiProvider {
2926 pub fn token(&self) -> &str {
2927 match self {
2928 Self::OpenAi => "openai",
2929 Self::Anthropic => "anthropic",
2930 Self::Groq => "groq",
2931 Self::OpenRouter => "openrouter",
2932 Self::OpenAiCompat => "openai-compat",
2933 Self::RedRouter => "red-router",
2934 Self::Together => "together",
2935 Self::Venice => "venice",
2936 Self::Ollama => "ollama",
2937 Self::DeepSeek => "deepseek",
2938 Self::MiniMax => "minimax",
2939 Self::HuggingFace => "huggingface",
2940 Self::Local => "local",
2941 Self::Custom(name) => name.as_str(),
2942 }
2943 }
2944
2945 pub fn default_prompt_model(&self) -> &str {
2946 match self {
2947 Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
2948 Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
2949 Self::Groq => "llama-3.3-70b-versatile",
2950 Self::OpenRouter => "auto",
2951 Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
2952 Self::Venice => "llama-3.3-70b",
2953 Self::Ollama => "llama3",
2954 Self::DeepSeek => "deepseek-chat",
2955 Self::MiniMax => "abab6.5s-chat",
2956 Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
2957 Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2958 Self::Custom(_) | Self::OpenAiCompat | Self::RedRouter => DEFAULT_OPENAI_PROMPT_MODEL,
2959 }
2960 }
2961
2962 pub fn prompt_model_env_name(&self) -> String {
2963 format!(
2964 "REDDB_{}_PROMPT_MODEL",
2965 self.token().replace('-', "_").to_ascii_uppercase()
2966 )
2967 }
2968
2969 pub fn default_embedding_model(&self) -> &str {
2970 match self {
2971 Self::Ollama => "nomic-embed-text",
2972 Self::OpenRouter => "openai/text-embedding-3-small",
2973 Self::Venice => "text-embedding-bge-m3",
2974 Self::MiniMax => "embo-01",
2975 Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2976 _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
2977 }
2978 }
2979
2980 pub fn default_api_base(&self) -> &str {
2981 match self {
2982 Self::OpenAi => DEFAULT_OPENAI_API_BASE,
2983 Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
2984 Self::Groq => "https://api.groq.com/openai/v1",
2985 Self::OpenRouter => "https://openrouter.ai/api/v1",
2986 Self::Together => "https://api.together.xyz/v1",
2987 Self::Venice => "https://api.venice.ai/api/v1",
2988 Self::Ollama => "http://localhost:11434/v1",
2989 Self::DeepSeek => "https://api.deepseek.com/v1",
2990 Self::MiniMax => "https://api.minimax.chat/v1",
2991 Self::HuggingFace => "https://api-inference.huggingface.co",
2992 Self::Local => "local",
2993 Self::Custom(base) => base.as_str(),
2994 Self::OpenAiCompat | Self::RedRouter => "",
2995 }
2996 }
2997
2998 pub fn api_base_env_name(&self) -> String {
2999 format!(
3000 "REDDB_{}_API_BASE",
3001 self.token().replace('-', "_").to_ascii_uppercase()
3002 )
3003 }
3004
3005 pub fn default_key_env_name(&self) -> String {
3006 format!(
3007 "REDDB_{}_API_KEY",
3008 self.token().replace('-', "_").to_ascii_uppercase()
3009 )
3010 }
3011
3012 pub fn alias_key_env_name(&self, alias: &str) -> String {
3013 let normalized = normalize_alias_token(alias);
3014 format!(
3015 "REDDB_{}_API_KEY_{normalized}",
3016 self.token().replace('-', "_").to_ascii_uppercase()
3017 )
3018 }
3019
3020 pub fn resolve_api_base(&self) -> String {
3021 if let Ok(value) = std::env::var(self.api_base_env_name()) {
3022 let value = value.trim().to_string();
3023 if !value.is_empty() {
3024 return value;
3025 }
3026 }
3027 self.default_api_base().to_string()
3028 }
3029
3030 pub fn resolve_api_base_checked<F>(&self, kv_getter: &F) -> crate::RedDBResult<String>
3032 where
3033 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3034 {
3035 let endpoint = match std::env::var(self.api_base_env_name()) {
3036 Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
3037 _ => kv_getter(&provider_base_url_key(self))?
3038 .filter(|value| !value.trim().is_empty())
3039 .map(|value| value.trim().to_string())
3040 .unwrap_or_else(|| self.default_api_base().to_string()),
3041 };
3042 if !matches!(self, Self::Local) {
3043 if endpoint.is_empty() {
3044 return Err(crate::RedDBError::Query(format!(
3045 "missing {} endpoint; set {} or '{}' alongside the API key",
3046 self.token(),
3047 self.api_base_env_name(),
3048 provider_base_url_key(self),
3049 )));
3050 }
3051 validate_custom_provider_url(&endpoint)?;
3052 }
3053 Ok(endpoint)
3054 }
3055
3056 pub fn requires_explicit_endpoint(&self) -> bool {
3058 matches!(self, Self::OpenAiCompat | Self::RedRouter | Self::Custom(_))
3059 }
3060
3061 pub fn is_openai_compatible(&self) -> bool {
3063 matches!(
3064 self,
3065 Self::OpenAi
3066 | Self::Groq
3067 | Self::OpenRouter
3068 | Self::OpenAiCompat
3069 | Self::RedRouter
3070 | Self::Together
3071 | Self::Venice
3072 | Self::Ollama
3073 | Self::DeepSeek
3074 | Self::MiniMax
3075 | Self::Custom(_)
3076 )
3077 }
3078
3079 pub fn requires_api_key(&self) -> bool {
3081 !matches!(self, Self::Ollama | Self::Local)
3082 }
3083
3084 pub fn supports_embeddings(&self) -> bool {
3086 crate::runtime::ai::provider_capabilities::Modalities::for_provider(self.token()).embed
3087 }
3088}
3089
3090pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
3092 let name = name.trim();
3093 match name.to_ascii_lowercase().as_str() {
3094 "openai" => Ok(AiProvider::OpenAi),
3095 "anthropic" => Ok(AiProvider::Anthropic),
3096 "groq" => Ok(AiProvider::Groq),
3097 "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
3098 "openai-compat" | "openai_compat" => Ok(AiProvider::OpenAiCompat),
3099 "red-router" | "red_router" => Ok(AiProvider::RedRouter),
3100 "together" => Ok(AiProvider::Together),
3101 "venice" => Ok(AiProvider::Venice),
3102 "ollama" => Ok(AiProvider::Ollama),
3103 "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
3104 "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
3105 "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
3106 "local" => Ok(AiProvider::Local),
3107 other => {
3108 if other.starts_with("http://") || other.starts_with("https://") {
3110 validate_custom_provider_url(name)?;
3111 Ok(AiProvider::Custom(name.to_string()))
3112 } else {
3113 Err(crate::RedDBError::Query(format!(
3114 "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
3115 openrouter, openai-compat, red-router, together, venice, ollama, deepseek, minimax, huggingface, local"
3116 )))
3117 }
3118 }
3119 }
3120}
3121
3122pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
3142 "openai, ollama, openrouter, openai-compat, red-router, together, venice, minimax, huggingface, local";
3143
3144pub fn provider_base_url_key(provider: &AiProvider) -> String {
3146 format!("red.config.ai.providers.{}.base_url", provider.token())
3147}
3148
3149pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
3152 format!(
3153 "red.config.ai.providers.{}.models.{modality}",
3154 provider.token()
3155 )
3156}
3157
3158pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
3163 let key = key.trim();
3164 if key == "red.config.ai.default.provider" {
3165 return Err(crate::RedDBError::Query(
3166 "AI config key 'red.config.ai.default.provider' was removed in the ADR-0068 \
3167 clean break; set the task pointer 'red.config.ai.inference.provider' (or \
3168 'red.config.ai.ask.provider' for the ASK planner) instead"
3169 .to_string(),
3170 ));
3171 }
3172 if key == "red.config.ai.default.model" {
3173 return Err(crate::RedDBError::Query(
3174 "AI config key 'red.config.ai.default.model' was removed in the ADR-0068 clean \
3175 break; set 'red.config.ai.ask.model' or \
3176 'red.config.ai.providers.<provider>.models.inference' instead"
3177 .to_string(),
3178 ));
3179 }
3180 if key.starts_with("red.config.ai.")
3184 && key.ends_with(".base_url")
3185 && !key.starts_with("red.config.ai.providers.")
3186 {
3187 return Err(crate::RedDBError::Query(format!(
3188 "AI config key '{key}' uses the removed per-credential base-URL shape; set \
3189 'red.config.ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
3190 )));
3191 }
3192 Ok(())
3193}
3194
3195pub fn resolve_default_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
3202where
3203 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3204{
3205 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
3206 if !value.trim().is_empty() {
3207 return parse_provider(&value);
3208 }
3209 }
3210 for key in [
3211 "red.config.ai.ask.provider",
3212 "red.config.ai.inference.provider",
3213 ] {
3214 if let Some(value) = kv_getter(key)? {
3215 if !value.trim().is_empty() {
3216 return parse_provider(&value);
3217 }
3218 }
3219 }
3220 Ok(resolve_provider_mode(kv_getter)
3221 .map(provider_mode_to_provider)
3222 .unwrap_or(AiProvider::OpenAi))
3223}
3224
3225pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
3232where
3233 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3234{
3235 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
3237 let value = value.trim().to_string();
3238 if !value.is_empty() {
3239 return value;
3240 }
3241 }
3242 if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
3244 let value = value.trim().to_string();
3245 if !value.is_empty() {
3246 return value;
3247 }
3248 }
3249 for key in [
3251 "red.config.ai.ask.model".to_string(),
3252 provider_models_key(provider, "inference"),
3253 ] {
3254 if let Ok(Some(value)) = kv_getter(&key) {
3255 let value = value.trim().to_string();
3256 if !value.is_empty() {
3257 return value;
3258 }
3259 }
3260 }
3261 provider.default_prompt_model().to_string()
3262}
3263
3264pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
3276where
3277 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3278{
3279 let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
3280 .ok()
3281 .map(|v| v.trim().to_string())
3282 .filter(|v| !v.is_empty())
3283 {
3284 parse_provider(&value)?
3285 } else if let Some(value) = kv_getter("red.config.ai.embeddings.provider")?
3286 .map(|v| v.trim().to_string())
3287 .filter(|v| !v.is_empty())
3288 {
3289 parse_provider(&value)?
3290 } else {
3291 resolve_default_provider(kv_getter)?
3295 };
3296 ensure_provider_supports_embeddings(&provider)?;
3297 Ok(provider)
3298}
3299
3300pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
3302 if provider.supports_embeddings() {
3303 return Ok(());
3304 }
3305 Err(crate::RedDBError::Query(format!(
3306 "the embeddings task pointer 'red.config.ai.embeddings.provider' names '{}', which \
3307 has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
3308 re-routes embeddings to a different provider than the one you named.",
3309 provider.token(),
3310 EMBEDDING_CAPABLE_PROVIDERS
3311 )))
3312}
3313
3314pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
3327where
3328 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3329{
3330 if let Ok(value) = std::env::var(format!(
3331 "REDDB_{}_EMBEDDING_MODEL",
3332 provider.token().replace('-', "_").to_ascii_uppercase()
3333 )) {
3334 let value = value.trim().to_string();
3335 if !value.is_empty() {
3336 return value;
3337 }
3338 }
3339 if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
3340 let value = value.trim().to_string();
3341 if !value.is_empty() {
3342 return value;
3343 }
3344 }
3345 if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
3346 let value = value.trim().to_string();
3347 if !value.is_empty() {
3348 return value;
3349 }
3350 }
3351 if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
3352 let value = value.trim().to_string();
3353 if !value.is_empty() {
3354 return value;
3355 }
3356 }
3357 provider.default_embedding_model().to_string()
3358}
3359
3360pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
3364where
3365 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3366{
3367 if let Ok(Some(value)) = kv_getter("red.config.ai.ask.planner_model") {
3368 let value = value.trim().to_string();
3369 if !value.is_empty() {
3370 return value;
3371 }
3372 }
3373 fallback_model.to_string()
3374}
3375
3376pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
3379where
3380 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3381{
3382 if let Ok(Some(value)) = kv_getter("red.config.ai.ask.effort") {
3383 let value = value.trim().to_string();
3384 if !value.is_empty() {
3385 return Some(value);
3386 }
3387 }
3388 None
3389}
3390
3391pub fn resolve_defaults_from_runtime(
3393 runtime: &crate::runtime::RedDBRuntime,
3394) -> crate::RedDBResult<(AiProvider, String)> {
3395 use crate::application::ports::RuntimeEntityPort;
3396 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3397 match runtime.get_kv("red_config", key)? {
3398 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3399 _ => Ok(None),
3400 }
3401 };
3402 let provider = resolve_default_provider(&kv_getter)?;
3403 let model = resolve_default_model(&provider, &kv_getter);
3404 Ok((provider, model))
3405}
3406
3407pub fn resolve_prompt_model_from_runtime(
3408 runtime: &crate::runtime::RedDBRuntime,
3409 provider: &AiProvider,
3410 explicit: Option<&str>,
3411) -> String {
3412 use crate::application::ports::RuntimeEntityPort;
3413 if let Some(model) = explicit {
3414 return model.to_string();
3415 }
3416 resolve_default_model(provider, &|key| match runtime.get_kv("red_config", key)? {
3417 Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3418 _ => Ok(None),
3419 })
3420}
3421
3422pub fn resolve_ask_planner_model_from_runtime(
3427 runtime: &crate::runtime::RedDBRuntime,
3428 fallback_model: &str,
3429) -> String {
3430 use crate::application::ports::RuntimeEntityPort;
3431 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3432 match runtime.get_kv("red_config", key)? {
3433 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3434 _ => Ok(None),
3435 }
3436 };
3437 resolve_ask_planner_model(&kv_getter, fallback_model)
3438}
3439
3440pub fn resolve_defaults_from_runtime_port<
3442 P: crate::application::ports::RuntimeEntityPort + ?Sized,
3443>(
3444 runtime: &P,
3445) -> crate::RedDBResult<(AiProvider, String)> {
3446 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3447 match runtime.get_kv("red_config", key)? {
3448 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3449 _ => Ok(None),
3450 }
3451 };
3452 let provider = resolve_default_provider(&kv_getter)?;
3453 let model = resolve_default_model(&provider, &kv_getter);
3454 Ok((provider, model))
3455}
3456
3457pub fn resolve_embeddings_provider_from_runtime<
3462 P: crate::application::ports::RuntimeEntityPort + ?Sized,
3463>(
3464 runtime: &P,
3465 explicit: &str,
3466) -> crate::RedDBResult<AiProvider> {
3467 let explicit = explicit.trim();
3468 if !explicit.is_empty() {
3469 let provider = parse_provider(explicit)?;
3470 ensure_provider_supports_embeddings(&provider)?;
3471 return Ok(provider);
3472 }
3473 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3474 match runtime.get_kv("red_config", key)? {
3475 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3476 _ => Ok(None),
3477 }
3478 };
3479 resolve_embeddings_provider(&kv_getter)
3480}
3481
3482pub fn resolve_embeddings_model_from_runtime<
3486 P: crate::application::ports::RuntimeEntityPort + ?Sized,
3487>(
3488 runtime: &P,
3489 provider: &AiProvider,
3490 explicit: Option<&str>,
3491) -> String {
3492 if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
3493 return model.to_string();
3494 }
3495 let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3496 match runtime.get_kv("red_config", key)? {
3497 Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3498 _ => Ok(None),
3499 }
3500 };
3501 resolve_embeddings_model(provider, &kv_getter)
3502}
3503
3504pub fn resolve_api_key<F>(
3531 provider: &AiProvider,
3532 credential_alias: Option<&str>,
3533 kv_getter: F,
3534) -> crate::RedDBResult<String>
3535where
3536 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3537{
3538 let configured_alias = if credential_alias.is_none() {
3539 kv_getter(&format!(
3540 "red.config.ai.providers.{}.credential",
3541 provider.token()
3542 ))?
3543 } else {
3544 None
3545 };
3546 let credential_alias = credential_alias.or(configured_alias.as_deref());
3547 if let Some(alias) = credential_alias
3548 .map(str::trim)
3549 .filter(|a| !a.is_empty() && *a != "default")
3550 {
3551 if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
3553 if !key.trim().is_empty() {
3554 return Ok(key);
3555 }
3556 }
3557 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
3559 let secret_ref = secret_ref.trim();
3560 validate_ai_secret_ref(secret_ref)?;
3564 if let Some(key) = kv_getter(secret_ref)? {
3565 if !key.trim().is_empty() {
3566 return Ok(key);
3567 }
3568 }
3569 }
3570 if let Some(key) = resolve_key_from_env_alias(provider, alias) {
3572 return Ok(key);
3573 }
3574 reject_removed_credential_paths(provider, alias, &kv_getter)?;
3577 return Err(crate::RedDBError::Query(format!(
3578 "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
3579 provider.token(),
3580 provider.alias_key_env_name(alias),
3581 ai_api_secret_path(provider, alias),
3582 )));
3583 }
3584
3585 if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
3587 if !key.trim().is_empty() {
3588 return Ok(key);
3589 }
3590 }
3591 if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
3593 let secret_ref = secret_ref.trim();
3594 validate_ai_secret_ref(secret_ref)?;
3595 if let Some(key) = kv_getter(secret_ref)? {
3596 if !key.trim().is_empty() {
3597 return Ok(key);
3598 }
3599 }
3600 }
3601
3602 if let Ok(value) = std::env::var(provider.default_key_env_name()) {
3604 let value = value.trim().to_string();
3605 if !value.is_empty() {
3606 return Ok(value);
3607 }
3608 }
3609
3610 reject_removed_credential_paths(provider, "default", &kv_getter)?;
3613 if !provider.requires_api_key() {
3614 return Ok(String::new());
3615 }
3616
3617 Err(crate::RedDBError::Query(format!(
3618 "missing {} API key. Set {} or store it in the vault at '{}'",
3619 provider.token(),
3620 provider.default_key_env_name(),
3621 ai_api_secret_path(provider, "default"),
3622 )))
3623}
3624
3625fn validate_ai_secret_ref(secret_ref: &str) -> crate::RedDBResult<()> {
3626 if !secret_ref
3627 .to_ascii_lowercase()
3628 .starts_with("red.secret.ai.")
3629 {
3630 return Err(crate::RedDBError::Query(format!(
3631 "secret_ref '{secret_ref}' must reference a vault key under red.secret.ai.*"
3632 )));
3633 }
3634 Ok(())
3635}
3636
3637pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
3639 format!(
3640 "red.secret.ai.providers.{}.tokens.{}",
3641 provider.token(),
3642 normalize_credential_alias_path(alias)
3643 )
3644}
3645
3646pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
3648 format!(
3649 "red.config.ai.providers.{}.tokens.{}.secret_ref",
3650 provider.token(),
3651 normalize_credential_alias_path(alias)
3652 )
3653}
3654
3655fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
3659 format!(
3660 "red.secret.ai.{}.{}.api_key",
3661 provider.token(),
3662 normalize_credential_alias_path(alias)
3663 )
3664}
3665
3666fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
3669 format!(
3670 "red.config.ai.{}.{}.key",
3671 provider.token(),
3672 normalize_credential_alias_path(alias)
3673 )
3674}
3675
3676fn reject_removed_credential_paths<F>(
3680 provider: &AiProvider,
3681 alias: &str,
3682 kv_getter: &F,
3683) -> crate::RedDBResult<()>
3684where
3685 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3686{
3687 let new_path = ai_api_secret_path(provider, alias);
3688 for removed in [
3689 removed_vault_api_key_path(provider, alias),
3690 removed_plaintext_config_key(provider, alias),
3691 ] {
3692 if let Some(value) = kv_getter(&removed)? {
3693 if !value.trim().is_empty() {
3694 return Err(crate::RedDBError::Query(format!(
3695 "AI credential found at removed path '{removed}'. The AI credential vault \
3696 path changed (issue #1745): store the token at '{new_path}' instead. The \
3697 old vault path shape and the legacy plaintext config path are no longer read."
3698 )));
3699 }
3700 }
3701 }
3702 Ok(())
3703}
3704
3705fn normalize_credential_alias_path(alias: &str) -> String {
3706 let alias = alias.trim();
3707 if alias.is_empty() {
3708 "default".to_string()
3709 } else {
3710 alias.to_ascii_lowercase()
3711 }
3712}
3713
3714fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
3715 let env_name = provider.alias_key_env_name(alias);
3716 std::env::var(env_name)
3717 .ok()
3718 .map(|v| v.trim().to_string())
3719 .filter(|v| !v.is_empty())
3720}
3721
3722fn normalize_alias_token(alias: &str) -> String {
3723 let mut out = String::with_capacity(alias.len());
3724 for character in alias.chars() {
3725 if character.is_ascii_alphanumeric() {
3726 out.push(character.to_ascii_uppercase());
3727 } else {
3728 out.push('_');
3729 }
3730 }
3731 while out.contains("__") {
3732 out = out.replace("__", "_");
3733 }
3734 out.trim_matches('_').to_string()
3735}
3736
3737pub struct AiConnection {
3740 pub api_base: String,
3741 pub api_key: String,
3742}
3743
3744pub fn resolve_connection<F>(
3745 provider: &AiProvider,
3746 credential_alias: Option<&str>,
3747 kv_getter: &F,
3748) -> crate::RedDBResult<AiConnection>
3749where
3750 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3751{
3752 let api_base = provider.resolve_api_base_checked(kv_getter)?;
3753 let api_key = resolve_api_key(provider, credential_alias, kv_getter)?;
3754 Ok(AiConnection { api_base, api_key })
3755}
3756
3757pub fn resolve_connection_from_runtime(
3758 provider: &AiProvider,
3759 credential_alias: Option<&str>,
3760 runtime: &crate::runtime::RedDBRuntime,
3761) -> crate::RedDBResult<AiConnection> {
3762 use crate::application::ports::RuntimeEntityPort;
3763 let api_base =
3764 provider.resolve_api_base_checked(&|key| match runtime.get_kv("red_config", key)? {
3765 Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3766 _ => Ok(None),
3767 })?;
3768 let api_key = resolve_api_key_from_runtime(provider, credential_alias, runtime)?;
3769 Ok(AiConnection { api_base, api_key })
3770}
3771
3772pub fn resolve_api_key_from_runtime(
3782 provider: &AiProvider,
3783 credential_alias: Option<&str>,
3784 runtime: &crate::runtime::RedDBRuntime,
3785) -> crate::RedDBResult<String> {
3786 use crate::application::ports::RuntimeEntityPort;
3787 let configured_alias = if credential_alias.is_none() {
3788 match runtime.get_kv(
3789 "red_config",
3790 &format!("red.config.ai.providers.{}.credential", provider.token()),
3791 )? {
3792 Some((reddb_types::Value::Text(value), _)) => Some(value.to_string()),
3793 _ => None,
3794 }
3795 } else {
3796 None
3797 };
3798 let credential_alias = credential_alias.or(configured_alias.as_deref());
3799 let alias_for_audit = credential_alias.unwrap_or("default").to_string();
3800 let provider_token = provider.token().to_string();
3801 let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
3802 std::cell::RefCell::new(Vec::new());
3803 let result = resolve_api_key(provider, credential_alias, |kv_key| {
3804 if kv_key.starts_with("red.secret.") {
3805 let value = runtime.vault_kv_get(kv_key);
3806 audited_paths
3807 .borrow_mut()
3808 .push((kv_key.to_string(), value.is_some()));
3809 return Ok(value);
3810 }
3811 match runtime.get_kv("red_config", kv_key)? {
3812 Some((reddb_types::Value::Text(secret), _)) => {
3813 audited_paths.borrow_mut().push((kv_key.to_string(), true));
3814 Ok(Some(secret.to_string()))
3815 }
3816 Some(_) => {
3817 audited_paths.borrow_mut().push((kv_key.to_string(), false));
3818 Ok(None)
3819 }
3820 None => {
3821 audited_paths.borrow_mut().push((kv_key.to_string(), false));
3822 Ok(None)
3823 }
3824 }
3825 });
3826 let audited_paths = audited_paths.into_inner();
3827
3828 let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
3829 .map(|(user, _role)| user)
3830 .unwrap_or_else(|| "system".to_string());
3831 let outcome = if result.is_ok() { "hit" } else { "miss" };
3832 let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
3833 let paths_json: Vec<crate::serde_json::Value> = audited_paths
3834 .iter()
3835 .map(|(p, hit)| {
3836 crate::serde_json::json!({
3837 "path": p,
3838 "hit": hit,
3839 })
3840 })
3841 .collect();
3842 let details = crate::serde_json::json!({
3843 "provider": provider_token,
3844 "alias": alias_for_audit,
3845 "paths_checked": paths_json,
3846 });
3847 runtime.audit_log().record(
3848 "ai.credential.resolve",
3849 &principal,
3850 &target,
3851 outcome,
3852 details,
3853 );
3854 result
3855}
3856
3857pub fn huggingface_embeddings(
3863 api_key: &str,
3864 model: &str,
3865 inputs: &[String],
3866 api_base: &str,
3867) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3868 let url = format!("{api_base}/pipeline/feature-extraction/{model}");
3869 let mut embeddings = Vec::with_capacity(inputs.len());
3870
3871 for input in inputs {
3872 let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
3873 let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
3874 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3875 if !(200..300).contains(&status) {
3876 return Err(crate::RedDBError::Query(format!(
3877 "HuggingFace API error (status {status}): {body_str}"
3878 )));
3879 }
3880 let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
3881 crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
3882 })?;
3883
3884 let vector: Vec<f32> = match &body {
3886 JsonValue::Array(outer) => outer
3887 .iter()
3888 .filter_map(|v| v.as_f64().map(|n| n as f32))
3889 .collect(),
3890 _ => {
3891 return Err(crate::RedDBError::Query(
3892 "unexpected HuggingFace embedding response format".to_string(),
3893 ))
3894 }
3895 };
3896 embeddings.push(vector);
3897 }
3898
3899 Ok(OpenAiEmbeddingResponse {
3900 provider: "huggingface",
3901 model: model.to_string(),
3902 embeddings,
3903 prompt_tokens: None,
3904 total_tokens: None,
3905 })
3906}
3907
3908pub fn huggingface_prompt(
3910 api_key: &str,
3911 model: &str,
3912 prompt: &str,
3913 temperature: Option<f32>,
3914 max_tokens: Option<usize>,
3915 api_base: &str,
3916) -> crate::RedDBResult<AiPromptResponse> {
3917 let url = format!("{api_base}/models/{model}");
3918 let mut params = Map::new();
3919 if let Some(t) = temperature {
3920 params.insert("temperature".into(), JsonValue::Number(t as f64));
3921 }
3922 params.insert(
3923 "max_new_tokens".into(),
3924 JsonValue::Number(max_tokens.unwrap_or(512) as f64),
3925 );
3926 let payload = crate::serde_json::json!({
3927 "inputs": prompt,
3928 "parameters": JsonValue::Object(params)
3929 });
3930
3931 let (status, body_str) =
3932 http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
3933 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3934 if !(200..300).contains(&status) {
3935 return Err(crate::RedDBError::Query(format!(
3936 "HuggingFace API error (status {status}): {body_str}"
3937 )));
3938 }
3939 let body: JsonValue = crate::serde_json::from_str(&body_str)
3940 .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
3941
3942 let output_text = match &body {
3943 JsonValue::Array(arr) => arr
3944 .first()
3945 .and_then(|v| v.get("generated_text"))
3946 .and_then(JsonValue::as_str)
3947 .unwrap_or("")
3948 .to_string(),
3949 _ => body
3950 .get("generated_text")
3951 .and_then(JsonValue::as_str)
3952 .unwrap_or("")
3953 .to_string(),
3954 };
3955
3956 Ok(AiPromptResponse {
3957 provider: "huggingface",
3958 model: model.to_string(),
3959 output_text,
3960 output_chunks: None,
3961 prompt_tokens: None,
3962 completion_tokens: None,
3963 total_tokens: None,
3964 stop_reason: None,
3965 })
3966}
3967
3968const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
3973flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
3974the 'ollama' provider with a local Ollama server.";
3975
3976const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
3977`local-models` feature, but local model artifact execution is not implemented in this slice. \
3978Alternatively, use the 'ollama' provider with a local Ollama server.";
3979
3980const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
3981the `local-models` feature; the local provider contract is embeddings-only for this slice.";
3982
3983pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
3984 if cfg!(feature = "local-models") {
3985 crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
3986 } else {
3987 crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
3988 }
3989}
3990
3991pub fn local_prompt_unavailable_error() -> crate::RedDBError {
3992 crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
3993}
3994
3995pub fn local_embeddings(
3997 _model_id: &str,
3998 _texts: &[String],
3999) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
4000 Err(local_embeddings_unavailable_error())
4001}
4002
4003pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
4005 Err(local_prompt_unavailable_error())
4006}
4007
4008fn grpc_collect_embedding_inputs(
4021 runtime: &crate::runtime::RedDBRuntime,
4022 payload: &JsonValue,
4023) -> crate::RedDBResult<Vec<String>> {
4024 if let Some(source_query) = payload
4025 .get("source_query")
4026 .and_then(|v| v.as_str())
4027 .map(str::trim)
4028 .filter(|s| !s.is_empty())
4029 {
4030 return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
4031 }
4032
4033 if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
4034 let mut out = Vec::with_capacity(arr.len());
4035 for (idx, v) in arr.iter().enumerate() {
4036 let text = v.as_str().ok_or_else(|| {
4037 crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
4038 })?;
4039 if text.trim().is_empty() {
4040 return Err(crate::RedDBError::Query(format!(
4041 "field 'inputs[{idx}]' cannot be empty"
4042 )));
4043 }
4044 out.push(text.to_string());
4045 }
4046 if out.is_empty() {
4047 return Err(crate::RedDBError::Query(
4048 "field 'inputs' must be a non-empty array of strings".to_string(),
4049 ));
4050 }
4051 return Ok(out);
4052 }
4053
4054 if let Some(single) = payload
4055 .get("input")
4056 .and_then(|v| v.as_str())
4057 .map(str::trim)
4058 .filter(|s| !s.is_empty())
4059 {
4060 return Ok(vec![single.to_string()]);
4061 }
4062
4063 Err(crate::RedDBError::Query(
4064 "provide either 'input', 'inputs', or 'source_query'".to_string(),
4065 ))
4066}
4067
4068fn grpc_collect_inputs_from_source_query(
4069 runtime: &crate::runtime::RedDBRuntime,
4070 payload: &JsonValue,
4071 source_query: &str,
4072) -> crate::RedDBResult<Vec<String>> {
4073 let result = runtime
4074 .execute_query(source_query)
4075 .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
4076
4077 let source_mode = payload
4078 .get("source_mode")
4079 .and_then(|v| v.as_str())
4080 .map(str::trim)
4081 .filter(|s| !s.is_empty())
4082 .unwrap_or("row")
4083 .to_ascii_lowercase();
4084
4085 let mut out: Vec<String> = Vec::new();
4086 match source_mode.as_str() {
4087 "row" => {
4088 let field = payload
4089 .get("source_field")
4090 .and_then(|v| v.as_str())
4091 .map(str::trim)
4092 .filter(|s| !s.is_empty())
4093 .ok_or_else(|| {
4094 crate::RedDBError::Query(
4095 "field 'source_field' is required when source_mode='row'".to_string(),
4096 )
4097 })?;
4098 for rec in &result.result.records {
4099 for (key, value) in rec.iter_fields() {
4100 if key.as_ref() == field {
4101 if let reddb_types::Value::Text(text) = value {
4102 let trimmed = text.trim();
4103 if !trimmed.is_empty() {
4104 out.push(trimmed.to_string());
4105 }
4106 }
4107 }
4108 }
4109 }
4110 }
4111 "result" => {
4112 for rec in &result.result.records {
4113 for (_, value) in rec.iter_fields() {
4114 if let reddb_types::Value::Text(text) = value {
4115 let trimmed = text.trim();
4116 if !trimmed.is_empty() {
4117 out.push(trimmed.to_string());
4118 }
4119 }
4120 }
4121 }
4122 }
4123 other => {
4124 return Err(crate::RedDBError::Query(format!(
4125 "field 'source_mode' must be 'row' or 'result' (got '{other}')"
4126 )));
4127 }
4128 }
4129
4130 if out.is_empty() {
4131 return Err(crate::RedDBError::Query(
4132 "source_query produced zero non-empty text inputs".to_string(),
4133 ));
4134 }
4135 Ok(out)
4136}
4137
4138pub fn grpc_embeddings(
4160 runtime: &crate::runtime::RedDBRuntime,
4161 payload: &JsonValue,
4162) -> crate::RedDBResult<JsonValue> {
4163 let provider = match payload
4166 .get("provider")
4167 .and_then(|v| v.as_str())
4168 .map(str::trim)
4169 .filter(|s| !s.is_empty())
4170 {
4171 Some(name) => parse_provider(name)?,
4172 None => resolve_embeddings_provider_from_runtime(runtime, "")?,
4173 };
4174 if matches!(provider, AiProvider::Groq | AiProvider::DeepSeek) {
4175 ensure_provider_supports_embeddings(&provider)?;
4176 }
4177 match &provider {
4182 AiProvider::Anthropic => {
4183 return Err(crate::RedDBError::Query(
4184 "Anthropic does not offer an embeddings API. \
4185 Re-issue the request against an OpenAI-compatible \
4186 provider (openai, ollama, openrouter, together, \
4187 venice, red-router), HuggingFace, or a custom base URL — \
4188 RedDB does not silently route embeddings to a \
4189 different provider than the one you named."
4190 .to_string(),
4191 ));
4192 }
4193 AiProvider::Local => {
4194 return grpc_embeddings_local(runtime, payload);
4195 }
4196 _ => {}
4197 }
4198
4199 let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
4200
4201 let explicit_model = payload.get("model").and_then(|v| v.as_str());
4202 let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
4203
4204 let credential = payload
4205 .get("credential")
4206 .and_then(|v| v.as_str())
4207 .map(str::to_string);
4208 let AiConnection { api_key, api_base } =
4209 resolve_connection_from_runtime(&provider, credential.as_deref(), runtime)?;
4210
4211 let dimensions = payload
4212 .get("dimensions")
4213 .and_then(|v| v.as_i64())
4214 .and_then(|v| usize::try_from(v).ok())
4215 .filter(|v| *v > 0);
4216
4217 let response = match &provider {
4218 AiProvider::HuggingFace => huggingface_embeddings(&api_key, &model, &inputs, &api_base)?,
4219 _ => {
4220 let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
4221 let request = OpenAiEmbeddingRequest {
4222 api_key,
4223 model,
4224 inputs,
4225 dimensions,
4226 api_base,
4227 };
4228 crate::runtime::ai::block_on_ai(async move {
4229 openai_embeddings_async(&transport, request).await
4230 })
4231 .and_then(|result| result)?
4232 }
4233 };
4234
4235 let embeddings_json: Vec<JsonValue> = response
4236 .embeddings
4237 .into_iter()
4238 .map(|vec| {
4239 JsonValue::Array(
4240 vec.into_iter()
4241 .map(|f| JsonValue::Number(f as f64))
4242 .collect(),
4243 )
4244 })
4245 .collect();
4246
4247 let mut obj = Map::new();
4248 obj.insert(
4249 "provider".to_string(),
4250 JsonValue::String(response.provider.to_string()),
4251 );
4252 obj.insert("model".to_string(), JsonValue::String(response.model));
4253 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
4254 if let Some(pt) = response.prompt_tokens {
4255 obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
4256 }
4257 if let Some(tt) = response.total_tokens {
4258 obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
4259 }
4260 Ok(JsonValue::Object(obj))
4261}
4262
4263fn grpc_embeddings_local(
4272 runtime: &crate::runtime::RedDBRuntime,
4273 payload: &JsonValue,
4274) -> crate::RedDBResult<JsonValue> {
4275 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
4276
4277 let model_name = payload
4278 .get("model")
4279 .and_then(|v| v.as_str())
4280 .map(str::trim)
4281 .filter(|s| !s.is_empty())
4282 .ok_or_else(|| {
4283 crate::RedDBError::Query(
4284 "field 'model' is required for the local provider and must be the \
4285 registered local model name (see POST /ai/models)"
4286 .to_string(),
4287 )
4288 })?
4289 .to_string();
4290
4291 let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
4292 let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
4293
4294 let embeddings_json: Vec<JsonValue> = response
4295 .embeddings
4296 .into_iter()
4297 .map(|vec| {
4298 JsonValue::Array(
4299 vec.into_iter()
4300 .map(|f| JsonValue::Number(f as f64))
4301 .collect(),
4302 )
4303 })
4304 .collect();
4305
4306 let mut obj = Map::new();
4307 obj.insert(
4308 "provider".to_string(),
4309 JsonValue::String(response.provider.to_string()),
4310 );
4311 obj.insert("model".to_string(), JsonValue::String(response.name));
4312 obj.insert(
4313 "model_source".to_string(),
4314 JsonValue::String(response.source),
4315 );
4316 obj.insert(
4317 "model_revision".to_string(),
4318 JsonValue::String(response.revision),
4319 );
4320 obj.insert(
4321 "model_engine".to_string(),
4322 JsonValue::String(response.engine),
4323 );
4324 obj.insert(
4325 "dimensions".to_string(),
4326 JsonValue::Number(response.dimensions as f64),
4327 );
4328 obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
4329 Ok(JsonValue::Object(obj))
4330}
4331
4332pub fn grpc_prompt(
4334 _runtime: &crate::runtime::RedDBRuntime,
4335 _payload: &JsonValue,
4336) -> crate::RedDBResult<JsonValue> {
4337 Err(crate::RedDBError::FeatureNotEnabled(
4338 "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
4339 ))
4340}
4341
4342pub fn grpc_credentials(
4344 _runtime: &crate::runtime::RedDBRuntime,
4345 _payload: &JsonValue,
4346) -> crate::RedDBResult<JsonValue> {
4347 Err(crate::RedDBError::FeatureNotEnabled(
4348 "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
4349 ))
4350}
4351
4352#[derive(Debug, Clone, Default, PartialEq, Eq)]
4368pub struct OpenAiCompatUsage {
4369 pub input_tokens: Option<u64>,
4370 pub output_tokens: Option<u64>,
4371 pub total_tokens: Option<u64>,
4372}
4373
4374#[derive(Debug, Clone)]
4375pub struct OpenAiCompatChatRequest {
4376 pub api_base: String,
4377 pub api_key: String,
4378 pub model: String,
4379 pub prompt: String,
4380 pub temperature: Option<f32>,
4381 pub seed: Option<u64>,
4382 pub max_output_tokens: Option<usize>,
4383 pub extra_headers: Vec<(String, String)>,
4384}
4385
4386#[derive(Debug, Clone)]
4387pub struct OpenAiCompatChatResponse {
4388 pub model: String,
4389 pub output_text: String,
4390 pub stop_reason: Option<String>,
4391 pub usage: OpenAiCompatUsage,
4392}
4393
4394#[derive(Debug, Clone)]
4395pub struct OpenAiCompatEmbeddingsRequest {
4396 pub api_base: String,
4397 pub api_key: String,
4398 pub model: String,
4399 pub inputs: Vec<String>,
4400 pub dimensions: Option<usize>,
4401 pub extra_headers: Vec<(String, String)>,
4402}
4403
4404#[derive(Debug, Clone)]
4405pub struct OpenAiCompatEmbeddingsResponse {
4406 pub model: String,
4407 pub embeddings: Vec<Vec<f32>>,
4408 pub usage: OpenAiCompatUsage,
4409}
4410
4411fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
4412 headers
4413 .iter()
4414 .map(|(k, v)| (k.as_str(), v.as_str()))
4415 .collect()
4416}
4417
4418pub fn openai_compat_chat(
4426 request: OpenAiCompatChatRequest,
4427) -> RedDBResult<OpenAiCompatChatResponse> {
4428 if request.model.trim().is_empty() {
4429 return Err(RedDBError::Query(
4430 "openai-compat: model cannot be empty".to_string(),
4431 ));
4432 }
4433 if request.prompt.trim().is_empty() {
4434 return Err(RedDBError::Query(
4435 "openai-compat: prompt cannot be empty".to_string(),
4436 ));
4437 }
4438
4439 let url = format!(
4440 "{}/chat/completions",
4441 request.api_base.trim_end_matches('/')
4442 );
4443 let payload = build_openai_prompt_payload(
4444 &request.model,
4445 &request.prompt,
4446 request.temperature,
4447 request.seed,
4448 request.max_output_tokens,
4449 false,
4450 );
4451
4452 let extra = extra_header_refs(&request.extra_headers);
4453 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
4454 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
4455
4456 if !(200..300).contains(&status) {
4457 let message = openai_error_message(&body).unwrap_or_else(|| {
4458 if body.trim().is_empty() {
4459 "openai-compat chat request failed".to_string()
4460 } else {
4461 body.clone()
4462 }
4463 });
4464 return Err(RedDBError::Query(format!(
4465 "openai-compat chat request failed (status {status}): {message}"
4466 )));
4467 }
4468
4469 let parsed = parse_openai_prompt_response(&body, &request.model)?;
4470 Ok(OpenAiCompatChatResponse {
4471 model: parsed.model,
4472 output_text: parsed.output_text,
4473 stop_reason: parsed.stop_reason,
4474 usage: OpenAiCompatUsage {
4475 input_tokens: parsed.prompt_tokens,
4476 output_tokens: parsed.completion_tokens,
4477 total_tokens: parsed.total_tokens,
4478 },
4479 })
4480}
4481
4482pub fn openai_compat_embeddings(
4484 request: OpenAiCompatEmbeddingsRequest,
4485) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
4486 if request.model.trim().is_empty() {
4487 return Err(RedDBError::Query(
4488 "openai-compat: embedding model cannot be empty".to_string(),
4489 ));
4490 }
4491 if request.inputs.is_empty() {
4492 return Err(RedDBError::Query(
4493 "openai-compat: at least one input is required".to_string(),
4494 ));
4495 }
4496
4497 let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
4498 let payload =
4499 build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
4500
4501 let extra = extra_header_refs(&request.extra_headers);
4502 let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
4503 .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
4504
4505 if !(200..300).contains(&status) {
4506 let message = openai_error_message(&body).unwrap_or_else(|| {
4507 if body.trim().is_empty() {
4508 "openai-compat embeddings request failed".to_string()
4509 } else {
4510 body.clone()
4511 }
4512 });
4513 return Err(RedDBError::Query(format!(
4514 "openai-compat embeddings request failed (status {status}): {message}"
4515 )));
4516 }
4517
4518 let parsed = parse_openai_embedding_response(&body)?;
4519 Ok(OpenAiCompatEmbeddingsResponse {
4520 model: parsed.model,
4521 embeddings: parsed.embeddings,
4522 usage: OpenAiCompatUsage {
4523 input_tokens: parsed.prompt_tokens,
4524 output_tokens: None,
4525 total_tokens: parsed.total_tokens,
4526 },
4527 })
4528}
4529
4530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4542pub enum AiProviderMode {
4543 OpenAiCompat,
4545 OpenAiNative,
4547 AnthropicNative,
4549}
4550
4551impl AiProviderMode {
4552 pub fn token(&self) -> &'static str {
4553 match self {
4554 Self::OpenAiCompat => "openai-compat",
4555 Self::OpenAiNative => "openai-native",
4556 Self::AnthropicNative => "anthropic-native",
4557 }
4558 }
4559}
4560
4561pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
4563 match name.trim().to_ascii_lowercase().as_str() {
4564 "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
4565 "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
4566 "anthropic-native" | "anthropic_native" | "anthropicnative" => {
4567 Some(AiProviderMode::AnthropicNative)
4568 }
4569 _ => None,
4570 }
4571}
4572
4573pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
4579where
4580 F: Fn(&str) -> crate::RedDBResult<Option<String>>,
4581{
4582 if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
4583 if let Some(mode) = parse_provider_mode(&value) {
4584 return Some(mode);
4585 }
4586 }
4587 if let Ok(Some(value)) = kv_getter("red.config.ai.provider") {
4588 if let Some(mode) = parse_provider_mode(&value) {
4589 return Some(mode);
4590 }
4591 }
4592 None
4593}
4594
4595pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
4598 match mode {
4599 AiProviderMode::OpenAiNative => AiProvider::OpenAi,
4600 AiProviderMode::AnthropicNative => AiProvider::Anthropic,
4601 AiProviderMode::OpenAiCompat => AiProvider::OpenAiCompat,
4602 }
4603}
4604
4605pub(crate) fn validate_custom_provider_url(url: &str) -> crate::RedDBResult<()> {
4615 let uri = url.parse::<http::Uri>().map_err(|_| {
4616 crate::RedDBError::Query("AI endpoint must be an absolute http(s) URL".to_string())
4617 })?;
4618 if !matches!(uri.scheme_str(), Some("http" | "https"))
4619 || uri.host().is_none_or(str::is_empty)
4620 || uri
4621 .authority()
4622 .is_some_and(|authority| authority.as_str().contains('@'))
4623 || uri.query().is_some()
4624 || url.contains('#')
4625 {
4626 return Err(crate::RedDBError::Query(
4627 "AI endpoint must be an absolute http(s) URL without userinfo, query or fragment"
4628 .to_string(),
4629 ));
4630 }
4631 if std::env::var("REDDB_AI_ALLOW_PRIVATE_PROVIDERS")
4632 .map(|v| matches!(v.trim(), "1" | "true" | "yes"))
4633 .unwrap_or(false)
4634 {
4635 return Ok(());
4636 }
4637 let (plaintext, rest) = match url.strip_prefix("https://") {
4638 Some(rest) => (false, rest),
4639 None => (true, url.strip_prefix("http://").unwrap_or(url)),
4640 };
4641 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
4642 let host_port = authority.rsplit('@').next().unwrap_or(authority);
4643 let host = if let Some(stripped) = host_port.strip_prefix('[') {
4644 stripped.split(']').next().unwrap_or(stripped)
4645 } else {
4646 host_port.split(':').next().unwrap_or(host_port)
4647 };
4648 let host_lower = host.to_ascii_lowercase();
4649 let literal_ip = host.parse::<std::net::IpAddr>().ok();
4650 let loopback = host_lower == "localhost"
4651 || host_lower.ends_with(".localhost")
4652 || literal_ip.is_some_and(|ip| ip.is_loopback());
4653 if plaintext && !loopback {
4654 return Err(crate::RedDBError::Query(format!(
4655 "custom AI provider '{url}' must use https:// (plaintext http is only allowed for loopback hosts)"
4656 )));
4657 }
4658 if let Some(ip) = literal_ip {
4659 let private = match ip {
4660 std::net::IpAddr::V4(v4) => {
4661 v4.is_private()
4662 || v4.is_link_local()
4663 || v4.is_broadcast()
4664 || v4.is_multicast()
4665 || v4.is_unspecified()
4666 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
4667 }
4668 std::net::IpAddr::V6(v6) => {
4669 v6.is_multicast()
4670 || v6.is_unspecified()
4671 || (v6.segments()[0] & 0xfe00) == 0xfc00
4672 || (v6.segments()[0] & 0xffc0) == 0xfe80
4673 }
4674 };
4675 if private && !loopback {
4676 return Err(crate::RedDBError::Query(format!(
4677 "custom AI provider '{url}' targets a private or link-local address; set REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1 to allow"
4678 )));
4679 }
4680 }
4681 Ok(())
4682}