Skip to main content

reddb_server/
ai.rs

1//! External AI provider integration primitives.
2//!
3//! This module currently supports OpenAI embeddings and is intended to be
4//! consumed by server handlers and future query/runtime integrations.
5
6use std::io::BufRead;
7use std::time::Duration;
8
9use crate::json::{parse_json, Map, Value as JsonValue};
10use crate::{RedDBError, RedDBResult};
11
12/// Shared HTTP helper for every outbound AI provider call. Centralises
13/// the ureq 3.x builder and error conversion. Returns
14/// `(status, body)` even on 4xx/5xx (via
15/// `http_status_as_error(false)`) so callers can format a
16/// provider-specific error without re-plumbing the 3.x error enum.
17fn 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/// Provider-neutral chat message used by agentic ASK calls.
119///
120/// Tool calls and results stay structured across rounds instead of being
121/// flattened into prompt text. Provider adapters translate this transcript to
122/// their native wire format.
123#[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/// Model-independent request consumed by the OpenAI and Anthropic adapters.
153#[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    // Anthropic uses its own `x-api-key` header instead of
288    // `Authorization: Bearer`, so skip the shared helper's default
289    // auth header path — we pass an empty API key and set
290    // `x-api-key` via extra_headers instead.
291    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
309/// Async OpenAI-compatible embeddings via [`AiTransport`].
310///
311/// Uses the transport's connection pool and retry policy (429/5xx backoff)
312/// instead of the deprecated one-shot blocking path.
313pub 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
346/// Async OpenAI chat-completion prompt via [`AiTransport`].
347///
348/// Uses the transport's connection pool and retry policy (429/5xx backoff)
349/// instead of the deprecated one-shot blocking path.
350pub 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
391/// Async OpenAI-compatible chat with native function tools.
392pub 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
413/// Blocking OpenAI-compatible streaming prompt.
414///
415/// This is used by the socket-level `ASK ... STREAM` path so each provider
416/// `delta.content` can be forwarded to the HTTP client before the provider
417/// body has completed.
418pub 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
583/// Async Anthropic messages-API prompt via [`AiTransport`].
584///
585/// Uses the transport's connection pool and retry policy (429/5xx backoff)
586/// instead of the deprecated one-shot blocking path.
587pub 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
626/// Async Anthropic messages call with native tools.
627pub 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
652/// Build an OpenAI-compatible embedding request payload.
653pub(crate) fn build_embedding_payload(model: &str, inputs: &[String]) -> String {
654    build_openai_embedding_payload(model, inputs, None)
655}
656
657/// Parse an OpenAI-compatible embedding response, returning only the vectors.
658pub(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        // Only the removed plaintext config path holds a value: resolution
1805        // must reject didactically, naming the new vault path.
1806        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        // A request naming the `prod` alias resolves the tenant token; a
1853        // request naming no credential resolves the implicit `default`.
1854        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    // Vault-first credential resolution with env fallback (issue #1270).
1890    // Each test uses a unique `Custom` provider token so the derived env
1891    // var name (`REDDB_<TOKEN>_API_KEY`) is process-unique and the tests
1892    // can set/unset env without racing other tests.
1893
1894    #[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        // kv_getter returns nothing → no vault/legacy entry exists.
1901        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        // Both the vault secret and the env var are set; vault wins.
1915        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    // ========================================================================
2103    // openai-compat client tests (issue gh-516)
2104    //
2105    // Each test spins up a tiny TCP server, hands its base URL to the
2106    // new generic client, and asserts on the captured request +
2107    // synthesised response. Tests run in parallel-safe fashion (each
2108    // server binds to port 0).
2109    // ========================================================================
2110
2111    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    /// Spawn a one-shot HTTP server that replies with `(status, body)`
2178    /// to a single request, captures it, and returns `(base_url, captured)`.
2179    fn spawn_mock(
2180        status: u16,
2181        response_body: &'static str,
2182    ) -> (String, Arc<Mutex<Option<CapturedRequest>>>) {
2183        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2184        let addr = listener.local_addr().expect("addr");
2185        let captured: Arc<Mutex<Option<CapturedRequest>>> = Arc::new(Mutex::new(None));
2186        let captured_clone = Arc::clone(&captured);
2187        thread::spawn(move || {
2188            if let Ok((mut stream, _)) = listener.accept() {
2189                let req = parse_http_request(&mut stream);
2190                *captured_clone.lock().unwrap() = Some(req);
2191                let status_line = match status {
2192                    200 => "200 OK",
2193                    400 => "400 Bad Request",
2194                    401 => "401 Unauthorized",
2195                    500 => "500 Internal Server Error",
2196                    _ => "200 OK",
2197                };
2198                let resp = format!(
2199                    "HTTP/1.1 {status_line}\r\n\
2200                     Content-Type: application/json\r\n\
2201                     Content-Length: {}\r\n\
2202                     Connection: close\r\n\r\n{}",
2203                    response_body.len(),
2204                    response_body
2205                );
2206                let _ = stream.write_all(resp.as_bytes());
2207            }
2208        });
2209        (format!("http://{}", addr), captured)
2210    }
2211
2212    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2213    async fn gateway_registration_drives_http_grpc_and_batch_with_saved_connection() {
2214        use crate::application::ports::RuntimeEntityPort;
2215        use crate::auth::{AuthConfig, AuthStore};
2216        use crate::{RedDBOptions, RedDBRuntime, StorageDeployPreset};
2217        let path = std::env::temp_dir().join(format!(
2218            "reddb_gateway_roundtrip_{}_{}.rdb",
2219            std::process::id(),
2220            std::time::SystemTime::now()
2221                .duration_since(std::time::UNIX_EPOCH)
2222                .expect("clock")
2223                .as_nanos(),
2224        ));
2225        let runtime = RedDBRuntime::with_options(
2226            RedDBOptions::persistent(&path)
2227                .with_storage_profile(StorageDeployPreset::Serverless.selection())
2228                .expect("profile"),
2229        )
2230        .expect("runtime");
2231        let db = runtime.db();
2232        let store = db.store();
2233        let pager = store.pager().expect("pager");
2234        let auth = Arc::new(
2235            AuthStore::with_vault_certificate(
2236                AuthConfig::default(),
2237                Arc::clone(pager),
2238                "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
2239            )
2240            .expect("vault"),
2241        );
2242        let server = crate::server::RedDBServer::new(runtime.clone()).with_auth(auth);
2243        let embedding_body =
2244            r#"{"data":[{"index":0,"embedding":[0.1,0.2]}],"model":"Gateway/Embedding"}"#;
2245        for operation in [
2246            "prompt",
2247            "ask",
2248            "http-embeddings",
2249            "grpc-embeddings",
2250            "batch",
2251            "auto-embed",
2252        ] {
2253            let response_body = if matches!(operation, "prompt" | "ask") {
2254                r#"{"choices":[{"message":{"content":"hello"}}],"model":"Gateway/Chat"}"#
2255            } else {
2256                embedding_body
2257            };
2258            let (base, captured) = spawn_mock(200, response_body);
2259            let endpoint = format!("{base}/Gateway/v1");
2260            let registration = format!(
2261                r#"{{"provider":"red-router","alias":"mine","api_key":"synthetic-gateway-key","endpoint":"{endpoint}","default":true,"model":"Gateway/Chat"}}"#
2262            );
2263            let response = server.handle_ai_credentials(registration.into_bytes());
2264            assert_eq!(
2265                response.status,
2266                200,
2267                "{}",
2268                String::from_utf8_lossy(&response.body)
2269            );
2270            assert!(!String::from_utf8_lossy(&response.body).contains("synthetic-gateway-key"));
2271            assert_eq!(
2272                runtime
2273                    .db()
2274                    .store()
2275                    .get_config("ai.providers.red-router.base_url"),
2276                Some(reddb_types::Value::text(endpoint.clone())),
2277                "registration must replace the endpoint in red.config"
2278            );
2279            assert_eq!(
2280                runtime
2281                    .get_kv("red.config", "ai.providers.red-router.base_url")
2282                    .expect("read registered gateway endpoint")
2283                    .map(|(value, _)| value),
2284                Some(reddb_types::Value::text(endpoint.clone())),
2285                "registration must replace the endpoint visible to runtime readers"
2286            );
2287            assert_eq!(
2288                runtime
2289                    .get_kv("red.config", "ai.inference.provider")
2290                    .expect("read registered gateway provider")
2291                    .map(|(value, _)| value),
2292                Some(reddb_types::Value::text("red-router")),
2293                "registration must select the canonical inference provider key"
2294            );
2295            match operation {
2296                "prompt" => {
2297                    let response = server.handle_ai_prompt(br#"{"prompt":"hi"}"#.to_vec());
2298                    assert_eq!(
2299                        response.status,
2300                        200,
2301                        "{}",
2302                        String::from_utf8_lossy(&response.body)
2303                    );
2304                }
2305                "ask" => {
2306                    let response = server.handle_ai_ask(br#"{"question":"hi"}"#.to_vec());
2307                    assert_eq!(
2308                        response.status,
2309                        200,
2310                        "{}",
2311                        String::from_utf8_lossy(&response.body)
2312                    );
2313                }
2314                "auto-embed" => {
2315                    runtime.execute_query("INSERT INTO gateway_batch (body) VALUES ('hi') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'").expect("auto embed");
2316                    let rows = runtime
2317                        .execute_query("SELECT * FROM gateway_batch")
2318                        .expect("rows");
2319                    assert_eq!(
2320                        rows.result.records.len(),
2321                        2,
2322                        "one row and its embedding vector"
2323                    );
2324                }
2325                "http-embeddings" => {
2326                    let response = server.handle_ai_embeddings(
2327                        br#"{"model":"Gateway/Embedding","input":"hi"}"#.to_vec(),
2328                    );
2329                    assert_eq!(
2330                        response.status,
2331                        200,
2332                        "{}",
2333                        String::from_utf8_lossy(&response.body)
2334                    );
2335                }
2336                "grpc-embeddings" => {
2337                    let payload =
2338                        crate::serde_json::json!({"model":"Gateway/Embedding","input":"hi"});
2339                    grpc_embeddings(&runtime, &payload).expect("gRPC embedding");
2340                }
2341                "batch" => {
2342                    let connection =
2343                        resolve_connection_from_runtime(&AiProvider::RedRouter, None, &runtime)
2344                            .expect("connection");
2345                    let client =
2346                        crate::runtime::ai::batch_client::AiBatchClient::from_runtime(&runtime);
2347                    let vectors = client
2348                        .embed_batch_at(
2349                            &AiProvider::RedRouter,
2350                            "Gateway/Embedding",
2351                            &connection.api_key,
2352                            &connection.api_base,
2353                            vec!["hi".to_string()],
2354                        )
2355                        .await
2356                        .expect("batch");
2357                    assert_eq!(vectors, vec![vec![0.1, 0.2]]);
2358                }
2359                _ => unreachable!("listed operation"),
2360            }
2361            let request = captured
2362                .lock()
2363                .expect("capture lock")
2364                .take()
2365                .expect("request");
2366            let suffix = if matches!(operation, "prompt" | "ask") {
2367                "chat/completions"
2368            } else {
2369                "embeddings"
2370            };
2371            assert_eq!(request.path, format!("/Gateway/v1/{suffix}"));
2372            assert!(request
2373                .headers
2374                .iter()
2375                .any(|(name, value)| name.eq_ignore_ascii_case("authorization")
2376                    && value == "Bearer synthetic-gateway-key"));
2377            let model = if matches!(operation, "prompt" | "ask") {
2378                "Gateway/Chat"
2379            } else {
2380                "Gateway/Embedding"
2381            };
2382            assert!(request.body.contains(model), "{}", request.body);
2383        }
2384        drop(server);
2385        drop(runtime);
2386        drop(db);
2387        let _ = std::fs::remove_file(path);
2388    }
2389
2390    #[test]
2391    fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
2392        let body = r#"{
2393            "id":"chatcmpl_x",
2394            "model":"custom-model",
2395            "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
2396            "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
2397        }"#;
2398        let (base, captured) = spawn_mock(200, body);
2399
2400        let req = OpenAiCompatChatRequest {
2401            api_base: base.clone(),
2402            api_key: "sk-test".to_string(),
2403            model: "custom-model".to_string(),
2404            prompt: "say hi".to_string(),
2405            temperature: None,
2406            seed: None,
2407            max_output_tokens: None,
2408            extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
2409        };
2410        let resp = openai_compat_chat(req).expect("ok");
2411
2412        assert_eq!(resp.output_text, "hi");
2413        assert_eq!(resp.model, "custom-model");
2414        assert_eq!(resp.usage.input_tokens, Some(7));
2415        assert_eq!(resp.usage.output_tokens, Some(2));
2416        assert_eq!(resp.usage.total_tokens, Some(9));
2417        assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
2418
2419        let cap = captured.lock().unwrap().take().expect("captured");
2420        assert_eq!(cap.method, "POST");
2421        assert_eq!(cap.path, "/chat/completions");
2422        let has_auth = cap
2423            .headers
2424            .iter()
2425            .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
2426        assert!(has_auth, "Authorization header missing");
2427        let has_custom = cap
2428            .headers
2429            .iter()
2430            .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
2431        assert!(has_custom, "extra header missing");
2432        assert!(cap.body.contains("\"model\":\"custom-model\""));
2433    }
2434
2435    #[test]
2436    fn openai_compat_embeddings_roundtrip_with_dimensions() {
2437        let body = r#"{
2438            "object":"list",
2439            "model":"embed-model",
2440            "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
2441            "usage":{"prompt_tokens":4,"total_tokens":4}
2442        }"#;
2443        let (base, captured) = spawn_mock(200, body);
2444
2445        let req = OpenAiCompatEmbeddingsRequest {
2446            api_base: base,
2447            api_key: "sk-emb".to_string(),
2448            model: "embed-model".to_string(),
2449            inputs: vec!["hello".to_string()],
2450            dimensions: Some(2),
2451            extra_headers: vec![],
2452        };
2453        let resp = openai_compat_embeddings(req).expect("ok");
2454
2455        assert_eq!(resp.embeddings.len(), 1);
2456        assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
2457        assert_eq!(resp.usage.total_tokens, Some(4));
2458        assert_eq!(resp.usage.input_tokens, Some(4));
2459
2460        let cap = captured.lock().unwrap().take().expect("captured");
2461        assert_eq!(cap.path, "/embeddings");
2462        assert!(cap.body.contains("\"dimensions\":2"));
2463    }
2464
2465    #[test]
2466    fn openai_compat_chat_non_2xx_returns_structured_error() {
2467        let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
2468        let (base, _captured) = spawn_mock(401, body);
2469
2470        let req = OpenAiCompatChatRequest {
2471            api_base: base,
2472            api_key: "bad".to_string(),
2473            model: "m".to_string(),
2474            prompt: "hi".to_string(),
2475            temperature: None,
2476            seed: None,
2477            max_output_tokens: None,
2478            extra_headers: vec![],
2479        };
2480        let err = openai_compat_chat(req).unwrap_err().to_string();
2481        assert!(err.contains("status 401"), "got: {err}");
2482        assert!(err.contains("bad api key"), "got: {err}");
2483    }
2484
2485    #[test]
2486    fn openai_compat_chat_rejects_empty_model_and_prompt() {
2487        let req = OpenAiCompatChatRequest {
2488            api_base: "http://localhost:1".to_string(),
2489            api_key: "k".to_string(),
2490            model: "  ".to_string(),
2491            prompt: "hi".to_string(),
2492            temperature: None,
2493            seed: None,
2494            max_output_tokens: None,
2495            extra_headers: vec![],
2496        };
2497        let err = openai_compat_chat(req).unwrap_err().to_string();
2498        assert!(err.contains("model cannot be empty"), "got: {err}");
2499
2500        let req = OpenAiCompatChatRequest {
2501            api_base: "http://localhost:1".to_string(),
2502            api_key: "k".to_string(),
2503            model: "m".to_string(),
2504            prompt: "  ".to_string(),
2505            temperature: None,
2506            seed: None,
2507            max_output_tokens: None,
2508            extra_headers: vec![],
2509        };
2510        let err = openai_compat_chat(req).unwrap_err().to_string();
2511        assert!(err.contains("prompt cannot be empty"), "got: {err}");
2512    }
2513
2514    #[test]
2515    fn parse_provider_mode_recognizes_all_three_tokens() {
2516        assert_eq!(
2517            parse_provider_mode("openai-compat"),
2518            Some(AiProviderMode::OpenAiCompat)
2519        );
2520        assert_eq!(
2521            parse_provider_mode("OPENAI_NATIVE"),
2522            Some(AiProviderMode::OpenAiNative)
2523        );
2524        assert_eq!(
2525            parse_provider_mode("anthropic-native"),
2526            Some(AiProviderMode::AnthropicNative)
2527        );
2528        assert_eq!(parse_provider_mode("groq"), None);
2529    }
2530
2531    #[test]
2532    fn resolve_provider_mode_reads_kv_key() {
2533        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2534            if key == "ai.provider" {
2535                Ok(Some("anthropic-native".to_string()))
2536            } else {
2537                Ok(None)
2538            }
2539        };
2540        assert_eq!(
2541            resolve_provider_mode(&kv),
2542            Some(AiProviderMode::AnthropicNative)
2543        );
2544    }
2545
2546    #[test]
2547    fn resolve_default_provider_keeps_vendor_when_mode_is_configured() {
2548        // A wire protocol must not replace the configured provider identity.
2549        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2550            match key {
2551                "ai.provider" => Ok(Some("anthropic-native".to_string())),
2552                "ai.inference.provider" => Ok(Some("groq".to_string())),
2553                _ => Ok(None),
2554            }
2555        };
2556        assert_eq!(
2557            resolve_default_provider(&kv).expect("provider"),
2558            AiProvider::Groq
2559        );
2560    }
2561
2562    #[test]
2563    fn auto_embed_missing_gateway_connection_fails_before_row_write() {
2564        let runtime = crate::RedDBRuntime::in_memory().expect("runtime");
2565        runtime
2566            .execute_query("CREATE TABLE gateway_docs (id INT, body TEXT)")
2567            .expect("table");
2568        let error = runtime.execute_query(
2569            "INSERT INTO gateway_docs (id, body) VALUES (1, 'hello') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'",
2570        ).expect_err("missing connection");
2571        assert!(error.to_string().contains("endpoint"), "{error}");
2572        let result = runtime
2573            .execute_query("SELECT * FROM gateway_docs")
2574            .expect("rows");
2575        assert!(
2576            result.result.records.is_empty(),
2577            "invalid connection must leave no rows"
2578        );
2579    }
2580
2581    #[test]
2582    fn gateway_connections_fail_closed_when_either_part_is_missing() {
2583        let provider = parse_provider("red-router").expect("router");
2584        let no_endpoint = |key: &str| -> crate::RedDBResult<Option<String>> {
2585            assert!(
2586                !key.starts_with("red.secret."),
2587                "validate endpoint before reading secrets"
2588            );
2589            Ok(None)
2590        };
2591        let err = resolve_connection(&provider, None, &no_endpoint)
2592            .err()
2593            .expect("endpoint required");
2594        assert!(err.to_string().contains("endpoint"));
2595        let endpoint_only = |key: &str| -> crate::RedDBResult<Option<String>> {
2596            Ok((key == "ai.providers.red-router.base_url")
2597                .then(|| "https://example.com/v1".to_string()))
2598        };
2599        let err = resolve_connection(&provider, None, &endpoint_only)
2600            .err()
2601            .expect("key required");
2602        assert!(err.to_string().contains("API key"));
2603    }
2604
2605    #[test]
2606    fn gateway_connections_preserve_endpoint_and_selected_credential() {
2607        let provider = parse_provider("openai-compat").expect("gateway");
2608        let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2609            Ok(match key {
2610                "ai.providers.openai-compat.base_url" => {
2611                    Some("https://example.com/Gateway/v1".to_string())
2612                }
2613                "ai.providers.openai-compat.credential" => Some("my-key".to_string()),
2614                "red.secret.ai.providers.openai-compat.tokens.my-key" => {
2615                    Some("synthetic-key".to_string())
2616                }
2617                _ => None,
2618            })
2619        };
2620        let connection = resolve_connection(&provider, None, &getter).expect("connection");
2621        assert_eq!(connection.api_base, "https://example.com/Gateway/v1");
2622        assert_eq!(connection.api_key, "synthetic-key");
2623    }
2624
2625    #[test]
2626    fn endpoint_validation_rejects_malformed_and_credential_bearing_urls() {
2627        for endpoint in [
2628            "local",
2629            "ftp://example.com/v1",
2630            "https://",
2631            "https://user:pass@example.com/v1",
2632            "https://example.com/v1?key=secret",
2633            "https://example.com/v1#fragment",
2634        ] {
2635            assert!(
2636                validate_custom_provider_url(endpoint).is_err(),
2637                "{endpoint}"
2638            );
2639        }
2640        assert!(validate_custom_provider_url("http://127.0.0.1:8000/MyGateway/v1").is_ok());
2641    }
2642
2643    #[test]
2644    fn gateway_provider_names_and_url_paths_keep_their_identity() {
2645        for name in ["openai-compat", "red-router"] {
2646            let provider = parse_provider(name).expect("named gateway");
2647            assert_eq!(provider.token(), name);
2648            assert!(provider.is_openai_compatible());
2649            assert!(provider.requires_api_key());
2650            assert_eq!(provider.default_api_base(), "");
2651            assert!(!provider.default_key_env_name().contains('-'));
2652        }
2653        let url = "https://example.com/MyGateway/v1";
2654        let provider = parse_provider(url).expect("URL provider");
2655        assert_eq!(provider.default_api_base(), url);
2656    }
2657
2658    #[test]
2659    fn implicit_credentials_reject_non_ai_secret_refs_before_reading_secret() {
2660        let provider = AiProvider::Custom("invalid_secret_ref_test".to_string());
2661        let key = ai_api_secret_ref_config_key(&provider, "default");
2662        let err = resolve_api_key(&provider, None, |path| {
2663            if path == key {
2664                return Ok(Some("red.secret.aes_key".to_string()));
2665            }
2666            assert_ne!(path, "red.secret.aes_key", "must not read engine secret");
2667            Ok(None)
2668        })
2669        .expect_err("non-AI secret ref must fail");
2670        assert!(err.to_string().contains("must reference"));
2671    }
2672
2673    // ---- ADR-0068 §5 config schema (issue #1746) --------------------------
2674
2675    #[test]
2676    fn invalid_provider_selection_does_not_fall_back_to_vendor_credentials() {
2677        let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2678            Ok((key == "ai.inference.provider").then(|| "typo-provider".to_string()))
2679        };
2680        assert!(resolve_default_provider(&getter).is_err());
2681        assert!(resolve_embeddings_provider(&getter).is_err());
2682    }
2683
2684    #[test]
2685    fn inference_provider_ask_specific_beats_task_pointer() {
2686        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2687            match key {
2688                "ai.ask.provider" => Ok(Some("groq".to_string())),
2689                "ai.inference.provider" => Ok(Some("deepseek".to_string())),
2690                _ => Ok(None),
2691            }
2692        };
2693        assert_eq!(
2694            resolve_default_provider(&kv).expect("provider"),
2695            AiProvider::Groq
2696        );
2697    }
2698
2699    #[test]
2700    fn inference_provider_falls_through_task_pointer_then_global_default() {
2701        let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
2702            match key {
2703                "ai.inference.provider" => Ok(Some("deepseek".to_string())),
2704                _ => Ok(None),
2705            }
2706        };
2707        assert_eq!(
2708            resolve_default_provider(&pointer).expect("provider"),
2709            AiProvider::DeepSeek
2710        );
2711
2712        let global = |key: &str| -> crate::RedDBResult<Option<String>> {
2713            Ok((key == "ai.default.provider").then(|| "groq".to_string()))
2714        };
2715        assert_eq!(
2716            resolve_default_provider(&global).expect("provider"),
2717            AiProvider::Groq
2718        );
2719
2720        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2721        assert_eq!(
2722            resolve_default_provider(&empty).expect("provider"),
2723            AiProvider::OpenAi
2724        );
2725    }
2726
2727    #[test]
2728    fn inference_model_ask_specific_beats_models_block_then_global_default() {
2729        let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
2730            match key {
2731                "ai.ask.model" => Ok(Some("gpt-ask".to_string())),
2732                "ai.providers.openai.models.inference" => Ok(Some("gpt-block".to_string())),
2733                _ => Ok(None),
2734            }
2735        };
2736        assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
2737
2738        let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2739            match key {
2740                "ai.providers.openai.models.inference" => Ok(Some("gpt-block".to_string())),
2741                _ => Ok(None),
2742            }
2743        };
2744        assert_eq!(
2745            resolve_default_model(&AiProvider::OpenAi, &block),
2746            "gpt-block"
2747        );
2748
2749        let global = |key: &str| -> crate::RedDBResult<Option<String>> {
2750            Ok((key == "ai.default.model").then(|| "gpt-global".to_string()))
2751        };
2752        assert_eq!(
2753            resolve_default_model(&AiProvider::OpenAi, &global),
2754            "gpt-global"
2755        );
2756
2757        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2758        assert_eq!(
2759            resolve_default_model(&AiProvider::OpenAi, &empty),
2760            AiProvider::OpenAi.default_prompt_model()
2761        );
2762    }
2763
2764    #[test]
2765    fn embeddings_provider_follows_task_pointer() {
2766        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2767            match key {
2768                "ai.embeddings.provider" => Ok(Some("ollama".to_string())),
2769                _ => Ok(None),
2770            }
2771        };
2772        assert_eq!(
2773            resolve_embeddings_provider(&kv).unwrap(),
2774            AiProvider::Ollama
2775        );
2776
2777        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2778        assert_eq!(
2779            resolve_embeddings_provider(&empty).unwrap(),
2780            AiProvider::OpenAi
2781        );
2782    }
2783
2784    #[test]
2785    fn embeddings_selection_rejects_chat_only_providers_and_accepts_routers() {
2786        for token in ["anthropic", "groq", "deepseek"] {
2787            let provider = parse_provider(token).expect("provider");
2788            assert!(!provider.supports_embeddings());
2789            assert!(ensure_provider_supports_embeddings(&provider).is_err());
2790        }
2791        for token in ["openrouter", "venice", "openai-compat", "red-router"] {
2792            let provider = parse_provider(token).expect("provider");
2793            assert!(ensure_provider_supports_embeddings(&provider).is_ok());
2794        }
2795    }
2796
2797    #[test]
2798    fn embeddings_provider_rejects_modality_incapable_pointer() {
2799        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2800            match key {
2801                "ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
2802                _ => Ok(None),
2803            }
2804        };
2805        let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
2806        assert!(
2807            err.contains("ai.embeddings.provider"),
2808            "error must name the pointer to fix: {err}"
2809        );
2810        assert!(
2811            err.contains("openai") && err.contains("no embeddings API"),
2812            "error must name capable alternatives: {err}"
2813        );
2814    }
2815
2816    #[test]
2817    fn embeddings_provider_falls_back_to_default_provider_chain() {
2818        // With no embeddings-specific override, the embeddings provider
2819        // follows the general default-provider chain (here the inference
2820        // task pointer). This keeps a single-global-config deployment
2821        // routing embeddings through the one provider it named — the
2822        // regression the local-vector text-search tests guard against.
2823        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2824            match key {
2825                "ai.inference.provider" => Ok(Some("ollama".to_string())),
2826                _ => Ok(None),
2827            }
2828        };
2829        assert_eq!(
2830            resolve_embeddings_provider(&kv).unwrap(),
2831            AiProvider::Ollama
2832        );
2833    }
2834
2835    #[test]
2836    fn embeddings_model_block_beats_builtin() {
2837        let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2838            match key {
2839                "ai.providers.openai.models.embeddings" => {
2840                    Ok(Some("text-embedding-custom".to_string()))
2841                }
2842                _ => Ok(None),
2843            }
2844        };
2845        assert_eq!(
2846            resolve_embeddings_model(&AiProvider::OpenAi, &block),
2847            "text-embedding-custom"
2848        );
2849
2850        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2851        assert_eq!(
2852            resolve_embeddings_model(&AiProvider::Ollama, &empty),
2853            AiProvider::Ollama.default_embedding_model()
2854        );
2855    }
2856
2857    #[test]
2858    fn base_url_reads_provider_block_key() {
2859        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2860            if key == "ai.providers.openai.base_url" {
2861                Ok(Some("https://proxy.example/v1".to_string()))
2862            } else {
2863                Ok(None)
2864            }
2865        };
2866        assert_eq!(
2867            AiProvider::OpenAi
2868                .resolve_api_base_checked(&kv)
2869                .expect("endpoint"),
2870            "https://proxy.example/v1"
2871        );
2872    }
2873
2874    #[test]
2875    fn removed_config_keys_rejected_naming_replacements() {
2876        let err = validate_ai_config_key_on_write("ai.openai.default.base_url")
2877            .unwrap_err()
2878            .to_string();
2879        assert!(err.contains("ai.providers.<provider>.base_url"), "{err}");
2880
2881        // Global defaults reported by existing databases remain valid,
2882        // alongside the task/provider-specific schema.
2883        assert!(validate_ai_config_key_on_write("ai.default.provider").is_ok());
2884        assert!(validate_ai_config_key_on_write("ai.default.model").is_ok());
2885        assert!(validate_ai_config_key_on_write("ai.inference.provider").is_ok());
2886        assert!(validate_ai_config_key_on_write("ai.providers.openai.base_url").is_ok());
2887        assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
2888    }
2889
2890    #[test]
2891    fn ask_planner_model_and_effort_resolve() {
2892        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2893            match key {
2894                "ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
2895                "ai.ask.effort" => Ok(Some("high".to_string())),
2896                _ => Ok(None),
2897            }
2898        };
2899        assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
2900        assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
2901
2902        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2903        assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
2904        assert_eq!(resolve_ask_effort(&empty), None);
2905    }
2906
2907    #[tokio::test]
2908    async fn anthropic_prompt_async_rejects_empty_api_key() {
2909        let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2910        let request = AnthropicPromptRequest {
2911            api_key: "  ".to_string(),
2912            model: "claude-3-5-haiku-latest".to_string(),
2913            prompt: "hello".to_string(),
2914            temperature: None,
2915            max_output_tokens: None,
2916            api_base: "https://api.anthropic.com/v1".to_string(),
2917            anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
2918        };
2919        let err = anthropic_prompt_async(&transport, request)
2920            .await
2921            .unwrap_err();
2922        assert!(err.to_string().contains("API key cannot be empty"));
2923    }
2924}
2925
2926// ============================================================================
2927// Provider & Credential Resolution (shared between HTTP, gRPC, and runtime)
2928// ============================================================================
2929
2930/// AI provider identifier.
2931#[derive(Debug, Clone, PartialEq, Eq)]
2932pub enum AiProvider {
2933    OpenAi,
2934    Anthropic,
2935    Groq,
2936    OpenRouter,
2937    OpenAiCompat,
2938    RedRouter,
2939    Together,
2940    Venice,
2941    Ollama,
2942    DeepSeek,
2943    MiniMax,
2944    HuggingFace,
2945    Local,
2946    Custom(String),
2947}
2948
2949impl AiProvider {
2950    pub fn token(&self) -> &str {
2951        match self {
2952            Self::OpenAi => "openai",
2953            Self::Anthropic => "anthropic",
2954            Self::Groq => "groq",
2955            Self::OpenRouter => "openrouter",
2956            Self::OpenAiCompat => "openai-compat",
2957            Self::RedRouter => "red-router",
2958            Self::Together => "together",
2959            Self::Venice => "venice",
2960            Self::Ollama => "ollama",
2961            Self::DeepSeek => "deepseek",
2962            Self::MiniMax => "minimax",
2963            Self::HuggingFace => "huggingface",
2964            Self::Local => "local",
2965            Self::Custom(name) => name.as_str(),
2966        }
2967    }
2968
2969    pub fn default_prompt_model(&self) -> &str {
2970        match self {
2971            Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
2972            Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
2973            Self::Groq => "llama-3.3-70b-versatile",
2974            Self::OpenRouter => "auto",
2975            Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
2976            Self::Venice => "llama-3.3-70b",
2977            Self::Ollama => "llama3",
2978            Self::DeepSeek => "deepseek-chat",
2979            Self::MiniMax => "abab6.5s-chat",
2980            Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
2981            Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2982            Self::Custom(_) | Self::OpenAiCompat | Self::RedRouter => DEFAULT_OPENAI_PROMPT_MODEL,
2983        }
2984    }
2985
2986    pub fn prompt_model_env_name(&self) -> String {
2987        format!(
2988            "REDDB_{}_PROMPT_MODEL",
2989            self.token().replace('-', "_").to_ascii_uppercase()
2990        )
2991    }
2992
2993    pub fn default_embedding_model(&self) -> &str {
2994        match self {
2995            Self::Ollama => "nomic-embed-text",
2996            Self::OpenRouter => "openai/text-embedding-3-small",
2997            Self::Venice => "text-embedding-bge-m3",
2998            Self::MiniMax => "embo-01",
2999            Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
3000            _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
3001        }
3002    }
3003
3004    pub fn default_api_base(&self) -> &str {
3005        match self {
3006            Self::OpenAi => DEFAULT_OPENAI_API_BASE,
3007            Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
3008            Self::Groq => "https://api.groq.com/openai/v1",
3009            Self::OpenRouter => "https://openrouter.ai/api/v1",
3010            Self::Together => "https://api.together.xyz/v1",
3011            Self::Venice => "https://api.venice.ai/api/v1",
3012            Self::Ollama => "http://localhost:11434/v1",
3013            Self::DeepSeek => "https://api.deepseek.com/v1",
3014            Self::MiniMax => "https://api.minimax.chat/v1",
3015            Self::HuggingFace => "https://api-inference.huggingface.co",
3016            Self::Local => "local",
3017            Self::Custom(base) => base.as_str(),
3018            Self::OpenAiCompat | Self::RedRouter => "",
3019        }
3020    }
3021
3022    pub fn api_base_env_name(&self) -> String {
3023        format!(
3024            "REDDB_{}_API_BASE",
3025            self.token().replace('-', "_").to_ascii_uppercase()
3026        )
3027    }
3028
3029    pub fn default_key_env_name(&self) -> String {
3030        format!(
3031            "REDDB_{}_API_KEY",
3032            self.token().replace('-', "_").to_ascii_uppercase()
3033        )
3034    }
3035
3036    pub fn alias_key_env_name(&self, alias: &str) -> String {
3037        let normalized = normalize_alias_token(alias);
3038        format!(
3039            "REDDB_{}_API_KEY_{normalized}",
3040            self.token().replace('-', "_").to_ascii_uppercase()
3041        )
3042    }
3043
3044    pub fn resolve_api_base(&self) -> String {
3045        if let Ok(value) = std::env::var(self.api_base_env_name()) {
3046            let value = value.trim().to_string();
3047            if !value.is_empty() {
3048                return value;
3049            }
3050        }
3051        self.default_api_base().to_string()
3052    }
3053
3054    /// Resolve and validate the endpoint before any provider credential is read.
3055    pub fn resolve_api_base_checked<F>(&self, kv_getter: &F) -> crate::RedDBResult<String>
3056    where
3057        F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3058    {
3059        let endpoint = match std::env::var(self.api_base_env_name()) {
3060            Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
3061            _ => kv_getter(&provider_base_url_key(self))?
3062                .filter(|value| !value.trim().is_empty())
3063                .map(|value| value.trim().to_string())
3064                .unwrap_or_else(|| self.default_api_base().to_string()),
3065        };
3066        if !matches!(self, Self::Local) {
3067            if endpoint.is_empty() {
3068                return Err(crate::RedDBError::Query(format!(
3069                    "missing {} endpoint; set {} or '{}' alongside the API key",
3070                    self.token(),
3071                    self.api_base_env_name(),
3072                    provider_base_url_key(self),
3073                )));
3074            }
3075            validate_custom_provider_url(&endpoint)?;
3076        }
3077        Ok(endpoint)
3078    }
3079
3080    /// Gateways have no trusted built-in endpoint; registration requires the pair.
3081    pub fn requires_explicit_endpoint(&self) -> bool {
3082        matches!(self, Self::OpenAiCompat | Self::RedRouter | Self::Custom(_))
3083    }
3084
3085    /// Whether this provider uses the OpenAI-compatible API format.
3086    pub fn is_openai_compatible(&self) -> bool {
3087        matches!(
3088            self,
3089            Self::OpenAi
3090                | Self::Groq
3091                | Self::OpenRouter
3092                | Self::OpenAiCompat
3093                | Self::RedRouter
3094                | Self::Together
3095                | Self::Venice
3096                | Self::Ollama
3097                | Self::DeepSeek
3098                | Self::MiniMax
3099                | Self::Custom(_)
3100        )
3101    }
3102
3103    /// Whether this provider requires an API key (Ollama/Local don't).
3104    pub fn requires_api_key(&self) -> bool {
3105        !matches!(self, Self::Ollama | Self::Local)
3106    }
3107
3108    /// Embeddings routing and policy validation share one modality matrix.
3109    pub fn supports_embeddings(&self) -> bool {
3110        crate::runtime::ai::provider_capabilities::Modalities::for_provider(self.token()).embed
3111    }
3112}
3113
3114/// Parse a provider string into AiProvider.
3115pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
3116    let name = name.trim();
3117    match name.to_ascii_lowercase().as_str() {
3118        "openai" => Ok(AiProvider::OpenAi),
3119        "anthropic" => Ok(AiProvider::Anthropic),
3120        "groq" => Ok(AiProvider::Groq),
3121        "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
3122        "openai-compat" | "openai_compat" => Ok(AiProvider::OpenAiCompat),
3123        "red-router" | "red_router" => Ok(AiProvider::RedRouter),
3124        "together" => Ok(AiProvider::Together),
3125        "venice" => Ok(AiProvider::Venice),
3126        "ollama" => Ok(AiProvider::Ollama),
3127        "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
3128        "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
3129        "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
3130        "local" => Ok(AiProvider::Local),
3131        other => {
3132            // Treat as custom provider if it looks like a URL
3133            if other.starts_with("http://") || other.starts_with("https://") {
3134                validate_custom_provider_url(name)?;
3135                Ok(AiProvider::Custom(name.to_string()))
3136            } else {
3137                Err(crate::RedDBError::Query(format!(
3138                    "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
3139                     openrouter, openai-compat, red-router, together, venice, ollama, deepseek, minimax, huggingface, local"
3140                )))
3141            }
3142        }
3143    }
3144}
3145
3146// ============================================================================
3147// AI config schema (ADR-0068 §5) — clean break
3148//
3149//   ai.default.{provider,model}       # global fallbacks
3150//   ai.ask.{provider,model,planner_model,effort,max_plan_steps}
3151//   ai.providers.<provider>.base_url
3152//   ai.providers.<provider>.models.{inference,embeddings}
3153//   ai.inference.provider   # task pointer: who generates
3154//   ai.embeddings.provider  # task pointer: who embeds
3155//
3156// Resolution order for any AI call:
3157//   ASK-specific config -> task pointer -> pointed provider's models block
3158//   -> provider built-in default.
3159//
3160// The old per-alias `ai.<provider>.<alias>.base_url` base-URL shape is
3161// removed and rejected on write; there is no deprecation window.
3162// ============================================================================
3163
3164/// Providers that can serve embeddings, listed for didactic errors.
3165pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
3166    "openai, ollama, openrouter, openai-compat, red-router, together, venice, minimax, huggingface, local";
3167
3168/// KV key for a provider's base URL under the new schema.
3169pub fn provider_base_url_key(provider: &AiProvider) -> String {
3170    format!("ai.providers.{}.base_url", provider.token())
3171}
3172
3173/// KV key for a provider's per-modality model under the new schema.
3174/// `modality` is `"inference"` or `"embeddings"`.
3175pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
3176    format!("ai.providers.{}.models.{modality}", provider.token())
3177}
3178
3179/// Reject an AI config key that was removed in the ADR-0068 clean break,
3180/// naming the replacement key. Called from the `SET CONFIG` write path so
3181/// operators cannot silently persist a key nothing reads. Returns `Ok(())`
3182/// for every key that is still valid (including non-AI keys).
3183pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
3184    let key = key.trim();
3185    // Old per-alias base-URL shape: ai.<provider>.<alias>.base_url
3186    // (anything under ai.* ending in .base_url that is NOT the
3187    // new ai.providers.<provider>.base_url form).
3188    if key.starts_with("ai.") && key.ends_with(".base_url") && !key.starts_with("ai.providers.") {
3189        return Err(crate::RedDBError::Query(format!(
3190            "AI config key '{key}' uses the removed per-credential base-URL shape; set \
3191             'ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
3192        )));
3193    }
3194    Ok(())
3195}
3196
3197/// Resolve the inference (generation) provider. Precedence:
3198/// 1. `REDDB_AI_PROVIDER` env var.
3199/// 2. ASK-specific config `ai.ask.provider`.
3200/// 3. Inference task pointer `ai.inference.provider`.
3201/// 4. Global default `ai.default.provider`.
3202/// 5. Legacy wire-protocol mode selector when no provider was selected.
3203/// 6. Falls back to OpenAI.
3204pub fn resolve_default_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
3205where
3206    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3207{
3208    if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
3209        if !value.trim().is_empty() {
3210            return parse_provider(&value);
3211        }
3212    }
3213    for key in [
3214        "ai.ask.provider",
3215        "ai.inference.provider",
3216        "ai.default.provider",
3217    ] {
3218        if let Some(value) = kv_getter(key)? {
3219            if !value.trim().is_empty() {
3220                return parse_provider(&value);
3221            }
3222        }
3223    }
3224    Ok(resolve_provider_mode(kv_getter)
3225        .map(provider_mode_to_provider)
3226        .unwrap_or(AiProvider::OpenAi))
3227}
3228
3229/// Resolve the inference (generation) model for `provider`. Precedence:
3230/// 1. `REDDB_AI_MODEL` env var.
3231/// 2. Provider-specific prompt-model env var.
3232/// 3. ASK-specific config `ai.ask.model`.
3233/// 4. Provider models block `…providers.<p>.models.inference`.
3234/// 5. Global default `ai.default.model`.
3235/// 6. Provider built-in default prompt model.
3236pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
3237where
3238    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3239{
3240    // 1. Env var
3241    if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
3242        let value = value.trim().to_string();
3243        if !value.is_empty() {
3244            return value;
3245        }
3246    }
3247    // 2. Provider-specific env var
3248    if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
3249        let value = value.trim().to_string();
3250        if !value.is_empty() {
3251            return value;
3252        }
3253    }
3254    // 3. ASK-specific config, then 4. provider models block.
3255    for key in [
3256        "ai.ask.model".to_string(),
3257        provider_models_key(provider, "inference"),
3258        "ai.default.model".to_string(),
3259    ] {
3260        if let Ok(Some(value)) = kv_getter(&key) {
3261            let value = value.trim().to_string();
3262            if !value.is_empty() {
3263                return value;
3264            }
3265        }
3266    }
3267    provider.default_prompt_model().to_string()
3268}
3269
3270/// Resolve the embeddings provider. Precedence:
3271/// 1. `REDDB_AI_EMBEDDINGS_PROVIDER` env var (embeddings-specific override).
3272/// 2. `ai.embeddings.provider` task pointer.
3273/// 3. Fall back to the general default provider — [`resolve_default_provider`]
3274///    honours `REDDB_AI_PROVIDER`, the wire-protocol mode selector, and the
3275///    ASK/inference chain — so a deployment that only sets a single global
3276///    provider (e.g. `REDDB_AI_PROVIDER=local`) still embeds through it.
3277///
3278/// Fails with a didactic error — naming the embeddings pointer and the capable
3279/// providers — when the resolved provider has no embeddings API, rather than
3280/// silently re-routing (ADR-0068 §5; the Anthropic case generalized).
3281pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
3282where
3283    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3284{
3285    let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
3286        .ok()
3287        .map(|v| v.trim().to_string())
3288        .filter(|v| !v.is_empty())
3289    {
3290        parse_provider(&value)?
3291    } else if let Some(value) = kv_getter("ai.embeddings.provider")?
3292        .map(|v| v.trim().to_string())
3293        .filter(|v| !v.is_empty())
3294    {
3295        parse_provider(&value)?
3296    } else {
3297        // No embeddings-specific override: follow the general default
3298        // provider so the historical global `REDDB_AI_PROVIDER` selector
3299        // keeps driving embeddings when the task pointer is unset.
3300        resolve_default_provider(kv_getter)?
3301    };
3302    ensure_provider_supports_embeddings(&provider)?;
3303    Ok(provider)
3304}
3305
3306/// Fail with a didactic error when `provider` cannot serve embeddings.
3307pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
3308    if provider.supports_embeddings() {
3309        return Ok(());
3310    }
3311    Err(crate::RedDBError::Query(format!(
3312        "the embeddings task pointer 'ai.embeddings.provider' names '{}', which \
3313         has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
3314         re-routes embeddings to a different provider than the one you named.",
3315        provider.token(),
3316        EMBEDDING_CAPABLE_PROVIDERS
3317    )))
3318}
3319
3320/// Resolve the embeddings model for `provider`. Precedence:
3321/// 1. Provider-specific `REDDB_<P>_EMBEDDING_MODEL` env var.
3322/// 2. `REDDB_OPENAI_EMBEDDING_MODEL` env var (legacy shared override).
3323/// 3. Provider models block `…providers.<p>.models.embeddings`.
3324/// 4. General `REDDB_AI_MODEL` env var (historical global model selector).
3325/// 5. Provider built-in default embedding model.
3326///
3327/// The embeddings-specific sources (1–3) outrank the general `REDDB_AI_MODEL`
3328/// selector so an explicit embeddings model always wins; `REDDB_AI_MODEL`
3329/// only fills in for the built-in default, which is what keeps a
3330/// single-global-config deployment (`REDDB_AI_PROVIDER`+`REDDB_AI_MODEL`)
3331/// routing text embeddings through the named model.
3332pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
3333where
3334    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3335{
3336    if let Ok(value) = std::env::var(format!(
3337        "REDDB_{}_EMBEDDING_MODEL",
3338        provider.token().replace('-', "_").to_ascii_uppercase()
3339    )) {
3340        let value = value.trim().to_string();
3341        if !value.is_empty() {
3342            return value;
3343        }
3344    }
3345    if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
3346        let value = value.trim().to_string();
3347        if !value.is_empty() {
3348            return value;
3349        }
3350    }
3351    if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
3352        let value = value.trim().to_string();
3353        if !value.is_empty() {
3354            return value;
3355        }
3356    }
3357    if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
3358        let value = value.trim().to_string();
3359        if !value.is_empty() {
3360            return value;
3361        }
3362    }
3363    provider.default_embedding_model().to_string()
3364}
3365
3366/// Resolve the ASK planner model (`ai.ask.planner_model`),
3367/// falling back to `fallback_model` (typically the resolved ASK model).
3368/// Inert until the ASK planner slice consumes it.
3369pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
3370where
3371    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3372{
3373    if let Ok(Some(value)) = kv_getter("ai.ask.planner_model") {
3374        let value = value.trim().to_string();
3375        if !value.is_empty() {
3376            return value;
3377        }
3378    }
3379    fallback_model.to_string()
3380}
3381
3382/// Resolve the ASK planner effort (`ai.ask.effort`) if set.
3383/// Inert until the ASK planner slice consumes it.
3384pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
3385where
3386    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3387{
3388    if let Ok(Some(value)) = kv_getter("ai.ask.effort") {
3389        let value = value.trim().to_string();
3390        if !value.is_empty() {
3391            return Some(value);
3392        }
3393    }
3394    None
3395}
3396
3397/// Resolve default provider + model from runtime KV store.
3398pub fn resolve_defaults_from_runtime(
3399    runtime: &crate::runtime::RedDBRuntime,
3400) -> crate::RedDBResult<(AiProvider, String)> {
3401    use crate::application::ports::RuntimeEntityPort;
3402    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3403        match runtime.get_kv("red.config", key)? {
3404            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3405            _ => Ok(None),
3406        }
3407    };
3408    let provider = resolve_default_provider(&kv_getter)?;
3409    let model = resolve_default_model(&provider, &kv_getter);
3410    Ok((provider, model))
3411}
3412
3413pub fn resolve_prompt_model_from_runtime(
3414    runtime: &crate::runtime::RedDBRuntime,
3415    provider: &AiProvider,
3416    explicit: Option<&str>,
3417) -> String {
3418    use crate::application::ports::RuntimeEntityPort;
3419    if let Some(model) = explicit {
3420        return model.to_string();
3421    }
3422    resolve_default_model(provider, &|key| match runtime.get_kv("red.config", key)? {
3423        Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3424        _ => Ok(None),
3425    })
3426}
3427
3428/// Resolve the ASK planner model from runtime KV (`ai.ask.planner_model`),
3429/// falling back to `fallback_model` — the resolved synthesis/ASK model — when
3430/// no planner model is configured (ADR 0068 §3). Lets a cheap/fast model plan
3431/// while the user-chosen model synthesizes.
3432pub fn resolve_ask_planner_model_from_runtime(
3433    runtime: &crate::runtime::RedDBRuntime,
3434    fallback_model: &str,
3435) -> String {
3436    use crate::application::ports::RuntimeEntityPort;
3437    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3438        match runtime.get_kv("red.config", key)? {
3439            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3440            _ => Ok(None),
3441        }
3442    };
3443    resolve_ask_planner_model(&kv_getter, fallback_model)
3444}
3445
3446/// Resolve the default provider and model for runtime semantic search.
3447pub fn resolve_defaults_from_runtime_port<
3448    P: crate::application::ports::RuntimeEntityPort + ?Sized,
3449>(
3450    runtime: &P,
3451) -> crate::RedDBResult<(AiProvider, String)> {
3452    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3453        match runtime.get_kv("red.config", key)? {
3454            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3455            _ => Ok(None),
3456        }
3457    };
3458    let provider = resolve_default_provider(&kv_getter)?;
3459    let model = resolve_default_model(&provider, &kv_getter);
3460    Ok((provider, model))
3461}
3462
3463/// Resolve the embeddings provider for an AUTO EMBED / embeddings call from
3464/// a runtime port. `explicit` is the `USING <provider>` override (empty when
3465/// none was given); when empty the embeddings task pointer drives selection.
3466/// A modality-incapable provider fails didactically (ADR-0068 §5).
3467pub fn resolve_embeddings_provider_from_runtime<
3468    P: crate::application::ports::RuntimeEntityPort + ?Sized,
3469>(
3470    runtime: &P,
3471    explicit: &str,
3472) -> crate::RedDBResult<AiProvider> {
3473    let explicit = explicit.trim();
3474    if !explicit.is_empty() {
3475        let provider = parse_provider(explicit)?;
3476        ensure_provider_supports_embeddings(&provider)?;
3477        return Ok(provider);
3478    }
3479    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3480        match runtime.get_kv("red.config", key)? {
3481            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3482            _ => Ok(None),
3483        }
3484    };
3485    resolve_embeddings_provider(&kv_getter)
3486}
3487
3488/// Resolve the embeddings model for `provider` from a runtime port,
3489/// honouring an explicit `MODEL '<name>'` override before the config
3490/// resolution order (env → provider models block → built-in default).
3491pub fn resolve_embeddings_model_from_runtime<
3492    P: crate::application::ports::RuntimeEntityPort + ?Sized,
3493>(
3494    runtime: &P,
3495    provider: &AiProvider,
3496    explicit: Option<&str>,
3497) -> String {
3498    if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
3499        return model.to_string();
3500    }
3501    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
3502        match runtime.get_kv("red.config", key)? {
3503            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
3504            _ => Ok(None),
3505        }
3506    };
3507    resolve_embeddings_model(provider, &kv_getter)
3508}
3509
3510/// Resolve an API key for a provider, **preferring the encrypted vault
3511/// over environment variables** (issue #1270). The env vars are a
3512/// bootstrap fallback so a fresh deployment can talk to a provider
3513/// before any key has been written to the vault.
3514///
3515/// Resolution order (issue #1745 — clean break, no deprecation window):
3516/// 1. Vault token path: `red.secret.ai.providers.<provider>.tokens.<alias>`
3517/// 2. Vault token indirected via
3518///    `ai.providers.<provider>.tokens.<alias>.secret_ref`
3519/// 3. Env fallback: `REDDB_<PROVIDER>_API_KEY[_<ALIAS>]`
3520///
3521/// With no explicit alias, use `providers.<provider>.credential`, then `default`.
3522/// First non-empty source wins per request.
3523///
3524/// The old vault path shape (`red.secret.ai.<provider>.<alias>.api_key`)
3525/// and the legacy plaintext config path (`ai.<provider>.<alias>.key`)
3526/// are **removed**: a credential still sitting at either is rejected with a
3527/// didactic error naming the new vault path to populate — never silently
3528/// read.
3529///
3530/// `kv_getter` receives either a `red.secret.*` path (routed to the
3531/// encrypted vault by [`resolve_api_key_from_runtime`]) or a
3532/// canonical content key from `red.config` and returns the value if found. Vault-stored keys
3533/// are therefore encrypted at rest and rotatable via the existing vault
3534/// KV path; the env vars carry no such guarantees, which is why they are
3535/// the fallback rather than the primary source.
3536pub fn resolve_api_key<F>(
3537    provider: &AiProvider,
3538    credential_alias: Option<&str>,
3539    kv_getter: F,
3540) -> crate::RedDBResult<String>
3541where
3542    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3543{
3544    let configured_alias = if credential_alias.is_none() {
3545        kv_getter(&format!("ai.providers.{}.credential", provider.token()))?
3546    } else {
3547        None
3548    };
3549    let credential_alias = credential_alias.or(configured_alias.as_deref());
3550    if let Some(alias) = credential_alias
3551        .map(str::trim)
3552        .filter(|a| !a.is_empty() && *a != "default")
3553    {
3554        // 1. Vault token path (managed, encrypted at rest).
3555        if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
3556            if !key.trim().is_empty() {
3557                return Ok(key);
3558            }
3559        }
3560        // 2. Vault token reachable through a configured indirection ref.
3561        if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
3562            let secret_ref = secret_ref.trim();
3563            // The indirection may only name an AI provider credential.
3564            // Pointing it at `red.secret.aes_key` or an IAM blob would send
3565            // engine-internal key material as a bearer to the provider URL.
3566            validate_ai_secret_ref(secret_ref)?;
3567            if let Some(key) = kv_getter(secret_ref)? {
3568                if !key.trim().is_empty() {
3569                    return Ok(key);
3570                }
3571            }
3572        }
3573        // 3. Env fallback with alias (bootstrap before vault is populated).
3574        if let Some(key) = resolve_key_from_env_alias(provider, alias) {
3575            return Ok(key);
3576        }
3577        // Clean break: a credential still sitting at a removed path is
3578        // rejected didactically, never silently read (issue #1745).
3579        reject_removed_credential_paths(provider, alias, &kv_getter)?;
3580        return Err(crate::RedDBError::Query(format!(
3581            "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
3582            provider.token(),
3583            provider.alias_key_env_name(alias),
3584            ai_api_secret_path(provider, alias),
3585        )));
3586    }
3587
3588    // 1. Vault token path (managed, encrypted at rest).
3589    if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
3590        if !key.trim().is_empty() {
3591            return Ok(key);
3592        }
3593    }
3594    // 2. Vault token reachable through a configured indirection ref.
3595    if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
3596        let secret_ref = secret_ref.trim();
3597        validate_ai_secret_ref(secret_ref)?;
3598        if let Some(key) = kv_getter(secret_ref)? {
3599            if !key.trim().is_empty() {
3600                return Ok(key);
3601            }
3602        }
3603    }
3604
3605    // 3. Env fallback (bootstrap before the vault is populated).
3606    if let Ok(value) = std::env::var(provider.default_key_env_name()) {
3607        let value = value.trim().to_string();
3608        if !value.is_empty() {
3609            return Ok(value);
3610        }
3611    }
3612
3613    // Clean break: reject a credential still sitting at a removed path
3614    // instead of silently reading it (issue #1745).
3615    reject_removed_credential_paths(provider, "default", &kv_getter)?;
3616    if !provider.requires_api_key() {
3617        return Ok(String::new());
3618    }
3619
3620    Err(crate::RedDBError::Query(format!(
3621        "missing {} API key. Set {} or store it in the vault at '{}'",
3622        provider.token(),
3623        provider.default_key_env_name(),
3624        ai_api_secret_path(provider, "default"),
3625    )))
3626}
3627
3628fn validate_ai_secret_ref(secret_ref: &str) -> crate::RedDBResult<()> {
3629    if !secret_ref
3630        .to_ascii_lowercase()
3631        .starts_with("red.secret.ai.")
3632    {
3633        return Err(crate::RedDBError::Query(format!(
3634            "secret_ref '{secret_ref}' must reference a vault key under red.secret.ai.*"
3635        )));
3636    }
3637    Ok(())
3638}
3639
3640/// Vault token path (issue #1745): the sole credential source in the vault.
3641pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
3642    format!(
3643        "red.secret.ai.providers.{}.tokens.{}",
3644        provider.token(),
3645        normalize_credential_alias_path(alias)
3646    )
3647}
3648
3649/// Config key holding a vault indirection ref for the token (issue #1745).
3650pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
3651    format!(
3652        "ai.providers.{}.tokens.{}.secret_ref",
3653        provider.token(),
3654        normalize_credential_alias_path(alias)
3655    )
3656}
3657
3658/// Removed vault path shape (`red.secret.ai.<provider>.<alias>.api_key`,
3659/// issue #1745). Probed ONLY to reject with a migration error — never read
3660/// as a credential source.
3661fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
3662    format!(
3663        "red.secret.ai.{}.{}.api_key",
3664        provider.token(),
3665        normalize_credential_alias_path(alias)
3666    )
3667}
3668
3669/// Removed plaintext config path (`ai.<provider>.<alias>.key`,
3670/// issue #1745). Probed ONLY to reject with a migration error.
3671fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
3672    format!(
3673        "ai.{}.{}.key",
3674        provider.token(),
3675        normalize_credential_alias_path(alias)
3676    )
3677}
3678
3679/// Fail with a didactic error if a credential is still parked at either of
3680/// the removed paths (old vault shape or legacy plaintext config). The
3681/// clean break forbids silently falling back to them (issue #1745).
3682fn reject_removed_credential_paths<F>(
3683    provider: &AiProvider,
3684    alias: &str,
3685    kv_getter: &F,
3686) -> crate::RedDBResult<()>
3687where
3688    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3689{
3690    let new_path = ai_api_secret_path(provider, alias);
3691    for removed in [
3692        removed_vault_api_key_path(provider, alias),
3693        removed_plaintext_config_key(provider, alias),
3694    ] {
3695        if let Some(value) = kv_getter(&removed)? {
3696            if !value.trim().is_empty() {
3697                return Err(crate::RedDBError::Query(format!(
3698                    "AI credential found at removed path '{removed}'. The AI credential vault \
3699                     path changed (issue #1745): store the token at '{new_path}' instead. The \
3700                     old vault path shape and the legacy plaintext config path are no longer read."
3701                )));
3702            }
3703        }
3704    }
3705    Ok(())
3706}
3707
3708fn normalize_credential_alias_path(alias: &str) -> String {
3709    let alias = alias.trim();
3710    if alias.is_empty() {
3711        "default".to_string()
3712    } else {
3713        alias.to_ascii_lowercase()
3714    }
3715}
3716
3717fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
3718    let env_name = provider.alias_key_env_name(alias);
3719    std::env::var(env_name)
3720        .ok()
3721        .map(|v| v.trim().to_string())
3722        .filter(|v| !v.is_empty())
3723}
3724
3725fn normalize_alias_token(alias: &str) -> String {
3726    let mut out = String::with_capacity(alias.len());
3727    for character in alias.chars() {
3728        if character.is_ascii_alphanumeric() {
3729            out.push(character.to_ascii_uppercase());
3730        } else {
3731            out.push('_');
3732        }
3733    }
3734    while out.contains("__") {
3735        out = out.replace("__", "_");
3736    }
3737    out.trim_matches('_').to_string()
3738}
3739
3740/// A validated endpoint and its resolved credential. Deliberately has no Debug
3741/// implementation because the credential must not appear in diagnostics.
3742pub struct AiConnection {
3743    pub api_base: String,
3744    pub api_key: String,
3745}
3746
3747pub fn resolve_connection<F>(
3748    provider: &AiProvider,
3749    credential_alias: Option<&str>,
3750    kv_getter: &F,
3751) -> crate::RedDBResult<AiConnection>
3752where
3753    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3754{
3755    let api_base = provider.resolve_api_base_checked(kv_getter)?;
3756    let api_key = resolve_api_key(provider, credential_alias, kv_getter)?;
3757    Ok(AiConnection { api_base, api_key })
3758}
3759
3760pub fn resolve_connection_from_runtime(
3761    provider: &AiProvider,
3762    credential_alias: Option<&str>,
3763    runtime: &crate::runtime::RedDBRuntime,
3764) -> crate::RedDBResult<AiConnection> {
3765    use crate::application::ports::RuntimeEntityPort;
3766    let api_base =
3767        provider.resolve_api_base_checked(&|key| match runtime.get_kv("red.config", key)? {
3768            Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3769            _ => Ok(None),
3770        })?;
3771    let api_key = resolve_api_key_from_runtime(provider, credential_alias, runtime)?;
3772    Ok(AiConnection { api_base, api_key })
3773}
3774
3775/// Convenience: resolve API key using a RedDBRuntime's KV store.
3776///
3777/// Emits an `ai.credential.resolve` audit event so operators can answer
3778/// "which principal caused us to read
3779/// `red.secret.ai.providers.<provider>.tokens.*`?"
3780/// even though the read itself is performed as system (the AI subsystem
3781/// must always be able to fetch the key the query needs — denying it
3782/// would be denying the query at the wrong layer). The audit record
3783/// never contains the secret value.
3784pub fn resolve_api_key_from_runtime(
3785    provider: &AiProvider,
3786    credential_alias: Option<&str>,
3787    runtime: &crate::runtime::RedDBRuntime,
3788) -> crate::RedDBResult<String> {
3789    use crate::application::ports::RuntimeEntityPort;
3790    let configured_alias = if credential_alias.is_none() {
3791        match runtime.get_kv(
3792            "red.config",
3793            &format!("ai.providers.{}.credential", provider.token()),
3794        )? {
3795            Some((reddb_types::Value::Text(value), _)) => Some(value.to_string()),
3796            _ => None,
3797        }
3798    } else {
3799        None
3800    };
3801    let credential_alias = credential_alias.or(configured_alias.as_deref());
3802    let alias_for_audit = credential_alias.unwrap_or("default").to_string();
3803    let provider_token = provider.token().to_string();
3804    let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
3805        std::cell::RefCell::new(Vec::new());
3806    let result = resolve_api_key(provider, credential_alias, |kv_key| {
3807        if kv_key.starts_with("red.secret.") {
3808            let value = runtime.vault_kv_get(kv_key);
3809            audited_paths
3810                .borrow_mut()
3811                .push((kv_key.to_string(), value.is_some()));
3812            return Ok(value);
3813        }
3814        match runtime.get_kv("red.config", kv_key)? {
3815            Some((reddb_types::Value::Text(secret), _)) => {
3816                audited_paths.borrow_mut().push((kv_key.to_string(), true));
3817                Ok(Some(secret.to_string()))
3818            }
3819            Some(_) => {
3820                audited_paths.borrow_mut().push((kv_key.to_string(), false));
3821                Ok(None)
3822            }
3823            None => {
3824                audited_paths.borrow_mut().push((kv_key.to_string(), false));
3825                Ok(None)
3826            }
3827        }
3828    });
3829    let audited_paths = audited_paths.into_inner();
3830
3831    let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
3832        .map(|(user, _role)| user)
3833        .unwrap_or_else(|| "system".to_string());
3834    let outcome = if result.is_ok() { "hit" } else { "miss" };
3835    let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
3836    let paths_json: Vec<crate::serde_json::Value> = audited_paths
3837        .iter()
3838        .map(|(p, hit)| {
3839            crate::serde_json::json!({
3840                "path": p,
3841                "hit": hit,
3842            })
3843        })
3844        .collect();
3845    let details = crate::serde_json::json!({
3846        "provider": provider_token,
3847        "alias": alias_for_audit,
3848        "paths_checked": paths_json,
3849    });
3850    runtime.audit_log().record(
3851        "ai.credential.resolve",
3852        &principal,
3853        &target,
3854        outcome,
3855        details,
3856    );
3857    result
3858}
3859
3860// ============================================================================
3861// HuggingFace Inference API
3862// ============================================================================
3863
3864/// Generate embeddings via HuggingFace Inference API.
3865pub fn huggingface_embeddings(
3866    api_key: &str,
3867    model: &str,
3868    inputs: &[String],
3869    api_base: &str,
3870) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3871    let url = format!("{api_base}/pipeline/feature-extraction/{model}");
3872    let mut embeddings = Vec::with_capacity(inputs.len());
3873
3874    for input in inputs {
3875        let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
3876        let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
3877            .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3878        if !(200..300).contains(&status) {
3879            return Err(crate::RedDBError::Query(format!(
3880                "HuggingFace API error (status {status}): {body_str}"
3881            )));
3882        }
3883        let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
3884            crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
3885        })?;
3886
3887        // HF returns [[f32, ...]] for single input
3888        let vector: Vec<f32> = match &body {
3889            JsonValue::Array(outer) => outer
3890                .iter()
3891                .filter_map(|v| v.as_f64().map(|n| n as f32))
3892                .collect(),
3893            _ => {
3894                return Err(crate::RedDBError::Query(
3895                    "unexpected HuggingFace embedding response format".to_string(),
3896                ))
3897            }
3898        };
3899        embeddings.push(vector);
3900    }
3901
3902    Ok(OpenAiEmbeddingResponse {
3903        provider: "huggingface",
3904        model: model.to_string(),
3905        embeddings,
3906        prompt_tokens: None,
3907        total_tokens: None,
3908    })
3909}
3910
3911/// Generate text via HuggingFace Inference API.
3912pub fn huggingface_prompt(
3913    api_key: &str,
3914    model: &str,
3915    prompt: &str,
3916    temperature: Option<f32>,
3917    max_tokens: Option<usize>,
3918    api_base: &str,
3919) -> crate::RedDBResult<AiPromptResponse> {
3920    let url = format!("{api_base}/models/{model}");
3921    let mut params = Map::new();
3922    if let Some(t) = temperature {
3923        params.insert("temperature".into(), JsonValue::Number(t as f64));
3924    }
3925    params.insert(
3926        "max_new_tokens".into(),
3927        JsonValue::Number(max_tokens.unwrap_or(512) as f64),
3928    );
3929    let payload = crate::serde_json::json!({
3930        "inputs": prompt,
3931        "parameters": JsonValue::Object(params)
3932    });
3933
3934    let (status, body_str) =
3935        http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
3936            .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3937    if !(200..300).contains(&status) {
3938        return Err(crate::RedDBError::Query(format!(
3939            "HuggingFace API error (status {status}): {body_str}"
3940        )));
3941    }
3942    let body: JsonValue = crate::serde_json::from_str(&body_str)
3943        .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
3944
3945    let output_text = match &body {
3946        JsonValue::Array(arr) => arr
3947            .first()
3948            .and_then(|v| v.get("generated_text"))
3949            .and_then(JsonValue::as_str)
3950            .unwrap_or("")
3951            .to_string(),
3952        _ => body
3953            .get("generated_text")
3954            .and_then(JsonValue::as_str)
3955            .unwrap_or("")
3956            .to_string(),
3957    };
3958
3959    Ok(AiPromptResponse {
3960        provider: "huggingface",
3961        model: model.to_string(),
3962        output_text,
3963        output_chunks: None,
3964        prompt_tokens: None,
3965        completion_tokens: None,
3966        total_tokens: None,
3967        stop_reason: None,
3968    })
3969}
3970
3971// ============================================================================
3972// Local model stubs (requires 'local-models' feature flag)
3973// ============================================================================
3974
3975const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
3976flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
3977the 'ollama' provider with a local Ollama server.";
3978
3979const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
3980`local-models` feature, but local model artifact execution is not implemented in this slice. \
3981Alternatively, use the 'ollama' provider with a local Ollama server.";
3982
3983const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
3984the `local-models` feature; the local provider contract is embeddings-only for this slice.";
3985
3986pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
3987    if cfg!(feature = "local-models") {
3988        crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
3989    } else {
3990        crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
3991    }
3992}
3993
3994pub fn local_prompt_unavailable_error() -> crate::RedDBError {
3995    crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
3996}
3997
3998/// Local embedding via candle — requires `local-models` feature.
3999pub fn local_embeddings(
4000    _model_id: &str,
4001    _texts: &[String],
4002) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
4003    Err(local_embeddings_unavailable_error())
4004}
4005
4006/// Local prompt via candle — requires `local-models` feature.
4007pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
4008    Err(local_prompt_unavailable_error())
4009}
4010
4011// ============================================================================
4012// gRPC input collection — parity with HTTP /ai/embeddings
4013// ============================================================================
4014
4015/// Collect embedding inputs from any of the three supported shapes.
4016///
4017/// * `input: "..."` — single string.
4018/// * `inputs: ["...", ...]` — array of strings.
4019/// * `source_query: "SELECT ..."` — runs a SQL query and projects
4020///   either the named `source_field` from each row (source_mode =
4021///   "row", default) or every string cell of every result row
4022///   (source_mode = "result").
4023fn grpc_collect_embedding_inputs(
4024    runtime: &crate::runtime::RedDBRuntime,
4025    payload: &JsonValue,
4026) -> crate::RedDBResult<Vec<String>> {
4027    if let Some(source_query) = payload
4028        .get("source_query")
4029        .and_then(|v| v.as_str())
4030        .map(str::trim)
4031        .filter(|s| !s.is_empty())
4032    {
4033        return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
4034    }
4035
4036    if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
4037        let mut out = Vec::with_capacity(arr.len());
4038        for (idx, v) in arr.iter().enumerate() {
4039            let text = v.as_str().ok_or_else(|| {
4040                crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
4041            })?;
4042            if text.trim().is_empty() {
4043                return Err(crate::RedDBError::Query(format!(
4044                    "field 'inputs[{idx}]' cannot be empty"
4045                )));
4046            }
4047            out.push(text.to_string());
4048        }
4049        if out.is_empty() {
4050            return Err(crate::RedDBError::Query(
4051                "field 'inputs' must be a non-empty array of strings".to_string(),
4052            ));
4053        }
4054        return Ok(out);
4055    }
4056
4057    if let Some(single) = payload
4058        .get("input")
4059        .and_then(|v| v.as_str())
4060        .map(str::trim)
4061        .filter(|s| !s.is_empty())
4062    {
4063        return Ok(vec![single.to_string()]);
4064    }
4065
4066    Err(crate::RedDBError::Query(
4067        "provide either 'input', 'inputs', or 'source_query'".to_string(),
4068    ))
4069}
4070
4071fn grpc_collect_inputs_from_source_query(
4072    runtime: &crate::runtime::RedDBRuntime,
4073    payload: &JsonValue,
4074    source_query: &str,
4075) -> crate::RedDBResult<Vec<String>> {
4076    let result = runtime
4077        .execute_query(source_query)
4078        .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
4079
4080    let source_mode = payload
4081        .get("source_mode")
4082        .and_then(|v| v.as_str())
4083        .map(str::trim)
4084        .filter(|s| !s.is_empty())
4085        .unwrap_or("row")
4086        .to_ascii_lowercase();
4087
4088    let mut out: Vec<String> = Vec::new();
4089    match source_mode.as_str() {
4090        "row" => {
4091            let field = payload
4092                .get("source_field")
4093                .and_then(|v| v.as_str())
4094                .map(str::trim)
4095                .filter(|s| !s.is_empty())
4096                .ok_or_else(|| {
4097                    crate::RedDBError::Query(
4098                        "field 'source_field' is required when source_mode='row'".to_string(),
4099                    )
4100                })?;
4101            for rec in &result.result.records {
4102                for (key, value) in rec.iter_fields() {
4103                    if key.as_ref() == field {
4104                        if let reddb_types::Value::Text(text) = value {
4105                            let trimmed = text.trim();
4106                            if !trimmed.is_empty() {
4107                                out.push(trimmed.to_string());
4108                            }
4109                        }
4110                    }
4111                }
4112            }
4113        }
4114        "result" => {
4115            for rec in &result.result.records {
4116                for (_, value) in rec.iter_fields() {
4117                    if let reddb_types::Value::Text(text) = value {
4118                        let trimmed = text.trim();
4119                        if !trimmed.is_empty() {
4120                            out.push(trimmed.to_string());
4121                        }
4122                    }
4123                }
4124            }
4125        }
4126        other => {
4127            return Err(crate::RedDBError::Query(format!(
4128                "field 'source_mode' must be 'row' or 'result' (got '{other}')"
4129            )));
4130        }
4131    }
4132
4133    if out.is_empty() {
4134        return Err(crate::RedDBError::Query(
4135            "source_query produced zero non-empty text inputs".to_string(),
4136        ));
4137    }
4138    Ok(out)
4139}
4140
4141// ============================================================================
4142// gRPC stubs — delegate to the same logic as HTTP handlers
4143// ============================================================================
4144
4145/// gRPC embeddings — shared entrypoint that mirrors the HTTP handler.
4146///
4147/// Accepts the same JSON payload shape as `POST /ai/embeddings`:
4148///
4149/// ```json
4150/// { "provider": "openai", "model": "text-embedding-3-small",
4151///   "inputs": ["hello", "world"], "credential": "optional-alias" }
4152/// ```
4153///
4154/// Input shapes at parity with HTTP: `input` (single string),
4155/// `inputs` (array of strings), and `source_query` (SQL that the
4156/// runtime executes to materialise the input texts; `source_mode`
4157/// = `row` needs `source_collection` + `source_field`, `result`
4158/// uses the projected columns). Returns a JSON object with
4159/// `provider`, `model`, `embeddings`, `prompt_tokens`,
4160/// `total_tokens`. Non-OpenAI-compatible providers are rejected
4161/// with a clear message, matching the HTTP handler.
4162pub fn grpc_embeddings(
4163    runtime: &crate::runtime::RedDBRuntime,
4164    payload: &JsonValue,
4165) -> crate::RedDBResult<JsonValue> {
4166    // An explicit `provider` is honoured verbatim; when absent, the
4167    // embeddings task pointer drives selection (ADR-0068 §5).
4168    let provider = match payload
4169        .get("provider")
4170        .and_then(|v| v.as_str())
4171        .map(str::trim)
4172        .filter(|s| !s.is_empty())
4173    {
4174        Some(name) => parse_provider(name)?,
4175        None => resolve_embeddings_provider_from_runtime(runtime, "")?,
4176    };
4177    if matches!(provider, AiProvider::Groq | AiProvider::DeepSeek) {
4178        ensure_provider_supports_embeddings(&provider)?;
4179    }
4180    // Routing matrix mirrors `handle_ai_embeddings`. See that function
4181    // for the rationale; in short: HuggingFace gets its own wire
4182    // shape, Anthropic fails fast (no embeddings product), and Local
4183    // requires a build-time feature flag.
4184    match &provider {
4185        AiProvider::Anthropic => {
4186            return Err(crate::RedDBError::Query(
4187                "Anthropic does not offer an embeddings API. \
4188                 Re-issue the request against an OpenAI-compatible \
4189                 provider (openai, ollama, openrouter, together, \
4190                 venice, red-router), HuggingFace, or a custom base URL — \
4191                 RedDB does not silently route embeddings to a \
4192                 different provider than the one you named."
4193                    .to_string(),
4194            ));
4195        }
4196        AiProvider::Local => {
4197            return grpc_embeddings_local(runtime, payload);
4198        }
4199        _ => {}
4200    }
4201
4202    let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
4203
4204    let explicit_model = payload.get("model").and_then(|v| v.as_str());
4205    let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
4206
4207    let credential = payload
4208        .get("credential")
4209        .and_then(|v| v.as_str())
4210        .map(str::to_string);
4211    let AiConnection { api_key, api_base } =
4212        resolve_connection_from_runtime(&provider, credential.as_deref(), runtime)?;
4213
4214    let dimensions = payload
4215        .get("dimensions")
4216        .and_then(|v| v.as_i64())
4217        .and_then(|v| usize::try_from(v).ok())
4218        .filter(|v| *v > 0);
4219
4220    let response = match &provider {
4221        AiProvider::HuggingFace => huggingface_embeddings(&api_key, &model, &inputs, &api_base)?,
4222        _ => {
4223            let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
4224            let request = OpenAiEmbeddingRequest {
4225                api_key,
4226                model,
4227                inputs,
4228                dimensions,
4229                api_base,
4230            };
4231            crate::runtime::ai::block_on_ai(async move {
4232                openai_embeddings_async(&transport, request).await
4233            })
4234            .and_then(|result| result)?
4235        }
4236    };
4237
4238    let embeddings_json: Vec<JsonValue> = response
4239        .embeddings
4240        .into_iter()
4241        .map(|vec| {
4242            JsonValue::Array(
4243                vec.into_iter()
4244                    .map(|f| JsonValue::Number(f as f64))
4245                    .collect(),
4246            )
4247        })
4248        .collect();
4249
4250    let mut obj = Map::new();
4251    obj.insert(
4252        "provider".to_string(),
4253        JsonValue::String(response.provider.to_string()),
4254    );
4255    obj.insert("model".to_string(), JsonValue::String(response.model));
4256    obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
4257    if let Some(pt) = response.prompt_tokens {
4258        obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
4259    }
4260    if let Some(tt) = response.total_tokens {
4261        obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
4262    }
4263    Ok(JsonValue::Object(obj))
4264}
4265
4266/// gRPC local-provider embedding path (#680).
4267///
4268/// Mirrors the HTTP local path: resolves a registered+installed local
4269/// model, runs the runtime backend, and returns the same JSON shape
4270/// the HTTP handler produces (`provider`, `model`, `model_source`,
4271/// `model_revision`, `model_engine`, `dimensions`, `embeddings`).
4272/// Save-side behaviour is HTTP-only; gRPC mirrors the OpenAI-compatible
4273/// gRPC path which also does not persist.
4274fn grpc_embeddings_local(
4275    runtime: &crate::runtime::RedDBRuntime,
4276    payload: &JsonValue,
4277) -> crate::RedDBResult<JsonValue> {
4278    crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
4279
4280    let model_name = payload
4281        .get("model")
4282        .and_then(|v| v.as_str())
4283        .map(str::trim)
4284        .filter(|s| !s.is_empty())
4285        .ok_or_else(|| {
4286            crate::RedDBError::Query(
4287                "field 'model' is required for the local provider and must be the \
4288                 registered local model name (see POST /ai/models)"
4289                    .to_string(),
4290            )
4291        })?
4292        .to_string();
4293
4294    let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
4295    let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
4296
4297    let embeddings_json: Vec<JsonValue> = response
4298        .embeddings
4299        .into_iter()
4300        .map(|vec| {
4301            JsonValue::Array(
4302                vec.into_iter()
4303                    .map(|f| JsonValue::Number(f as f64))
4304                    .collect(),
4305            )
4306        })
4307        .collect();
4308
4309    let mut obj = Map::new();
4310    obj.insert(
4311        "provider".to_string(),
4312        JsonValue::String(response.provider.to_string()),
4313    );
4314    obj.insert("model".to_string(), JsonValue::String(response.name));
4315    obj.insert(
4316        "model_source".to_string(),
4317        JsonValue::String(response.source),
4318    );
4319    obj.insert(
4320        "model_revision".to_string(),
4321        JsonValue::String(response.revision),
4322    );
4323    obj.insert(
4324        "model_engine".to_string(),
4325        JsonValue::String(response.engine),
4326    );
4327    obj.insert(
4328        "dimensions".to_string(),
4329        JsonValue::Number(response.dimensions as f64),
4330    );
4331    obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
4332    Ok(JsonValue::Object(obj))
4333}
4334
4335/// gRPC stub for AI prompt.
4336pub fn grpc_prompt(
4337    _runtime: &crate::runtime::RedDBRuntime,
4338    _payload: &JsonValue,
4339) -> crate::RedDBResult<JsonValue> {
4340    Err(crate::RedDBError::FeatureNotEnabled(
4341        "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
4342    ))
4343}
4344
4345/// gRPC stub for AI credentials.
4346pub fn grpc_credentials(
4347    _runtime: &crate::runtime::RedDBRuntime,
4348    _payload: &JsonValue,
4349) -> crate::RedDBResult<JsonValue> {
4350    Err(crate::RedDBError::FeatureNotEnabled(
4351        "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
4352    ))
4353}
4354
4355// ============================================================================
4356// Generic OpenAI-compatible client (issue gh-516)
4357//
4358// Thin blocking client that targets any `{api_base}/chat/completions`
4359// and `{api_base}/embeddings` endpoint with arbitrary auth headers.
4360// Existing vendor-native paths (`openai_prompt_async`,
4361// `anthropic_prompt_async`) remain unchanged; this exists so callers
4362// can talk to non-OpenAI providers that expose an OpenAI-compatible
4363// surface (Groq, OpenRouter, Together, Ollama, vLLM, LM Studio, ...)
4364// without having to register a new `AiProvider` variant.
4365// ============================================================================
4366
4367/// Normalized usage block. Field names follow the Anthropic shape
4368/// (`input_tokens` / `output_tokens`) so downstream cost-accounting
4369/// has one canonical schema regardless of the upstream provider.
4370#[derive(Debug, Clone, Default, PartialEq, Eq)]
4371pub struct OpenAiCompatUsage {
4372    pub input_tokens: Option<u64>,
4373    pub output_tokens: Option<u64>,
4374    pub total_tokens: Option<u64>,
4375}
4376
4377#[derive(Debug, Clone)]
4378pub struct OpenAiCompatChatRequest {
4379    pub api_base: String,
4380    pub api_key: String,
4381    pub model: String,
4382    pub prompt: String,
4383    pub temperature: Option<f32>,
4384    pub seed: Option<u64>,
4385    pub max_output_tokens: Option<usize>,
4386    pub extra_headers: Vec<(String, String)>,
4387}
4388
4389#[derive(Debug, Clone)]
4390pub struct OpenAiCompatChatResponse {
4391    pub model: String,
4392    pub output_text: String,
4393    pub stop_reason: Option<String>,
4394    pub usage: OpenAiCompatUsage,
4395}
4396
4397#[derive(Debug, Clone)]
4398pub struct OpenAiCompatEmbeddingsRequest {
4399    pub api_base: String,
4400    pub api_key: String,
4401    pub model: String,
4402    pub inputs: Vec<String>,
4403    pub dimensions: Option<usize>,
4404    pub extra_headers: Vec<(String, String)>,
4405}
4406
4407#[derive(Debug, Clone)]
4408pub struct OpenAiCompatEmbeddingsResponse {
4409    pub model: String,
4410    pub embeddings: Vec<Vec<f32>>,
4411    pub usage: OpenAiCompatUsage,
4412}
4413
4414fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
4415    headers
4416        .iter()
4417        .map(|(k, v)| (k.as_str(), v.as_str()))
4418        .collect()
4419}
4420
4421/// POST `{api_base}/chat/completions` and return a normalized response.
4422///
4423/// Errors:
4424/// * empty model / prompt → `RedDBError::Query`.
4425/// * transport / non-2xx → `RedDBError::Query` carrying the status code
4426///   and the provider's parsed `error.message` when available, raw body
4427///   otherwise.
4428pub fn openai_compat_chat(
4429    request: OpenAiCompatChatRequest,
4430) -> RedDBResult<OpenAiCompatChatResponse> {
4431    if request.model.trim().is_empty() {
4432        return Err(RedDBError::Query(
4433            "openai-compat: model cannot be empty".to_string(),
4434        ));
4435    }
4436    if request.prompt.trim().is_empty() {
4437        return Err(RedDBError::Query(
4438            "openai-compat: prompt cannot be empty".to_string(),
4439        ));
4440    }
4441
4442    let url = format!(
4443        "{}/chat/completions",
4444        request.api_base.trim_end_matches('/')
4445    );
4446    let payload = build_openai_prompt_payload(
4447        &request.model,
4448        &request.prompt,
4449        request.temperature,
4450        request.seed,
4451        request.max_output_tokens,
4452        false,
4453    );
4454
4455    let extra = extra_header_refs(&request.extra_headers);
4456    let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
4457        .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
4458
4459    if !(200..300).contains(&status) {
4460        let message = openai_error_message(&body).unwrap_or_else(|| {
4461            if body.trim().is_empty() {
4462                "openai-compat chat request failed".to_string()
4463            } else {
4464                body.clone()
4465            }
4466        });
4467        return Err(RedDBError::Query(format!(
4468            "openai-compat chat request failed (status {status}): {message}"
4469        )));
4470    }
4471
4472    let parsed = parse_openai_prompt_response(&body, &request.model)?;
4473    Ok(OpenAiCompatChatResponse {
4474        model: parsed.model,
4475        output_text: parsed.output_text,
4476        stop_reason: parsed.stop_reason,
4477        usage: OpenAiCompatUsage {
4478            input_tokens: parsed.prompt_tokens,
4479            output_tokens: parsed.completion_tokens,
4480            total_tokens: parsed.total_tokens,
4481        },
4482    })
4483}
4484
4485/// POST `{api_base}/embeddings` and return a normalized response.
4486pub fn openai_compat_embeddings(
4487    request: OpenAiCompatEmbeddingsRequest,
4488) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
4489    if request.model.trim().is_empty() {
4490        return Err(RedDBError::Query(
4491            "openai-compat: embedding model cannot be empty".to_string(),
4492        ));
4493    }
4494    if request.inputs.is_empty() {
4495        return Err(RedDBError::Query(
4496            "openai-compat: at least one input is required".to_string(),
4497        ));
4498    }
4499
4500    let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
4501    let payload =
4502        build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
4503
4504    let extra = extra_header_refs(&request.extra_headers);
4505    let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
4506        .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
4507
4508    if !(200..300).contains(&status) {
4509        let message = openai_error_message(&body).unwrap_or_else(|| {
4510            if body.trim().is_empty() {
4511                "openai-compat embeddings request failed".to_string()
4512            } else {
4513                body.clone()
4514            }
4515        });
4516        return Err(RedDBError::Query(format!(
4517            "openai-compat embeddings request failed (status {status}): {message}"
4518        )));
4519    }
4520
4521    let parsed = parse_openai_embedding_response(&body)?;
4522    Ok(OpenAiCompatEmbeddingsResponse {
4523        model: parsed.model,
4524        embeddings: parsed.embeddings,
4525        usage: OpenAiCompatUsage {
4526            input_tokens: parsed.prompt_tokens,
4527            output_tokens: None,
4528            total_tokens: parsed.total_tokens,
4529        },
4530    })
4531}
4532
4533// ============================================================================
4534// Provider mode selector (issue gh-516)
4535//
4536// `ai.provider` picks the wire-protocol family that engine
4537// consumers (currently AskPipeline) should use. This is intentionally
4538// distinct from `ai.default.provider`, which names a
4539// concrete vendor (openai, groq, ollama, ...). The mode selector
4540// answers the prior question of which HTTP shape to speak.
4541// ============================================================================
4542
4543/// Wire-protocol family used by engine-side AI consumers.
4544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4545pub enum AiProviderMode {
4546    /// Generic OpenAI-compatible client (`POST {api_base}/chat/completions`).
4547    OpenAiCompat,
4548    /// Vendor-native OpenAI client (api.openai.com, default headers).
4549    OpenAiNative,
4550    /// Vendor-native Anthropic client (api.anthropic.com, x-api-key).
4551    AnthropicNative,
4552}
4553
4554impl AiProviderMode {
4555    pub fn token(&self) -> &'static str {
4556        match self {
4557            Self::OpenAiCompat => "openai-compat",
4558            Self::OpenAiNative => "openai-native",
4559            Self::AnthropicNative => "anthropic-native",
4560        }
4561    }
4562}
4563
4564/// Parse a mode token. Accepts hyphen or underscore spellings.
4565pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
4566    match name.trim().to_ascii_lowercase().as_str() {
4567        "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
4568        "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
4569        "anthropic-native" | "anthropic_native" | "anthropicnative" => {
4570            Some(AiProviderMode::AnthropicNative)
4571        }
4572        _ => None,
4573    }
4574}
4575
4576/// Resolve the provider mode. Lookup chain:
4577/// 1. `REDDB_AI_PROVIDER_MODE` env var.
4578/// 2. `red.config` key `ai.provider`.
4579/// 3. Returns `None` so callers can fall back to their existing
4580///    vendor-based routing.
4581pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
4582where
4583    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
4584{
4585    if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
4586        if let Some(mode) = parse_provider_mode(&value) {
4587            return Some(mode);
4588        }
4589    }
4590    if let Ok(Some(value)) = kv_getter("ai.provider") {
4591        if let Some(mode) = parse_provider_mode(&value) {
4592            return Some(mode);
4593        }
4594    }
4595    None
4596}
4597
4598/// Map a mode to the matching [`AiProvider`] variant. `OpenAiCompat`
4599/// uses the named gateway provider, which requires an explicit endpoint.
4600pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
4601    match mode {
4602        AiProviderMode::OpenAiNative => AiProvider::OpenAi,
4603        AiProviderMode::AnthropicNative => AiProvider::Anthropic,
4604        AiProviderMode::OpenAiCompat => AiProvider::OpenAiCompat,
4605    }
4606}
4607
4608/// Egress policy for `AiProvider::Custom` base URLs. The server sends the
4609/// resolved provider credential as a bearer to this host, so an arbitrary
4610/// URL is a credential-exfiltration / SSRF primitive. Rules:
4611///   * plaintext `http://` only for loopback hosts (local OpenAI-compatible
4612///     servers), `https://` everywhere else;
4613///   * literal private / link-local / multicast IP addresses are refused.
4614///
4615/// `REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1` lifts both restrictions for
4616/// deployments that run providers on a private network.
4617pub(crate) fn validate_custom_provider_url(url: &str) -> crate::RedDBResult<()> {
4618    let uri = url.parse::<http::Uri>().map_err(|_| {
4619        crate::RedDBError::Query("AI endpoint must be an absolute http(s) URL".to_string())
4620    })?;
4621    if !matches!(uri.scheme_str(), Some("http" | "https"))
4622        || uri.host().is_none_or(str::is_empty)
4623        || uri
4624            .authority()
4625            .is_some_and(|authority| authority.as_str().contains('@'))
4626        || uri.query().is_some()
4627        || url.contains('#')
4628    {
4629        return Err(crate::RedDBError::Query(
4630            "AI endpoint must be an absolute http(s) URL without userinfo, query or fragment"
4631                .to_string(),
4632        ));
4633    }
4634    if std::env::var("REDDB_AI_ALLOW_PRIVATE_PROVIDERS")
4635        .map(|v| matches!(v.trim(), "1" | "true" | "yes"))
4636        .unwrap_or(false)
4637    {
4638        return Ok(());
4639    }
4640    let (plaintext, rest) = match url.strip_prefix("https://") {
4641        Some(rest) => (false, rest),
4642        None => (true, url.strip_prefix("http://").unwrap_or(url)),
4643    };
4644    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
4645    let host_port = authority.rsplit('@').next().unwrap_or(authority);
4646    let host = if let Some(stripped) = host_port.strip_prefix('[') {
4647        stripped.split(']').next().unwrap_or(stripped)
4648    } else {
4649        host_port.split(':').next().unwrap_or(host_port)
4650    };
4651    let host_lower = host.to_ascii_lowercase();
4652    let literal_ip = host.parse::<std::net::IpAddr>().ok();
4653    let loopback = host_lower == "localhost"
4654        || host_lower.ends_with(".localhost")
4655        || literal_ip.is_some_and(|ip| ip.is_loopback());
4656    if plaintext && !loopback {
4657        return Err(crate::RedDBError::Query(format!(
4658            "custom AI provider '{url}' must use https:// (plaintext http is only allowed for loopback hosts)"
4659        )));
4660    }
4661    if let Some(ip) = literal_ip {
4662        let private = match ip {
4663            std::net::IpAddr::V4(v4) => {
4664                v4.is_private()
4665                    || v4.is_link_local()
4666                    || v4.is_broadcast()
4667                    || v4.is_multicast()
4668                    || v4.is_unspecified()
4669                    || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
4670            }
4671            std::net::IpAddr::V6(v6) => {
4672                v6.is_multicast()
4673                    || v6.is_unspecified()
4674                    || (v6.segments()[0] & 0xfe00) == 0xfc00
4675                    || (v6.segments()[0] & 0xffc0) == 0xfe80
4676            }
4677        };
4678        if private && !loopback {
4679            return Err(crate::RedDBError::Query(format!(
4680                "custom AI provider '{url}' targets a private or link-local address; set REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1 to allow"
4681            )));
4682        }
4683    }
4684    Ok(())
4685}