Skip to main content

reddb_server/
ai.rs

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