Skip to main content

aidens_provider_kit/
lib.rs

1//! Provider construction, execution, and route truth.
2
3use aidens_contracts::{
4    ProviderBackendMatrixEntryV1, ProviderBackendMatrixV1, ProviderBackendStatusV1,
5    ProviderCertificationFixtureDraftV1, ProviderCertificationFixtureV1,
6    ProviderReadinessReportDraftV1, ProviderReadinessReportV1, ProviderRouteKindV1,
7    ProviderRouteReportDraftV2, ProviderRouteReportV1, ProviderRouteReportV2, ToolCallRequestV1,
8    ToolCallResultV1, ToolCallSourceV1, ToolProviderSchemaV1,
9};
10use anyhow::{anyhow, bail, Context};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17pub mod canonical_stack {
18    pub use llm_tool_runtime::{
19        render_ollama_tool, render_openai_tool, ToolDescriptor as CanonicalToolDescriptor,
20        ToolError as CanonicalToolError, ToolReceipt as CanonicalToolReceipt,
21        ToolRuntime as CanonicalToolRuntime, ToolRuntimeConfig as CanonicalToolRuntimeConfig,
22    };
23
24    pub fn render_openai_tool_schema(
25        descriptor: &CanonicalToolDescriptor,
26        strict: bool,
27    ) -> Result<serde_json::Value, CanonicalToolError> {
28        render_openai_tool(descriptor, strict)
29    }
30
31    pub fn render_ollama_tool_schema(
32        descriptor: &CanonicalToolDescriptor,
33    ) -> Result<serde_json::Value, CanonicalToolError> {
34        render_ollama_tool(descriptor)
35    }
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
39pub struct AiDENsChatMessageV1 {
40    pub role: String,
41    pub content: String,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
45pub struct AiDENsCompletionRequestV1 {
46    pub messages: Vec<AiDENsChatMessageV1>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub provider_tool_schemas: Vec<ToolProviderSchemaV1>,
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub tool_results: Vec<ToolCallResultV1>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub temperature: Option<f32>,
53}
54
55impl AiDENsCompletionRequestV1 {
56    pub fn single_user(prompt: impl Into<String>) -> Self {
57        Self {
58            messages: vec![AiDENsChatMessageV1 {
59                role: "user".into(),
60                content: prompt.into(),
61            }],
62            provider_tool_schemas: Vec::new(),
63            tool_results: Vec::new(),
64            temperature: None,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
70pub struct AiDENsCompletionResponseV1 {
71    pub text: String,
72    pub provider_kind: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub model: Option<String>,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub tool_calls: Vec<ToolCallRequestV1>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub prompt_eval_count: Option<u64>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub eval_count: Option<u64>,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct ProviderCapabilitiesV1 {
85    pub chat_completion: bool,
86    pub native_tool_calling: bool,
87    pub streaming: bool,
88    pub structured_output: bool,
89}
90
91impl ProviderCapabilitiesV1 {
92    pub fn executable_by_backend(kind: &str) -> Self {
93        match normalized_provider_kind(kind).as_str() {
94            "mock" => Self {
95                chat_completion: true,
96                native_tool_calling: false,
97                streaming: false,
98                structured_output: true,
99            },
100            "ollama" => Self {
101                chat_completion: true,
102                native_tool_calling: false,
103                streaming: false,
104                structured_output: false,
105            },
106            _ => Self::default(),
107        }
108    }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ProviderReadinessV1 {
113    pub configured: bool,
114    pub executable: bool,
115    pub native_tool_loop_executable: bool,
116    pub route_label: String,
117    pub reason_codes: Vec<String>,
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
121pub struct ProviderSpecV1 {
122    pub kind: String,
123    pub model: Option<String>,
124    pub api_key: Option<String>,
125    pub base_url: Option<String>,
126    pub mock_response: Option<String>,
127}
128
129impl ProviderSpecV1 {
130    pub fn new(kind: impl Into<String>) -> Self {
131        Self {
132            kind: kind.into(),
133            ..Self::default()
134        }
135    }
136}
137
138#[async_trait]
139pub trait AiDENsProvider: Send + Sync {
140    fn provider_kind(&self) -> &str;
141    fn model(&self) -> Option<&str>;
142    fn capabilities(&self) -> ProviderCapabilitiesV1;
143    async fn complete(
144        &self,
145        request: AiDENsCompletionRequestV1,
146    ) -> anyhow::Result<AiDENsCompletionResponseV1>;
147}
148
149#[derive(Debug, Clone, Default)]
150pub struct DisabledProvider {
151    model: Option<String>,
152}
153
154impl DisabledProvider {
155    pub fn new(model: Option<String>) -> Self {
156        Self { model }
157    }
158}
159
160#[async_trait]
161impl AiDENsProvider for DisabledProvider {
162    fn provider_kind(&self) -> &str {
163        "disabled"
164    }
165
166    fn model(&self) -> Option<&str> {
167        self.model.as_deref()
168    }
169
170    fn capabilities(&self) -> ProviderCapabilitiesV1 {
171        ProviderCapabilitiesV1::default()
172    }
173
174    async fn complete(
175        &self,
176        _request: AiDENsCompletionRequestV1,
177    ) -> anyhow::Result<AiDENsCompletionResponseV1> {
178        bail!("provider disabled: no completion provider is executable")
179    }
180}
181
182#[derive(Debug, Clone)]
183pub struct MockProvider {
184    /// Original response text. Retained for Debug display; segment dispatch
185    /// uses `segments` exclusively.
186    #[allow(dead_code)]
187    response: String,
188    segments: Vec<String>,
189    model: Option<String>,
190}
191
192const MOCK_RESPONSE_DELIMITER: &str = "\n---aidens-next-response---\n";
193
194impl MockProvider {
195    pub fn new(response: impl Into<String>, model: Option<String>) -> anyhow::Result<Self> {
196        let response = response.into();
197        if response.trim().is_empty() {
198            bail!("mock provider requires an explicit non-empty mock_response")
199        }
200        // Pre-split response segments at construction time to avoid
201        // re-splitting on every completion call.
202        let segments = response
203            .split(MOCK_RESPONSE_DELIMITER)
204            .map(|s| s.to_string())
205            .collect::<Vec<_>>();
206        Ok(Self {
207            response,
208            segments,
209            model,
210        })
211    }
212
213    fn response_for_request(&self, request: &AiDENsCompletionRequestV1) -> String {
214        let index = request
215            .tool_results
216            .len()
217            .min(self.segments.len().saturating_sub(1));
218        render_mock_response_template(&self.segments[index], request)
219    }
220}
221
222#[async_trait]
223impl AiDENsProvider for MockProvider {
224    fn provider_kind(&self) -> &str {
225        "mock"
226    }
227
228    fn model(&self) -> Option<&str> {
229        self.model.as_deref()
230    }
231
232    fn capabilities(&self) -> ProviderCapabilitiesV1 {
233        ProviderCapabilitiesV1 {
234            chat_completion: true,
235            native_tool_calling: false,
236            streaming: false,
237            structured_output: true,
238        }
239    }
240
241    async fn complete(
242        &self,
243        request: AiDENsCompletionRequestV1,
244    ) -> anyhow::Result<AiDENsCompletionResponseV1> {
245        Ok(AiDENsCompletionResponseV1 {
246            text: self.response_for_request(&request),
247            provider_kind: self.provider_kind().into(),
248            model: self.model.clone(),
249            tool_calls: Vec::new(),
250            prompt_eval_count: None,
251            eval_count: None,
252        })
253    }
254}
255
256#[derive(Debug, Clone)]
257pub struct OllamaProvider {
258    base_url: String,
259    model: String,
260    client: reqwest::Client,
261}
262
263impl OllamaProvider {
264    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> anyhow::Result<Self> {
265        let base_url = base_url.into().trim_end_matches('/').to_string();
266        let model = model.into();
267        if base_url.trim().is_empty() {
268            bail!("ollama provider unavailable: base_url is empty")
269        }
270        if model.trim().is_empty() {
271            bail!("ollama provider unavailable: model is empty")
272        }
273        let client = reqwest::Client::builder()
274            .connect_timeout(Duration::from_secs(5))
275            .timeout(Duration::from_secs(300))
276            .build()
277            .context("ollama provider unavailable: failed to build HTTP client")?;
278        Ok(Self {
279            base_url,
280            model,
281            client,
282        })
283    }
284}
285
286#[async_trait]
287impl AiDENsProvider for OllamaProvider {
288    fn provider_kind(&self) -> &str {
289        "ollama"
290    }
291
292    fn model(&self) -> Option<&str> {
293        Some(&self.model)
294    }
295
296    fn capabilities(&self) -> ProviderCapabilitiesV1 {
297        ProviderCapabilitiesV1 {
298            chat_completion: true,
299            native_tool_calling: true,
300            streaming: false,
301            structured_output: false,
302        }
303    }
304
305    async fn complete(
306        &self,
307        request: AiDENsCompletionRequestV1,
308    ) -> anyhow::Result<AiDENsCompletionResponseV1> {
309        let messages = request
310            .messages
311            .iter()
312            .map(|message| {
313                // If this is an assistant message containing tool_calls JSON, parse it and
314                // send it as native Ollama tool_calls field instead of content string.
315                if message.role == "assistant" && message.content.starts_with("{\"tool_calls\":") {
316                    if let Ok(tool_calls) =
317                        serde_json::from_str::<serde_json::Value>(&message.content)
318                    {
319                        if let Some(calls) = tool_calls["tool_calls"].as_array() {
320                            // Only use native tool_calls if the array is non-empty
321                            // and each entry has a function.name field.
322                            let valid = !calls.is_empty()
323                                && calls
324                                    .iter()
325                                    .all(|c| c["function"]["name"].as_str().is_some());
326                            if valid {
327                                return serde_json::json!({
328                                    "role": "assistant",
329                                    "content": "",
330                                    "tool_calls": calls,
331                                });
332                            }
333                        }
334                    }
335                }
336                serde_json::json!({
337                    "role": message.role,
338                    "content": message.content,
339                })
340            })
341            .collect::<Vec<_>>();
342        let mut options = serde_json::json!({});
343        if let Some(temp) = request.temperature {
344            options["temperature"] = serde_json::json!(temp);
345        }
346
347        // Build tools array from provider_tool_schemas (Ollama uses OpenAI function format)
348        // Use the `name` field (simple name) as the function name, not the tool_id
349        // (which contains colons that confuse the model).
350        // We'll map back from name to tool_id when parsing the response.
351        let tools: Vec<serde_json::Value> = request
352            .provider_tool_schemas
353            .iter()
354            .map(|schema| {
355                serde_json::json!({
356                    "type": "function",
357                    "function": {
358                        "name": schema.name,
359                        "description": schema.description,
360                        "parameters": schema.input_schema,
361                    }
362                })
363            })
364            .collect();
365
366        let mut body = serde_json::json!({
367            "model": self.model,
368            "messages": messages,
369            "stream": false,
370            "options": options,
371        });
372
373        if !tools.is_empty() {
374            body["tools"] = serde_json::json!(tools);
375        }
376
377        let url = format!("{}/api/chat", self.base_url);
378        let response = self
379            .client
380            .post(url)
381            .json(&body)
382            .send()
383            .await
384            .map_err(|error| anyhow!("ollama provider unavailable: {error}"))?;
385
386        if !response.status().is_success() {
387            let status = response.status();
388            let body = response.text().await.unwrap_or_default();
389            bail!("ollama provider unavailable: returned {status}: {body}")
390        }
391
392        let json = response
393            .json::<serde_json::Value>()
394            .await
395            .context("ollama provider unavailable: response JSON parse failed")?;
396
397        let text = json["message"]["content"]
398            .as_str()
399            .unwrap_or("")
400            .to_string();
401
402        // Parse tool_calls from Ollama response
403        // Map function names back to tool_ids (Ollama uses simple names, runner expects namespaced IDs)
404        let name_to_tool_id: std::collections::HashMap<String, String> = request
405            .provider_tool_schemas
406            .iter()
407            .map(|s| (s.name.clone(), s.tool_id.clone()))
408            .collect();
409        let tool_calls: Vec<ToolCallRequestV1> = json["message"]["tool_calls"]
410            .as_array()
411            .map(|calls| {
412                calls
413                    .iter()
414                    .filter_map(|call| {
415                        let name = call["function"]["name"].as_str()?;
416                        let arguments = call["function"]["arguments"].clone();
417                        // Map simple name back to namespaced tool_id
418                        let tool_id = name_to_tool_id
419                            .get(name)
420                            .cloned()
421                            .unwrap_or_else(|| name.to_string());
422                        Some(ToolCallRequestV1::new(
423                            ToolCallSourceV1::NativeProvider,
424                            tool_id,
425                            arguments,
426                            None,
427                            vec![],
428                        ))
429                    })
430                    .collect()
431            })
432            .unwrap_or_default();
433
434        // If we got tool_calls, text may be empty — that's OK.
435        // If text is empty AND no tool_calls, check if this was a follow-up
436        // after tool results (the model may return empty when it's done).
437        if text.is_empty() && tool_calls.is_empty() {
438            // Return empty text — the runner handles this as a normal (empty) response.
439        }
440
441        Ok(AiDENsCompletionResponseV1 {
442            text,
443            provider_kind: self.provider_kind().into(),
444            model: Some(self.model.clone()),
445            tool_calls,
446            prompt_eval_count: json["prompt_eval_count"].as_u64(),
447            eval_count: json["eval_count"].as_u64(),
448        })
449    }
450}
451
452fn render_mock_response_template(template: &str, request: &AiDENsCompletionRequestV1) -> String {
453    let last_result = request.tool_results.last();
454    let last_tool_output_text = last_result
455        .map(ToolCallResultV1::output_text)
456        .unwrap_or_default();
457    let last_tool_content = last_result
458        .and_then(|result| result.output.as_ref())
459        .and_then(|output| output.get("content"))
460        .and_then(serde_json::Value::as_str)
461        .unwrap_or_default();
462    template
463        .replace("{{last_tool_output_text}}", &last_tool_output_text)
464        .replace("{{last_tool_content}}", last_tool_content)
465        .to_string()
466}
467
468// ── OpenAiCompatibleProvider ──────────────────────────────────────────
469
470#[derive(Debug, Clone)]
471pub struct OpenAiCompatibleProvider {
472    base_url: String,
473    model: String,
474    client: reqwest::Client,
475    api_key: Option<String>,
476}
477
478impl OpenAiCompatibleProvider {
479    pub fn new(
480        base_url: impl Into<String>,
481        model: impl Into<String>,
482        api_key: Option<String>,
483    ) -> anyhow::Result<Self> {
484        let base_url = base_url.into().trim_end_matches('/').to_string();
485        let model = model.into();
486        if base_url.trim().is_empty() {
487            bail!("openai-compatible provider unavailable: base_url is empty")
488        }
489        if model.trim().is_empty() {
490            bail!("openai-compatible provider unavailable: model is empty")
491        }
492        let client = reqwest::Client::builder()
493            .connect_timeout(Duration::from_secs(10))
494            .timeout(Duration::from_secs(300))
495            .build()
496            .context("openai-compatible provider unavailable: failed to build HTTP client")?;
497        Ok(Self {
498            base_url,
499            model,
500            client,
501            api_key,
502        })
503    }
504}
505
506#[async_trait]
507impl AiDENsProvider for OpenAiCompatibleProvider {
508    fn provider_kind(&self) -> &str {
509        "openai-compatible"
510    }
511    fn model(&self) -> Option<&str> {
512        Some(&self.model)
513    }
514    fn capabilities(&self) -> ProviderCapabilitiesV1 {
515        ProviderCapabilitiesV1 {
516            chat_completion: true,
517            native_tool_calling: true,
518            streaming: false,
519            structured_output: false,
520        }
521    }
522
523    async fn complete(
524        &self,
525        request: AiDENsCompletionRequestV1,
526    ) -> anyhow::Result<AiDENsCompletionResponseV1> {
527        let messages: Vec<serde_json::Value> = request
528            .messages
529            .iter()
530            .map(|m| serde_json::json!({"role": m.role, "content": m.content}))
531            .collect();
532        let mut body =
533            serde_json::json!({"model": self.model, "messages": messages, "stream": false});
534        if let Some(temp) = request.temperature {
535            body["temperature"] = serde_json::json!(temp);
536        }
537        let tools: Vec<serde_json::Value> = request.provider_tool_schemas.iter().map(|s| serde_json::json!({"type": "function", "function": {"name": s.name, "description": s.description, "parameters": s.input_schema}})).collect();
538        if !tools.is_empty() {
539            body["tools"] = serde_json::json!(tools);
540        }
541        let url = if self.base_url.ends_with("/v1") || self.base_url.contains("/v1/") {
542            format!("{}/chat/completions", self.base_url)
543        } else {
544            format!("{}/v1/chat/completions", self.base_url)
545        };
546        let mut req = self.client.post(&url).json(&body);
547        if let Some(ref key) = self.api_key {
548            req = req.header("Authorization", format!("Bearer {}", key));
549        }
550        let response = req
551            .send()
552            .await
553            .map_err(|e| anyhow!("openai-compatible provider unavailable: {}", e))?;
554        if !response.status().is_success() {
555            let s = response.status();
556            let b = response.text().await.unwrap_or_default();
557            bail!("openai-compatible provider returned {s}: {b}")
558        }
559        let json = response
560            .json::<serde_json::Value>()
561            .await
562            .context("openai-compatible provider: response JSON parse failed")?;
563        let choice = &json["choices"][0]["message"];
564        let text = choice["content"].as_str().unwrap_or("").to_string();
565        let name_to_tool_id: std::collections::HashMap<String, String> = request
566            .provider_tool_schemas
567            .iter()
568            .map(|s| (s.name.clone(), s.tool_id.clone()))
569            .collect();
570        let tool_calls: Vec<ToolCallRequestV1> = choice["tool_calls"]
571            .as_array()
572            .map(|calls| {
573                calls
574                    .iter()
575                    .filter_map(|call| {
576                        let name = call["function"]["name"].as_str()?;
577                        let args = call["function"]["arguments"].clone();
578                        let tid = name_to_tool_id
579                            .get(name)
580                            .cloned()
581                            .unwrap_or_else(|| name.to_string());
582                        Some(ToolCallRequestV1::new(
583                            ToolCallSourceV1::NativeProvider,
584                            tid,
585                            args,
586                            None,
587                            vec![],
588                        ))
589                    })
590                    .collect()
591            })
592            .unwrap_or_default();
593        let usage = &json["usage"];
594        Ok(AiDENsCompletionResponseV1 {
595            text,
596            provider_kind: self.provider_kind().into(),
597            model: Some(self.model.clone()),
598            prompt_eval_count: usage["prompt_tokens"].as_u64(),
599            eval_count: usage["completion_tokens"].as_u64(),
600            tool_calls,
601        })
602    }
603}
604
605pub struct UnavailableProvider {
606    kind: String,
607    model: Option<String>,
608    reason: String,
609}
610
611impl UnavailableProvider {
612    pub fn new(kind: impl Into<String>, model: Option<String>, reason: impl Into<String>) -> Self {
613        Self {
614            kind: kind.into(),
615            model,
616            reason: reason.into(),
617        }
618    }
619}
620
621#[async_trait]
622impl AiDENsProvider for UnavailableProvider {
623    fn provider_kind(&self) -> &str {
624        &self.kind
625    }
626
627    fn model(&self) -> Option<&str> {
628        self.model.as_deref()
629    }
630
631    fn capabilities(&self) -> ProviderCapabilitiesV1 {
632        ProviderCapabilitiesV1::default()
633    }
634
635    async fn complete(
636        &self,
637        _request: AiDENsCompletionRequestV1,
638    ) -> anyhow::Result<AiDENsCompletionResponseV1> {
639        bail!("provider unavailable: {}", self.reason)
640    }
641}
642
643pub fn build_provider(spec: ProviderSpecV1) -> anyhow::Result<Arc<dyn AiDENsProvider>> {
644    let normalized = normalized_provider_kind(&spec.kind);
645    let provider: Arc<dyn AiDENsProvider> = match normalized.as_str() {
646        "" | "disabled" | "none" => Arc::new(DisabledProvider::new(spec.model)),
647        "local" => Arc::new(UnavailableProvider::new(
648            "local",
649            spec.model,
650            "local-provider-boundary-unavailable: configure kind 'mock' for fixture replay or kind 'ollama' for a local service boundary",
651        )),
652        "mock" => Arc::new(MockProvider::new(
653            spec.mock_response.unwrap_or_default(),
654            spec.model,
655        )?),
656        "openai-compatible" | "opencode" => Arc::new(OpenAiCompatibleProvider::new(
657            spec.base_url.unwrap_or_else(|| "https://opencode.ai/zen/go/v1".into()),
658            spec.model.ok_or_else(|| anyhow!("openai-compatible provider unavailable: model is not configured"))?,
659            spec.api_key.clone(),
660        )?),
661        "ollama" => Arc::new(OllamaProvider::new(
662            spec.base_url
663                .unwrap_or_else(|| "http://localhost:11434".into()),
664            spec.model
665                .ok_or_else(|| anyhow!("ollama provider unavailable: model is not configured"))?,
666        )?),
667        other => Arc::new(UnavailableProvider::new(
668            other,
669            spec.model,
670            format!(
671                "provider-boundary-unavailable: provider kind '{other}' has no executable boundary in this build"
672            ),
673        )),
674    };
675    Ok(provider)
676}
677
678pub fn provider_backend_matrix() -> ProviderBackendMatrixV1 {
679    ProviderBackendMatrixV1::new(vec![
680        ProviderBackendMatrixEntryV1 {
681            provider_kind: "disabled".into(),
682            status: ProviderBackendStatusV1::Disabled,
683            route_label: ProviderRouteKindV1::Disabled.to_string(),
684            api_key_required: false,
685            chat_completion_executable: false,
686            native_tool_loop_executable: false,
687            streaming_executable: false,
688            structured_output_executable: false,
689            reason_codes: vec!["provider-disabled".into()],
690        },
691        ProviderBackendMatrixEntryV1 {
692            provider_kind: "local".into(),
693            status: ProviderBackendStatusV1::BoundaryUnavailable,
694            route_label: ProviderRouteKindV1::Unavailable.to_string(),
695            api_key_required: false,
696            chat_completion_executable: false,
697            native_tool_loop_executable: false,
698            streaming_executable: false,
699            structured_output_executable: false,
700            reason_codes: vec!["local-provider-boundary-unavailable".into()],
701        },
702        ProviderBackendMatrixEntryV1 {
703            provider_kind: "mock".into(),
704            status: ProviderBackendStatusV1::Executable,
705            route_label: ProviderRouteKindV1::Mock.to_string(),
706            api_key_required: false,
707            chat_completion_executable: true,
708            native_tool_loop_executable: false,
709            streaming_executable: false,
710            structured_output_executable: true,
711            reason_codes: vec!["mock-provider-executable".into()],
712        },
713        ProviderBackendMatrixEntryV1 {
714            provider_kind: "ollama".into(),
715            status: ProviderBackendStatusV1::Executable,
716            route_label: ProviderRouteKindV1::OllamaChat.to_string(),
717            api_key_required: false,
718            chat_completion_executable: true,
719            native_tool_loop_executable: true,
720            streaming_executable: false,
721            structured_output_executable: false,
722            reason_codes: vec![
723                "ollama-chat-boundary-implemented".into(),
724                "ollama-local-service-required".into(),
725                "ollama-native-tool-loop-via-function-calling".into(),
726            ],
727        },
728        ProviderBackendMatrixEntryV1 {
729            provider_kind: "openai-compatible".into(),
730            status: ProviderBackendStatusV1::Executable,
731            route_label: ProviderRouteKindV1::Unavailable.to_string(),
732            api_key_required: true,
733            chat_completion_executable: false,
734            native_tool_loop_executable: false,
735            streaming_executable: false,
736            structured_output_executable: false,
737            reason_codes: vec!["provider-boundary-unavailable".into()],
738        },
739        ProviderBackendMatrixEntryV1 {
740            provider_kind: "compatible".into(),
741            status: ProviderBackendStatusV1::BoundaryUnavailable,
742            route_label: ProviderRouteKindV1::Unavailable.to_string(),
743            api_key_required: true,
744            chat_completion_executable: false,
745            native_tool_loop_executable: false,
746            streaming_executable: false,
747            structured_output_executable: false,
748            reason_codes: vec!["provider-boundary-unavailable".into()],
749        },
750        ProviderBackendMatrixEntryV1 {
751            provider_kind: "openai".into(),
752            status: ProviderBackendStatusV1::BoundaryUnavailable,
753            route_label: ProviderRouteKindV1::Unavailable.to_string(),
754            api_key_required: true,
755            chat_completion_executable: false,
756            native_tool_loop_executable: false,
757            streaming_executable: false,
758            structured_output_executable: false,
759            reason_codes: vec!["provider-boundary-unavailable".into()],
760        },
761        ProviderBackendMatrixEntryV1 {
762            provider_kind: "openrouter".into(),
763            status: ProviderBackendStatusV1::BoundaryUnavailable,
764            route_label: ProviderRouteKindV1::Unavailable.to_string(),
765            api_key_required: true,
766            chat_completion_executable: false,
767            native_tool_loop_executable: false,
768            streaming_executable: false,
769            structured_output_executable: false,
770            reason_codes: vec!["provider-boundary-unavailable".into()],
771        },
772        ProviderBackendMatrixEntryV1 {
773            provider_kind: "anthropic".into(),
774            status: ProviderBackendStatusV1::BoundaryUnavailable,
775            route_label: ProviderRouteKindV1::Unavailable.to_string(),
776            api_key_required: true,
777            chat_completion_executable: false,
778            native_tool_loop_executable: false,
779            streaming_executable: false,
780            structured_output_executable: false,
781            reason_codes: vec!["provider-boundary-unavailable".into()],
782        },
783    ])
784}
785
786pub fn provider_certification_fixtures() -> Vec<ProviderCertificationFixtureV1> {
787    let fixture = |provider_kind: &str,
788                   scenario: &str,
789                   input_config: &[(&str, &str)],
790                   expected_configured: bool,
791                   expected_executable: bool,
792                   expected_route_label: &str,
793                   expected_native_tool_loop: bool,
794                   expected_reason_codes: &[&str]| {
795        ProviderCertificationFixtureV1::new(ProviderCertificationFixtureDraftV1 {
796            provider_kind: provider_kind.into(),
797            scenario: scenario.into(),
798            input_config: input_config
799                .iter()
800                .map(|(key, value)| ((*key).into(), (*value).into()))
801                .collect::<BTreeMap<_, _>>(),
802            expected_configured,
803            expected_executable,
804            expected_route_label: expected_route_label.into(),
805            expected_native_tool_loop,
806            expected_reason_codes: expected_reason_codes
807                .iter()
808                .map(|reason| (*reason).into())
809                .collect(),
810        })
811    };
812
813    vec![
814        fixture(
815            "disabled",
816            "provider-disabled",
817            &[("kind", "disabled")],
818            false,
819            false,
820            "disabled",
821            false,
822            &["provider-disabled"],
823        ),
824        fixture(
825            "mock",
826            "configured",
827            &[("kind", "mock"), ("mock_response", "ok")],
828            true,
829            true,
830            "mock",
831            false,
832            &["mock-response-configured"],
833        ),
834        fixture(
835            "mock",
836            "mock-response-missing",
837            &[("kind", "mock")],
838            true,
839            false,
840            "unavailable",
841            false,
842            &["mock-response-missing"],
843        ),
844        fixture(
845            "ollama",
846            "configured",
847            &[("kind", "ollama"), ("model", "llama3")],
848            true,
849            true,
850            "ollama-chat",
851            false,
852            &[
853                "ollama-chat-boundary-configured",
854                "ollama-local-service-required",
855                "ollama-native-tool-loop-unimplemented",
856            ],
857        ),
858        fixture(
859            "ollama",
860            "missing-model",
861            &[("kind", "ollama")],
862            false,
863            false,
864            "unavailable",
865            false,
866            &["ollama-model-missing"],
867        ),
868        fixture(
869            "ollama",
870            "network-failure",
871            &[("kind", "ollama"), ("model", "llama3")],
872            true,
873            true,
874            "ollama-chat",
875            false,
876            &[
877                "ollama-runtime-network-failure",
878                "ollama-local-service-required",
879                "ollama-native-tool-loop-unimplemented",
880            ],
881        ),
882        fixture(
883            "ollama",
884            "malformed-response",
885            &[("kind", "ollama"), ("model", "llama3")],
886            true,
887            true,
888            "ollama-chat",
889            false,
890            &[
891                "ollama-runtime-malformed-response",
892                "ollama-local-service-required",
893                "ollama-native-tool-loop-unimplemented",
894            ],
895        ),
896        fixture(
897            "ollama",
898            "tool-loop-unavailable",
899            &[("kind", "ollama"), ("model", "llama3")],
900            true,
901            true,
902            "ollama-chat",
903            false,
904            &[
905                "ollama-local-service-required",
906                "ollama-native-tool-loop-unimplemented",
907            ],
908        ),
909        fixture(
910            "openai-compatible",
911            "configured-boundary-unavailable",
912            &[
913                ("kind", "openai-compatible"),
914                ("model", "model"),
915                ("api_key", "configured"),
916            ],
917            true,
918            false,
919            "unavailable",
920            false,
921            &["provider-boundary-unavailable"],
922        ),
923        fixture(
924            "openai-compatible",
925            "missing-key",
926            &[("kind", "openai-compatible"), ("model", "model")],
927            true,
928            false,
929            "unavailable",
930            false,
931            &["api-key-missing"],
932        ),
933        fixture(
934            "openai",
935            "configured-boundary-unavailable",
936            &[
937                ("kind", "openai"),
938                ("model", "gpt-test"),
939                ("api_key", "configured"),
940            ],
941            true,
942            false,
943            "unavailable",
944            false,
945            &["provider-boundary-unavailable"],
946        ),
947        fixture(
948            "openai",
949            "missing-key",
950            &[("kind", "openai"), ("model", "gpt-test")],
951            true,
952            false,
953            "unavailable",
954            false,
955            &["api-key-missing"],
956        ),
957        fixture(
958            "openrouter",
959            "configured-boundary-unavailable",
960            &[
961                ("kind", "openrouter"),
962                ("model", "router-test"),
963                ("api_key", "configured"),
964            ],
965            true,
966            false,
967            "unavailable",
968            false,
969            &["provider-boundary-unavailable"],
970        ),
971        fixture(
972            "openrouter",
973            "missing-key",
974            &[("kind", "openrouter"), ("model", "router-test")],
975            true,
976            false,
977            "unavailable",
978            false,
979            &["api-key-missing"],
980        ),
981        fixture(
982            "anthropic",
983            "configured-boundary-unavailable",
984            &[
985                ("kind", "anthropic"),
986                ("model", "claude-test"),
987                ("api_key", "configured"),
988            ],
989            true,
990            false,
991            "unavailable",
992            false,
993            &["provider-boundary-unavailable"],
994        ),
995        fixture(
996            "anthropic",
997            "missing-key",
998            &[("kind", "anthropic"), ("model", "claude-test")],
999            true,
1000            false,
1001            "unavailable",
1002            false,
1003            &["api-key-missing"],
1004        ),
1005    ]
1006}
1007
1008pub fn provider_readiness(kind: &str, api_key: Option<&str>) -> ProviderReadinessV1 {
1009    let mut spec = ProviderSpecV1::new(kind);
1010    spec.api_key = api_key.map(str::to_string);
1011    provider_readiness_for_spec(&spec)
1012}
1013
1014pub fn provider_readiness_for_spec(spec: &ProviderSpecV1) -> ProviderReadinessV1 {
1015    let receipt = provider_readiness_receipt_for_spec(spec);
1016    ProviderReadinessV1 {
1017        configured: receipt.configured,
1018        executable: receipt.executable,
1019        native_tool_loop_executable: receipt.native_tool_loop_executable,
1020        route_label: receipt.route_label,
1021        reason_codes: receipt.reason_codes,
1022    }
1023}
1024
1025pub fn provider_readiness_receipt_for_spec(spec: &ProviderSpecV1) -> ProviderReadinessReportV1 {
1026    let normalized = normalized_provider_kind(&spec.kind);
1027    if matches!(normalized.as_str(), "" | "disabled" | "none") {
1028        return ProviderReadinessReportV1::new(ProviderReadinessReportDraftV1 {
1029            provider_kind: "disabled".into(),
1030            model: spec.model.clone(),
1031            configured: false,
1032            executable: false,
1033            native_tool_loop_executable: false,
1034            route_label: ProviderRouteKindV1::Disabled.to_string(),
1035            reason_codes: vec!["provider-disabled".into()],
1036        });
1037    }
1038
1039    if normalized == "mock" {
1040        let executable = spec
1041            .mock_response
1042            .as_deref()
1043            .is_some_and(|response| !response.trim().is_empty());
1044        return ProviderReadinessReportV1::new(ProviderReadinessReportDraftV1 {
1045            provider_kind: normalized,
1046            model: spec.model.clone(),
1047            configured: true,
1048            executable,
1049            native_tool_loop_executable: false,
1050            route_label: if executable {
1051                ProviderRouteKindV1::Mock.to_string()
1052            } else {
1053                ProviderRouteKindV1::Unavailable.to_string()
1054            },
1055            reason_codes: if executable {
1056                vec!["mock-response-configured".into()]
1057            } else {
1058                vec!["mock-response-missing".into()]
1059            },
1060        });
1061    }
1062
1063    if normalized == "local" {
1064        return ProviderReadinessReportV1::new(ProviderReadinessReportDraftV1 {
1065            provider_kind: normalized,
1066            model: spec.model.clone(),
1067            configured: true,
1068            executable: false,
1069            native_tool_loop_executable: false,
1070            route_label: ProviderRouteKindV1::Unavailable.to_string(),
1071            reason_codes: vec!["local-provider-boundary-unavailable".into()],
1072        });
1073    }
1074
1075    if normalized == "ollama" {
1076        let model_configured = spec
1077            .model
1078            .as_deref()
1079            .is_some_and(|model| !model.trim().is_empty());
1080        return ProviderReadinessReportV1::new(ProviderReadinessReportDraftV1 {
1081            provider_kind: normalized,
1082            model: spec.model.clone(),
1083            configured: model_configured,
1084            executable: model_configured,
1085            native_tool_loop_executable: true,
1086            route_label: if model_configured {
1087                ProviderRouteKindV1::OllamaChat.to_string()
1088            } else {
1089                ProviderRouteKindV1::Unavailable.to_string()
1090            },
1091            reason_codes: if model_configured {
1092                vec![
1093                    "ollama-chat-boundary-configured".into(),
1094                    "ollama-local-service-required".into(),
1095                    "ollama-native-tool-loop-via-function-calling".into(),
1096                ]
1097            } else {
1098                vec!["ollama-model-missing".into()]
1099            },
1100        });
1101    }
1102
1103    let missing_api_key = provider_requires_api_key(&normalized)
1104        && spec
1105            .api_key
1106            .as_deref()
1107            .map_or(true, |key| key.trim().is_empty());
1108    let known_boundary = provider_backend_matrix().entry_for(&normalized).is_some();
1109    ProviderReadinessReportV1::new(ProviderReadinessReportDraftV1 {
1110        provider_kind: normalized.clone(),
1111        model: spec.model.clone(),
1112        configured: !normalized.is_empty(),
1113        executable: false,
1114        native_tool_loop_executable: false,
1115        route_label: ProviderRouteKindV1::Unavailable.to_string(),
1116        reason_codes: if missing_api_key {
1117            vec!["api-key-missing".into()]
1118        } else if known_boundary {
1119            vec!["provider-boundary-unavailable".into()]
1120        } else {
1121            vec!["unsupported-provider-kind".into()]
1122        },
1123    })
1124}
1125
1126pub fn provider_requires_api_key(kind: &str) -> bool {
1127    matches!(
1128        normalized_provider_kind(kind).as_str(),
1129        "openai" | "openrouter" | "anthropic" | "openai-compatible" | "compatible"
1130    )
1131}
1132
1133fn normalized_provider_kind(kind: &str) -> String {
1134    kind.trim().to_ascii_lowercase()
1135}
1136
1137pub fn resolve_provider_route(kind: &str, caps: &ProviderCapabilitiesV1) -> ProviderRouteKindV1 {
1138    let normalized = normalized_provider_kind(kind);
1139    if matches!(normalized.as_str(), "" | "disabled" | "none") {
1140        return ProviderRouteKindV1::Disabled;
1141    }
1142    if normalized == "mock" {
1143        return if caps.chat_completion {
1144            ProviderRouteKindV1::Mock
1145        } else {
1146            ProviderRouteKindV1::Unavailable
1147        };
1148    }
1149    if matches!(normalized.as_str(), "unavailable" | "local") {
1150        return ProviderRouteKindV1::Unavailable;
1151    }
1152    if !caps.chat_completion {
1153        return ProviderRouteKindV1::Unavailable;
1154    }
1155    if normalized == "ollama" && !caps.native_tool_calling {
1156        return ProviderRouteKindV1::OllamaChat;
1157    }
1158    if !caps.native_tool_calling {
1159        return ProviderRouteKindV1::ParserFallback;
1160    }
1161    match normalized.as_str() {
1162        "openai" => ProviderRouteKindV1::NativeOpenAiResponses,
1163        "openrouter" => ProviderRouteKindV1::NativeOpenAiChat,
1164        "ollama" => ProviderRouteKindV1::NativeOllama,
1165        "anthropic" => ProviderRouteKindV1::NativeAnthropic,
1166        "openai-compatible" | "compatible" => ProviderRouteKindV1::OpenAiCompatible,
1167        _ => ProviderRouteKindV1::Degraded,
1168    }
1169}
1170
1171pub fn route_receipt(
1172    kind: &str,
1173    model: Option<String>,
1174    caps: &ProviderCapabilitiesV1,
1175) -> ProviderRouteReportV1 {
1176    let route = resolve_provider_route(kind, caps);
1177    ProviderRouteReportV1::new(kind, model, route, route_reason_codes(kind, caps, &route))
1178}
1179
1180pub fn route_receipt_v2_for_spec(spec: &ProviderSpecV1) -> ProviderRouteReportV2 {
1181    let readiness = provider_readiness_receipt_for_spec(spec);
1182    let normalized = normalized_provider_kind(&spec.kind);
1183    let route = if matches!(normalized.as_str(), "" | "disabled" | "none") {
1184        ProviderRouteKindV1::Disabled
1185    } else if !readiness.executable {
1186        ProviderRouteKindV1::Unavailable
1187    } else {
1188        match normalized.as_str() {
1189            "mock" => ProviderRouteKindV1::Mock,
1190            "ollama" => ProviderRouteKindV1::OllamaChat,
1191            _ if readiness.native_tool_loop_executable => resolve_provider_route(
1192                &normalized,
1193                &ProviderCapabilitiesV1::executable_by_backend(&normalized),
1194            ),
1195            _ => ProviderRouteKindV1::Unavailable,
1196        }
1197    };
1198    let degraded = matches!(
1199        route,
1200        ProviderRouteKindV1::ParserFallback
1201            | ProviderRouteKindV1::Unavailable
1202            | ProviderRouteKindV1::Degraded
1203    );
1204    let reason_codes = readiness.reason_codes.clone();
1205    let degraded_reason = degraded.then(|| reason_codes.join(", "));
1206    ProviderRouteReportV2::new(ProviderRouteReportDraftV2 {
1207        provider_kind: readiness.provider_kind,
1208        model: readiness.model,
1209        route,
1210        route_label: route.to_string(),
1211        chat_completion_executable: readiness.executable
1212            && matches!(
1213                route,
1214                ProviderRouteKindV1::Mock
1215                    | ProviderRouteKindV1::NativeOpenAiResponses
1216                    | ProviderRouteKindV1::NativeOpenAiChat
1217                    | ProviderRouteKindV1::NativeAnthropic
1218                    | ProviderRouteKindV1::NativeOllama
1219                    | ProviderRouteKindV1::OllamaChat
1220                    | ProviderRouteKindV1::OpenAiCompatible
1221                    | ProviderRouteKindV1::ParserFallback
1222            ),
1223        native_tool_loop: readiness.native_tool_loop_executable,
1224        degraded,
1225        degraded_reason,
1226        reason_codes,
1227    })
1228}
1229
1230pub fn route_receipt_for_spec(spec: &ProviderSpecV1) -> ProviderRouteReportV1 {
1231    route_receipt_v2_for_spec(spec).to_v1()
1232}
1233
1234fn route_reason_codes(
1235    kind: &str,
1236    caps: &ProviderCapabilitiesV1,
1237    route: &ProviderRouteKindV1,
1238) -> Vec<String> {
1239    match route {
1240        ProviderRouteKindV1::Mock => vec!["mock-provider-selected".into()],
1241        ProviderRouteKindV1::NativeOpenAiResponses
1242        | ProviderRouteKindV1::NativeOpenAiChat
1243        | ProviderRouteKindV1::NativeAnthropic
1244        | ProviderRouteKindV1::NativeOllama
1245        | ProviderRouteKindV1::OpenAiCompatible => vec!["native-tool-loop-selected".into()],
1246        ProviderRouteKindV1::OllamaChat => vec![
1247            "ollama-chat-boundary-configured".into(),
1248            "ollama-local-service-required".into(),
1249            "ollama-native-tool-loop-unimplemented".into(),
1250        ],
1251        ProviderRouteKindV1::ParserFallback => vec![
1252            "native-tool-calling-unavailable".into(),
1253            "parser-fallback-selected".into(),
1254        ],
1255        ProviderRouteKindV1::Disabled => vec!["provider-disabled".into()],
1256        ProviderRouteKindV1::Unavailable => {
1257            if caps.chat_completion {
1258                vec!["provider-unavailable".into()]
1259            } else {
1260                vec!["provider-boundary-unavailable".into()]
1261            }
1262        }
1263        ProviderRouteKindV1::Degraded => {
1264            if caps.native_tool_calling {
1265                vec![format!(
1266                    "unknown-native-provider:{}",
1267                    kind.trim().to_ascii_lowercase()
1268                )]
1269            } else {
1270                vec!["provider-degraded".into()]
1271            }
1272        }
1273    }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279
1280    #[test]
1281    fn openrouter_is_unavailable_without_executable_boundary() {
1282        let mut spec = ProviderSpecV1::new("openrouter");
1283        spec.model = Some("router-test".into());
1284        spec.api_key = Some("configured".into());
1285
1286        let route = route_receipt_v2_for_spec(&spec);
1287
1288        assert_eq!(route.route, ProviderRouteKindV1::Unavailable);
1289        assert_ne!(route.route, ProviderRouteKindV1::NativeOllama);
1290        assert_ne!(route.route, ProviderRouteKindV1::NativeOpenAiChat);
1291        assert!(!route.native_tool_loop);
1292    }
1293
1294    #[test]
1295    fn no_executable_chat_boundary_is_unavailable() {
1296        let route = resolve_provider_route("openai", &ProviderCapabilitiesV1::default());
1297        assert_eq!(route, ProviderRouteKindV1::Unavailable);
1298    }
1299
1300    #[test]
1301    fn mock_provider_has_explicit_route() {
1302        let receipt = route_receipt(
1303            "mock",
1304            Some("model".into()),
1305            &ProviderCapabilitiesV1::executable_by_backend("mock"),
1306        );
1307        assert_eq!(receipt.route, ProviderRouteKindV1::Mock);
1308        assert_eq!(receipt.route_label, "mock");
1309        assert!(!receipt.native_tool_loop);
1310        assert!(!receipt.degraded);
1311    }
1312
1313    #[tokio::test]
1314    async fn local_provider_is_unavailable_not_mock_alias() {
1315        let mut spec = ProviderSpecV1::new("local");
1316        spec.mock_response = Some("fixture".into());
1317
1318        let readiness = provider_readiness_for_spec(&spec);
1319        let route = route_receipt_v2_for_spec(&spec);
1320        let provider = build_provider(spec).unwrap();
1321
1322        assert!(!readiness.executable);
1323        assert!(readiness
1324            .reason_codes
1325            .contains(&"local-provider-boundary-unavailable".into()));
1326        assert_eq!(route.route, ProviderRouteKindV1::Unavailable);
1327        assert_eq!(provider.provider_kind(), "local");
1328        let error = provider
1329            .complete(AiDENsCompletionRequestV1::single_user("hello"))
1330            .await
1331            .unwrap_err();
1332        assert!(error
1333            .to_string()
1334            .contains("local-provider-boundary-unavailable"));
1335    }
1336
1337    #[test]
1338    fn unknown_provider_is_unavailable_not_native_compatible() {
1339        let receipt = route_receipt(
1340            "mystery-ai",
1341            Some("model".into()),
1342            &ProviderCapabilitiesV1::default(),
1343        );
1344        assert_eq!(receipt.route, ProviderRouteKindV1::Unavailable);
1345        assert_eq!(receipt.route_label, "unavailable");
1346        assert!(!receipt.native_tool_loop);
1347        assert!(receipt.degraded);
1348        assert!(receipt
1349            .reason_codes
1350            .contains(&"provider-boundary-unavailable".into()));
1351    }
1352
1353    #[test]
1354    fn parser_fallback_is_degraded_and_receipt_bearing_when_chat_is_executable() {
1355        let receipt = route_receipt(
1356            "openai",
1357            None,
1358            &ProviderCapabilitiesV1 {
1359                chat_completion: true,
1360                native_tool_calling: false,
1361                streaming: false,
1362                structured_output: false,
1363            },
1364        );
1365        assert_eq!(receipt.route, ProviderRouteKindV1::ParserFallback);
1366        assert_eq!(receipt.route_label, "parser-fallback");
1367        assert!(!receipt.native_tool_loop);
1368        assert!(receipt.degraded);
1369        assert_eq!(
1370            receipt.degraded_reason.as_deref(),
1371            Some("native-tool-calling-unavailable, parser-fallback-selected")
1372        );
1373    }
1374
1375    #[test]
1376    fn disabled_provider_is_absent_not_fallback() {
1377        let route = resolve_provider_route("disabled", &ProviderCapabilitiesV1::default());
1378        assert_eq!(route, ProviderRouteKindV1::Disabled);
1379    }
1380
1381    #[test]
1382    fn executable_provider_capabilities_do_not_promote_cloud_or_native_tools() {
1383        let executable = ProviderCapabilitiesV1::executable_by_backend(" openai ");
1384
1385        assert!(!executable.native_tool_calling);
1386        assert!(!executable.chat_completion);
1387
1388        for kind in [
1389            "openai",
1390            "openrouter",
1391            "anthropic",
1392            "openai-compatible",
1393            "compatible",
1394        ] {
1395            let caps = ProviderCapabilitiesV1::executable_by_backend(kind);
1396            assert!(!caps.chat_completion, "{kind}");
1397            assert!(!caps.native_tool_calling, "{kind}");
1398            assert!(!caps.streaming, "{kind}");
1399            assert!(!caps.structured_output, "{kind}");
1400        }
1401    }
1402
1403    #[test]
1404    fn api_provider_without_key_is_configured_but_not_executable() {
1405        let readiness = provider_readiness("openai", None);
1406
1407        assert!(readiness.configured);
1408        assert!(!readiness.executable);
1409        assert!(!readiness.native_tool_loop_executable);
1410        assert!(readiness.reason_codes.contains(&"api-key-missing".into()));
1411    }
1412
1413    #[test]
1414    fn api_provider_with_key_is_boundary_unavailable_without_native_loop() {
1415        let mut spec = ProviderSpecV1::new("openrouter");
1416        spec.model = Some("test".into());
1417        spec.api_key = Some("configured".into());
1418
1419        let route = route_receipt_v2_for_spec(&spec);
1420
1421        assert_eq!(route.route, ProviderRouteKindV1::Unavailable);
1422        assert_eq!(route.route_label, "unavailable");
1423        assert!(!route.chat_completion_executable);
1424        assert!(!route.native_tool_loop);
1425        assert!(route
1426            .reason_codes
1427            .contains(&"provider-boundary-unavailable".into()));
1428    }
1429
1430    #[test]
1431    fn ollama_chat_route_supports_native_tool_loop() {
1432        let mut spec = ProviderSpecV1::new("ollama");
1433        spec.model = Some("llama3".into());
1434
1435        let readiness = provider_readiness_for_spec(&spec);
1436        let route = route_receipt_v2_for_spec(&spec);
1437
1438        assert!(readiness.executable);
1439        assert!(readiness.native_tool_loop_executable);
1440        assert_eq!(route.route, ProviderRouteKindV1::OllamaChat);
1441        assert_eq!(route.route_label, "ollama-chat");
1442        assert!(route.chat_completion_executable);
1443        assert!(route.native_tool_loop);
1444        assert!(route
1445            .reason_codes
1446            .contains(&"ollama-native-tool-loop-via-function-calling".into()));
1447        assert!(route
1448            .reason_codes
1449            .contains(&"ollama-local-service-required".into()));
1450    }
1451
1452    #[tokio::test]
1453    async fn ollama_chat_boundary_executes_against_http_fixture_without_native_tools() {
1454        use std::io::{Read, Write};
1455
1456        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1457        let base_url = format!("http://{}", listener.local_addr().unwrap());
1458        let server = std::thread::spawn(move || {
1459            let (mut stream, _) = listener.accept().unwrap();
1460            let mut request = [0_u8; 4096];
1461            let bytes = stream.read(&mut request).unwrap();
1462            let request = String::from_utf8_lossy(&request[..bytes]);
1463            assert!(request.starts_with("POST /api/chat "));
1464            let body = r#"{"message":{"content":"ollama fixture response"},"prompt_eval_count":3,"eval_count":5}"#;
1465            write!(
1466                stream,
1467                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1468                body.len(),
1469                body
1470            )
1471            .unwrap();
1472        });
1473
1474        let provider = OllamaProvider::new(base_url, "llama3").unwrap();
1475        let response = provider
1476            .complete(AiDENsCompletionRequestV1::single_user("hello"))
1477            .await
1478            .unwrap();
1479
1480        assert_eq!(response.text, "ollama fixture response");
1481        assert_eq!(response.provider_kind, "ollama");
1482        assert_eq!(response.prompt_eval_count, Some(3));
1483        assert_eq!(response.eval_count, Some(5));
1484        assert!(response.tool_calls.is_empty());
1485        assert!(provider.capabilities().native_tool_calling);
1486        server.join().unwrap();
1487    }
1488
1489    #[test]
1490    fn provider_backend_matrix_lists_p02_backends() {
1491        let matrix = provider_backend_matrix();
1492        for provider in [
1493            "disabled",
1494            "mock",
1495            "ollama",
1496            "openai-compatible",
1497            "compatible",
1498            "openai",
1499            "openrouter",
1500            "anthropic",
1501        ] {
1502            assert!(matrix.entry_for(provider).is_some(), "{provider}");
1503        }
1504        let ollama = matrix.entry_for("ollama").unwrap();
1505        assert!(ollama.chat_completion_executable);
1506        assert!(ollama.native_tool_loop_ready());
1507        assert!(ollama
1508            .reason_codes
1509            .contains(&"ollama-local-service-required".into()));
1510        for provider in ["compatible", "openai", "openrouter", "anthropic"] {
1511            let entry = matrix.entry_for(provider).unwrap();
1512            assert_eq!(entry.status, ProviderBackendStatusV1::BoundaryUnavailable);
1513            assert!(!entry.chat_completion_executable);
1514            assert!(!entry.native_tool_loop_executable);
1515            assert!(!entry.streaming_executable);
1516            assert!(!entry.structured_output_executable);
1517        }
1518        let openai_compatible = matrix.entry_for("openai-compatible").unwrap();
1519        assert_eq!(
1520            openai_compatible.status,
1521            ProviderBackendStatusV1::Executable
1522        );
1523        assert!(!openai_compatible.chat_completion_executable);
1524        assert!(!openai_compatible.native_tool_loop_executable);
1525        assert!(!openai_compatible.streaming_executable);
1526        assert!(!openai_compatible.structured_output_executable);
1527    }
1528
1529    #[test]
1530    fn p20_provider_capability_matrix_matches_executable_truth() {
1531        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1532            .ancestors()
1533            .nth(2)
1534            .unwrap();
1535        let fixture: serde_json::Value = serde_json::from_str(
1536            &std::fs::read_to_string(root.join("fixtures/provider_capability_expected_v0_1.json"))
1537                .unwrap(),
1538        )
1539        .unwrap();
1540        let matrix = provider_backend_matrix();
1541
1542        assert_eq!(fixture.as_object().unwrap().len() + 1, matrix.entries.len());
1543        let disabled = matrix.entry_for("disabled").unwrap();
1544        assert_eq!(disabled.status, ProviderBackendStatusV1::Disabled);
1545        assert!(!disabled.chat_completion_executable);
1546        assert!(!disabled.native_tool_loop_executable);
1547
1548        for entry in matrix
1549            .entries
1550            .iter()
1551            .filter(|entry| entry.provider_kind != "disabled")
1552        {
1553            let expected = fixture
1554                .get(&entry.provider_kind)
1555                .unwrap_or_else(|| panic!("missing fixture row for {}", entry.provider_kind));
1556            assert_eq!(
1557                expected["chat_completion"], entry.chat_completion_executable,
1558                "{}",
1559                entry.provider_kind
1560            );
1561            assert_eq!(
1562                expected["native_tool_calling"], entry.native_tool_loop_executable,
1563                "{}",
1564                entry.provider_kind
1565            );
1566            assert_eq!(expected["cloud_support"], false, "{}", entry.provider_kind);
1567            assert!(!entry.streaming_executable, "{}", entry.provider_kind);
1568        }
1569
1570        let mock = fixture.get("mock").unwrap();
1571        assert_eq!(mock["status"], "fixture-supported-not-cloud");
1572        assert_eq!(mock["requires_test"], true);
1573
1574        for provider in ["compatible", "openai", "openrouter", "anthropic"] {
1575            let expected = fixture.get(provider).unwrap();
1576            let actual = matrix.entry_for(provider).unwrap();
1577            assert_eq!(
1578                expected["status"],
1579                "unavailable_unless_implemented_and_tested"
1580            );
1581            assert_eq!(actual.status, ProviderBackendStatusV1::BoundaryUnavailable);
1582            assert!(!actual.chat_completion_executable);
1583            assert!(!actual.native_tool_loop_executable);
1584        }
1585        let openai_compatible = matrix.entry_for("openai-compatible").unwrap();
1586        assert_eq!(
1587            openai_compatible.status,
1588            ProviderBackendStatusV1::Executable
1589        );
1590        assert!(!openai_compatible.chat_completion_executable);
1591        assert!(!openai_compatible.native_tool_loop_executable);
1592    }
1593
1594    #[test]
1595    fn p20_provider_fixture_does_not_overclaim_native_or_cloud_support() {
1596        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1597            .ancestors()
1598            .nth(2)
1599            .unwrap();
1600        let fixture: serde_json::Value = serde_json::from_str(
1601            &std::fs::read_to_string(root.join("fixtures/provider_capability_expected_v0_1.json"))
1602                .unwrap(),
1603        )
1604        .unwrap();
1605        let matrix = provider_backend_matrix();
1606
1607        for provider in [
1608            "mock",
1609            "ollama",
1610            "compatible",
1611            "openai-compatible",
1612            "openai",
1613            "openrouter",
1614            "anthropic",
1615        ] {
1616            let expected = fixture
1617                .get(provider)
1618                .unwrap_or_else(|| panic!("missing fixture row for {provider}"));
1619            let actual = matrix
1620                .entry_for(provider)
1621                .unwrap_or_else(|| panic!("missing matrix row for {provider}"));
1622            assert_eq!(
1623                expected["native_tool_calling"],
1624                actual.native_tool_loop_executable
1625            );
1626            assert_eq!(
1627                expected["chat_completion"],
1628                actual.chat_completion_executable
1629            );
1630            assert_eq!(expected["cloud_support"], false);
1631        }
1632
1633        assert_eq!(
1634            fixture["mock"]["status"],
1635            serde_json::json!("fixture-supported-not-cloud")
1636        );
1637        for provider in ["compatible", "openai", "openrouter", "anthropic"] {
1638            assert_eq!(
1639                fixture[provider]["status"],
1640                serde_json::json!("unavailable_unless_implemented_and_tested")
1641            );
1642            assert_eq!(
1643                matrix
1644                    .entry_for(provider)
1645                    .unwrap_or_else(|| panic!("missing matrix row for {provider}"))
1646                    .status,
1647                ProviderBackendStatusV1::BoundaryUnavailable
1648            );
1649        }
1650        assert_eq!(
1651            matrix
1652                .entry_for("openai-compatible")
1653                .expect("missing matrix row for openai-compatible")
1654                .status,
1655            ProviderBackendStatusV1::Executable
1656        );
1657    }
1658
1659    #[test]
1660    fn provider_certification_fixtures_cover_p02_matrix_and_scenarios() {
1661        let fixtures = provider_certification_fixtures();
1662        for provider in [
1663            "disabled",
1664            "mock",
1665            "ollama",
1666            "openai-compatible",
1667            "openai",
1668            "openrouter",
1669            "anthropic",
1670        ] {
1671            assert!(
1672                fixtures
1673                    .iter()
1674                    .any(|fixture| fixture.provider_kind == provider),
1675                "{provider}"
1676            );
1677        }
1678        for scenario in [
1679            "configured",
1680            "missing-key",
1681            "missing-model",
1682            "network-failure",
1683            "malformed-response",
1684            "tool-loop-unavailable",
1685        ] {
1686            assert!(
1687                fixtures.iter().any(|fixture| fixture.scenario == scenario),
1688                "{scenario}"
1689            );
1690        }
1691        assert!(fixtures
1692            .iter()
1693            .all(|fixture| !fixture.expected_native_tool_loop));
1694    }
1695
1696    #[test]
1697    fn provider_readiness_matches_reference_interpreter() {
1698        for provider_kind in aidens_testkit::all_provider_kinds() {
1699            let case = aidens_testkit::reference_provider_case(&provider_kind);
1700            let mut spec = ProviderSpecV1::new(provider_kind);
1701            if case
1702                .input
1703                .get("api_key_configured")
1704                .and_then(serde_json::Value::as_bool)
1705                .unwrap_or(false)
1706            {
1707                spec.api_key = Some("configured".into());
1708            }
1709            if case
1710                .input
1711                .get("model_configured")
1712                .and_then(serde_json::Value::as_bool)
1713                .unwrap_or(false)
1714            {
1715                spec.model = Some("model".into());
1716            }
1717            if case
1718                .input
1719                .get("mock_response_configured")
1720                .and_then(serde_json::Value::as_bool)
1721                .unwrap_or(false)
1722            {
1723                spec.mock_response = Some("ok".into());
1724            }
1725            let readiness = provider_readiness_for_spec(&spec);
1726            let actual = serde_json::json!({
1727                "configured": readiness.configured,
1728                "executable": readiness.executable,
1729                "route_label": readiness.route_label,
1730                "native_tool_loop": readiness.native_tool_loop_executable,
1731                "reason_codes": readiness.reason_codes
1732            });
1733            let report = aidens_testkit::compare_case_to_actual(
1734                &case,
1735                "aidens-provider-kit::provider_readiness_for_spec",
1736                actual,
1737            );
1738
1739            assert!(
1740                report.passed,
1741                "{}",
1742                report
1743                    .findings
1744                    .iter()
1745                    .map(|finding| finding.human_diff.as_str())
1746                    .collect::<Vec<_>>()
1747                    .join("\n")
1748            );
1749        }
1750    }
1751
1752    #[tokio::test]
1753    async fn disabled_provider_does_not_answer() {
1754        let provider = DisabledProvider::default();
1755        let error = provider
1756            .complete(AiDENsCompletionRequestV1::single_user("hello"))
1757            .await
1758            .unwrap_err();
1759        assert!(error.to_string().contains("disabled"));
1760    }
1761
1762    #[tokio::test]
1763    async fn mock_provider_returns_only_configured_text() {
1764        let provider = MockProvider::new("configured", Some("mock-model".into())).unwrap();
1765        let response = provider
1766            .complete(AiDENsCompletionRequestV1::single_user("hello"))
1767            .await
1768            .unwrap();
1769        assert_eq!(response.text, "configured");
1770    }
1771}