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