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    #[test]
1498    fn openai_compat_chat_roundtrip_honors_arbitrary_api_base_and_headers() {
1499        let body = r#"{
1500            "id":"chatcmpl_x",
1501            "model":"custom-model",
1502            "choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],
1503            "usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}
1504        }"#;
1505        let (base, captured) = spawn_mock(200, body);
1506
1507        let req = OpenAiCompatChatRequest {
1508            api_base: base.clone(),
1509            api_key: "sk-test".to_string(),
1510            model: "custom-model".to_string(),
1511            prompt: "say hi".to_string(),
1512            temperature: None,
1513            seed: None,
1514            max_output_tokens: None,
1515            extra_headers: vec![("X-Custom-Tag".to_string(), "abc".to_string())],
1516        };
1517        let resp = openai_compat_chat(req).expect("ok");
1518
1519        assert_eq!(resp.output_text, "hi");
1520        assert_eq!(resp.model, "custom-model");
1521        assert_eq!(resp.usage.input_tokens, Some(7));
1522        assert_eq!(resp.usage.output_tokens, Some(2));
1523        assert_eq!(resp.usage.total_tokens, Some(9));
1524        assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
1525
1526        let cap = captured.lock().unwrap().take().expect("captured");
1527        assert_eq!(cap.method, "POST");
1528        assert_eq!(cap.path, "/chat/completions");
1529        let has_auth = cap
1530            .headers
1531            .iter()
1532            .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test");
1533        assert!(has_auth, "Authorization header missing");
1534        let has_custom = cap
1535            .headers
1536            .iter()
1537            .any(|(k, v)| k.eq_ignore_ascii_case("x-custom-tag") && v == "abc");
1538        assert!(has_custom, "extra header missing");
1539        assert!(cap.body.contains("\"model\":\"custom-model\""));
1540    }
1541
1542    #[test]
1543    fn openai_compat_embeddings_roundtrip_with_dimensions() {
1544        let body = r#"{
1545            "object":"list",
1546            "model":"embed-model",
1547            "data":[{"object":"embedding","index":0,"embedding":[0.5,0.25]}],
1548            "usage":{"prompt_tokens":4,"total_tokens":4}
1549        }"#;
1550        let (base, captured) = spawn_mock(200, body);
1551
1552        let req = OpenAiCompatEmbeddingsRequest {
1553            api_base: base,
1554            api_key: "sk-emb".to_string(),
1555            model: "embed-model".to_string(),
1556            inputs: vec!["hello".to_string()],
1557            dimensions: Some(2),
1558            extra_headers: vec![],
1559        };
1560        let resp = openai_compat_embeddings(req).expect("ok");
1561
1562        assert_eq!(resp.embeddings.len(), 1);
1563        assert_eq!(resp.embeddings[0], vec![0.5_f32, 0.25_f32]);
1564        assert_eq!(resp.usage.total_tokens, Some(4));
1565        assert_eq!(resp.usage.input_tokens, Some(4));
1566
1567        let cap = captured.lock().unwrap().take().expect("captured");
1568        assert_eq!(cap.path, "/embeddings");
1569        assert!(cap.body.contains("\"dimensions\":2"));
1570    }
1571
1572    #[test]
1573    fn openai_compat_chat_non_2xx_returns_structured_error() {
1574        let body = r#"{"error":{"message":"bad api key","type":"invalid_request_error"}}"#;
1575        let (base, _captured) = spawn_mock(401, body);
1576
1577        let req = OpenAiCompatChatRequest {
1578            api_base: base,
1579            api_key: "bad".to_string(),
1580            model: "m".to_string(),
1581            prompt: "hi".to_string(),
1582            temperature: None,
1583            seed: None,
1584            max_output_tokens: None,
1585            extra_headers: vec![],
1586        };
1587        let err = openai_compat_chat(req).unwrap_err().to_string();
1588        assert!(err.contains("status 401"), "got: {err}");
1589        assert!(err.contains("bad api key"), "got: {err}");
1590    }
1591
1592    #[test]
1593    fn openai_compat_chat_rejects_empty_model_and_prompt() {
1594        let req = OpenAiCompatChatRequest {
1595            api_base: "http://localhost:1".to_string(),
1596            api_key: "k".to_string(),
1597            model: "  ".to_string(),
1598            prompt: "hi".to_string(),
1599            temperature: None,
1600            seed: None,
1601            max_output_tokens: None,
1602            extra_headers: vec![],
1603        };
1604        let err = openai_compat_chat(req).unwrap_err().to_string();
1605        assert!(err.contains("model cannot be empty"), "got: {err}");
1606
1607        let req = OpenAiCompatChatRequest {
1608            api_base: "http://localhost:1".to_string(),
1609            api_key: "k".to_string(),
1610            model: "m".to_string(),
1611            prompt: "  ".to_string(),
1612            temperature: None,
1613            seed: None,
1614            max_output_tokens: None,
1615            extra_headers: vec![],
1616        };
1617        let err = openai_compat_chat(req).unwrap_err().to_string();
1618        assert!(err.contains("prompt cannot be empty"), "got: {err}");
1619    }
1620
1621    #[test]
1622    fn parse_provider_mode_recognizes_all_three_tokens() {
1623        assert_eq!(
1624            parse_provider_mode("openai-compat"),
1625            Some(AiProviderMode::OpenAiCompat)
1626        );
1627        assert_eq!(
1628            parse_provider_mode("OPENAI_NATIVE"),
1629            Some(AiProviderMode::OpenAiNative)
1630        );
1631        assert_eq!(
1632            parse_provider_mode("anthropic-native"),
1633            Some(AiProviderMode::AnthropicNative)
1634        );
1635        assert_eq!(parse_provider_mode("groq"), None);
1636    }
1637
1638    #[test]
1639    fn resolve_provider_mode_reads_kv_key() {
1640        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1641            if key == "red.config.ai.provider" {
1642                Ok(Some("anthropic-native".to_string()))
1643            } else {
1644                Ok(None)
1645            }
1646        };
1647        assert_eq!(
1648            resolve_provider_mode(&kv),
1649            Some(AiProviderMode::AnthropicNative)
1650        );
1651    }
1652
1653    #[test]
1654    fn resolve_default_provider_honors_mode_key() {
1655        // The wire-protocol mode selector still wins over the task pointer.
1656        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1657            match key {
1658                "red.config.ai.provider" => Ok(Some("anthropic-native".to_string())),
1659                "red.config.ai.inference.provider" => Ok(Some("groq".to_string())),
1660                _ => Ok(None),
1661            }
1662        };
1663        assert_eq!(resolve_default_provider(&kv), AiProvider::Anthropic);
1664    }
1665
1666    // ---- ADR-0068 §5 config schema (issue #1746) --------------------------
1667
1668    #[test]
1669    fn inference_provider_ask_specific_beats_task_pointer() {
1670        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1671            match key {
1672                "red.config.ai.ask.provider" => Ok(Some("groq".to_string())),
1673                "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1674                _ => Ok(None),
1675            }
1676        };
1677        assert_eq!(resolve_default_provider(&kv), AiProvider::Groq);
1678    }
1679
1680    #[test]
1681    fn inference_provider_falls_through_to_task_pointer_then_default() {
1682        let pointer = |key: &str| -> crate::RedDBResult<Option<String>> {
1683            match key {
1684                "red.config.ai.inference.provider" => Ok(Some("deepseek".to_string())),
1685                _ => Ok(None),
1686            }
1687        };
1688        assert_eq!(resolve_default_provider(&pointer), AiProvider::DeepSeek);
1689
1690        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1691        assert_eq!(resolve_default_provider(&empty), AiProvider::OpenAi);
1692    }
1693
1694    #[test]
1695    fn inference_model_ask_specific_beats_models_block_beats_builtin() {
1696        let ask = |key: &str| -> crate::RedDBResult<Option<String>> {
1697            match key {
1698                "red.config.ai.ask.model" => Ok(Some("gpt-ask".to_string())),
1699                "red.config.ai.providers.openai.models.inference" => {
1700                    Ok(Some("gpt-block".to_string()))
1701                }
1702                _ => Ok(None),
1703            }
1704        };
1705        assert_eq!(resolve_default_model(&AiProvider::OpenAi, &ask), "gpt-ask");
1706
1707        let block = |key: &str| -> crate::RedDBResult<Option<String>> {
1708            match key {
1709                "red.config.ai.providers.openai.models.inference" => {
1710                    Ok(Some("gpt-block".to_string()))
1711                }
1712                _ => Ok(None),
1713            }
1714        };
1715        assert_eq!(
1716            resolve_default_model(&AiProvider::OpenAi, &block),
1717            "gpt-block"
1718        );
1719
1720        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1721        assert_eq!(
1722            resolve_default_model(&AiProvider::OpenAi, &empty),
1723            AiProvider::OpenAi.default_prompt_model()
1724        );
1725    }
1726
1727    #[test]
1728    fn embeddings_provider_follows_task_pointer() {
1729        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1730            match key {
1731                "red.config.ai.embeddings.provider" => Ok(Some("ollama".to_string())),
1732                _ => Ok(None),
1733            }
1734        };
1735        assert_eq!(
1736            resolve_embeddings_provider(&kv).unwrap(),
1737            AiProvider::Ollama
1738        );
1739
1740        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1741        assert_eq!(
1742            resolve_embeddings_provider(&empty).unwrap(),
1743            AiProvider::OpenAi
1744        );
1745    }
1746
1747    #[test]
1748    fn embeddings_provider_rejects_modality_incapable_pointer() {
1749        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1750            match key {
1751                "red.config.ai.embeddings.provider" => Ok(Some("anthropic".to_string())),
1752                _ => Ok(None),
1753            }
1754        };
1755        let err = resolve_embeddings_provider(&kv).unwrap_err().to_string();
1756        assert!(
1757            err.contains("red.config.ai.embeddings.provider"),
1758            "error must name the pointer to fix: {err}"
1759        );
1760        assert!(
1761            err.contains("openai") && err.contains("no embeddings API"),
1762            "error must name capable alternatives: {err}"
1763        );
1764    }
1765
1766    #[test]
1767    fn embeddings_provider_falls_back_to_default_provider_chain() {
1768        // With no embeddings-specific override, the embeddings provider
1769        // follows the general default-provider chain (here the inference
1770        // task pointer). This keeps a single-global-config deployment
1771        // routing embeddings through the one provider it named — the
1772        // regression the local-vector text-search tests guard against.
1773        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1774            match key {
1775                "red.config.ai.inference.provider" => Ok(Some("ollama".to_string())),
1776                _ => Ok(None),
1777            }
1778        };
1779        assert_eq!(
1780            resolve_embeddings_provider(&kv).unwrap(),
1781            AiProvider::Ollama
1782        );
1783    }
1784
1785    #[test]
1786    fn embeddings_model_block_beats_builtin() {
1787        let block = |key: &str| -> crate::RedDBResult<Option<String>> {
1788            match key {
1789                "red.config.ai.providers.openai.models.embeddings" => {
1790                    Ok(Some("text-embedding-custom".to_string()))
1791                }
1792                _ => Ok(None),
1793            }
1794        };
1795        assert_eq!(
1796            resolve_embeddings_model(&AiProvider::OpenAi, &block),
1797            "text-embedding-custom"
1798        );
1799
1800        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1801        assert_eq!(
1802            resolve_embeddings_model(&AiProvider::Ollama, &empty),
1803            AiProvider::Ollama.default_embedding_model()
1804        );
1805    }
1806
1807    #[test]
1808    fn base_url_reads_provider_block_key() {
1809        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1810            if key == "red.config.ai.providers.openai.base_url" {
1811                Ok(Some("https://proxy.example/v1".to_string()))
1812            } else {
1813                Ok(None)
1814            }
1815        };
1816        assert_eq!(
1817            AiProvider::OpenAi.resolve_api_base_with_kv("default", &kv),
1818            "https://proxy.example/v1"
1819        );
1820    }
1821
1822    #[test]
1823    fn removed_config_keys_rejected_naming_replacements() {
1824        let err = validate_ai_config_key_on_write("red.config.ai.default.provider")
1825            .unwrap_err()
1826            .to_string();
1827        assert!(err.contains("red.config.ai.inference.provider"), "{err}");
1828
1829        let err = validate_ai_config_key_on_write("red.config.ai.default.model")
1830            .unwrap_err()
1831            .to_string();
1832        assert!(
1833            err.contains("red.config.ai.providers.<provider>.models.inference"),
1834            "{err}"
1835        );
1836
1837        let err = validate_ai_config_key_on_write("red.config.ai.openai.default.base_url")
1838            .unwrap_err()
1839            .to_string();
1840        assert!(
1841            err.contains("red.config.ai.providers.<provider>.base_url"),
1842            "{err}"
1843        );
1844
1845        // New-schema keys and unrelated keys are accepted.
1846        assert!(validate_ai_config_key_on_write("red.config.ai.inference.provider").is_ok());
1847        assert!(validate_ai_config_key_on_write("red.config.ai.providers.openai.base_url").is_ok());
1848        assert!(validate_ai_config_key_on_write("acme.some.other.key").is_ok());
1849    }
1850
1851    #[test]
1852    fn ask_planner_model_and_effort_resolve() {
1853        let kv = |key: &str| -> crate::RedDBResult<Option<String>> {
1854            match key {
1855                "red.config.ai.ask.planner_model" => Ok(Some("planner-x".to_string())),
1856                "red.config.ai.ask.effort" => Ok(Some("high".to_string())),
1857                _ => Ok(None),
1858            }
1859        };
1860        assert_eq!(resolve_ask_planner_model(&kv, "fallback"), "planner-x");
1861        assert_eq!(resolve_ask_effort(&kv), Some("high".to_string()));
1862
1863        let empty = |_: &str| -> crate::RedDBResult<Option<String>> { Ok(None) };
1864        assert_eq!(resolve_ask_planner_model(&empty, "fallback"), "fallback");
1865        assert_eq!(resolve_ask_effort(&empty), None);
1866    }
1867
1868    #[tokio::test]
1869    async fn anthropic_prompt_async_rejects_empty_api_key() {
1870        let transport = crate::runtime::ai::transport::AiTransport::new(Default::default());
1871        let request = AnthropicPromptRequest {
1872            api_key: "  ".to_string(),
1873            model: "claude-3-5-haiku-latest".to_string(),
1874            prompt: "hello".to_string(),
1875            temperature: None,
1876            max_output_tokens: None,
1877            api_base: "https://api.anthropic.com/v1".to_string(),
1878            anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
1879        };
1880        let err = anthropic_prompt_async(&transport, request)
1881            .await
1882            .unwrap_err();
1883        assert!(err.to_string().contains("API key cannot be empty"));
1884    }
1885}
1886
1887// ============================================================================
1888// Provider & Credential Resolution (shared between HTTP, gRPC, and runtime)
1889// ============================================================================
1890
1891/// AI provider identifier.
1892#[derive(Debug, Clone, PartialEq, Eq)]
1893pub enum AiProvider {
1894    OpenAi,
1895    Anthropic,
1896    Groq,
1897    OpenRouter,
1898    Together,
1899    Venice,
1900    Ollama,
1901    DeepSeek,
1902    MiniMax,
1903    HuggingFace,
1904    Local,
1905    Custom(String),
1906}
1907
1908impl AiProvider {
1909    pub fn token(&self) -> &str {
1910        match self {
1911            Self::OpenAi => "openai",
1912            Self::Anthropic => "anthropic",
1913            Self::Groq => "groq",
1914            Self::OpenRouter => "openrouter",
1915            Self::Together => "together",
1916            Self::Venice => "venice",
1917            Self::Ollama => "ollama",
1918            Self::DeepSeek => "deepseek",
1919            Self::MiniMax => "minimax",
1920            Self::HuggingFace => "huggingface",
1921            Self::Local => "local",
1922            Self::Custom(name) => name.as_str(),
1923        }
1924    }
1925
1926    pub fn default_prompt_model(&self) -> &str {
1927        match self {
1928            Self::OpenAi => DEFAULT_OPENAI_PROMPT_MODEL,
1929            Self::Anthropic => DEFAULT_ANTHROPIC_PROMPT_MODEL,
1930            Self::Groq => "llama-3.3-70b-versatile",
1931            Self::OpenRouter => "auto",
1932            Self::Together => "meta-llama/Meta-Llama-3-8B-Instruct",
1933            Self::Venice => "llama-3.3-70b",
1934            Self::Ollama => "llama3",
1935            Self::DeepSeek => "deepseek-chat",
1936            Self::MiniMax => "abab6.5s-chat",
1937            Self::HuggingFace => "mistralai/Mistral-7B-Instruct-v0.3",
1938            Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
1939            Self::Custom(_) => DEFAULT_OPENAI_PROMPT_MODEL,
1940        }
1941    }
1942
1943    pub fn prompt_model_env_name(&self) -> String {
1944        format!("REDDB_{}_PROMPT_MODEL", self.token().to_ascii_uppercase())
1945    }
1946
1947    pub fn default_embedding_model(&self) -> &str {
1948        match self {
1949            Self::Ollama => "nomic-embed-text",
1950            Self::MiniMax => "embo-01",
1951            Self::HuggingFace | Self::Local => "sentence-transformers/all-MiniLM-L6-v2",
1952            _ => DEFAULT_OPENAI_EMBEDDING_MODEL,
1953        }
1954    }
1955
1956    pub fn default_api_base(&self) -> &str {
1957        match self {
1958            Self::OpenAi => DEFAULT_OPENAI_API_BASE,
1959            Self::Anthropic => DEFAULT_ANTHROPIC_API_BASE,
1960            Self::Groq => "https://api.groq.com/openai/v1",
1961            Self::OpenRouter => "https://openrouter.ai/api/v1",
1962            Self::Together => "https://api.together.xyz/v1",
1963            Self::Venice => "https://api.venice.ai/api/v1",
1964            Self::Ollama => "http://localhost:11434/v1",
1965            Self::DeepSeek => "https://api.deepseek.com/v1",
1966            Self::MiniMax => "https://api.minimax.chat/v1",
1967            Self::HuggingFace => "https://api-inference.huggingface.co",
1968            Self::Local => "local",
1969            Self::Custom(base) => base.as_str(),
1970        }
1971    }
1972
1973    pub fn api_base_env_name(&self) -> String {
1974        format!("REDDB_{}_API_BASE", self.token().to_ascii_uppercase())
1975    }
1976
1977    pub fn default_key_env_name(&self) -> String {
1978        format!("REDDB_{}_API_KEY", self.token().to_ascii_uppercase())
1979    }
1980
1981    pub fn alias_key_env_name(&self, alias: &str) -> String {
1982        let normalized = normalize_alias_token(alias);
1983        format!(
1984            "REDDB_{}_API_KEY_{normalized}",
1985            self.token().to_ascii_uppercase()
1986        )
1987    }
1988
1989    pub fn resolve_api_base(&self) -> String {
1990        if let Ok(value) = std::env::var(self.api_base_env_name()) {
1991            let value = value.trim().to_string();
1992            if !value.is_empty() {
1993                return value;
1994            }
1995        }
1996        self.default_api_base().to_string()
1997    }
1998
1999    /// Resolve API base URL checking KV store too (for custom base_url
2000    /// config). ADR-0068 §5 clean break: the base URL now lives at
2001    /// `red.config.ai.providers.<provider>.base_url` (per provider, no
2002    /// credential alias). The old `red.config.ai.<provider>.<alias>.base_url`
2003    /// shape is rejected on write; see [`validate_ai_config_key_on_write`].
2004    pub fn resolve_api_base_with_kv<F>(&self, _alias: &str, kv_getter: &F) -> String
2005    where
2006        F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2007    {
2008        // 1. Env var
2009        if let Ok(value) = std::env::var(self.api_base_env_name()) {
2010            let value = value.trim().to_string();
2011            if !value.is_empty() {
2012                return value;
2013            }
2014        }
2015        // 2. Provider block: red.config.ai.providers.<provider>.base_url
2016        if let Ok(Some(value)) = kv_getter(&provider_base_url_key(self)) {
2017            let value = value.trim().to_string();
2018            if !value.is_empty() {
2019                return value;
2020            }
2021        }
2022        self.default_api_base().to_string()
2023    }
2024
2025    /// Whether this provider uses the OpenAI-compatible API format.
2026    pub fn is_openai_compatible(&self) -> bool {
2027        matches!(
2028            self,
2029            Self::OpenAi
2030                | Self::Groq
2031                | Self::OpenRouter
2032                | Self::Together
2033                | Self::Venice
2034                | Self::Ollama
2035                | Self::DeepSeek
2036                | Self::MiniMax
2037                | Self::Custom(_)
2038        )
2039    }
2040
2041    /// Whether this provider requires an API key (Ollama/Local don't).
2042    pub fn requires_api_key(&self) -> bool {
2043        !matches!(self, Self::Ollama | Self::Local)
2044    }
2045
2046    /// Whether this provider offers an embeddings API. Anthropic famously
2047    /// does not; every other provider RedDB speaks does (Local embeds
2048    /// in-process). Used to fail an embeddings task pointer loudly rather
2049    /// than silently re-routing to a different provider (ADR-0068 §5).
2050    pub fn supports_embeddings(&self) -> bool {
2051        !matches!(self, Self::Anthropic)
2052    }
2053}
2054
2055/// Parse a provider string into AiProvider.
2056pub fn parse_provider(name: &str) -> crate::RedDBResult<AiProvider> {
2057    match name.trim().to_ascii_lowercase().as_str() {
2058        "openai" => Ok(AiProvider::OpenAi),
2059        "anthropic" => Ok(AiProvider::Anthropic),
2060        "groq" => Ok(AiProvider::Groq),
2061        "openrouter" | "open_router" => Ok(AiProvider::OpenRouter),
2062        "together" => Ok(AiProvider::Together),
2063        "venice" => Ok(AiProvider::Venice),
2064        "ollama" => Ok(AiProvider::Ollama),
2065        "deepseek" | "deep_seek" => Ok(AiProvider::DeepSeek),
2066        "minimax" | "mini_max" => Ok(AiProvider::MiniMax),
2067        "huggingface" | "hf" => Ok(AiProvider::HuggingFace),
2068        "local" => Ok(AiProvider::Local),
2069        other => {
2070            // Treat as custom provider if it looks like a URL
2071            if other.starts_with("http://") || other.starts_with("https://") {
2072                Ok(AiProvider::Custom(other.to_string()))
2073            } else {
2074                Err(crate::RedDBError::Query(format!(
2075                    "unsupported AI provider '{other}'; expected: openai, anthropic, groq, \
2076                     openrouter, together, venice, ollama, deepseek, minimax, huggingface, local"
2077                )))
2078            }
2079        }
2080    }
2081}
2082
2083// ============================================================================
2084// AI config schema (ADR-0068 §5) — clean break
2085//
2086//   red.config.ai.ask.{provider,model,planner_model,effort,max_plan_steps}
2087//   red.config.ai.providers.<provider>.base_url
2088//   red.config.ai.providers.<provider>.models.{inference,embeddings}
2089//   red.config.ai.inference.provider   # task pointer: who generates
2090//   red.config.ai.embeddings.provider  # task pointer: who embeds
2091//
2092// Resolution order for any AI call:
2093//   ASK-specific config -> task pointer -> pointed provider's models block
2094//   -> provider built-in default.
2095//
2096// The old flat keys (`red.config.ai.default.provider|model` and the old
2097// per-alias `red.config.ai.<provider>.<alias>.base_url` base-URL shape)
2098// are removed and rejected on write; there is no deprecation window.
2099// ============================================================================
2100
2101/// Providers that can serve embeddings, listed for didactic errors.
2102pub const EMBEDDING_CAPABLE_PROVIDERS: &str =
2103    "openai, groq, ollama, openrouter, together, venice, deepseek, minimax, huggingface, local";
2104
2105/// KV key for a provider's base URL under the new schema.
2106pub fn provider_base_url_key(provider: &AiProvider) -> String {
2107    format!("red.config.ai.providers.{}.base_url", provider.token())
2108}
2109
2110/// KV key for a provider's per-modality model under the new schema.
2111/// `modality` is `"inference"` or `"embeddings"`.
2112pub fn provider_models_key(provider: &AiProvider, modality: &str) -> String {
2113    format!(
2114        "red.config.ai.providers.{}.models.{modality}",
2115        provider.token()
2116    )
2117}
2118
2119/// Reject an AI config key that was removed in the ADR-0068 clean break,
2120/// naming the replacement key. Called from the `SET CONFIG` write path so
2121/// operators cannot silently persist a key nothing reads. Returns `Ok(())`
2122/// for every key that is still valid (including non-AI keys).
2123pub fn validate_ai_config_key_on_write(key: &str) -> crate::RedDBResult<()> {
2124    let key = key.trim();
2125    if key == "red.config.ai.default.provider" {
2126        return Err(crate::RedDBError::Query(
2127            "AI config key 'red.config.ai.default.provider' was removed in the ADR-0068 \
2128             clean break; set the task pointer 'red.config.ai.inference.provider' (or \
2129             'red.config.ai.ask.provider' for the ASK planner) instead"
2130                .to_string(),
2131        ));
2132    }
2133    if key == "red.config.ai.default.model" {
2134        return Err(crate::RedDBError::Query(
2135            "AI config key 'red.config.ai.default.model' was removed in the ADR-0068 clean \
2136             break; set 'red.config.ai.ask.model' or \
2137             'red.config.ai.providers.<provider>.models.inference' instead"
2138                .to_string(),
2139        ));
2140    }
2141    // Old per-alias base-URL shape: red.config.ai.<provider>.<alias>.base_url
2142    // (anything under red.config.ai.* ending in .base_url that is NOT the
2143    // new red.config.ai.providers.<provider>.base_url form).
2144    if key.starts_with("red.config.ai.")
2145        && key.ends_with(".base_url")
2146        && !key.starts_with("red.config.ai.providers.")
2147    {
2148        return Err(crate::RedDBError::Query(format!(
2149            "AI config key '{key}' uses the removed per-credential base-URL shape; set \
2150             'red.config.ai.providers.<provider>.base_url' instead (ADR-0068 clean break)"
2151        )));
2152    }
2153    Ok(())
2154}
2155
2156/// Resolve the inference (generation) provider. Precedence:
2157/// 0. Wire-protocol mode selector (`red.config.ai.provider`) when set.
2158/// 1. `REDDB_AI_PROVIDER` env var.
2159/// 2. ASK-specific config `red.config.ai.ask.provider`.
2160/// 3. Inference task pointer `red.config.ai.inference.provider`.
2161/// 4. Falls back to OpenAI.
2162pub fn resolve_default_provider<F>(kv_getter: &F) -> AiProvider
2163where
2164    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2165{
2166    // 0. Wire-protocol mode selector takes precedence when explicitly set.
2167    if let Some(mode) = resolve_provider_mode(kv_getter) {
2168        return provider_mode_to_provider(mode);
2169    }
2170    // 1. Env var
2171    if let Ok(value) = std::env::var("REDDB_AI_PROVIDER") {
2172        let value = value.trim().to_string();
2173        if !value.is_empty() {
2174            if let Ok(provider) = parse_provider(&value) {
2175                return provider;
2176            }
2177        }
2178    }
2179    // 2. ASK-specific config, then 3. inference task pointer.
2180    for key in [
2181        "red.config.ai.ask.provider",
2182        "red.config.ai.inference.provider",
2183    ] {
2184        if let Ok(Some(value)) = kv_getter(key) {
2185            let value = value.trim().to_string();
2186            if !value.is_empty() {
2187                if let Ok(provider) = parse_provider(&value) {
2188                    return provider;
2189                }
2190            }
2191        }
2192    }
2193    AiProvider::OpenAi
2194}
2195
2196/// Resolve the inference (generation) model for `provider`. Precedence:
2197/// 1. `REDDB_AI_MODEL` env var.
2198/// 2. Provider-specific prompt-model env var.
2199/// 3. ASK-specific config `red.config.ai.ask.model`.
2200/// 4. Provider models block `…providers.<p>.models.inference`.
2201/// 5. Provider built-in default prompt model.
2202pub fn resolve_default_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2203where
2204    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2205{
2206    // 1. Env var
2207    if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2208        let value = value.trim().to_string();
2209        if !value.is_empty() {
2210            return value;
2211        }
2212    }
2213    // 2. Provider-specific env var
2214    if let Ok(value) = std::env::var(provider.prompt_model_env_name()) {
2215        let value = value.trim().to_string();
2216        if !value.is_empty() {
2217            return value;
2218        }
2219    }
2220    // 3. ASK-specific config, then 4. provider models block.
2221    for key in [
2222        "red.config.ai.ask.model".to_string(),
2223        provider_models_key(provider, "inference"),
2224    ] {
2225        if let Ok(Some(value)) = kv_getter(&key) {
2226            let value = value.trim().to_string();
2227            if !value.is_empty() {
2228                return value;
2229            }
2230        }
2231    }
2232    provider.default_prompt_model().to_string()
2233}
2234
2235/// Resolve the embeddings provider. Precedence:
2236/// 1. `REDDB_AI_EMBEDDINGS_PROVIDER` env var (embeddings-specific override).
2237/// 2. `red.config.ai.embeddings.provider` task pointer.
2238/// 3. Fall back to the general default provider — [`resolve_default_provider`]
2239///    honours `REDDB_AI_PROVIDER`, the wire-protocol mode selector, and the
2240///    ASK/inference chain — so a deployment that only sets a single global
2241///    provider (e.g. `REDDB_AI_PROVIDER=local`) still embeds through it.
2242///
2243/// Fails with a didactic error — naming the embeddings pointer and the capable
2244/// providers — when the resolved provider has no embeddings API, rather than
2245/// silently re-routing (ADR-0068 §5; the Anthropic case generalized).
2246pub fn resolve_embeddings_provider<F>(kv_getter: &F) -> crate::RedDBResult<AiProvider>
2247where
2248    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2249{
2250    let provider = if let Some(value) = std::env::var("REDDB_AI_EMBEDDINGS_PROVIDER")
2251        .ok()
2252        .map(|v| v.trim().to_string())
2253        .filter(|v| !v.is_empty())
2254    {
2255        parse_provider(&value)?
2256    } else if let Some(value) = kv_getter("red.config.ai.embeddings.provider")
2257        .ok()
2258        .flatten()
2259        .map(|v| v.trim().to_string())
2260        .filter(|v| !v.is_empty())
2261    {
2262        parse_provider(&value)?
2263    } else {
2264        // No embeddings-specific override: follow the general default
2265        // provider so the historical global `REDDB_AI_PROVIDER` selector
2266        // keeps driving embeddings when the task pointer is unset.
2267        resolve_default_provider(kv_getter)
2268    };
2269    ensure_provider_supports_embeddings(&provider)?;
2270    Ok(provider)
2271}
2272
2273/// Fail with a didactic error when `provider` cannot serve embeddings.
2274pub fn ensure_provider_supports_embeddings(provider: &AiProvider) -> crate::RedDBResult<()> {
2275    if provider.supports_embeddings() {
2276        return Ok(());
2277    }
2278    Err(crate::RedDBError::Query(format!(
2279        "the embeddings task pointer 'red.config.ai.embeddings.provider' names '{}', which \
2280         has no embeddings API. Point it at a capable provider ({}) — RedDB never silently \
2281         re-routes embeddings to a different provider than the one you named.",
2282        provider.token(),
2283        EMBEDDING_CAPABLE_PROVIDERS
2284    )))
2285}
2286
2287/// Resolve the embeddings model for `provider`. Precedence:
2288/// 1. Provider-specific `REDDB_<P>_EMBEDDING_MODEL` env var.
2289/// 2. `REDDB_OPENAI_EMBEDDING_MODEL` env var (legacy shared override).
2290/// 3. Provider models block `…providers.<p>.models.embeddings`.
2291/// 4. General `REDDB_AI_MODEL` env var (historical global model selector).
2292/// 5. Provider built-in default embedding model.
2293///
2294/// The embeddings-specific sources (1–3) outrank the general `REDDB_AI_MODEL`
2295/// selector so an explicit embeddings model always wins; `REDDB_AI_MODEL`
2296/// only fills in for the built-in default, which is what keeps a
2297/// single-global-config deployment (`REDDB_AI_PROVIDER`+`REDDB_AI_MODEL`)
2298/// routing text embeddings through the named model.
2299pub fn resolve_embeddings_model<F>(provider: &AiProvider, kv_getter: &F) -> String
2300where
2301    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2302{
2303    if let Ok(value) = std::env::var(format!(
2304        "REDDB_{}_EMBEDDING_MODEL",
2305        provider.token().to_ascii_uppercase()
2306    )) {
2307        let value = value.trim().to_string();
2308        if !value.is_empty() {
2309            return value;
2310        }
2311    }
2312    if let Ok(value) = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL") {
2313        let value = value.trim().to_string();
2314        if !value.is_empty() {
2315            return value;
2316        }
2317    }
2318    if let Ok(Some(value)) = kv_getter(&provider_models_key(provider, "embeddings")) {
2319        let value = value.trim().to_string();
2320        if !value.is_empty() {
2321            return value;
2322        }
2323    }
2324    if let Ok(value) = std::env::var("REDDB_AI_MODEL") {
2325        let value = value.trim().to_string();
2326        if !value.is_empty() {
2327            return value;
2328        }
2329    }
2330    provider.default_embedding_model().to_string()
2331}
2332
2333/// Resolve the ASK planner model (`red.config.ai.ask.planner_model`),
2334/// falling back to `fallback_model` (typically the resolved ASK model).
2335/// Inert until the ASK planner slice consumes it.
2336pub fn resolve_ask_planner_model<F>(kv_getter: &F, fallback_model: &str) -> String
2337where
2338    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2339{
2340    if let Ok(Some(value)) = kv_getter("red.config.ai.ask.planner_model") {
2341        let value = value.trim().to_string();
2342        if !value.is_empty() {
2343            return value;
2344        }
2345    }
2346    fallback_model.to_string()
2347}
2348
2349/// Resolve the ASK planner effort (`red.config.ai.ask.effort`) if set.
2350/// Inert until the ASK planner slice consumes it.
2351pub fn resolve_ask_effort<F>(kv_getter: &F) -> Option<String>
2352where
2353    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2354{
2355    if let Ok(Some(value)) = kv_getter("red.config.ai.ask.effort") {
2356        let value = value.trim().to_string();
2357        if !value.is_empty() {
2358            return Some(value);
2359        }
2360    }
2361    None
2362}
2363
2364/// Resolve default provider + model from runtime KV store.
2365pub fn resolve_defaults_from_runtime(
2366    runtime: &crate::runtime::RedDBRuntime,
2367) -> (AiProvider, String) {
2368    use crate::application::ports::RuntimeEntityPort;
2369    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2370        match runtime.get_kv("red_config", key)? {
2371            Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2372            _ => Ok(None),
2373        }
2374    };
2375    let provider = resolve_default_provider(&kv_getter);
2376    let model = resolve_default_model(&provider, &kv_getter);
2377    (provider, model)
2378}
2379
2380/// Resolve the ASK planner model from runtime KV (`red.config.ai.ask.planner_model`),
2381/// falling back to `fallback_model` — the resolved synthesis/ASK model — when
2382/// no planner model is configured (ADR 0068 §3). Lets a cheap/fast model plan
2383/// while the user-chosen model synthesizes.
2384pub fn resolve_ask_planner_model_from_runtime(
2385    runtime: &crate::runtime::RedDBRuntime,
2386    fallback_model: &str,
2387) -> String {
2388    use crate::application::ports::RuntimeEntityPort;
2389    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2390        match runtime.get_kv("red_config", key)? {
2391            Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2392            _ => Ok(None),
2393        }
2394    };
2395    resolve_ask_planner_model(&kv_getter, fallback_model)
2396}
2397
2398/// Resolve default provider + model via RuntimeEntityPort trait (for use in QueryUseCases).
2399pub fn resolve_defaults_from_runtime_port<
2400    P: crate::application::ports::RuntimeEntityPort + ?Sized,
2401>(
2402    runtime: &P,
2403) -> (AiProvider, String) {
2404    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2405        match runtime.get_kv("red_config", key)? {
2406            Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2407            _ => Ok(None),
2408        }
2409    };
2410    let provider = resolve_default_provider(&kv_getter);
2411    let model = resolve_default_model(&provider, &kv_getter);
2412    (provider, model)
2413}
2414
2415/// Resolve the embeddings provider for an AUTO EMBED / embeddings call from
2416/// a runtime port. `explicit` is the `USING <provider>` override (empty when
2417/// none was given); when empty the embeddings task pointer drives selection.
2418/// A modality-incapable provider fails didactically (ADR-0068 §5).
2419pub fn resolve_embeddings_provider_from_runtime<
2420    P: crate::application::ports::RuntimeEntityPort + ?Sized,
2421>(
2422    runtime: &P,
2423    explicit: &str,
2424) -> crate::RedDBResult<AiProvider> {
2425    let explicit = explicit.trim();
2426    if !explicit.is_empty() {
2427        let provider = parse_provider(explicit)?;
2428        ensure_provider_supports_embeddings(&provider)?;
2429        return Ok(provider);
2430    }
2431    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2432        match runtime.get_kv("red_config", key)? {
2433            Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2434            _ => Ok(None),
2435        }
2436    };
2437    resolve_embeddings_provider(&kv_getter)
2438}
2439
2440/// Resolve the embeddings model for `provider` from a runtime port,
2441/// honouring an explicit `MODEL '<name>'` override before the config
2442/// resolution order (env → provider models block → built-in default).
2443pub fn resolve_embeddings_model_from_runtime<
2444    P: crate::application::ports::RuntimeEntityPort + ?Sized,
2445>(
2446    runtime: &P,
2447    provider: &AiProvider,
2448    explicit: Option<&str>,
2449) -> String {
2450    if let Some(model) = explicit.map(str::trim).filter(|m| !m.is_empty()) {
2451        return model.to_string();
2452    }
2453    let kv_getter = |key: &str| -> crate::RedDBResult<Option<String>> {
2454        match runtime.get_kv("red_config", key)? {
2455            Some((crate::storage::schema::Value::Text(s), _)) => Ok(Some(s.to_string())),
2456            _ => Ok(None),
2457        }
2458    };
2459    resolve_embeddings_model(provider, &kv_getter)
2460}
2461
2462/// Resolve an API key for a provider, **preferring the encrypted vault
2463/// over environment variables** (issue #1270). The env vars are a
2464/// bootstrap fallback so a fresh deployment can talk to a provider
2465/// before any key has been written to the vault.
2466///
2467/// Resolution order (issue #1745 — clean break, no deprecation window):
2468/// 1. Vault token path: `red.secret.ai.providers.<provider>.tokens.<alias>`
2469/// 2. Vault token indirected via
2470///    `red.config.ai.providers.<provider>.tokens.<alias>.secret_ref`
2471/// 3. Env fallback: `REDDB_<PROVIDER>_API_KEY[_<ALIAS>]`
2472///
2473/// The alias `default` is implicit when `credential_alias = None`. First
2474/// non-empty source wins per request.
2475///
2476/// The old vault path shape (`red.secret.ai.<provider>.<alias>.api_key`)
2477/// and the legacy plaintext config path (`red.config.ai.<provider>.<alias>.key`)
2478/// are **removed**: a credential still sitting at either is rejected with a
2479/// didactic error naming the new vault path to populate — never silently
2480/// read.
2481///
2482/// `kv_getter` receives either a `red.secret.*` path (routed to the
2483/// encrypted vault by [`resolve_api_key_from_runtime`]) or a
2484/// `red.config.*` key and returns the value if found. Vault-stored keys
2485/// are therefore encrypted at rest and rotatable via the existing vault
2486/// KV path; the env vars carry no such guarantees, which is why they are
2487/// the fallback rather than the primary source.
2488pub fn resolve_api_key<F>(
2489    provider: &AiProvider,
2490    credential_alias: Option<&str>,
2491    kv_getter: F,
2492) -> crate::RedDBResult<String>
2493where
2494    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2495{
2496    // Providers that don't require API keys
2497    if !provider.requires_api_key() {
2498        // Still try to find a key (user may have one for auth'd Ollama)
2499        if let Ok(value) = std::env::var(provider.default_key_env_name()) {
2500            let value = value.trim().to_string();
2501            if !value.is_empty() {
2502                return Ok(value);
2503            }
2504        }
2505        return Ok(String::new());
2506    }
2507
2508    if let Some(alias) = credential_alias.map(str::trim).filter(|a| !a.is_empty()) {
2509        // 1. Vault token path (managed, encrypted at rest).
2510        if let Some(key) = kv_getter(&ai_api_secret_path(provider, alias))? {
2511            if !key.trim().is_empty() {
2512                return Ok(key);
2513            }
2514        }
2515        // 2. Vault token reachable through a configured indirection ref.
2516        if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, alias))? {
2517            if let Some(key) = kv_getter(secret_ref.trim())? {
2518                if !key.trim().is_empty() {
2519                    return Ok(key);
2520                }
2521            }
2522        }
2523        // 3. Env fallback with alias (bootstrap before vault is populated).
2524        if let Some(key) = resolve_key_from_env_alias(provider, alias) {
2525            return Ok(key);
2526        }
2527        // Clean break: a credential still sitting at a removed path is
2528        // rejected didactically, never silently read (issue #1745).
2529        reject_removed_credential_paths(provider, alias, &kv_getter)?;
2530        return Err(crate::RedDBError::Query(format!(
2531            "credential '{alias}' not found for {}. Set env {} or store it in the vault at '{}'",
2532            provider.token(),
2533            provider.alias_key_env_name(alias),
2534            ai_api_secret_path(provider, alias),
2535        )));
2536    }
2537
2538    // 1. Vault token path (managed, encrypted at rest).
2539    if let Some(key) = kv_getter(&ai_api_secret_path(provider, "default"))? {
2540        if !key.trim().is_empty() {
2541            return Ok(key);
2542        }
2543    }
2544    // 2. Vault token reachable through a configured indirection ref.
2545    if let Some(secret_ref) = kv_getter(&ai_api_secret_ref_config_key(provider, "default"))? {
2546        if let Some(key) = kv_getter(secret_ref.trim())? {
2547            if !key.trim().is_empty() {
2548                return Ok(key);
2549            }
2550        }
2551    }
2552
2553    // 3. Env fallback (bootstrap before the vault is populated).
2554    if let Ok(value) = std::env::var(provider.default_key_env_name()) {
2555        let value = value.trim().to_string();
2556        if !value.is_empty() {
2557            return Ok(value);
2558        }
2559    }
2560
2561    // Clean break: reject a credential still sitting at a removed path
2562    // instead of silently reading it (issue #1745).
2563    reject_removed_credential_paths(provider, "default", &kv_getter)?;
2564
2565    Err(crate::RedDBError::Query(format!(
2566        "missing {} API key. Set {} or store it in the vault at '{}'",
2567        provider.token(),
2568        provider.default_key_env_name(),
2569        ai_api_secret_path(provider, "default"),
2570    )))
2571}
2572
2573/// Vault token path (issue #1745): the sole credential source in the vault.
2574pub fn ai_api_secret_path(provider: &AiProvider, alias: &str) -> String {
2575    format!(
2576        "red.secret.ai.providers.{}.tokens.{}",
2577        provider.token(),
2578        normalize_credential_alias_path(alias)
2579    )
2580}
2581
2582/// Config key holding a vault indirection ref for the token (issue #1745).
2583pub fn ai_api_secret_ref_config_key(provider: &AiProvider, alias: &str) -> String {
2584    format!(
2585        "red.config.ai.providers.{}.tokens.{}.secret_ref",
2586        provider.token(),
2587        normalize_credential_alias_path(alias)
2588    )
2589}
2590
2591/// Removed vault path shape (`red.secret.ai.<provider>.<alias>.api_key`,
2592/// issue #1745). Probed ONLY to reject with a migration error — never read
2593/// as a credential source.
2594fn removed_vault_api_key_path(provider: &AiProvider, alias: &str) -> String {
2595    format!(
2596        "red.secret.ai.{}.{}.api_key",
2597        provider.token(),
2598        normalize_credential_alias_path(alias)
2599    )
2600}
2601
2602/// Removed plaintext config path (`red.config.ai.<provider>.<alias>.key`,
2603/// issue #1745). Probed ONLY to reject with a migration error.
2604fn removed_plaintext_config_key(provider: &AiProvider, alias: &str) -> String {
2605    format!(
2606        "red.config.ai.{}.{}.key",
2607        provider.token(),
2608        normalize_credential_alias_path(alias)
2609    )
2610}
2611
2612/// Fail with a didactic error if a credential is still parked at either of
2613/// the removed paths (old vault shape or legacy plaintext config). The
2614/// clean break forbids silently falling back to them (issue #1745).
2615fn reject_removed_credential_paths<F>(
2616    provider: &AiProvider,
2617    alias: &str,
2618    kv_getter: &F,
2619) -> crate::RedDBResult<()>
2620where
2621    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
2622{
2623    let new_path = ai_api_secret_path(provider, alias);
2624    for removed in [
2625        removed_vault_api_key_path(provider, alias),
2626        removed_plaintext_config_key(provider, alias),
2627    ] {
2628        if let Some(value) = kv_getter(&removed)? {
2629            if !value.trim().is_empty() {
2630                return Err(crate::RedDBError::Query(format!(
2631                    "AI credential found at removed path '{removed}'. The AI credential vault \
2632                     path changed (issue #1745): store the token at '{new_path}' instead. The \
2633                     old vault path shape and the legacy plaintext config path are no longer read."
2634                )));
2635            }
2636        }
2637    }
2638    Ok(())
2639}
2640
2641fn normalize_credential_alias_path(alias: &str) -> String {
2642    let alias = alias.trim();
2643    if alias.is_empty() {
2644        "default".to_string()
2645    } else {
2646        alias.to_ascii_lowercase()
2647    }
2648}
2649
2650fn resolve_key_from_env_alias(provider: &AiProvider, alias: &str) -> Option<String> {
2651    let env_name = provider.alias_key_env_name(alias);
2652    std::env::var(env_name)
2653        .ok()
2654        .map(|v| v.trim().to_string())
2655        .filter(|v| !v.is_empty())
2656}
2657
2658fn normalize_alias_token(alias: &str) -> String {
2659    let mut out = String::with_capacity(alias.len());
2660    for character in alias.chars() {
2661        if character.is_ascii_alphanumeric() {
2662            out.push(character.to_ascii_uppercase());
2663        } else {
2664            out.push('_');
2665        }
2666    }
2667    while out.contains("__") {
2668        out = out.replace("__", "_");
2669    }
2670    out.trim_matches('_').to_string()
2671}
2672
2673/// Convenience: resolve API key using a RedDBRuntime's KV store.
2674///
2675/// Emits an `ai.credential.resolve` audit event so operators can answer
2676/// "which principal caused us to read
2677/// `red.secret.ai.providers.<provider>.tokens.*`?"
2678/// even though the read itself is performed as system (the AI subsystem
2679/// must always be able to fetch the key the query needs — denying it
2680/// would be denying the query at the wrong layer). The audit record
2681/// never contains the secret value.
2682pub fn resolve_api_key_from_runtime(
2683    provider: &AiProvider,
2684    credential_alias: Option<&str>,
2685    runtime: &crate::runtime::RedDBRuntime,
2686) -> crate::RedDBResult<String> {
2687    use crate::application::ports::RuntimeEntityPort;
2688    let alias_for_audit = credential_alias.unwrap_or("default").to_string();
2689    let provider_token = provider.token().to_string();
2690    let audited_paths: std::cell::RefCell<Vec<(String, bool)>> =
2691        std::cell::RefCell::new(Vec::new());
2692    let result = resolve_api_key(provider, credential_alias, |kv_key| {
2693        if kv_key.starts_with("red.secret.") {
2694            let value = runtime.vault_kv_get(kv_key);
2695            audited_paths
2696                .borrow_mut()
2697                .push((kv_key.to_string(), value.is_some()));
2698            return Ok(value);
2699        }
2700        match runtime.get_kv("red_config", kv_key)? {
2701            Some((crate::storage::schema::Value::Text(secret), _)) => {
2702                audited_paths.borrow_mut().push((kv_key.to_string(), true));
2703                Ok(Some(secret.to_string()))
2704            }
2705            Some(_) => {
2706                audited_paths.borrow_mut().push((kv_key.to_string(), false));
2707                Ok(None)
2708            }
2709            None => {
2710                audited_paths.borrow_mut().push((kv_key.to_string(), false));
2711                Ok(None)
2712            }
2713        }
2714    });
2715    let audited_paths = audited_paths.into_inner();
2716
2717    let principal = crate::runtime::impl_core::current_auth_identity_for_audit()
2718        .map(|(user, _role)| user)
2719        .unwrap_or_else(|| "system".to_string());
2720    let outcome = if result.is_ok() { "hit" } else { "miss" };
2721    let target = format!("ai.credential:{provider_token}/{alias_for_audit}");
2722    let paths_json: Vec<crate::serde_json::Value> = audited_paths
2723        .iter()
2724        .map(|(p, hit)| {
2725            crate::serde_json::json!({
2726                "path": p,
2727                "hit": hit,
2728            })
2729        })
2730        .collect();
2731    let details = crate::serde_json::json!({
2732        "provider": provider_token,
2733        "alias": alias_for_audit,
2734        "paths_checked": paths_json,
2735    });
2736    runtime.audit_log().record(
2737        "ai.credential.resolve",
2738        &principal,
2739        &target,
2740        outcome,
2741        details,
2742    );
2743    result
2744}
2745
2746// ============================================================================
2747// HuggingFace Inference API
2748// ============================================================================
2749
2750/// Generate embeddings via HuggingFace Inference API.
2751pub fn huggingface_embeddings(
2752    api_key: &str,
2753    model: &str,
2754    inputs: &[String],
2755    api_base: &str,
2756) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
2757    let url = format!("{api_base}/pipeline/feature-extraction/{model}");
2758    let mut embeddings = Vec::with_capacity(inputs.len());
2759
2760    for input in inputs {
2761        let payload = crate::serde_json::json!({ "inputs": input }).to_string_compact();
2762        let (status, body_str) = http_post_json(&url, api_key, &[], payload, 90)
2763            .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
2764        if !(200..300).contains(&status) {
2765            return Err(crate::RedDBError::Query(format!(
2766                "HuggingFace API error (status {status}): {body_str}"
2767            )));
2768        }
2769        let body: JsonValue = crate::serde_json::from_str(&body_str).map_err(|e| {
2770            crate::RedDBError::Query(format!("HuggingFace response parse error: {e}"))
2771        })?;
2772
2773        // HF returns [[f32, ...]] for single input
2774        let vector: Vec<f32> = match &body {
2775            JsonValue::Array(outer) => outer
2776                .iter()
2777                .filter_map(|v| v.as_f64().map(|n| n as f32))
2778                .collect(),
2779            _ => {
2780                return Err(crate::RedDBError::Query(
2781                    "unexpected HuggingFace embedding response format".to_string(),
2782                ))
2783            }
2784        };
2785        embeddings.push(vector);
2786    }
2787
2788    Ok(OpenAiEmbeddingResponse {
2789        provider: "huggingface",
2790        model: model.to_string(),
2791        embeddings,
2792        prompt_tokens: None,
2793        total_tokens: None,
2794    })
2795}
2796
2797/// Generate text via HuggingFace Inference API.
2798pub fn huggingface_prompt(
2799    api_key: &str,
2800    model: &str,
2801    prompt: &str,
2802    temperature: Option<f32>,
2803    max_tokens: Option<usize>,
2804    api_base: &str,
2805) -> crate::RedDBResult<AiPromptResponse> {
2806    let url = format!("{api_base}/models/{model}");
2807    let mut params = Map::new();
2808    if let Some(t) = temperature {
2809        params.insert("temperature".into(), JsonValue::Number(t as f64));
2810    }
2811    params.insert(
2812        "max_new_tokens".into(),
2813        JsonValue::Number(max_tokens.unwrap_or(512) as f64),
2814    );
2815    let payload = crate::serde_json::json!({
2816        "inputs": prompt,
2817        "parameters": JsonValue::Object(params)
2818    });
2819
2820    let (status, body_str) =
2821        http_post_json(&url, api_key, &[], payload.to_string_compact(), 120)
2822            .map_err(|e| crate::RedDBError::Query(format!("HuggingFace API error: {e}")))?;
2823    if !(200..300).contains(&status) {
2824        return Err(crate::RedDBError::Query(format!(
2825            "HuggingFace API error (status {status}): {body_str}"
2826        )));
2827    }
2828    let body: JsonValue = crate::serde_json::from_str(&body_str)
2829        .map_err(|e| crate::RedDBError::Query(format!("HuggingFace response parse error: {e}")))?;
2830
2831    let output_text = match &body {
2832        JsonValue::Array(arr) => arr
2833            .first()
2834            .and_then(|v| v.get("generated_text"))
2835            .and_then(JsonValue::as_str)
2836            .unwrap_or("")
2837            .to_string(),
2838        _ => body
2839            .get("generated_text")
2840            .and_then(JsonValue::as_str)
2841            .unwrap_or("")
2842            .to_string(),
2843    };
2844
2845    Ok(AiPromptResponse {
2846        provider: "huggingface",
2847        model: model.to_string(),
2848        output_text,
2849        output_chunks: None,
2850        prompt_tokens: None,
2851        completion_tokens: None,
2852        total_tokens: None,
2853        stop_reason: None,
2854    })
2855}
2856
2857// ============================================================================
2858// Local model stubs (requires 'local-models' feature flag)
2859// ============================================================================
2860
2861const LOCAL_MODELS_DISABLED_MESSAGE: &str = "local embeddings require the `local-models` feature \
2862flag at engine build time. Build with: cargo build --features local-models. Alternatively, use \
2863the 'ollama' provider with a local Ollama server.";
2864
2865const LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE: &str = "local embeddings are registered by the \
2866`local-models` feature, but local model artifact execution is not implemented in this slice. \
2867Alternatively, use the 'ollama' provider with a local Ollama server.";
2868
2869const LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE: &str = "local prompt and generation are out of scope for \
2870the `local-models` feature; the local provider contract is embeddings-only for this slice.";
2871
2872pub fn local_embeddings_unavailable_error() -> crate::RedDBError {
2873    if cfg!(feature = "local-models") {
2874        crate::RedDBError::Query(LOCAL_EMBEDDINGS_NOT_IMPLEMENTED_MESSAGE.to_string())
2875    } else {
2876        crate::RedDBError::FeatureNotEnabled(LOCAL_MODELS_DISABLED_MESSAGE.to_string())
2877    }
2878}
2879
2880pub fn local_prompt_unavailable_error() -> crate::RedDBError {
2881    crate::RedDBError::Query(LOCAL_PROMPT_OUT_OF_SCOPE_MESSAGE.to_string())
2882}
2883
2884/// Local embedding via candle — requires `local-models` feature.
2885pub fn local_embeddings(
2886    _model_id: &str,
2887    _texts: &[String],
2888) -> crate::RedDBResult<OpenAiEmbeddingResponse> {
2889    Err(local_embeddings_unavailable_error())
2890}
2891
2892/// Local prompt via candle — requires `local-models` feature.
2893pub fn local_prompt(_model_id: &str, _prompt: &str) -> crate::RedDBResult<AiPromptResponse> {
2894    Err(local_prompt_unavailable_error())
2895}
2896
2897// ============================================================================
2898// gRPC input collection — parity with HTTP /ai/embeddings
2899// ============================================================================
2900
2901/// Collect embedding inputs from any of the three supported shapes.
2902///
2903/// * `input: "..."` — single string.
2904/// * `inputs: ["...", ...]` — array of strings.
2905/// * `source_query: "SELECT ..."` — runs a SQL query and projects
2906///   either the named `source_field` from each row (source_mode =
2907///   "row", default) or every string cell of every result row
2908///   (source_mode = "result").
2909fn grpc_collect_embedding_inputs(
2910    runtime: &crate::runtime::RedDBRuntime,
2911    payload: &JsonValue,
2912) -> crate::RedDBResult<Vec<String>> {
2913    if let Some(source_query) = payload
2914        .get("source_query")
2915        .and_then(|v| v.as_str())
2916        .map(str::trim)
2917        .filter(|s| !s.is_empty())
2918    {
2919        return grpc_collect_inputs_from_source_query(runtime, payload, source_query);
2920    }
2921
2922    if let Some(arr) = payload.get("inputs").and_then(|v| v.as_array()) {
2923        let mut out = Vec::with_capacity(arr.len());
2924        for (idx, v) in arr.iter().enumerate() {
2925            let text = v.as_str().ok_or_else(|| {
2926                crate::RedDBError::Query(format!("field 'inputs[{idx}]' must be a string"))
2927            })?;
2928            if text.trim().is_empty() {
2929                return Err(crate::RedDBError::Query(format!(
2930                    "field 'inputs[{idx}]' cannot be empty"
2931                )));
2932            }
2933            out.push(text.to_string());
2934        }
2935        if out.is_empty() {
2936            return Err(crate::RedDBError::Query(
2937                "field 'inputs' must be a non-empty array of strings".to_string(),
2938            ));
2939        }
2940        return Ok(out);
2941    }
2942
2943    if let Some(single) = payload
2944        .get("input")
2945        .and_then(|v| v.as_str())
2946        .map(str::trim)
2947        .filter(|s| !s.is_empty())
2948    {
2949        return Ok(vec![single.to_string()]);
2950    }
2951
2952    Err(crate::RedDBError::Query(
2953        "provide either 'input', 'inputs', or 'source_query'".to_string(),
2954    ))
2955}
2956
2957fn grpc_collect_inputs_from_source_query(
2958    runtime: &crate::runtime::RedDBRuntime,
2959    payload: &JsonValue,
2960    source_query: &str,
2961) -> crate::RedDBResult<Vec<String>> {
2962    let result = runtime
2963        .execute_query(source_query)
2964        .map_err(|err| crate::RedDBError::Query(format!("source_query failed: {err}")))?;
2965
2966    let source_mode = payload
2967        .get("source_mode")
2968        .and_then(|v| v.as_str())
2969        .map(str::trim)
2970        .filter(|s| !s.is_empty())
2971        .unwrap_or("row")
2972        .to_ascii_lowercase();
2973
2974    let mut out: Vec<String> = Vec::new();
2975    match source_mode.as_str() {
2976        "row" => {
2977            let field = payload
2978                .get("source_field")
2979                .and_then(|v| v.as_str())
2980                .map(str::trim)
2981                .filter(|s| !s.is_empty())
2982                .ok_or_else(|| {
2983                    crate::RedDBError::Query(
2984                        "field 'source_field' is required when source_mode='row'".to_string(),
2985                    )
2986                })?;
2987            for rec in &result.result.records {
2988                for (key, value) in rec.iter_fields() {
2989                    if key.as_ref() == field {
2990                        if let crate::storage::schema::Value::Text(text) = value {
2991                            let trimmed = text.trim();
2992                            if !trimmed.is_empty() {
2993                                out.push(trimmed.to_string());
2994                            }
2995                        }
2996                    }
2997                }
2998            }
2999        }
3000        "result" => {
3001            for rec in &result.result.records {
3002                for (_, value) in rec.iter_fields() {
3003                    if let crate::storage::schema::Value::Text(text) = value {
3004                        let trimmed = text.trim();
3005                        if !trimmed.is_empty() {
3006                            out.push(trimmed.to_string());
3007                        }
3008                    }
3009                }
3010            }
3011        }
3012        other => {
3013            return Err(crate::RedDBError::Query(format!(
3014                "field 'source_mode' must be 'row' or 'result' (got '{other}')"
3015            )));
3016        }
3017    }
3018
3019    if out.is_empty() {
3020        return Err(crate::RedDBError::Query(
3021            "source_query produced zero non-empty text inputs".to_string(),
3022        ));
3023    }
3024    Ok(out)
3025}
3026
3027// ============================================================================
3028// gRPC stubs — delegate to the same logic as HTTP handlers
3029// ============================================================================
3030
3031/// gRPC embeddings — shared entrypoint that mirrors the HTTP handler.
3032///
3033/// Accepts the same JSON payload shape as `POST /ai/embeddings`:
3034///
3035/// ```json
3036/// { "provider": "openai", "model": "text-embedding-3-small",
3037///   "inputs": ["hello", "world"], "credential": "optional-alias" }
3038/// ```
3039///
3040/// Input shapes at parity with HTTP: `input` (single string),
3041/// `inputs` (array of strings), and `source_query` (SQL that the
3042/// runtime executes to materialise the input texts; `source_mode`
3043/// = `row` needs `source_collection` + `source_field`, `result`
3044/// uses the projected columns). Returns a JSON object with
3045/// `provider`, `model`, `embeddings`, `prompt_tokens`,
3046/// `total_tokens`. Non-OpenAI-compatible providers are rejected
3047/// with a clear message, matching the HTTP handler.
3048pub fn grpc_embeddings(
3049    runtime: &crate::runtime::RedDBRuntime,
3050    payload: &JsonValue,
3051) -> crate::RedDBResult<JsonValue> {
3052    // An explicit `provider` is honoured verbatim; when absent, the
3053    // embeddings task pointer drives selection (ADR-0068 §5).
3054    let provider = match payload
3055        .get("provider")
3056        .and_then(|v| v.as_str())
3057        .map(str::trim)
3058        .filter(|s| !s.is_empty())
3059    {
3060        Some(name) => parse_provider(name)?,
3061        None => resolve_embeddings_provider_from_runtime(runtime, "")?,
3062    };
3063    // Routing matrix mirrors `handle_ai_embeddings`. See that function
3064    // for the rationale; in short: HuggingFace gets its own wire
3065    // shape, Anthropic fails fast (no embeddings product), and Local
3066    // requires a build-time feature flag.
3067    match &provider {
3068        AiProvider::Anthropic => {
3069            return Err(crate::RedDBError::Query(
3070                "Anthropic does not offer an embeddings API. \
3071                 Re-issue the request against an OpenAI-compatible \
3072                 provider (openai, groq, ollama, openrouter, together, \
3073                 venice, deepseek), HuggingFace, or a custom base URL — \
3074                 RedDB does not silently route embeddings to a \
3075                 different provider than the one you named."
3076                    .to_string(),
3077            ));
3078        }
3079        AiProvider::Local => {
3080            return grpc_embeddings_local(runtime, payload);
3081        }
3082        _ => {}
3083    }
3084
3085    let inputs: Vec<String> = grpc_collect_embedding_inputs(runtime, payload)?;
3086
3087    let explicit_model = payload.get("model").and_then(|v| v.as_str());
3088    let model = resolve_embeddings_model_from_runtime(runtime, &provider, explicit_model);
3089
3090    let credential = payload
3091        .get("credential")
3092        .and_then(|v| v.as_str())
3093        .map(str::to_string);
3094    let api_key = resolve_api_key_from_runtime(&provider, credential.as_deref(), runtime)?;
3095
3096    let dimensions = payload
3097        .get("dimensions")
3098        .and_then(|v| v.as_i64())
3099        .and_then(|v| usize::try_from(v).ok())
3100        .filter(|v| *v > 0);
3101
3102    let response = match &provider {
3103        AiProvider::HuggingFace => {
3104            huggingface_embeddings(&api_key, &model, &inputs, &provider.resolve_api_base())?
3105        }
3106        _ => {
3107            let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
3108            let request = OpenAiEmbeddingRequest {
3109                api_key,
3110                model,
3111                inputs,
3112                dimensions,
3113                api_base: provider.resolve_api_base(),
3114            };
3115            crate::runtime::ai::block_on_ai(async move {
3116                openai_embeddings_async(&transport, request).await
3117            })
3118            .and_then(|result| result)?
3119        }
3120    };
3121
3122    let embeddings_json: Vec<JsonValue> = response
3123        .embeddings
3124        .into_iter()
3125        .map(|vec| {
3126            JsonValue::Array(
3127                vec.into_iter()
3128                    .map(|f| JsonValue::Number(f as f64))
3129                    .collect(),
3130            )
3131        })
3132        .collect();
3133
3134    let mut obj = Map::new();
3135    obj.insert(
3136        "provider".to_string(),
3137        JsonValue::String(response.provider.to_string()),
3138    );
3139    obj.insert("model".to_string(), JsonValue::String(response.model));
3140    obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3141    if let Some(pt) = response.prompt_tokens {
3142        obj.insert("prompt_tokens".to_string(), JsonValue::Number(pt as f64));
3143    }
3144    if let Some(tt) = response.total_tokens {
3145        obj.insert("total_tokens".to_string(), JsonValue::Number(tt as f64));
3146    }
3147    Ok(JsonValue::Object(obj))
3148}
3149
3150/// gRPC local-provider embedding path (#680).
3151///
3152/// Mirrors the HTTP local path: resolves a registered+installed local
3153/// model, runs the runtime backend, and returns the same JSON shape
3154/// the HTTP handler produces (`provider`, `model`, `model_source`,
3155/// `model_revision`, `model_engine`, `dimensions`, `embeddings`).
3156/// Save-side behaviour is HTTP-only; gRPC mirrors the OpenAI-compatible
3157/// gRPC path which also does not persist.
3158fn grpc_embeddings_local(
3159    runtime: &crate::runtime::RedDBRuntime,
3160    payload: &JsonValue,
3161) -> crate::RedDBResult<JsonValue> {
3162    crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
3163
3164    let model_name = payload
3165        .get("model")
3166        .and_then(|v| v.as_str())
3167        .map(str::trim)
3168        .filter(|s| !s.is_empty())
3169        .ok_or_else(|| {
3170            crate::RedDBError::Query(
3171                "field 'model' is required for the local provider and must be the \
3172                 registered local model name (see POST /ai/models)"
3173                    .to_string(),
3174            )
3175        })?
3176        .to_string();
3177
3178    let inputs = grpc_collect_embedding_inputs(runtime, payload)?;
3179    let response = crate::runtime::ai::local_embedding::embed_local(runtime, &model_name, inputs)?;
3180
3181    let embeddings_json: Vec<JsonValue> = response
3182        .embeddings
3183        .into_iter()
3184        .map(|vec| {
3185            JsonValue::Array(
3186                vec.into_iter()
3187                    .map(|f| JsonValue::Number(f as f64))
3188                    .collect(),
3189            )
3190        })
3191        .collect();
3192
3193    let mut obj = Map::new();
3194    obj.insert(
3195        "provider".to_string(),
3196        JsonValue::String(response.provider.to_string()),
3197    );
3198    obj.insert("model".to_string(), JsonValue::String(response.name));
3199    obj.insert(
3200        "model_source".to_string(),
3201        JsonValue::String(response.source),
3202    );
3203    obj.insert(
3204        "model_revision".to_string(),
3205        JsonValue::String(response.revision),
3206    );
3207    obj.insert(
3208        "model_engine".to_string(),
3209        JsonValue::String(response.engine),
3210    );
3211    obj.insert(
3212        "dimensions".to_string(),
3213        JsonValue::Number(response.dimensions as f64),
3214    );
3215    obj.insert("embeddings".to_string(), JsonValue::Array(embeddings_json));
3216    Ok(JsonValue::Object(obj))
3217}
3218
3219/// gRPC stub for AI prompt.
3220pub fn grpc_prompt(
3221    _runtime: &crate::runtime::RedDBRuntime,
3222    _payload: &JsonValue,
3223) -> crate::RedDBResult<JsonValue> {
3224    Err(crate::RedDBError::FeatureNotEnabled(
3225        "AI prompt via gRPC requires HTTP endpoint; use POST /ai/prompt".to_string(),
3226    ))
3227}
3228
3229/// gRPC stub for AI credentials.
3230pub fn grpc_credentials(
3231    _runtime: &crate::runtime::RedDBRuntime,
3232    _payload: &JsonValue,
3233) -> crate::RedDBResult<JsonValue> {
3234    Err(crate::RedDBError::FeatureNotEnabled(
3235        "AI credentials via gRPC requires HTTP endpoint; use POST /ai/credentials".to_string(),
3236    ))
3237}
3238
3239// ============================================================================
3240// Generic OpenAI-compatible client (issue gh-516)
3241//
3242// Thin blocking client that targets any `{api_base}/chat/completions`
3243// and `{api_base}/embeddings` endpoint with arbitrary auth headers.
3244// Existing vendor-native paths (`openai_prompt_async`,
3245// `anthropic_prompt_async`) remain unchanged; this exists so callers
3246// can talk to non-OpenAI providers that expose an OpenAI-compatible
3247// surface (Groq, OpenRouter, Together, Ollama, vLLM, LM Studio, ...)
3248// without having to register a new `AiProvider` variant.
3249// ============================================================================
3250
3251/// Normalized usage block. Field names follow the Anthropic shape
3252/// (`input_tokens` / `output_tokens`) so downstream cost-accounting
3253/// has one canonical schema regardless of the upstream provider.
3254#[derive(Debug, Clone, Default, PartialEq, Eq)]
3255pub struct OpenAiCompatUsage {
3256    pub input_tokens: Option<u64>,
3257    pub output_tokens: Option<u64>,
3258    pub total_tokens: Option<u64>,
3259}
3260
3261#[derive(Debug, Clone)]
3262pub struct OpenAiCompatChatRequest {
3263    pub api_base: String,
3264    pub api_key: String,
3265    pub model: String,
3266    pub prompt: String,
3267    pub temperature: Option<f32>,
3268    pub seed: Option<u64>,
3269    pub max_output_tokens: Option<usize>,
3270    pub extra_headers: Vec<(String, String)>,
3271}
3272
3273#[derive(Debug, Clone)]
3274pub struct OpenAiCompatChatResponse {
3275    pub model: String,
3276    pub output_text: String,
3277    pub stop_reason: Option<String>,
3278    pub usage: OpenAiCompatUsage,
3279}
3280
3281#[derive(Debug, Clone)]
3282pub struct OpenAiCompatEmbeddingsRequest {
3283    pub api_base: String,
3284    pub api_key: String,
3285    pub model: String,
3286    pub inputs: Vec<String>,
3287    pub dimensions: Option<usize>,
3288    pub extra_headers: Vec<(String, String)>,
3289}
3290
3291#[derive(Debug, Clone)]
3292pub struct OpenAiCompatEmbeddingsResponse {
3293    pub model: String,
3294    pub embeddings: Vec<Vec<f32>>,
3295    pub usage: OpenAiCompatUsage,
3296}
3297
3298fn extra_header_refs(headers: &[(String, String)]) -> Vec<(&str, &str)> {
3299    headers
3300        .iter()
3301        .map(|(k, v)| (k.as_str(), v.as_str()))
3302        .collect()
3303}
3304
3305/// POST `{api_base}/chat/completions` and return a normalized response.
3306///
3307/// Errors:
3308/// * empty model / prompt → `RedDBError::Query`.
3309/// * transport / non-2xx → `RedDBError::Query` carrying the status code
3310///   and the provider's parsed `error.message` when available, raw body
3311///   otherwise.
3312pub fn openai_compat_chat(
3313    request: OpenAiCompatChatRequest,
3314) -> RedDBResult<OpenAiCompatChatResponse> {
3315    if request.model.trim().is_empty() {
3316        return Err(RedDBError::Query(
3317            "openai-compat: model cannot be empty".to_string(),
3318        ));
3319    }
3320    if request.prompt.trim().is_empty() {
3321        return Err(RedDBError::Query(
3322            "openai-compat: prompt cannot be empty".to_string(),
3323        ));
3324    }
3325
3326    let url = format!(
3327        "{}/chat/completions",
3328        request.api_base.trim_end_matches('/')
3329    );
3330    let payload = build_openai_prompt_payload(
3331        &request.model,
3332        &request.prompt,
3333        request.temperature,
3334        request.seed,
3335        request.max_output_tokens,
3336        false,
3337    );
3338
3339    let extra = extra_header_refs(&request.extra_headers);
3340    let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 120)
3341        .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3342
3343    if !(200..300).contains(&status) {
3344        let message = openai_error_message(&body).unwrap_or_else(|| {
3345            if body.trim().is_empty() {
3346                "openai-compat chat request failed".to_string()
3347            } else {
3348                body.clone()
3349            }
3350        });
3351        return Err(RedDBError::Query(format!(
3352            "openai-compat chat request failed (status {status}): {message}"
3353        )));
3354    }
3355
3356    let parsed = parse_openai_prompt_response(&body, &request.model)?;
3357    Ok(OpenAiCompatChatResponse {
3358        model: parsed.model,
3359        output_text: parsed.output_text,
3360        stop_reason: parsed.stop_reason,
3361        usage: OpenAiCompatUsage {
3362            input_tokens: parsed.prompt_tokens,
3363            output_tokens: parsed.completion_tokens,
3364            total_tokens: parsed.total_tokens,
3365        },
3366    })
3367}
3368
3369/// POST `{api_base}/embeddings` and return a normalized response.
3370pub fn openai_compat_embeddings(
3371    request: OpenAiCompatEmbeddingsRequest,
3372) -> RedDBResult<OpenAiCompatEmbeddingsResponse> {
3373    if request.model.trim().is_empty() {
3374        return Err(RedDBError::Query(
3375            "openai-compat: embedding model cannot be empty".to_string(),
3376        ));
3377    }
3378    if request.inputs.is_empty() {
3379        return Err(RedDBError::Query(
3380            "openai-compat: at least one input is required".to_string(),
3381        ));
3382    }
3383
3384    let url = format!("{}/embeddings", request.api_base.trim_end_matches('/'));
3385    let payload =
3386        build_openai_embedding_payload(&request.model, &request.inputs, request.dimensions);
3387
3388    let extra = extra_header_refs(&request.extra_headers);
3389    let (status, body) = http_post_json(&url, &request.api_key, &extra, payload, 90)
3390        .map_err(|err| RedDBError::Query(format!("openai-compat transport error: {err}")))?;
3391
3392    if !(200..300).contains(&status) {
3393        let message = openai_error_message(&body).unwrap_or_else(|| {
3394            if body.trim().is_empty() {
3395                "openai-compat embeddings request failed".to_string()
3396            } else {
3397                body.clone()
3398            }
3399        });
3400        return Err(RedDBError::Query(format!(
3401            "openai-compat embeddings request failed (status {status}): {message}"
3402        )));
3403    }
3404
3405    let parsed = parse_openai_embedding_response(&body)?;
3406    Ok(OpenAiCompatEmbeddingsResponse {
3407        model: parsed.model,
3408        embeddings: parsed.embeddings,
3409        usage: OpenAiCompatUsage {
3410            input_tokens: parsed.prompt_tokens,
3411            output_tokens: None,
3412            total_tokens: parsed.total_tokens,
3413        },
3414    })
3415}
3416
3417// ============================================================================
3418// Provider mode selector (issue gh-516)
3419//
3420// `red.config.ai.provider` picks the wire-protocol family that engine
3421// consumers (currently AskPipeline) should use. This is intentionally
3422// distinct from `red.config.ai.default.provider`, which names a
3423// concrete vendor (openai, groq, ollama, ...). The mode selector
3424// answers the prior question of which HTTP shape to speak.
3425// ============================================================================
3426
3427/// Wire-protocol family used by engine-side AI consumers.
3428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3429pub enum AiProviderMode {
3430    /// Generic OpenAI-compatible client (`POST {api_base}/chat/completions`).
3431    OpenAiCompat,
3432    /// Vendor-native OpenAI client (api.openai.com, default headers).
3433    OpenAiNative,
3434    /// Vendor-native Anthropic client (api.anthropic.com, x-api-key).
3435    AnthropicNative,
3436}
3437
3438impl AiProviderMode {
3439    pub fn token(&self) -> &'static str {
3440        match self {
3441            Self::OpenAiCompat => "openai-compat",
3442            Self::OpenAiNative => "openai-native",
3443            Self::AnthropicNative => "anthropic-native",
3444        }
3445    }
3446}
3447
3448/// Parse a mode token. Accepts hyphen or underscore spellings.
3449pub fn parse_provider_mode(name: &str) -> Option<AiProviderMode> {
3450    match name.trim().to_ascii_lowercase().as_str() {
3451        "openai-compat" | "openai_compat" | "openaicompat" => Some(AiProviderMode::OpenAiCompat),
3452        "openai-native" | "openai_native" | "openainative" => Some(AiProviderMode::OpenAiNative),
3453        "anthropic-native" | "anthropic_native" | "anthropicnative" => {
3454            Some(AiProviderMode::AnthropicNative)
3455        }
3456        _ => None,
3457    }
3458}
3459
3460/// Resolve the provider mode. Lookup chain:
3461/// 1. `REDDB_AI_PROVIDER_MODE` env var.
3462/// 2. `red_config` KV key `red.config.ai.provider`.
3463/// 3. Returns `None` so callers can fall back to their existing
3464///    vendor-based routing.
3465pub fn resolve_provider_mode<F>(kv_getter: &F) -> Option<AiProviderMode>
3466where
3467    F: Fn(&str) -> crate::RedDBResult<Option<String>>,
3468{
3469    if let Ok(value) = std::env::var("REDDB_AI_PROVIDER_MODE") {
3470        if let Some(mode) = parse_provider_mode(&value) {
3471            return Some(mode);
3472        }
3473    }
3474    if let Ok(Some(value)) = kv_getter("red.config.ai.provider") {
3475        if let Some(mode) = parse_provider_mode(&value) {
3476            return Some(mode);
3477        }
3478    }
3479    None
3480}
3481
3482/// Map a mode to the matching [`AiProvider`] variant. `OpenAiCompat`
3483/// stays as a `Custom("")` marker — callers must resolve the actual
3484/// api_base separately (typically via `resolve_api_base_with_kv`).
3485pub fn provider_mode_to_provider(mode: AiProviderMode) -> AiProvider {
3486    match mode {
3487        AiProviderMode::OpenAiNative => AiProvider::OpenAi,
3488        AiProviderMode::AnthropicNative => AiProvider::Anthropic,
3489        AiProviderMode::OpenAiCompat => AiProvider::Custom(String::new()),
3490    }
3491}