1use 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 #[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 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 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 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 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 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 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 text.is_empty() && tool_calls.is_empty() {
438 }
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#[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}