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_enables_stream_options() {
1314        let payload =
1315            build_openai_prompt_payload("gpt-4.1-mini", "hello", Some(0.0), None, None, true);
1316        let parsed = JsonValue::from(parse_json(&payload).expect("valid json"));
1317
1318        assert_eq!(
1319            parsed.get("stream").and_then(JsonValue::as_bool),
1320            Some(true)
1321        );
1322        assert_eq!(
1323            parsed
1324                .get("stream_options")
1325                .and_then(|value| value.get("include_usage"))
1326                .and_then(JsonValue::as_bool),
1327            Some(true)
1328        );
1329    }
1330
1331    #[test]
1332    fn openai_streaming_prompt_response_collects_delta_chunks() {
1333        let body = concat!(
1334            "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"login \"},\"finish_reason\":null}]}\n\n",
1335            "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{\"content\":\"failed\"},\"finish_reason\":null}]}\n\n",
1336            "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"total_tokens\":14}}\n\n",
1337            "data: [DONE]\n\n",
1338        );
1339        let parsed = parse_openai_streaming_prompt_response(body, "fallback").unwrap();
1340
1341        assert_eq!(parsed.model, "gpt-test");
1342        assert_eq!(parsed.output_text, "login failed");
1343        assert_eq!(
1344            parsed.output_chunks.as_deref(),
1345            Some(["login ".to_string(), "failed".to_string()].as_slice())
1346        );
1347        assert_eq!(parsed.prompt_tokens, Some(12));
1348        assert_eq!(parsed.completion_tokens, Some(2));
1349        assert_eq!(parsed.total_tokens, Some(14));
1350        assert_eq!(parsed.stop_reason.as_deref(), Some("stop"));
1351    }
1352
1353    #[tokio::test]
1354    async fn openai_prompt_async_rejects_empty_model() {
1355        let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1356        let request = OpenAiPromptRequest {
1357            api_key: "key".to_string(),
1358            model: "  ".to_string(),
1359            prompt: "hello".to_string(),
1360            temperature: None,
1361            seed: None,
1362            max_output_tokens: None,
1363            api_base: "https://api.openai.com/v1".to_string(),
1364            stream: false,
1365        };
1366        let err = openai_prompt_async(&transport, request).await.unwrap_err();
1367        assert!(err.to_string().contains("model cannot be empty"));
1368    }
1369
1370    #[tokio::test]
1371    async fn openai_prompt_async_rejects_empty_prompt() {
1372        let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1373        let request = OpenAiPromptRequest {
1374            api_key: "key".to_string(),
1375            model: "gpt-4.1-mini".to_string(),
1376            prompt: "".to_string(),
1377            temperature: None,
1378            seed: None,
1379            max_output_tokens: None,
1380            api_base: "https://api.openai.com/v1".to_string(),
1381            stream: false,
1382        };
1383        let err = openai_prompt_async(&transport, request).await.unwrap_err();
1384        assert!(err.to_string().contains("prompt cannot be empty"));
1385    }
1386
1387    // ========================================================================
1388    // openai-compat client tests (issue gh-516)
1389    //
1390    // Each test spins up a tiny TCP server, hands its base URL to the
1391    // new generic client, and asserts on the captured request +
1392    // synthesised response. Tests run in parallel-safe fashion (each
1393    // server binds to port 0).
1394    // ========================================================================
1395
1396    use std::io::{Read as _, Write as _};
1397    use std::net::TcpListener;
1398    use std::sync::{Arc, Mutex};
1399    use std::thread;
1400
1401    struct CapturedRequest {
1402        method: String,
1403        path: String,
1404        headers: Vec<(String, String)>,
1405        body: String,
1406    }
1407
1408    fn parse_http_request(stream: &mut std::net::TcpStream) -> CapturedRequest {
1409        let mut buf = [0u8; 8192];
1410        let mut data = Vec::new();
1411        loop {
1412            let read = stream.read(&mut buf).unwrap_or(0);
1413            if read == 0 {
1414                break;
1415            }
1416            data.extend_from_slice(&buf[..read]);
1417            if let Some(idx) = data.windows(4).position(|w| w == b"\r\n\r\n") {
1418                let header_len = idx + 4;
1419                let header_str = String::from_utf8_lossy(&data[..idx]).to_string();
1420                let mut lines = header_str.split("\r\n");
1421                let request_line = lines.next().unwrap_or("");
1422                let mut parts = request_line.split_whitespace();
1423                let method = parts.next().unwrap_or("").to_string();
1424                let path = parts.next().unwrap_or("").to_string();
1425                let mut headers = Vec::new();
1426                let mut content_length: usize = 0;
1427                for line in lines {
1428                    if let Some((k, v)) = line.split_once(':') {
1429                        let k = k.trim().to_string();
1430                        let v = v.trim().to_string();
1431                        if k.eq_ignore_ascii_case("content-length") {
1432                            content_length = v.parse().unwrap_or(0);
1433                        }
1434                        headers.push((k, v));
1435                    }
1436                }
1437                while data.len() < header_len + content_length {
1438                    let read = stream.read(&mut buf).unwrap_or(0);
1439                    if read == 0 {
1440                        break;
1441                    }
1442                    data.extend_from_slice(&buf[..read]);
1443                }
1444                let body = String::from_utf8_lossy(&data[header_len..header_len + content_length])
1445                    .to_string();
1446                return CapturedRequest {
1447                    method,
1448                    path,
1449                    headers,
1450                    body,
1451                };
1452            }
1453        }
1454        CapturedRequest {
1455            method: String::new(),
1456            path: String::new(),
1457            headers: Vec::new(),
1458            body: String::new(),
1459        }
1460    }
1461
1462    /// Spawn a one-shot HTTP server that replies with `(status, body)`
1463    /// to a single request, captures it, and returns `(base_url, captured)`.
1464    fn spawn_mock(
1465        status: u16,
1466        response_body: &'static str,
1467    ) -> (String, Arc<Mutex<Option<CapturedRequest>>>) {
1468        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1469        let addr = listener.local_addr().expect("addr");
1470        let captured: Arc<Mutex<Option<CapturedRequest>>> = Arc::new(Mutex::new(None));
1471        let captured_clone = Arc::clone(&captured);
1472        thread::spawn(move || {
1473            if let Ok((mut stream, _)) = listener.accept() {
1474                let req = parse_http_request(&mut stream);
1475                *captured_clone.lock().unwrap() = Some(req);
1476                let status_line = match status {
1477                    200 => "200 OK",
1478                    400 => "400 Bad Request",
1479                    401 => "401 Unauthorized",
1480                    500 => "500 Internal Server Error",
1481                    _ => "200 OK",
1482                };
1483                let resp = format!(
1484                    "HTTP/1.1 {status_line}\r\n\
1485                     Content-Type: application/json\r\n\
1486                     Content-Length: {}\r\n\
1487                     Connection: close\r\n\r\n{}",
1488                    response_body.len(),
1489                    response_body
1490                );
1491                let _ = stream.write_all(resp.as_bytes());
1492            }
1493        });
1494        (format!("http://{}", addr), captured)
1495    }
1496
1497    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1498    async fn gateway_registration_drives_http_grpc_and_batch_with_saved_connection() {
1499        use crate::auth::{AuthConfig, AuthStore};
1500        use crate::{RedDBOptions, RedDBRuntime, StorageDeployPreset};
1501        let path = std::env::temp_dir().join(format!(
1502            "reddb_gateway_roundtrip_{}_{}.rdb",
1503            std::process::id(),
1504            std::time::SystemTime::now()
1505                .duration_since(std::time::UNIX_EPOCH)
1506                .expect("clock")
1507                .as_nanos(),
1508        ));
1509        let runtime = RedDBRuntime::with_options(
1510            RedDBOptions::persistent(&path)
1511                .with_storage_profile(StorageDeployPreset::Serverless.selection())
1512                .expect("profile"),
1513        )
1514        .expect("runtime");
1515        let db = runtime.db();
1516        let store = db.store();
1517        let pager = store.pager().expect("pager");
1518        let auth = Arc::new(
1519            AuthStore::with_vault_certificate(
1520                AuthConfig::default(),
1521                Arc::clone(pager),
1522                "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
1523            )
1524            .expect("vault"),
1525        );
1526        let server = crate::server::RedDBServer::new(runtime.clone()).with_auth(auth);
1527        let embedding_body =
1528            r#"{"data":[{"index":0,"embedding":[0.1,0.2]}],"model":"Gateway/Embedding"}"#;
1529        for operation in [
1530            "prompt",
1531            "ask",
1532            "http-embeddings",
1533            "grpc-embeddings",
1534            "batch",
1535            "auto-embed",
1536        ] {
1537            let response_body = if matches!(operation, "prompt" | "ask") {
1538                r#"{"choices":[{"message":{"content":"hello"}}],"model":"Gateway/Chat"}"#
1539            } else {
1540                embedding_body
1541            };
1542            let (base, captured) = spawn_mock(200, response_body);
1543            let endpoint = format!("{base}/Gateway/v1");
1544            let registration = format!(
1545                r#"{{"provider":"red-router","alias":"mine","api_key":"synthetic-gateway-key","endpoint":"{endpoint}","default":true,"model":"Gateway/Chat"}}"#
1546            );
1547            let response = server.handle_ai_credentials(registration.into_bytes());
1548            assert_eq!(
1549                response.status,
1550                200,
1551                "{}",
1552                String::from_utf8_lossy(&response.body)
1553            );
1554            assert!(!String::from_utf8_lossy(&response.body).contains("synthetic-gateway-key"));
1555            match operation {
1556                "prompt" => {
1557                    let response = server.handle_ai_prompt(br#"{"prompt":"hi"}"#.to_vec());
1558                    assert_eq!(
1559                        response.status,
1560                        200,
1561                        "{}",
1562                        String::from_utf8_lossy(&response.body)
1563                    );
1564                }
1565                "ask" => {
1566                    let response = server.handle_ai_ask(br#"{"question":"hi"}"#.to_vec());
1567                    assert_eq!(
1568                        response.status,
1569                        200,
1570                        "{}",
1571                        String::from_utf8_lossy(&response.body)
1572                    );
1573                }
1574                "auto-embed" => {
1575                    runtime.execute_query("INSERT INTO gateway_batch (body) VALUES ('hi') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'").expect("auto embed");
1576                    let rows = runtime
1577                        .execute_query("SELECT * FROM gateway_batch")
1578                        .expect("rows");
1579                    assert_eq!(
1580                        rows.result.records.len(),
1581                        2,
1582                        "one row and its embedding vector"
1583                    );
1584                }
1585                "http-embeddings" => {
1586                    let response = server.handle_ai_embeddings(
1587                        br#"{"model":"Gateway/Embedding","input":"hi"}"#.to_vec(),
1588                    );
1589                    assert_eq!(
1590                        response.status,
1591                        200,
1592                        "{}",
1593                        String::from_utf8_lossy(&response.body)
1594                    );
1595                }
1596                "grpc-embeddings" => {
1597                    let payload =
1598                        crate::serde_json::json!({"model":"Gateway/Embedding","input":"hi"});
1599                    grpc_embeddings(&runtime, &payload).expect("gRPC embedding");
1600                }
1601                "batch" => {
1602                    let connection =
1603                        resolve_connection_from_runtime(&AiProvider::RedRouter, None, &runtime)
1604                            .expect("connection");
1605                    let client =
1606                        crate::runtime::ai::batch_client::AiBatchClient::from_runtime(&runtime);
1607                    let vectors = client
1608                        .embed_batch_at(
1609                            &AiProvider::RedRouter,
1610                            "Gateway/Embedding",
1611                            &connection.api_key,
1612                            &connection.api_base,
1613                            vec!["hi".to_string()],
1614                        )
1615                        .await
1616                        .expect("batch");
1617                    assert_eq!(vectors, vec![vec![0.1, 0.2]]);
1618                }
1619                _ => unreachable!("listed operation"),
1620            }
1621            let request = captured
1622                .lock()
1623                .expect("capture lock")
1624                .take()
1625                .expect("request");
1626            let suffix = if matches!(operation, "prompt" | "ask") {
1627                "chat/completions"
1628            } else {
1629                "embeddings"
1630            };
1631            assert_eq!(request.path, format!("/Gateway/v1/{suffix}"));
1632            assert!(request
1633                .headers
1634                .iter()
1635                .any(|(name, value)| name.eq_ignore_ascii_case("authorization")
1636                    && value == "Bearer synthetic-gateway-key"));
1637            let model = if matches!(operation, "prompt" | "ask") {
1638                "Gateway/Chat"
1639            } else {
1640                "Gateway/Embedding"
1641            };
1642            assert!(request.body.contains(model), "{}", request.body);
1643        }
1644        drop(server);
1645        drop(runtime);
1646        drop(db);
1647        let _ = std::fs::remove_file(path);
1648    }
1649
1650    #[test]
1651    fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
1652        let body = r#"{
1653            "id":"chatcmpl_x",
1654            "model":"custom-model",
1655            "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
1656            "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
1657        }"#;
1658        let (base, captured) = spawn_mock(200, body);
1659
1660        let req = OpenAiCompatChatRequest {
1661            api_base: base.clone(),
1662            api_key: "sk-test".to_string(),
1663            model: "custom-model".to_string(),
1664            prompt: "say hi".to_string(),
1665            temperature: None,
1666            seed: None,
1667            max_output_tokens: None,
1668            extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
1669        };
1670        let resp = openai_compat_chat(req).expect("ok");
1671
1672        assert_eq!(resp.output_text, "hi");
1673        assert_eq!(resp.model, "custom-model");
1674        assert_eq!(resp.usage.input_tokens, Some(7));
1675        assert_eq!(resp.usage.output_tokens, Some(2));
1676        assert_eq!(resp.usage.total_tokens, Some(9));
1677        assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
1678
1679        let cap = captured.lock().unwrap().take().expect("captured");
1680        assert_eq!(cap.method, "POST");
1681        assert_eq!(cap.path, "/chat/completions");
1682        let has_auth = cap
1683            .headers
1684            .iter()
1685            .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
1686        assert!(has_auth, "Authorization header missing");
1687        let has_custom = cap
1688            .headers
1689            .iter()
1690            .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
1691        assert!(has_custom, "extra header missing");
1692        assert!(cap.body.contains("\"model\":\"custom-model\""));
1693    }
1694
1695    #[test]
1696    fn openai_compat_embeddings_roundtrip_with_dimensions() {
1697        let body = r#"{
1698            "object":"list",
1699            "model":"embed-model",
1700            "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
1701            "usage":{"prompt_tokens":4,"total_tokens":4}
1702        }"#;
1703        let (base, captured) = spawn_mock(200, body);
1704
1705        let req = OpenAiCompatEmbeddingsRequest {
1706            api_base: base,
1707            api_key: "sk-emb".to_string(),
1708            model: "embed-model".to_string(),
1709            inputs: vec!["hello".to_string()],
1710            dimensions: Some(2),
1711            extra_headers: vec![],
1712        };
1713        let resp = openai_compat_embeddings(req).expect("ok");
1714
1715        assert_eq!(resp.embeddings.len(), 1);
1716        assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
1717        assert_eq!(resp.usage.total_tokens, Some(4));
1718        assert_eq!(resp.usage.input_tokens, Some(4));
1719
1720        let cap = captured.lock().unwrap().take().expect("captured");
1721        assert_eq!(cap.path, "/embeddings");
1722        assert!(cap.body.contains("\"dimensions\":2"));
1723    }
1724
1725    #[test]
1726    fn openai_compat_chat_non_2xx_returns_structured_error() {
1727        let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
1728        let (base, _captured) = spawn_mock(401, body);
1729
1730        let req = OpenAiCompatChatRequest {
1731            api_base: base,
1732            api_key: "bad".to_string(),
1733            model: "m".to_string(),
1734            prompt: "hi".to_string(),
1735            temperature: None,
1736            seed: None,
1737            max_output_tokens: None,
1738            extra_headers: vec![],
1739        };
1740        let err = openai_compat_chat(req).unwrap_err().to_string();
1741        assert!(err.contains("status 401"), "got: {err}");
1742        assert!(err.contains("bad api key"), "got: {err}");
1743    }
1744
1745    #[test]
1746    fn openai_compat_chat_rejects_empty_model_and_prompt() {
1747        let req = OpenAiCompatChatRequest {
1748            api_base: "http://localhost:1".to_string(),
1749            api_key: "k".to_string(),
1750            model: "  ".to_string(),
1751            prompt: "hi".to_string(),
1752            temperature: None,
1753            seed: None,
1754            max_output_tokens: None,
1755            extra_headers: vec![],
1756        };
1757        let err = openai_compat_chat(req).unwrap_err().to_string();
1758        assert!(err.contains("model cannot be empty"), "got: {err}");
1759
1760        let req = OpenAiCompatChatRequest {
1761            api_base: "http://localhost:1".to_string(),
1762            api_key: "k".to_string(),
1763            model: "m".to_string(),
1764            prompt: "  ".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("prompt cannot be empty"), "got: {err}");
1772    }
1773
1774    #[test]
1775    fn parse_provider_mode_recognizes_all_three_tokens() {
1776        assert_eq!(
1777            parse_provider_mode("openai-compat"),
1778            Some(AiProviderMode::OpenAiCompat)
1779        );
1780        assert_eq!(
1781            parse_provider_mode("OPENAI_NATIVE"),
1782            Some(AiProviderMode::OpenAiNative)
1783        );
1784        assert_eq!(
1785            parse_provider_mode("anthropic-native"),
1786            Some(AiProviderMode::AnthropicNative)
1787        );
1788        assert_eq!(parse_provider_mode("groq"), None);
1789    }
1790
1791    #[test]
1792    fn resolve_provider_mode_reads_kv_key() {
1793        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1794            if key == "red.config.ai.provider" {
1795                Ok(Some("anthropic-native".to_string()))
1796            } else {
1797                Ok(None)
1798            }
1799        };
1800        assert_eq!(
1801            resolve_provider_mode(&kv),
1802            Some(AiProviderMode::AnthropicNative)
1803        );
1804    }
1805
1806    #[test]
1807    fn resolve_default_provider_keeps_vendor_when_mode_is_configured() {
1808        // A wire protocol must not replace the configured provider identity.
1809        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1810            match key {
1811                "red.config.ai.provider" => Ok(Some("anthropic-native".to_string())),
1812                "red.config.ai.inference.provider" => Ok(Some("groq".to_string())),
1813                _ => Ok(None),
1814            }
1815        };
1816        assert_eq!(
1817            resolve_default_provider(&kv).expect("provider"),
1818            AiProvider::Groq
1819        );
1820    }
1821
1822    #[test]
1823    fn auto_embed_missing_gateway_connection_fails_before_row_write() {
1824        let runtime = crate::RedDBRuntime::in_memory().expect("runtime");
1825        runtime
1826            .execute_query("CREATE TABLE gateway_docs (id INT, body TEXT)")
1827            .expect("table");
1828        let error = runtime.execute_query(
1829            "INSERT INTO gateway_docs (id, body) VALUES (1, 'hello') WITH AUTO EMBED (body) USING 'red-router' MODEL 'Gateway/Embedding'",
1830        ).expect_err("missing connection");
1831        assert!(error.to_string().contains("endpoint"), "{error}");
1832        let result = runtime
1833            .execute_query("SELECT * FROM gateway_docs")
1834            .expect("rows");
1835        assert!(
1836            result.result.records.is_empty(),
1837            "invalid connection must leave no rows"
1838        );
1839    }
1840
1841    #[test]
1842    fn gateway_connections_fail_closed_when_either_part_is_missing() {
1843        let provider = parse_provider("red-router").expect("router");
1844        let no_endpoint = |key: &str| -> crate::RedDBResult<Option<String>> {
1845            assert!(
1846                !key.starts_with("red.secret."),
1847                "validate endpoint before reading secrets"
1848            );
1849            Ok(None)
1850        };
1851        let err = resolve_connection(&provider, None, &no_endpoint)
1852            .err()
1853            .expect("endpoint required");
1854        assert!(err.to_string().contains("endpoint"));
1855        let endpoint_only = |key: &str| -> crate::RedDBResult<Option<String>> {
1856            Ok((key == "red.config.ai.providers.red-router.base_url")
1857                .then(|| "https://example.com/v1".to_string()))
1858        };
1859        let err = resolve_connection(&provider, None, &endpoint_only)
1860            .err()
1861            .expect("key required");
1862        assert!(err.to_string().contains("API key"));
1863    }
1864
1865    #[test]
1866    fn gateway_connections_preserve_endpoint_and_selected_credential() {
1867        let provider = parse_provider("openai-compat").expect("gateway");
1868        let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
1869            Ok(match key {
1870                "red.config.ai.providers.openai-compat.base_url" => {
1871                    Some("https://example.com/Gateway/v1".to_string())
1872                }
1873                "red.config.ai.providers.openai-compat.credential" => Some("my-key".to_string()),
1874                "red.secret.ai.providers.openai-compat.tokens.my-key" => {
1875                    Some("synthetic-key".to_string())
1876                }
1877                _ => None,
1878            })
1879        };
1880        let connection = resolve_connection(&provider, None, &getter).expect("connection");
1881        assert_eq!(connection.api_base, "https://example.com/Gateway/v1");
1882        assert_eq!(connection.api_key, "synthetic-key");
1883    }
1884
1885    #[test]
1886    fn endpoint_validation_rejects_malformed_and_credential_bearing_urls() {
1887        for endpoint in [
1888            "local",
1889            "ftp://example.com/v1",
1890            "https://",
1891            "https://user:pass@example.com/v1",
1892            "https://example.com/v1?key=secret",
1893            "https://example.com/v1#fragment",
1894        ] {
1895            assert!(
1896                validate_custom_provider_url(endpoint).is_err(),
1897                "{endpoint}"
1898            );
1899        }
1900        assert!(validate_custom_provider_url("http://127.0.0.1:8000/MyGateway/v1").is_ok());
1901    }
1902
1903    #[test]
1904    fn gateway_provider_names_and_url_paths_keep_their_identity() {
1905        for name in ["openai-compat", "red-router"] {
1906            let provider = parse_provider(name).expect("named gateway");
1907            assert_eq!(provider.token(), name);
1908            assert!(provider.is_openai_compatible());
1909            assert!(provider.requires_api_key());
1910            assert_eq!(provider.default_api_base(), "");
1911            assert!(!provider.default_key_env_name().contains('-'));
1912        }
1913        let url = "https://example.com/MyGateway/v1";
1914        let provider = parse_provider(url).expect("URL provider");
1915        assert_eq!(provider.default_api_base(), url);
1916    }
1917
1918    #[test]
1919    fn implicit_credentials_reject_non_ai_secret_refs_before_reading_secret() {
1920        let provider = AiProvider::Custom("invalid_secret_ref_test".to_string());
1921        let key = ai_api_secret_ref_config_key(&provider, "default");
1922        let err = resolve_api_key(&provider, None, |path| {
1923            if path == key {
1924                return Ok(Some("red.secret.aes_key".to_string()));
1925            }
1926            assert_ne!(path, "red.secret.aes_key", "must not read engine secret");
1927            Ok(None)
1928        })
1929        .expect_err("non-AI secret ref must fail");
1930        assert!(err.to_string().contains("must reference"));
1931    }
1932
1933    // ---- ADR-0068 §5 config schema (issue #1746) --------------------------
1934
1935    #[test]
1936    fn invalid_provider_selection_does_not_fall_back_to_vendor_credentials() {
1937        let getter = |key: &str| -> crate::RedDBResult<Option<String>> {
1938            Ok((key == "red.config.ai.inference.provider").then(|| "typo-provider".to_string()))
1939        };
1940        assert!(resolve_default_provider(&getter).is_err());
1941        assert!(resolve_embeddings_provider(&getter).is_err());
1942    }
1943
1944    #[test]
1945    fn inference_provider_ask_specific_beats_task_pointer() {
1946        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1947            match key {
1948                "red.config.ai.ask.provider" => Ok(Some("groq".to_string())),
1949                "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1950                _ => Ok(None),
1951            }
1952        };
1953        assert_eq!(
1954            resolve_default_provider(&kv).expect("provider"),
1955            AiProvider::Groq
1956        );
1957    }
1958
1959    #[test]
1960    fn inference_provider_falls_through_to_task_pointer_then_default() {
1961        let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
1962            match key {
1963                "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1964                _ => Ok(None),
1965            }
1966        };
1967        assert_eq!(
1968            resolve_default_provider(&pointer).expect("provider"),
1969            AiProvider::DeepSeek
1970        );
1971
1972        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1973        assert_eq!(
1974            resolve_default_provider(&empty).expect("provider"),
1975            AiProvider::OpenAi
1976        );
1977    }
1978
1979    #[test]
1980    fn inference_model_ask_specific_beats_models_block_beats_builtin() {
1981        let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
1982            match key {
1983                "red.config.ai.ask.model" => Ok(Some("gpt-ask".to_string())),
1984                "red.config.ai.providers.openai.models.inference" => {
1985                    Ok(Some("gpt-block".to_string()))
1986                }
1987                _ => Ok(None),
1988            }
1989        };
1990        assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
1991
1992        let block = |key: &str| -> crate::RedDBResult<Option<String>> {
1993            match key {
1994                "red.config.ai.providers.openai.models.inference" => {
1995                    Ok(Some("gpt-block".to_string()))
1996                }
1997                _ => Ok(None),
1998            }
1999        };
2000        assert_eq!(
2001            resolve_default_model(&AiProvider::OpenAi, &block),
2002            "gpt-block"
2003        );
2004
2005        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2006        assert_eq!(
2007            resolve_default_model(&AiProvider::OpenAi, &empty),
2008            AiProvider::OpenAi.default_prompt_model()
2009        );
2010    }
2011
2012    #[test]
2013    fn embeddings_provider_follows_task_pointer() {
2014        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2015            match key {
2016                "red.config.ai.embeddings.provider" => Ok(Some("ollama".to_string())),
2017                _ => Ok(None),
2018            }
2019        };
2020        assert_eq!(
2021            resolve_embeddings_provider(&kv).unwrap(),
2022            AiProvider::Ollama
2023        );
2024
2025        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2026        assert_eq!(
2027            resolve_embeddings_provider(&empty).unwrap(),
2028            AiProvider::OpenAi
2029        );
2030    }
2031
2032    #[test]
2033    fn embeddings_selection_rejects_chat_only_providers_and_accepts_routers() {
2034        for token in ["anthropic", "groq", "deepseek"] {
2035            let provider = parse_provider(token).expect("provider");
2036            assert!(!provider.supports_embeddings());
2037            assert!(ensure_provider_supports_embeddings(&provider).is_err());
2038        }
2039        for token in ["openrouter", "venice", "openai-compat", "red-router"] {
2040            let provider = parse_provider(token).expect("provider");
2041            assert!(ensure_provider_supports_embeddings(&provider).is_ok());
2042        }
2043    }
2044
2045    #[test]
2046    fn embeddings_provider_rejects_modality_incapable_pointer() {
2047        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2048            match key {
2049                "red.config.ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
2050                _ => Ok(None),
2051            }
2052        };
2053        let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
2054        assert!(
2055            err.contains("red.config.ai.embeddings.provider"),
2056            "error must name the pointer to fix: {err}"
2057        );
2058        assert!(
2059            err.contains("openai") && err.contains("no embeddings API"),
2060            "error must name capable alternatives: {err}"
2061        );
2062    }
2063
2064    #[test]
2065    fn embeddings_provider_falls_back_to_default_provider_chain() {
2066        // With no embeddings-specific override, the embeddings provider
2067        // follows the general default-provider chain (here the inference
2068        // task pointer). This keeps a single-global-config deployment
2069        // routing embeddings through the one provider it named — the
2070        // regression the local-vector text-search tests guard against.
2071        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2072            match key {
2073                "red.config.ai.inference.provider" => Ok(Some("ollama".to_string())),
2074                _ => Ok(None),
2075            }
2076        };
2077        assert_eq!(
2078            resolve_embeddings_provider(&kv).unwrap(),
2079            AiProvider::Ollama
2080        );
2081    }
2082
2083    #[test]
2084    fn embeddings_model_block_beats_builtin() {
2085        let block = |key: &str| -> crate::RedDBResult<Option<String>> {
2086            match key {
2087                "red.config.ai.providers.openai.models.embeddings" => {
2088                    Ok(Some("text-embedding-custom".to_string()))
2089                }
2090                _ => Ok(None),
2091            }
2092        };
2093        assert_eq!(
2094            resolve_embeddings_model(&AiProvider::OpenAi, &block),
2095            "text-embedding-custom"
2096        );
2097
2098        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2099        assert_eq!(
2100            resolve_embeddings_model(&AiProvider::Ollama, &empty),
2101            AiProvider::Ollama.default_embedding_model()
2102        );
2103    }
2104
2105    #[test]
2106    fn base_url_reads_provider_block_key() {
2107        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2108            if key == "red.config.ai.providers.openai.base_url" {
2109                Ok(Some("https://proxy.example/v1".to_string()))
2110            } else {
2111                Ok(None)
2112            }
2113        };
2114        assert_eq!(
2115            AiProvider::OpenAi
2116                .resolve_api_base_checked(&kv)
2117                .expect("endpoint"),
2118            "https://proxy.example/v1"
2119        );
2120    }
2121
2122    #[test]
2123    fn removed_config_keys_rejected_naming_replacements() {
2124        let err = validate_ai_config_key_on_write("red.config.ai.default.provider")
2125            .unwrap_err()
2126            .to_string();
2127        assert!(err.contains("red.config.ai.inference.provider"), "{err}");
2128
2129        let err = validate_ai_config_key_on_write("red.config.ai.default.model")
2130            .unwrap_err()
2131            .to_string();
2132        assert!(
2133            err.contains("red.config.ai.providers.<provider>.models.inference"),
2134            "{err}"
2135        );
2136
2137        let err = validate_ai_config_key_on_write("red.config.ai.openai.default.base_url")
2138            .unwrap_err()
2139            .to_string();
2140        assert!(
2141            err.contains("red.config.ai.providers.<provider>.base_url"),
2142            "{err}"
2143        );
2144
2145        // New-schema keys and unrelated keys are accepted.
2146        assert!(validate_ai_config_key_on_write("red.config.ai.inference.provider").is_ok());
2147        assert!(validate_ai_config_key_on_write("red.config.ai.providers.openai.base_url").is_ok());
2148        assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
2149    }
2150
2151    #[test]
2152    fn ask_planner_model_and_effort_resolve() {
2153        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
2154            match key {
2155                "red.config.ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
2156                "red.config.ai.ask.effort" => Ok(Some("high".to_string())),
2157                _ => Ok(None),
2158            }
2159        };
2160        assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
2161        assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
2162
2163        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
2164        assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
2165        assert_eq!(resolve_ask_effort(&empty), None);
2166    }
2167
2168    #[tokio::test]
2169    async fn anthropic_prompt_async_rejects_empty_api_key() {
2170        let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
2171        let request = AnthropicPromptRequest {
2172            api_key: "  ".to_string(),
2173            model: "claude-3-5-haiku-latest".to_string(),
2174            prompt: "hello".to_string(),
2175            temperature: None,
2176            max_output_tokens: None,
2177            api_base: "https://api.anthropic.com/v1".to_string(),
2178            anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
2179        };
2180        let err = anthropic_prompt_async(&transport, request)
2181            .await
2182            .unwrap_err();
2183        assert!(err.to_string().contains("API key cannot be empty"));
2184    }
2185}
2186
2187// ============================================================================
2188// Provider & Credential Resolution (shared between HTTP, gRPC, and runtime)
2189// ============================================================================
2190
2191/// AI provider identifier.
2192#[derive(Debug, Clone, PartialEq, Eq)]
2193pub enum AiProvider {
2194    OpenAi,
2195    Anthropic,
2196    Groq,
2197    OpenRouter,
2198    OpenAiCompat,
2199    RedRouter,
2200    Together,
2201    Venice,
2202    Ollama,
2203    DeepSeek,
2204    MiniMax,
2205    HuggingFace,
2206    Local,
2207    Custom(String),
2208}
2209
2210impl AiProvider {
2211    pub fn token(&self) -> &str {
2212        match self {
2213            Self::OpenAi => "openai",
2214            Self::Anthropic => "anthropic",
2215            Self::Groq => "groq",
2216            Self::OpenRouter => "openrouter",
2217            Self::OpenAiCompat => "openai-compat",
2218            Self::RedRouter => "red-router",
2219            Self::Together => "together",
2220            Self::Venice => "venice",
2221            Self::Ollama => "ollama",
2222            Self::DeepSeek => "deepseek",
2223            Self::MiniMax => "minimax",
2224            Self::HuggingFace => "huggingface",
2225            Self::Local => "local",
2226            Self::Custom(name) => name.as_str(),
2227        }
2228    }
2229
2230    pub fn default_prompt_model(&self) -> &str {
2231        match self {
2232            Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
2233            Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
2234            Self::Groq => "llama-3.3-70b-versatile",
2235            Self::OpenRouter => "auto",
2236            Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
2237            Self::Venice => "llama-3.3-70b",
2238            Self::Ollama => "llama3",
2239            Self::DeepSeek => "deepseek-chat",
2240            Self::MiniMax => "abab6.5s-chat",
2241            Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
2242            Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2243            Self::Custom(_) | Self::OpenAiCompat | Self::RedRouter => DEFAULT_OPENAI_PROMPT_MODEL,
2244        }
2245    }
2246
2247    pub fn prompt_model_env_name(&self) -> String {
2248        format!(
2249            "REDDB_{}_PROMPT_MODEL",
2250            self.token().replace('-', "_").to_ascii_uppercase()
2251        )
2252    }
2253
2254    pub fn default_embedding_model(&self) -> &str {
2255        match self {
2256            Self::Ollama => "nomic-embed-text",
2257            Self::OpenRouter => "openai/text-embedding-3-small",
2258            Self::Venice => "text-embedding-bge-m3",
2259            Self::MiniMax => "embo-01",
2260            Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
2261            _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
2262        }
2263    }
2264
2265    pub fn default_api_base(&self) -> &str {
2266        match self {
2267            Self::OpenAi => DEFAULT_OPENAI_API_BASE,
2268            Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
2269            Self::Groq => "https://api.groq.com/openai/v1",
2270            Self::OpenRouter => "https://openrouter.ai/api/v1",
2271            Self::Together => "https://api.together.xyz/v1",
2272            Self::Venice => "https://api.venice.ai/api/v1",
2273            Self::Ollama => "http://localhost:11434/v1",
2274            Self::DeepSeek => "https://api.deepseek.com/v1",
2275            Self::MiniMax => "https://api.minimax.chat/v1",
2276            Self::HuggingFace => "https://api-inference.huggingface.co",
2277            Self::Local => "local",
2278            Self::Custom(base) => base.as_str(),
2279            Self::OpenAiCompat | Self::RedRouter => "",
2280        }
2281    }
2282
2283    pub fn api_base_env_name(&self) -> String {
2284        format!(
2285            "REDDB_{}_API_BASE",
2286            self.token().replace('-', "_").to_ascii_uppercase()
2287        )
2288    }
2289
2290    pub fn default_key_env_name(&self) -> String {
2291        format!(
2292            "REDDB_{}_API_KEY",
2293            self.token().replace('-', "_").to_ascii_uppercase()
2294        )
2295    }
2296
2297    pub fn alias_key_env_name(&self, alias: &str) -> String {
2298        let normalized = normalize_alias_token(alias);
2299        format!(
2300            "REDDB_{}_API_KEY_{normalized}",
2301            self.token().replace('-', "_").to_ascii_uppercase()
2302        )
2303    }
2304
2305    pub fn resolve_api_base(&self) -> String {
2306        if let Ok(value) = std::env::var(self.api_base_env_name()) {
2307            let value = value.trim().to_string();
2308            if !value.is_empty() {
2309                return value;
2310            }
2311        }
2312        self.default_api_base().to_string()
2313    }
2314
2315    /// Resolve and validate the endpoint before any provider credential is read.
2316    pub fn resolve_api_base_checked<F>(&self, kv_getter: &F) -> crate::RedDBResult<String>
2317    where
2318        F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2319    {
2320        let endpoint = match std::env::var(self.api_base_env_name()) {
2321            Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
2322            _ => kv_getter(&provider_base_url_key(self))?
2323                .filter(|value| !value.trim().is_empty())
2324                .map(|value| value.trim().to_string())
2325                .unwrap_or_else(|| self.default_api_base().to_string()),
2326        };
2327        if !matches!(self, Self::Local) {
2328            if endpoint.is_empty() {
2329                return Err(crate::RedDBError::Query(format!(
2330                    "missing {} endpoint; set {} or '{}' alongside the API key",
2331                    self.token(),
2332                    self.api_base_env_name(),
2333                    provider_base_url_key(self),
2334                )));
2335            }
2336            validate_custom_provider_url(&endpoint)?;
2337        }
2338        Ok(endpoint)
2339    }
2340
2341    /// Gateways have no trusted built-in endpoint; registration requires the pair.
2342    pub fn requires_explicit_endpoint(&self) -> bool {
2343        matches!(self, Self::OpenAiCompat | Self::RedRouter | Self::Custom(_))
2344    }
2345
2346    /// Whether this provider uses the OpenAI-compatible API format.
2347    pub fn is_openai_compatible(&self) -> bool {
2348        matches!(
2349            self,
2350            Self::OpenAi
2351                | Self::Groq
2352                | Self::OpenRouter
2353                | Self::OpenAiCompat
2354                | Self::RedRouter
2355                | Self::Together
2356                | Self::Venice
2357                | Self::Ollama
2358                | Self::DeepSeek
2359                | Self::MiniMax
2360                | Self::Custom(_)
2361        )
2362    }
2363
2364    /// Whether this provider requires an API key (Ollama/Local don't).
2365    pub fn requires_api_key(&self) -> bool {
2366        !matches!(self, Self::Ollama | Self::Local)
2367    }
2368
2369    /// Embeddings routing and policy validation share one modality matrix.
2370    pub fn supports_embeddings(&self) -> bool {
2371        crate::runtime::ai::provider_capabilities::Modalities::for_provider(self.token()).embed
2372    }
2373}
2374
2375/// Parse a provider string into AiProvider.
2376pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
2377    let name = name.trim();
2378    match name.to_ascii_lowercase().as_str() {
2379        "openai" => Ok(AiProvider::OpenAi),
2380        "anthropic" => Ok(AiProvider::Anthropic),
2381        "groq" => Ok(AiProvider::Groq),
2382        "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
2383        "openai-compat" | "openai_compat" => Ok(AiProvider::OpenAiCompat),
2384        "red-router" | "red_router" => Ok(AiProvider::RedRouter),
2385        "together" => Ok(AiProvider::Together),
2386        "venice" => Ok(AiProvider::Venice),
2387        "ollama" => Ok(AiProvider::Ollama),
2388        "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
2389        "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
2390        "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
2391        "local" => Ok(AiProvider::Local),
2392        other => {
2393            // Treat as custom provider if it looks like a URL
2394            if other.starts_with("http://") || other.starts_with("https://") {
2395                validate_custom_provider_url(name)?;
2396                Ok(AiProvider::Custom(name.to_string()))
2397            } else {
2398                Err(crate::RedDBError::Query(format!(
2399                    "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
2400                     openrouter, openai-compat, red-router, together, venice, ollama, deepseek, minimax, huggingface, local"
2401                )))
2402            }
2403        }
2404    }
2405}
2406
2407// ============================================================================
2408// AI config schema (ADR-0068 §5) — clean break
2409//
2410//   red.config.ai.ask.{provider,model,planner_model,effort,max_plan_steps}
2411//   red.config.ai.providers.<provider>.base_url
2412//   red.config.ai.providers.<provider>.models.{inference,embeddings}
2413//   red.config.ai.inference.provider   # task pointer: who generates
2414//   red.config.ai.embeddings.provider  # task pointer: who embeds
2415//
2416// Resolution order for any AI call:
2417//   ASK-specific config -> task pointer -> pointed provider's models block
2418//   -> provider built-in default.
2419//
2420// The old flat keys (`red.config.ai.default.provider|model` and the old
2421// per-alias `red.config.ai.<provider>.<alias>.base_url` base-URL shape)
2422// are removed and rejected on write; there is no deprecation window.
2423// ============================================================================
2424
2425/// Providers that can serve embeddings, listed for didactic errors.
2426pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
2427    "openai, ollama, openrouter, openai-compat, red-router, together, venice, minimax, huggingface, local";
2428
2429/// KV key for a provider's base URL under the new schema.
2430pub fn provider_base_url_key(provider: &AiProvider) -> String {
2431    format!("red.config.ai.providers.{}.base_url", provider.token())
2432}
2433
2434/// KV key for a provider's per-modality model under the new schema.
2435/// `modality` is `"inference"` or `"embeddings"`.
2436pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
2437    format!(
2438        "red.config.ai.providers.{}.models.{modality}",
2439        provider.token()
2440    )
2441}
2442
2443/// Reject an AI config key that was removed in the ADR-0068 clean break,
2444/// naming the replacement key. Called from the `SET CONFIG` write path so
2445/// operators cannot silently persist a key nothing reads. Returns `Ok(())`
2446/// for every key that is still valid (including non-AI keys).
2447pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
2448    let key = key.trim();
2449    if key == "red.config.ai.default.provider" {
2450        return Err(crate::RedDBError::Query(
2451            "AI config key 'red.config.ai.default.provider' was removed in the ADR-0068 \
2452             clean break; set the task pointer 'red.config.ai.inference.provider' (or \
2453             'red.config.ai.ask.provider' for the ASK planner) instead"
2454                .to_string(),
2455        ));
2456    }
2457    if key == "red.config.ai.default.model" {
2458        return Err(crate::RedDBError::Query(
2459            "AI config key 'red.config.ai.default.model' was removed in the ADR-0068 clean \
2460             break; set 'red.config.ai.ask.model' or \
2461             'red.config.ai.providers.<provider>.models.inference' instead"
2462                .to_string(),
2463        ));
2464    }
2465    // Old per-alias base-URL shape: red.config.ai.<provider>.<alias>.base_url
2466    // (anything under red.config.ai.* ending in .base_url that is NOT the
2467    // new red.config.ai.providers.<provider>.base_url form).
2468    if key.starts_with("red.config.ai.")
2469        && key.ends_with(".base_url")
2470        && !key.starts_with("red.config.ai.providers.")
2471    {
2472        return Err(crate::RedDBError::Query(format!(
2473            "AI config key '{key}' uses the removed per-credential base-URL shape; set \
2474             'red.config.ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
2475        )));
2476    }
2477    Ok(())
2478}
2479
2480/// Resolve the inference (generation) provider. Precedence:
2481/// 1. `REDDB_AI_PROVIDER` env var.
2482/// 2. ASK-specific config `red.config.ai.ask.provider`.
2483/// 3. Inference task pointer `red.config.ai.inference.provider`.
2484/// 4. Legacy wire-protocol mode selector when no provider was selected.
2485/// 5. Falls back to OpenAI.
2486pub fn resolve_default_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
2487where
2488    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2489{
2490    if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
2491        if !value.trim().is_empty() {
2492            return parse_provider(&value);
2493        }
2494    }
2495    for key in [
2496        "red.config.ai.ask.provider",
2497        "red.config.ai.inference.provider",
2498    ] {
2499        if let Some(value) = kv_getter(key)? {
2500            if !value.trim().is_empty() {
2501                return parse_provider(&value);
2502            }
2503        }
2504    }
2505    Ok(resolve_provider_mode(kv_getter)
2506        .map(provider_mode_to_provider)
2507        .unwrap_or(AiProvider::OpenAi))
2508}
2509
2510/// Resolve the inference (generation) model for `provider`. Precedence:
2511/// 1. `REDDB_AI_MODEL` env var.
2512/// 2. Provider-specific prompt-model env var.
2513/// 3. ASK-specific config `red.config.ai.ask.model`.
2514/// 4. Provider models block `…providers.<p>.models.inference`.
2515/// 5. Provider built-in default prompt model.
2516pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2517where
2518    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2519{
2520    // 1. Env var
2521    if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2522        let value = value.trim().to_string();
2523        if !value.is_empty() {
2524            return value;
2525        }
2526    }
2527    // 2. Provider-specific env var
2528    if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
2529        let value = value.trim().to_string();
2530        if !value.is_empty() {
2531            return value;
2532        }
2533    }
2534    // 3. ASK-specific config, then 4. provider models block.
2535    for key in [
2536        "red.config.ai.ask.model".to_string(),
2537        provider_models_key(provider, "inference"),
2538    ] {
2539        if let Ok(Some(value)) = kv_getter(&key) {
2540            let value = value.trim().to_string();
2541            if !value.is_empty() {
2542                return value;
2543            }
2544        }
2545    }
2546    provider.default_prompt_model().to_string()
2547}
2548
2549/// Resolve the embeddings provider. Precedence:
2550/// 1. `REDDB_AI_EMBEDDINGS_PROVIDER` env var (embeddings-specific override).
2551/// 2. `red.config.ai.embeddings.provider` task pointer.
2552/// 3. Fall back to the general default provider — [`resolve_default_provider`]
2553///    honours `REDDB_AI_PROVIDER`, the wire-protocol mode selector, and the
2554///    ASK/inference chain — so a deployment that only sets a single global
2555///    provider (e.g. `REDDB_AI_PROVIDER=local`) still embeds through it.
2556///
2557/// Fails with a didactic error — naming the embeddings pointer and the capable
2558/// providers — when the resolved provider has no embeddings API, rather than
2559/// silently re-routing (ADR-0068 §5; the Anthropic case generalized).
2560pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
2561where
2562    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2563{
2564    let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
2565        .ok()
2566        .map(|v| v.trim().to_string())
2567        .filter(|v| !v.is_empty())
2568    {
2569        parse_provider(&value)?
2570    } else if let Some(value) = kv_getter("red.config.ai.embeddings.provider")?
2571        .map(|v| v.trim().to_string())
2572        .filter(|v| !v.is_empty())
2573    {
2574        parse_provider(&value)?
2575    } else {
2576        // No embeddings-specific override: follow the general default
2577        // provider so the historical global `REDDB_AI_PROVIDER` selector
2578        // keeps driving embeddings when the task pointer is unset.
2579        resolve_default_provider(kv_getter)?
2580    };
2581    ensure_provider_supports_embeddings(&provider)?;
2582    Ok(provider)
2583}
2584
2585/// Fail with a didactic error when `provider` cannot serve embeddings.
2586pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
2587    if provider.supports_embeddings() {
2588        return Ok(());
2589    }
2590    Err(crate::RedDBError::Query(format!(
2591        "the embeddings task pointer 'red.config.ai.embeddings.provider' names '{}', which \
2592         has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
2593         re-routes embeddings to a different provider than the one you named.",
2594        provider.token(),
2595        EMBEDDING_CAPABLE_PROVIDERS
2596    )))
2597}
2598
2599/// Resolve the embeddings model for `provider`. Precedence:
2600/// 1. Provider-specific `REDDB_<P>_EMBEDDING_MODEL` env var.
2601/// 2. `REDDB_OPENAI_EMBEDDING_MODEL` env var (legacy shared override).
2602/// 3. Provider models block `…providers.<p>.models.embeddings`.
2603/// 4. General `REDDB_AI_MODEL` env var (historical global model selector).
2604/// 5. Provider built-in default embedding model.
2605///
2606/// The embeddings-specific sources (1–3) outrank the general `REDDB_AI_MODEL`
2607/// selector so an explicit embeddings model always wins; `REDDB_AI_MODEL`
2608/// only fills in for the built-in default, which is what keeps a
2609/// single-global-config deployment (`REDDB_AI_PROVIDER`+`REDDB_AI_MODEL`)
2610/// routing text embeddings through the named model.
2611pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2612where
2613    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2614{
2615    if let Ok(value) = std::env::var(format!(
2616        "REDDB_{}_EMBEDDING_MODEL",
2617        provider.token().replace('-', "_").to_ascii_uppercase()
2618    )) {
2619        let value = value.trim().to_string();
2620        if !value.is_empty() {
2621            return value;
2622        }
2623    }
2624    if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
2625        let value = value.trim().to_string();
2626        if !value.is_empty() {
2627            return value;
2628        }
2629    }
2630    if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
2631        let value = value.trim().to_string();
2632        if !value.is_empty() {
2633            return value;
2634        }
2635    }
2636    if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2637        let value = value.trim().to_string();
2638        if !value.is_empty() {
2639            return value;
2640        }
2641    }
2642    provider.default_embedding_model().to_string()
2643}
2644
2645/// Resolve the ASK planner model (`red.config.ai.ask.planner_model`),
2646/// falling back to `fallback_model` (typically the resolved ASK model).
2647/// Inert until the ASK planner slice consumes it.
2648pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
2649where
2650    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2651{
2652    if let Ok(Some(value)) = kv_getter("red.config.ai.ask.planner_model") {
2653        let value = value.trim().to_string();
2654        if !value.is_empty() {
2655            return value;
2656        }
2657    }
2658    fallback_model.to_string()
2659}
2660
2661/// Resolve the ASK planner effort (`red.config.ai.ask.effort`) if set.
2662/// Inert until the ASK planner slice consumes it.
2663pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
2664where
2665    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2666{
2667    if let Ok(Some(value)) = kv_getter("red.config.ai.ask.effort") {
2668        let value = value.trim().to_string();
2669        if !value.is_empty() {
2670            return Some(value);
2671        }
2672    }
2673    None
2674}
2675
2676/// Resolve default provider + model from runtime KV store.
2677pub fn resolve_defaults_from_runtime(
2678    runtime: &crate::runtime::RedDBRuntime,
2679) -> crate::RedDBResult<(AiProvider, String)> {
2680    use crate::application::ports::RuntimeEntityPort;
2681    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2682        match runtime.get_kv("red_config", key)? {
2683            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2684            _ => Ok(None),
2685        }
2686    };
2687    let provider = resolve_default_provider(&kv_getter)?;
2688    let model = resolve_default_model(&provider, &kv_getter);
2689    Ok((provider, model))
2690}
2691
2692pub fn resolve_prompt_model_from_runtime(
2693    runtime: &crate::runtime::RedDBRuntime,
2694    provider: &AiProvider,
2695    explicit: Option<&str>,
2696) -> String {
2697    use crate::application::ports::RuntimeEntityPort;
2698    if let Some(model) = explicit {
2699        return model.to_string();
2700    }
2701    resolve_default_model(provider, &|key| match runtime.get_kv("red_config", key)? {
2702        Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
2703        _ => Ok(None),
2704    })
2705}
2706
2707/// Resolve the ASK planner model from runtime KV (`red.config.ai.ask.planner_model`),
2708/// falling back to `fallback_model` — the resolved synthesis/ASK model — when
2709/// no planner model is configured (ADR 0068 §3). Lets a cheap/fast model plan
2710/// while the user-chosen model synthesizes.
2711pub fn resolve_ask_planner_model_from_runtime(
2712    runtime: &crate::runtime::RedDBRuntime,
2713    fallback_model: &str,
2714) -> String {
2715    use crate::application::ports::RuntimeEntityPort;
2716    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2717        match runtime.get_kv("red_config", key)? {
2718            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2719            _ => Ok(None),
2720        }
2721    };
2722    resolve_ask_planner_model(&kv_getter, fallback_model)
2723}
2724
2725/// Resolve the default provider and model for runtime semantic search.
2726pub fn resolve_defaults_from_runtime_port<
2727    P: crate::application::ports::RuntimeEntityPort + ?Sized,
2728>(
2729    runtime: &P,
2730) -> crate::RedDBResult<(AiProvider, String)> {
2731    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2732        match runtime.get_kv("red_config", key)? {
2733            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2734            _ => Ok(None),
2735        }
2736    };
2737    let provider = resolve_default_provider(&kv_getter)?;
2738    let model = resolve_default_model(&provider, &kv_getter);
2739    Ok((provider, model))
2740}
2741
2742/// Resolve the embeddings provider for an AUTO EMBED / embeddings call from
2743/// a runtime port. `explicit` is the `USING <provider>` override (empty when
2744/// none was given); when empty the embeddings task pointer drives selection.
2745/// A modality-incapable provider fails didactically (ADR-0068 §5).
2746pub fn resolve_embeddings_provider_from_runtime<
2747    P: crate::application::ports::RuntimeEntityPort + ?Sized,
2748>(
2749    runtime: &P,
2750    explicit: &str,
2751) -> crate::RedDBResult<AiProvider> {
2752    let explicit = explicit.trim();
2753    if !explicit.is_empty() {
2754        let provider = parse_provider(explicit)?;
2755        ensure_provider_supports_embeddings(&provider)?;
2756        return Ok(provider);
2757    }
2758    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2759        match runtime.get_kv("red_config", key)? {
2760            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2761            _ => Ok(None),
2762        }
2763    };
2764    resolve_embeddings_provider(&kv_getter)
2765}
2766
2767/// Resolve the embeddings model for `provider` from a runtime port,
2768/// honouring an explicit `MODEL '<name>'` override before the config
2769/// resolution order (env → provider models block → built-in default).
2770pub fn resolve_embeddings_model_from_runtime<
2771    P: crate::application::ports::RuntimeEntityPort + ?Sized,
2772>(
2773    runtime: &P,
2774    provider: &AiProvider,
2775    explicit: Option<&str>,
2776) -> String {
2777    if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
2778        return model.to_string();
2779    }
2780    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2781        match runtime.get_kv("red_config", key)? {
2782            Some((reddb_types::Value::Text(s), _)) => Ok(Some(s.to_string())),
2783            _ => Ok(None),
2784        }
2785    };
2786    resolve_embeddings_model(provider, &kv_getter)
2787}
2788
2789/// Resolve an API key for a provider, **preferring the encrypted vault
2790/// over environment variables** (issue #1270). The env vars are a
2791/// bootstrap fallback so a fresh deployment can talk to a provider
2792/// before any key has been written to the vault.
2793///
2794/// Resolution order (issue #1745 — clean break, no deprecation window):
2795/// 1. Vault token path: `red.secret.ai.providers.<provider>.tokens.<alias>`
2796/// 2. Vault token indirected via
2797///    `red.config.ai.providers.<provider>.tokens.<alias>.secret_ref`
2798/// 3. Env fallback: `REDDB_<PROVIDER>_API_KEY[_<ALIAS>]`
2799///
2800/// With no explicit alias, use `providers.<provider>.credential`, then `default`.
2801/// First non-empty source wins per request.
2802///
2803/// The old vault path shape (`red.secret.ai.<provider>.<alias>.api_key`)
2804/// and the legacy plaintext config path (`red.config.ai.<provider>.<alias>.key`)
2805/// are **removed**: a credential still sitting at either is rejected with a
2806/// didactic error naming the new vault path to populate — never silently
2807/// read.
2808///
2809/// `kv_getter` receives either a `red.secret.*` path (routed to the
2810/// encrypted vault by [`resolve_api_key_from_runtime`]) or a
2811/// `red.config.*` key and returns the value if found. Vault-stored keys
2812/// are therefore encrypted at rest and rotatable via the existing vault
2813/// KV path; the env vars carry no such guarantees, which is why they are
2814/// the fallback rather than the primary source.
2815pub fn resolve_api_key<F>(
2816    provider: &AiProvider,
2817    credential_alias: Option<&str>,
2818    kv_getter: F,
2819) -> crate::RedDBResult<String>
2820where
2821    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2822{
2823    let configured_alias = if credential_alias.is_none() {
2824        kv_getter(&format!(
2825            "red.config.ai.providers.{}.credential",
2826            provider.token()
2827        ))?
2828    } else {
2829        None
2830    };
2831    let credential_alias = credential_alias.or(configured_alias.as_deref());
2832    if let Some(alias) = credential_alias
2833        .map(str::trim)
2834        .filter(|a| !a.is_empty() && *a != "default")
2835    {
2836        // 1. Vault token path (managed, encrypted at rest).
2837        if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
2838            if !key.trim().is_empty() {
2839                return Ok(key);
2840            }
2841        }
2842        // 2. Vault token reachable through a configured indirection ref.
2843        if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
2844            let secret_ref = secret_ref.trim();
2845            // The indirection may only name an AI provider credential.
2846            // Pointing it at `red.secret.aes_key` or an IAM blob would send
2847            // engine-internal key material as a bearer to the provider URL.
2848            validate_ai_secret_ref(secret_ref)?;
2849            if let Some(key) = kv_getter(secret_ref)? {
2850                if !key.trim().is_empty() {
2851                    return Ok(key);
2852                }
2853            }
2854        }
2855        // 3. Env fallback with alias (bootstrap before vault is populated).
2856        if let Some(key) = resolve_key_from_env_alias(provider, alias) {
2857            return Ok(key);
2858        }
2859        // Clean break: a credential still sitting at a removed path is
2860        // rejected didactically, never silently read (issue #1745).
2861        reject_removed_credential_paths(provider, alias, &kv_getter)?;
2862        return Err(crate::RedDBError::Query(format!(
2863            "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
2864            provider.token(),
2865            provider.alias_key_env_name(alias),
2866            ai_api_secret_path(provider, alias),
2867        )));
2868    }
2869
2870    // 1. Vault token path (managed, encrypted at rest).
2871    if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
2872        if !key.trim().is_empty() {
2873            return Ok(key);
2874        }
2875    }
2876    // 2. Vault token reachable through a configured indirection ref.
2877    if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
2878        let secret_ref = secret_ref.trim();
2879        validate_ai_secret_ref(secret_ref)?;
2880        if let Some(key) = kv_getter(secret_ref)? {
2881            if !key.trim().is_empty() {
2882                return Ok(key);
2883            }
2884        }
2885    }
2886
2887    // 3. Env fallback (bootstrap before the vault is populated).
2888    if let Ok(value) = std::env::var(provider.default_key_env_name()) {
2889        let value = value.trim().to_string();
2890        if !value.is_empty() {
2891            return Ok(value);
2892        }
2893    }
2894
2895    // Clean break: reject a credential still sitting at a removed path
2896    // instead of silently reading it (issue #1745).
2897    reject_removed_credential_paths(provider, "default", &kv_getter)?;
2898    if !provider.requires_api_key() {
2899        return Ok(String::new());
2900    }
2901
2902    Err(crate::RedDBError::Query(format!(
2903        "missing {} API key. Set {} or store it in the vault at '{}'",
2904        provider.token(),
2905        provider.default_key_env_name(),
2906        ai_api_secret_path(provider, "default"),
2907    )))
2908}
2909
2910fn validate_ai_secret_ref(secret_ref: &str) -> crate::RedDBResult<()> {
2911    if !secret_ref
2912        .to_ascii_lowercase()
2913        .starts_with("red.secret.ai.")
2914    {
2915        return Err(crate::RedDBError::Query(format!(
2916            "secret_ref '{secret_ref}' must reference a vault key under red.secret.ai.*"
2917        )));
2918    }
2919    Ok(())
2920}
2921
2922/// Vault token path (issue #1745): the sole credential source in the vault.
2923pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
2924    format!(
2925        "red.secret.ai.providers.{}.tokens.{}",
2926        provider.token(),
2927        normalize_credential_alias_path(alias)
2928    )
2929}
2930
2931/// Config key holding a vault indirection ref for the token (issue #1745).
2932pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
2933    format!(
2934        "red.config.ai.providers.{}.tokens.{}.secret_ref",
2935        provider.token(),
2936        normalize_credential_alias_path(alias)
2937    )
2938}
2939
2940/// Removed vault path shape (`red.secret.ai.<provider>.<alias>.api_key`,
2941/// issue #1745). Probed ONLY to reject with a migration error — never read
2942/// as a credential source.
2943fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
2944    format!(
2945        "red.secret.ai.{}.{}.api_key",
2946        provider.token(),
2947        normalize_credential_alias_path(alias)
2948    )
2949}
2950
2951/// Removed plaintext config path (`red.config.ai.<provider>.<alias>.key`,
2952/// issue #1745). Probed ONLY to reject with a migration error.
2953fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
2954    format!(
2955        "red.config.ai.{}.{}.key",
2956        provider.token(),
2957        normalize_credential_alias_path(alias)
2958    )
2959}
2960
2961/// Fail with a didactic error if a credential is still parked at either of
2962/// the removed paths (old vault shape or legacy plaintext config). The
2963/// clean break forbids silently falling back to them (issue #1745).
2964fn reject_removed_credential_paths<F>(
2965    provider: &AiProvider,
2966    alias: &str,
2967    kv_getter: &F,
2968) -> crate::RedDBResult<()>
2969where
2970    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2971{
2972    let new_path = ai_api_secret_path(provider, alias);
2973    for removed in [
2974        removed_vault_api_key_path(provider, alias),
2975        removed_plaintext_config_key(provider, alias),
2976    ] {
2977        if let Some(value) = kv_getter(&removed)? {
2978            if !value.trim().is_empty() {
2979                return Err(crate::RedDBError::Query(format!(
2980                    "AI credential found at removed path '{removed}'. The AI credential vault \
2981                     path changed (issue #1745): store the token at '{new_path}' instead. The \
2982                     old vault path shape and the legacy plaintext config path are no longer read."
2983                )));
2984            }
2985        }
2986    }
2987    Ok(())
2988}
2989
2990fn normalize_credential_alias_path(alias: &str) -> String {
2991    let alias = alias.trim();
2992    if alias.is_empty() {
2993        "default".to_string()
2994    } else {
2995        alias.to_ascii_lowercase()
2996    }
2997}
2998
2999fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
3000    let env_name = provider.alias_key_env_name(alias);
3001    std::env::var(env_name)
3002        .ok()
3003        .map(|v| v.trim().to_string())
3004        .filter(|v| !v.is_empty())
3005}
3006
3007fn normalize_alias_token(alias: &str) -> String {
3008    let mut out = String::with_capacity(alias.len());
3009    for character in alias.chars() {
3010        if character.is_ascii_alphanumeric() {
3011            out.push(character.to_ascii_uppercase());
3012        } else {
3013            out.push('_');
3014        }
3015    }
3016    while out.contains("__") {
3017        out = out.replace("__", "_");
3018    }
3019    out.trim_matches('_').to_string()
3020}
3021
3022/// A validated endpoint and its resolved credential. Deliberately has no Debug
3023/// implementation because the credential must not appear in diagnostics.
3024pub struct AiConnection {
3025    pub api_base: String,
3026    pub api_key: String,
3027}
3028
3029pub fn resolve_connection<F>(
3030    provider: &AiProvider,
3031    credential_alias: Option<&str>,
3032    kv_getter: &F,
3033) -> crate::RedDBResult<AiConnection>
3034where
3035    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3036{
3037    let api_base = provider.resolve_api_base_checked(kv_getter)?;
3038    let api_key = resolve_api_key(provider, credential_alias, kv_getter)?;
3039    Ok(AiConnection { api_base, api_key })
3040}
3041
3042pub fn resolve_connection_from_runtime(
3043    provider: &AiProvider,
3044    credential_alias: Option<&str>,
3045    runtime: &crate::runtime::RedDBRuntime,
3046) -> crate::RedDBResult<AiConnection> {
3047    use crate::application::ports::RuntimeEntityPort;
3048    let api_base =
3049        provider.resolve_api_base_checked(&|key| match runtime.get_kv("red_config", key)? {
3050            Some((reddb_types::Value::Text(value), _)) => Ok(Some(value.to_string())),
3051            _ => Ok(None),
3052        })?;
3053    let api_key = resolve_api_key_from_runtime(provider, credential_alias, runtime)?;
3054    Ok(AiConnection { api_base, api_key })
3055}
3056
3057/// Convenience: resolve API key using a RedDBRuntime's KV store.
3058///
3059/// Emits an `ai.credential.resolve` audit event so operators can answer
3060/// "which principal caused us to read
3061/// `red.secret.ai.providers.<provider>.tokens.*`?"
3062/// even though the read itself is performed as system (the AI subsystem
3063/// must always be able to fetch the key the query needs — denying it
3064/// would be denying the query at the wrong layer). The audit record
3065/// never contains the secret value.
3066pub fn resolve_api_key_from_runtime(
3067    provider: &AiProvider,
3068    credential_alias: Option<&str>,
3069    runtime: &crate::runtime::RedDBRuntime,
3070) -> crate::RedDBResult<String> {
3071    use crate::application::ports::RuntimeEntityPort;
3072    let configured_alias = if credential_alias.is_none() {
3073        match runtime.get_kv(
3074            "red_config",
3075            &format!("red.config.ai.providers.{}.credential", provider.token()),
3076        )? {
3077            Some((reddb_types::Value::Text(value), _)) => Some(value.to_string()),
3078            _ => None,
3079        }
3080    } else {
3081        None
3082    };
3083    let credential_alias = credential_alias.or(configured_alias.as_deref());
3084    let alias_for_audit = credential_alias.unwrap_or("default").to_string();
3085    let provider_token = provider.token().to_string();
3086    let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
3087        std::cell::RefCell::new(Vec::new());
3088    let result = resolve_api_key(provider, credential_alias, |kv_key| {
3089        if kv_key.starts_with("red.secret.") {
3090            let value = runtime.vault_kv_get(kv_key);
3091            audited_paths
3092                .borrow_mut()
3093                .push((kv_key.to_string(), value.is_some()));
3094            return Ok(value);
3095        }
3096        match runtime.get_kv("red_config", kv_key)? {
3097            Some((reddb_types::Value::Text(secret), _)) => {
3098                audited_paths.borrow_mut().push((kv_key.to_string(), true));
3099                Ok(Some(secret.to_string()))
3100            }
3101            Some(_) => {
3102                audited_paths.borrow_mut().push((kv_key.to_string(), false));
3103                Ok(None)
3104            }
3105            None => {
3106                audited_paths.borrow_mut().push((kv_key.to_string(), false));
3107                Ok(None)
3108            }
3109        }
3110    });
3111    let audited_paths = audited_paths.into_inner();
3112
3113    let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
3114        .map(|(user, _role)| user)
3115        .unwrap_or_else(|| "system".to_string());
3116    let outcome = if result.is_ok() { "hit" } else { "miss" };
3117    let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
3118    let paths_json: Vec<crate::serde_json::Value> = audited_paths
3119        .iter()
3120        .map(|(p, hit)| {
3121            crate::serde_json::json!({
3122                "path": p,
3123                "hit": hit,
3124            })
3125        })
3126        .collect();
3127    let details = crate::serde_json::json!({
3128        "provider": provider_token,
3129        "alias": alias_for_audit,
3130        "paths_checked": paths_json,
3131    });
3132    runtime.audit_log().record(
3133        "ai.credential.resolve",
3134        &principal,
3135        &target,
3136        outcome,
3137        details,
3138    );
3139    result
3140}
3141
3142// ============================================================================
3143// HuggingFace Inference API
3144// ============================================================================
3145
3146/// Generate embeddings via HuggingFace Inference API.
3147pub fn huggingface_embeddings(
3148    api_key: &str,
3149    model: &str,
3150    inputs: &[String],
3151    api_base: &str,
3152) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3153    let url = format!("{api_base}/pipeline/feature-extraction/{model}");
3154    let mut embeddings = Vec::with_capacity(inputs.len());
3155
3156    for input in inputs {
3157        let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
3158        let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
3159            .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3160        if !(200..300).contains(&status) {
3161            return Err(crate::RedDBError::Query(format!(
3162                "HuggingFace API error (status {status}): {body_str}"
3163            )));
3164        }
3165        let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
3166            crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
3167        })?;
3168
3169        // HF returns [[f32, ...]] for single input
3170        let vector: Vec<f32> = match &body {
3171            JsonValue::Array(outer) => outer
3172                .iter()
3173                .filter_map(|v| v.as_f64().map(|n| n as f32))
3174                .collect(),
3175            _ => {
3176                return Err(crate::RedDBError::Query(
3177                    "unexpected HuggingFace embedding response format".to_string(),
3178                ))
3179            }
3180        };
3181        embeddings.push(vector);
3182    }
3183
3184    Ok(OpenAiEmbeddingResponse {
3185        provider: "huggingface",
3186        model: model.to_string(),
3187        embeddings,
3188        prompt_tokens: None,
3189        total_tokens: None,
3190    })
3191}
3192
3193/// Generate text via HuggingFace Inference API.
3194pub fn huggingface_prompt(
3195    api_key: &str,
3196    model: &str,
3197    prompt: &str,
3198    temperature: Option<f32>,
3199    max_tokens: Option<usize>,
3200    api_base: &str,
3201) -> crate::RedDBResult<AiPromptResponse> {
3202    let url = format!("{api_base}/models/{model}");
3203    let mut params = Map::new();
3204    if let Some(t) = temperature {
3205        params.insert("temperature".into(), JsonValue::Number(t as f64));
3206    }
3207    params.insert(
3208        "max_new_tokens".into(),
3209        JsonValue::Number(max_tokens.unwrap_or(512) as f64),
3210    );
3211    let payload = crate::serde_json::json!({
3212        "inputs": prompt,
3213        "parameters": JsonValue::Object(params)
3214    });
3215
3216    let (status, body_str) =
3217        http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
3218            .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
3219    if !(200..300).contains(&status) {
3220        return Err(crate::RedDBError::Query(format!(
3221            "HuggingFace API error (status {status}): {body_str}"
3222        )));
3223    }
3224    let body: JsonValue = crate::serde_json::from_str(&body_str)
3225        .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
3226
3227    let output_text = match &body {
3228        JsonValue::Array(arr) => arr
3229            .first()
3230            .and_then(|v| v.get("generated_text"))
3231            .and_then(JsonValue::as_str)
3232            .unwrap_or("")
3233            .to_string(),
3234        _ => body
3235            .get("generated_text")
3236            .and_then(JsonValue::as_str)
3237            .unwrap_or("")
3238            .to_string(),
3239    };
3240
3241    Ok(AiPromptResponse {
3242        provider: "huggingface",
3243        model: model.to_string(),
3244        output_text,
3245        output_chunks: None,
3246        prompt_tokens: None,
3247        completion_tokens: None,
3248        total_tokens: None,
3249        stop_reason: None,
3250    })
3251}
3252
3253// ============================================================================
3254// Local model stubs (requires 'local-models' feature flag)
3255// ============================================================================
3256
3257const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
3258flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
3259the 'ollama' provider with a local Ollama server.";
3260
3261const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
3262`local-models` feature, but local model artifact execution is not implemented in this slice. \
3263Alternatively, use the 'ollama' provider with a local Ollama server.";
3264
3265const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
3266the `local-models` feature; the local provider contract is embeddings-only for this slice.";
3267
3268pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
3269    if cfg!(feature = "local-models") {
3270        crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
3271    } else {
3272        crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
3273    }
3274}
3275
3276pub fn local_prompt_unavailable_error() -> crate::RedDBError {
3277    crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
3278}
3279
3280/// Local embedding via candle — requires `local-models` feature.
3281pub fn local_embeddings(
3282    _model_id: &str,
3283    _texts: &[String],
3284) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
3285    Err(local_embeddings_unavailable_error())
3286}
3287
3288/// Local prompt via candle — requires `local-models` feature.
3289pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
3290    Err(local_prompt_unavailable_error())
3291}
3292
3293// ============================================================================
3294// gRPC input collection — parity with HTTP /ai/embeddings
3295// ============================================================================
3296
3297/// Collect embedding inputs from any of the three supported shapes.
3298///
3299/// * `input: "..."` — single string.
3300/// * `inputs: ["...", ...]` — array of strings.
3301/// * `source_query: "SELECT ..."` — runs a SQL query and projects
3302///   either the named `source_field` from each row (source_mode =
3303///   "row", default) or every string cell of every result row
3304///   (source_mode = "result").
3305fn grpc_collect_embedding_inputs(
3306    runtime: &crate::runtime::RedDBRuntime,
3307    payload: &JsonValue,
3308) -> crate::RedDBResult<Vec<String>> {
3309    if let Some(source_query) = payload
3310        .get("source_query")
3311        .and_then(|v| v.as_str())
3312        .map(str::trim)
3313        .filter(|s| !s.is_empty())
3314    {
3315        return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
3316    }
3317
3318    if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
3319        let mut out = Vec::with_capacity(arr.len());
3320        for (idx, v) in arr.iter().enumerate() {
3321            let text = v.as_str().ok_or_else(|| {
3322                crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
3323            })?;
3324            if text.trim().is_empty() {
3325                return Err(crate::RedDBError::Query(format!(
3326                    "field 'inputs[{idx}]' cannot be empty"
3327                )));
3328            }
3329            out.push(text.to_string());
3330        }
3331        if out.is_empty() {
3332            return Err(crate::RedDBError::Query(
3333                "field 'inputs' must be a non-empty array of strings".to_string(),
3334            ));
3335        }
3336        return Ok(out);
3337    }
3338
3339    if let Some(single) = payload
3340        .get("input")
3341        .and_then(|v| v.as_str())
3342        .map(str::trim)
3343        .filter(|s| !s.is_empty())
3344    {
3345        return Ok(vec![single.to_string()]);
3346    }
3347
3348    Err(crate::RedDBError::Query(
3349        "provide either 'input', 'inputs', or 'source_query'".to_string(),
3350    ))
3351}
3352
3353fn grpc_collect_inputs_from_source_query(
3354    runtime: &crate::runtime::RedDBRuntime,
3355    payload: &JsonValue,
3356    source_query: &str,
3357) -> crate::RedDBResult<Vec<String>> {
3358    let result = runtime
3359        .execute_query(source_query)
3360        .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
3361
3362    let source_mode = payload
3363        .get("source_mode")
3364        .and_then(|v| v.as_str())
3365        .map(str::trim)
3366        .filter(|s| !s.is_empty())
3367        .unwrap_or("row")
3368        .to_ascii_lowercase();
3369
3370    let mut out: Vec<String> = Vec::new();
3371    match source_mode.as_str() {
3372        "row" => {
3373            let field = payload
3374                .get("source_field")
3375                .and_then(|v| v.as_str())
3376                .map(str::trim)
3377                .filter(|s| !s.is_empty())
3378                .ok_or_else(|| {
3379                    crate::RedDBError::Query(
3380                        "field 'source_field' is required when source_mode='row'".to_string(),
3381                    )
3382                })?;
3383            for rec in &result.result.records {
3384                for (key, value) in rec.iter_fields() {
3385                    if key.as_ref() == field {
3386                        if let reddb_types::Value::Text(text) = value {
3387                            let trimmed = text.trim();
3388                            if !trimmed.is_empty() {
3389                                out.push(trimmed.to_string());
3390                            }
3391                        }
3392                    }
3393                }
3394            }
3395        }
3396        "result" => {
3397            for rec in &result.result.records {
3398                for (_, value) in rec.iter_fields() {
3399                    if let reddb_types::Value::Text(text) = value {
3400                        let trimmed = text.trim();
3401                        if !trimmed.is_empty() {
3402                            out.push(trimmed.to_string());
3403                        }
3404                    }
3405                }
3406            }
3407        }
3408        other => {
3409            return Err(crate::RedDBError::Query(format!(
3410                "field 'source_mode' must be 'row' or 'result' (got '{other}')"
3411            )));
3412        }
3413    }
3414
3415    if out.is_empty() {
3416        return Err(crate::RedDBError::Query(
3417            "source_query produced zero non-empty text inputs".to_string(),
3418        ));
3419    }
3420    Ok(out)
3421}
3422
3423// ============================================================================
3424// gRPC stubs — delegate to the same logic as HTTP handlers
3425// ============================================================================
3426
3427/// gRPC embeddings — shared entrypoint that mirrors the HTTP handler.
3428///
3429/// Accepts the same JSON payload shape as `POST /ai/embeddings`:
3430///
3431/// ```json
3432/// { "provider": "openai", "model": "text-embedding-3-small",
3433///   "inputs": ["hello", "world"], "credential": "optional-alias" }
3434/// ```
3435///
3436/// Input shapes at parity with HTTP: `input` (single string),
3437/// `inputs` (array of strings), and `source_query` (SQL that the
3438/// runtime executes to materialise the input texts; `source_mode`
3439/// = `row` needs `source_collection` + `source_field`, `result`
3440/// uses the projected columns). Returns a JSON object with
3441/// `provider`, `model`, `embeddings`, `prompt_tokens`,
3442/// `total_tokens`. Non-OpenAI-compatible providers are rejected
3443/// with a clear message, matching the HTTP handler.
3444pub fn grpc_embeddings(
3445    runtime: &crate::runtime::RedDBRuntime,
3446    payload: &JsonValue,
3447) -> crate::RedDBResult<JsonValue> {
3448    // An explicit `provider` is honoured verbatim; when absent, the
3449    // embeddings task pointer drives selection (ADR-0068 §5).
3450    let provider = match payload
3451        .get("provider")
3452        .and_then(|v| v.as_str())
3453        .map(str::trim)
3454        .filter(|s| !s.is_empty())
3455    {
3456        Some(name) => parse_provider(name)?,
3457        None => resolve_embeddings_provider_from_runtime(runtime, "")?,
3458    };
3459    if matches!(provider, AiProvider::Groq | AiProvider::DeepSeek) {
3460        ensure_provider_supports_embeddings(&provider)?;
3461    }
3462    // Routing matrix mirrors `handle_ai_embeddings`. See that function
3463    // for the rationale; in short: HuggingFace gets its own wire
3464    // shape, Anthropic fails fast (no embeddings product), and Local
3465    // requires a build-time feature flag.
3466    match &provider {
3467        AiProvider::Anthropic => {
3468            return Err(crate::RedDBError::Query(
3469                "Anthropic does not offer an embeddings API. \
3470                 Re-issue the request against an OpenAI-compatible \
3471                 provider (openai, ollama, openrouter, together, \
3472                 venice, red-router), HuggingFace, or a custom base URL — \
3473                 RedDB does not silently route embeddings to a \
3474                 different provider than the one you named."
3475                    .to_string(),
3476            ));
3477        }
3478        AiProvider::Local => {
3479            return grpc_embeddings_local(runtime, payload);
3480        }
3481        _ => {}
3482    }
3483
3484    let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
3485
3486    let explicit_model = payload.get("model").and_then(|v| v.as_str());
3487    let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
3488
3489    let credential = payload
3490        .get("credential")
3491        .and_then(|v| v.as_str())
3492        .map(str::to_string);
3493    let AiConnection { api_key, api_base } =
3494        resolve_connection_from_runtime(&provider, credential.as_deref(), runtime)?;
3495
3496    let dimensions = payload
3497        .get("dimensions")
3498        .and_then(|v| v.as_i64())
3499        .and_then(|v| usize::try_from(v).ok())
3500        .filter(|v| *v > 0);
3501
3502    let response = match &provider {
3503        AiProvider::HuggingFace => huggingface_embeddings(&api_key, &model, &inputs, &api_base)?,
3504        _ => {
3505            let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
3506            let request = OpenAiEmbeddingRequest {
3507                api_key,
3508                model,
3509                inputs,
3510                dimensions,
3511                api_base,
3512            };
3513            crate::runtime::ai::block_on_ai(async move {
3514                openai_embeddings_async(&transport, request).await
3515            })
3516            .and_then(|result| result)?
3517        }
3518    };
3519
3520    let embeddings_json: Vec<JsonValue> = response
3521        .embeddings
3522        .into_iter()
3523        .map(|vec| {
3524            JsonValue::Array(
3525                vec.into_iter()
3526                    .map(|f| JsonValue::Number(f as f64))
3527                    .collect(),
3528            )
3529        })
3530        .collect();
3531
3532    let mut obj = Map::new();
3533    obj.insert(
3534        "provider".to_string(),
3535        JsonValue::String(response.provider.to_string()),
3536    );
3537    obj.insert("model".to_string(), JsonValue::String(response.model));
3538    obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3539    if let Some(pt) = response.prompt_tokens {
3540        obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
3541    }
3542    if let Some(tt) = response.total_tokens {
3543        obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
3544    }
3545    Ok(JsonValue::Object(obj))
3546}
3547
3548/// gRPC local-provider embedding path (#680).
3549///
3550/// Mirrors the HTTP local path: resolves a registered+installed local
3551/// model, runs the runtime backend, and returns the same JSON shape
3552/// the HTTP handler produces (`provider`, `model`, `model_source`,
3553/// `model_revision`, `model_engine`, `dimensions`, `embeddings`).
3554/// Save-side behaviour is HTTP-only; gRPC mirrors the OpenAI-compatible
3555/// gRPC path which also does not persist.
3556fn grpc_embeddings_local(
3557    runtime: &crate::runtime::RedDBRuntime,
3558    payload: &JsonValue,
3559) -> crate::RedDBResult<JsonValue> {
3560    crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
3561
3562    let model_name = payload
3563        .get("model")
3564        .and_then(|v| v.as_str())
3565        .map(str::trim)
3566        .filter(|s| !s.is_empty())
3567        .ok_or_else(|| {
3568            crate::RedDBError::Query(
3569                "field 'model' is required for the local provider and must be the \
3570                 registered local model name (see POST /ai/models)"
3571                    .to_string(),
3572            )
3573        })?
3574        .to_string();
3575
3576    let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
3577    let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
3578
3579    let embeddings_json: Vec<JsonValue> = response
3580        .embeddings
3581        .into_iter()
3582        .map(|vec| {
3583            JsonValue::Array(
3584                vec.into_iter()
3585                    .map(|f| JsonValue::Number(f as f64))
3586                    .collect(),
3587            )
3588        })
3589        .collect();
3590
3591    let mut obj = Map::new();
3592    obj.insert(
3593        "provider".to_string(),
3594        JsonValue::String(response.provider.to_string()),
3595    );
3596    obj.insert("model".to_string(), JsonValue::String(response.name));
3597    obj.insert(
3598        "model_source".to_string(),
3599        JsonValue::String(response.source),
3600    );
3601    obj.insert(
3602        "model_revision".to_string(),
3603        JsonValue::String(response.revision),
3604    );
3605    obj.insert(
3606        "model_engine".to_string(),
3607        JsonValue::String(response.engine),
3608    );
3609    obj.insert(
3610        "dimensions".to_string(),
3611        JsonValue::Number(response.dimensions as f64),
3612    );
3613    obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3614    Ok(JsonValue::Object(obj))
3615}
3616
3617/// gRPC stub for AI prompt.
3618pub fn grpc_prompt(
3619    _runtime: &crate::runtime::RedDBRuntime,
3620    _payload: &JsonValue,
3621) -> crate::RedDBResult<JsonValue> {
3622    Err(crate::RedDBError::FeatureNotEnabled(
3623        "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
3624    ))
3625}
3626
3627/// gRPC stub for AI credentials.
3628pub fn grpc_credentials(
3629    _runtime: &crate::runtime::RedDBRuntime,
3630    _payload: &JsonValue,
3631) -> crate::RedDBResult<JsonValue> {
3632    Err(crate::RedDBError::FeatureNotEnabled(
3633        "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
3634    ))
3635}
3636
3637// ============================================================================
3638// Generic OpenAI-compatible client (issue gh-516)
3639//
3640// Thin blocking client that targets any `{api_base}/chat/completions`
3641// and `{api_base}/embeddings` endpoint with arbitrary auth headers.
3642// Existing vendor-native paths (`openai_prompt_async`,
3643// `anthropic_prompt_async`) remain unchanged; this exists so callers
3644// can talk to non-OpenAI providers that expose an OpenAI-compatible
3645// surface (Groq, OpenRouter, Together, Ollama, vLLM, LM Studio, ...)
3646// without having to register a new `AiProvider` variant.
3647// ============================================================================
3648
3649/// Normalized usage block. Field names follow the Anthropic shape
3650/// (`input_tokens` / `output_tokens`) so downstream cost-accounting
3651/// has one canonical schema regardless of the upstream provider.
3652#[derive(Debug, Clone, Default, PartialEq, Eq)]
3653pub struct OpenAiCompatUsage {
3654    pub input_tokens: Option<u64>,
3655    pub output_tokens: Option<u64>,
3656    pub total_tokens: Option<u64>,
3657}
3658
3659#[derive(Debug, Clone)]
3660pub struct OpenAiCompatChatRequest {
3661    pub api_base: String,
3662    pub api_key: String,
3663    pub model: String,
3664    pub prompt: String,
3665    pub temperature: Option<f32>,
3666    pub seed: Option<u64>,
3667    pub max_output_tokens: Option<usize>,
3668    pub extra_headers: Vec<(String, String)>,
3669}
3670
3671#[derive(Debug, Clone)]
3672pub struct OpenAiCompatChatResponse {
3673    pub model: String,
3674    pub output_text: String,
3675    pub stop_reason: Option<String>,
3676    pub usage: OpenAiCompatUsage,
3677}
3678
3679#[derive(Debug, Clone)]
3680pub struct OpenAiCompatEmbeddingsRequest {
3681    pub api_base: String,
3682    pub api_key: String,
3683    pub model: String,
3684    pub inputs: Vec<String>,
3685    pub dimensions: Option<usize>,
3686    pub extra_headers: Vec<(String, String)>,
3687}
3688
3689#[derive(Debug, Clone)]
3690pub struct OpenAiCompatEmbeddingsResponse {
3691    pub model: String,
3692    pub embeddings: Vec<Vec<f32>>,
3693    pub usage: OpenAiCompatUsage,
3694}
3695
3696fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
3697    headers
3698        .iter()
3699        .map(|(k, v)| (k.as_str(), v.as_str()))
3700        .collect()
3701}
3702
3703/// POST `{api_base}/chat/completions` and return a normalized response.
3704///
3705/// Errors:
3706/// * empty model / prompt → `RedDBError::Query`.
3707/// * transport / non-2xx → `RedDBError::Query` carrying the status code
3708///   and the provider's parsed `error.message` when available, raw body
3709///   otherwise.
3710pub fn openai_compat_chat(
3711    request: OpenAiCompatChatRequest,
3712) -> RedDBResult<OpenAiCompatChatResponse> {
3713    if request.model.trim().is_empty() {
3714        return Err(RedDBError::Query(
3715            "openai-compat: model cannot be empty".to_string(),
3716        ));
3717    }
3718    if request.prompt.trim().is_empty() {
3719        return Err(RedDBError::Query(
3720            "openai-compat: prompt cannot be empty".to_string(),
3721        ));
3722    }
3723
3724    let url = format!(
3725        "{}/chat/completions",
3726        request.api_base.trim_end_matches('/')
3727    );
3728    let payload = build_openai_prompt_payload(
3729        &request.model,
3730        &request.prompt,
3731        request.temperature,
3732        request.seed,
3733        request.max_output_tokens,
3734        false,
3735    );
3736
3737    let extra = extra_header_refs(&request.extra_headers);
3738    let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
3739        .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3740
3741    if !(200..300).contains(&status) {
3742        let message = openai_error_message(&body).unwrap_or_else(|| {
3743            if body.trim().is_empty() {
3744                "openai-compat chat request failed".to_string()
3745            } else {
3746                body.clone()
3747            }
3748        });
3749        return Err(RedDBError::Query(format!(
3750            "openai-compat chat request failed (status {status}): {message}"
3751        )));
3752    }
3753
3754    let parsed = parse_openai_prompt_response(&body, &request.model)?;
3755    Ok(OpenAiCompatChatResponse {
3756        model: parsed.model,
3757        output_text: parsed.output_text,
3758        stop_reason: parsed.stop_reason,
3759        usage: OpenAiCompatUsage {
3760            input_tokens: parsed.prompt_tokens,
3761            output_tokens: parsed.completion_tokens,
3762            total_tokens: parsed.total_tokens,
3763        },
3764    })
3765}
3766
3767/// POST `{api_base}/embeddings` and return a normalized response.
3768pub fn openai_compat_embeddings(
3769    request: OpenAiCompatEmbeddingsRequest,
3770) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
3771    if request.model.trim().is_empty() {
3772        return Err(RedDBError::Query(
3773            "openai-compat: embedding model cannot be empty".to_string(),
3774        ));
3775    }
3776    if request.inputs.is_empty() {
3777        return Err(RedDBError::Query(
3778            "openai-compat: at least one input is required".to_string(),
3779        ));
3780    }
3781
3782    let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
3783    let payload =
3784        build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
3785
3786    let extra = extra_header_refs(&request.extra_headers);
3787    let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
3788        .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3789
3790    if !(200..300).contains(&status) {
3791        let message = openai_error_message(&body).unwrap_or_else(|| {
3792            if body.trim().is_empty() {
3793                "openai-compat embeddings request failed".to_string()
3794            } else {
3795                body.clone()
3796            }
3797        });
3798        return Err(RedDBError::Query(format!(
3799            "openai-compat embeddings request failed (status {status}): {message}"
3800        )));
3801    }
3802
3803    let parsed = parse_openai_embedding_response(&body)?;
3804    Ok(OpenAiCompatEmbeddingsResponse {
3805        model: parsed.model,
3806        embeddings: parsed.embeddings,
3807        usage: OpenAiCompatUsage {
3808            input_tokens: parsed.prompt_tokens,
3809            output_tokens: None,
3810            total_tokens: parsed.total_tokens,
3811        },
3812    })
3813}
3814
3815// ============================================================================
3816// Provider mode selector (issue gh-516)
3817//
3818// `red.config.ai.provider` picks the wire-protocol family that engine
3819// consumers (currently AskPipeline) should use. This is intentionally
3820// distinct from `red.config.ai.default.provider`, which names a
3821// concrete vendor (openai, groq, ollama, ...). The mode selector
3822// answers the prior question of which HTTP shape to speak.
3823// ============================================================================
3824
3825/// Wire-protocol family used by engine-side AI consumers.
3826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3827pub enum AiProviderMode {
3828    /// Generic OpenAI-compatible client (`POST {api_base}/chat/completions`).
3829    OpenAiCompat,
3830    /// Vendor-native OpenAI client (api.openai.com, default headers).
3831    OpenAiNative,
3832    /// Vendor-native Anthropic client (api.anthropic.com, x-api-key).
3833    AnthropicNative,
3834}
3835
3836impl AiProviderMode {
3837    pub fn token(&self) -> &'static str {
3838        match self {
3839            Self::OpenAiCompat => "openai-compat",
3840            Self::OpenAiNative => "openai-native",
3841            Self::AnthropicNative => "anthropic-native",
3842        }
3843    }
3844}
3845
3846/// Parse a mode token. Accepts hyphen or underscore spellings.
3847pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
3848    match name.trim().to_ascii_lowercase().as_str() {
3849        "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
3850        "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
3851        "anthropic-native" | "anthropic_native" | "anthropicnative" => {
3852            Some(AiProviderMode::AnthropicNative)
3853        }
3854        _ => None,
3855    }
3856}
3857
3858/// Resolve the provider mode. Lookup chain:
3859/// 1. `REDDB_AI_PROVIDER_MODE` env var.
3860/// 2. `red_config` KV key `red.config.ai.provider`.
3861/// 3. Returns `None` so callers can fall back to their existing
3862///    vendor-based routing.
3863pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
3864where
3865    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3866{
3867    if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
3868        if let Some(mode) = parse_provider_mode(&value) {
3869            return Some(mode);
3870        }
3871    }
3872    if let Ok(Some(value)) = kv_getter("red.config.ai.provider") {
3873        if let Some(mode) = parse_provider_mode(&value) {
3874            return Some(mode);
3875        }
3876    }
3877    None
3878}
3879
3880/// Map a mode to the matching [`AiProvider`] variant. `OpenAiCompat`
3881/// uses the named gateway provider, which requires an explicit endpoint.
3882pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
3883    match mode {
3884        AiProviderMode::OpenAiNative => AiProvider::OpenAi,
3885        AiProviderMode::AnthropicNative => AiProvider::Anthropic,
3886        AiProviderMode::OpenAiCompat => AiProvider::OpenAiCompat,
3887    }
3888}
3889
3890/// Egress policy for `AiProvider::Custom` base URLs. The server sends the
3891/// resolved provider credential as a bearer to this host, so an arbitrary
3892/// URL is a credential-exfiltration / SSRF primitive. Rules:
3893///   * plaintext `http://` only for loopback hosts (local OpenAI-compatible
3894///     servers), `https://` everywhere else;
3895///   * literal private / link-local / multicast IP addresses are refused.
3896///
3897/// `REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1` lifts both restrictions for
3898/// deployments that run providers on a private network.
3899pub(crate) fn validate_custom_provider_url(url: &str) -> crate::RedDBResult<()> {
3900    let uri = url.parse::<http::Uri>().map_err(|_| {
3901        crate::RedDBError::Query("AI endpoint must be an absolute http(s) URL".to_string())
3902    })?;
3903    if !matches!(uri.scheme_str(), Some("http" | "https"))
3904        || uri.host().is_none_or(str::is_empty)
3905        || uri
3906            .authority()
3907            .is_some_and(|authority| authority.as_str().contains('@'))
3908        || uri.query().is_some()
3909        || url.contains('#')
3910    {
3911        return Err(crate::RedDBError::Query(
3912            "AI endpoint must be an absolute http(s) URL without userinfo, query or fragment"
3913                .to_string(),
3914        ));
3915    }
3916    if std::env::var("REDDB_AI_ALLOW_PRIVATE_PROVIDERS")
3917        .map(|v| matches!(v.trim(), "1" | "true" | "yes"))
3918        .unwrap_or(false)
3919    {
3920        return Ok(());
3921    }
3922    let (plaintext, rest) = match url.strip_prefix("https://") {
3923        Some(rest) => (false, rest),
3924        None => (true, url.strip_prefix("http://").unwrap_or(url)),
3925    };
3926    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
3927    let host_port = authority.rsplit('@').next().unwrap_or(authority);
3928    let host = if let Some(stripped) = host_port.strip_prefix('[') {
3929        stripped.split(']').next().unwrap_or(stripped)
3930    } else {
3931        host_port.split(':').next().unwrap_or(host_port)
3932    };
3933    let host_lower = host.to_ascii_lowercase();
3934    let literal_ip = host.parse::<std::net::IpAddr>().ok();
3935    let loopback = host_lower == "localhost"
3936        || host_lower.ends_with(".localhost")
3937        || literal_ip.is_some_and(|ip| ip.is_loopback());
3938    if plaintext && !loopback {
3939        return Err(crate::RedDBError::Query(format!(
3940            "custom AI provider '{url}' must use https:// (plaintext http is only allowed for loopback hosts)"
3941        )));
3942    }
3943    if let Some(ip) = literal_ip {
3944        let private = match ip {
3945            std::net::IpAddr::V4(v4) => {
3946                v4.is_private()
3947                    || v4.is_link_local()
3948                    || v4.is_broadcast()
3949                    || v4.is_multicast()
3950                    || v4.is_unspecified()
3951                    || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
3952            }
3953            std::net::IpAddr::V6(v6) => {
3954                v6.is_multicast()
3955                    || v6.is_unspecified()
3956                    || (v6.segments()[0] & 0xfe00) == 0xfc00
3957                    || (v6.segments()[0] & 0xffc0) == 0xfe80
3958            }
3959        };
3960        if private && !loopback {
3961            return Err(crate::RedDBError::Query(format!(
3962                "custom AI provider '{url}' targets a private or link-local address; set REDDB_AI_ALLOW_PRIVATE_PROVIDERS=1 to allow"
3963            )));
3964        }
3965    }
3966    Ok(())
3967}