//! Concrete adapters for structured model-catalogue enrichment. use std::collections::BTreeSet; use std::env; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use domain::model_catalogue::{CliVersion, CompatibilityMatrix}; use domain::ports::{ CliVersionReader, CompatibilityMatrixSource, ProcessSpawner, ProviderModelCatalogue, SpawnSpec, }; use domain::profile::StructuredAdapter; use domain::project::ProjectPath; use serde::Deserialize; const VERSION_TIMEOUT: Duration = Duration::from_millis(800); const PROVIDER_TIMEOUT: Duration = Duration::from_millis(1_500); const EMBEDDED_MATRIX: &str = include_str!("model_compatibility_matrix.json"); /// Reads local CLI versions through `codex --version` / `claude --version`. #[derive(Clone)] pub struct ProcessCliVersionReader { spawner: Arc, } impl ProcessCliVersionReader { /// Builds the adapter from the process-spawner port. #[must_use] pub fn new(spawner: Arc) -> Self { Self { spawner } } fn spec(adapter: StructuredAdapter) -> Option { let command = match adapter { StructuredAdapter::Claude => "claude", StructuredAdapter::Codex => "codex", StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => return None, }; Some(SpawnSpec { command: command.to_owned(), args: vec!["--version".to_owned()], cwd: ProjectPath::new("/").expect("root project path is valid"), env: Vec::new(), context_plan: None, sandbox: None, }) } } #[async_trait] impl CliVersionReader for ProcessCliVersionReader { async fn read_cli_version( &self, adapter: StructuredAdapter, ) -> Result, String> { let Some(spec) = Self::spec(adapter) else { return Ok(None); }; let command = spec.command.clone(); let output = tokio::time::timeout(VERSION_TIMEOUT, self.spawner.run(spec)) .await .map_err(|_| format!("{command} --version timed out"))? .map_err(|e| format!("{command} --version failed: {e}"))?; if output.status.code != Some(0) { return Err(format!( "{command} --version exited with {:?}", output.status.code )); } let text = String::from_utf8_lossy(&output.stdout) .trim() .to_owned() .if_empty_then(|| String::from_utf8_lossy(&output.stderr).trim().to_owned()); if text.is_empty() { return Ok(None); } CliVersion::parse(text) .map(Some) .map_err(|e| format!("{command} --version was not parseable: {e}")) } } trait EmptyStringExt { fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String; } impl EmptyStringExt for String { fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String { if self.is_empty() { fallback() } else { self } } } /// Provider HTTP catalogue using existing API keys from the process environment. #[derive(Clone)] pub struct HttpProviderModelCatalogue { client: reqwest::Client, } impl HttpProviderModelCatalogue { /// Builds the adapter. #[must_use] pub fn new() -> Self { Self { client: reqwest::Client::new(), } } async fn list_openai(&self, key: String) -> Result, String> { #[derive(Deserialize)] struct Response { data: Vec, } #[derive(Deserialize)] struct Model { id: String, } let response = tokio::time::timeout( PROVIDER_TIMEOUT, self.client .get("https://api.openai.com/v1/models") .bearer_auth(key) .send(), ) .await .map_err(|_| "OpenAI model catalogue timed out".to_owned())? .map_err(|e| format!("OpenAI model catalogue unavailable: {e}"))?; if !response.status().is_success() { return Err(format!( "OpenAI model catalogue returned HTTP {}", response.status() )); } let parsed = response .json::() .await .map_err(|e| format!("OpenAI model catalogue parse failed: {e}"))?; Ok(dedup_non_empty( parsed.data.into_iter().map(|model| model.id), )) } async fn list_anthropic(&self, key: String) -> Result, String> { #[derive(Deserialize)] struct Response { data: Vec, } #[derive(Deserialize)] struct Model { id: String, } let response = tokio::time::timeout( PROVIDER_TIMEOUT, self.client .get("https://api.anthropic.com/v1/models") .header("x-api-key", key) .header("anthropic-version", "2023-06-01") .send(), ) .await .map_err(|_| "Anthropic model catalogue timed out".to_owned())? .map_err(|e| format!("Anthropic model catalogue unavailable: {e}"))?; if !response.status().is_success() { return Err(format!( "Anthropic model catalogue returned HTTP {}", response.status() )); } let parsed = response .json::() .await .map_err(|e| format!("Anthropic model catalogue parse failed: {e}"))?; Ok(dedup_non_empty( parsed.data.into_iter().map(|model| model.id), )) } } impl Default for HttpProviderModelCatalogue { fn default() -> Self { Self::new() } } #[async_trait] impl ProviderModelCatalogue for HttpProviderModelCatalogue { async fn list_provider_models( &self, adapter: StructuredAdapter, ) -> Result, String> { match adapter { StructuredAdapter::Codex => match env::var("OPENAI_API_KEY") { Ok(key) if !key.trim().is_empty() => self.list_openai(key).await, _ => Ok(Vec::new()), }, StructuredAdapter::Claude => match env::var("ANTHROPIC_API_KEY") { Ok(key) if !key.trim().is_empty() => self.list_anthropic(key).await, _ => Ok(Vec::new()), }, StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Ok(Vec::new()), } } } fn dedup_non_empty(values: impl Iterator) -> Vec { values .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) .collect::>() .into_iter() .collect() } /// Compatibility matrix source backed by an embedded JSON seed and optional /// app-data override. #[derive(Debug, Clone)] pub struct EmbeddedCompatibilityMatrix { override_path: Option, } impl EmbeddedCompatibilityMatrix { /// Builds a matrix source with no override. #[must_use] pub const fn new() -> Self { Self { override_path: None, } } /// Builds a matrix source reading `model-compat.json` from the app data dir /// before falling back to the embedded seed. #[must_use] pub fn with_app_data_dir(app_data_dir: impl Into) -> Self { Self { override_path: Some(app_data_dir.into().join("model-compat.json")), } } fn embedded() -> CompatibilityMatrix { serde_json::from_str(EMBEDDED_MATRIX).expect("embedded compatibility matrix is valid") } } impl Default for EmbeddedCompatibilityMatrix { fn default() -> Self { Self::new() } } impl CompatibilityMatrixSource for EmbeddedCompatibilityMatrix { fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec) { let Some(path) = &self.override_path else { return (Self::embedded(), Vec::new()); }; match std::fs::read_to_string(path) { Ok(raw) => match serde_json::from_str::(&raw) { Ok(matrix) => (matrix, Vec::new()), Err(e) => ( Self::embedded(), vec![format!( "model compatibility override ignored because it is invalid: {e}" )], ), }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Self::embedded(), Vec::new()), Err(e) => ( Self::embedded(), vec![format!( "model compatibility override ignored because it is unreadable: {e}" )], ), } } } #[cfg(test)] mod tests { use super::*; use domain::ports::{ExitStatus, Output, ProcessError}; use std::sync::Mutex; struct RecordingSpawner { specs: Mutex>, } #[async_trait] impl ProcessSpawner for RecordingSpawner { async fn run(&self, spec: SpawnSpec) -> Result { self.specs.lock().unwrap().push(spec); Ok(Output { status: ExitStatus { code: Some(0) }, stdout: b"codex-cli 0.45.1\n".to_vec(), stderr: Vec::new(), }) } } #[tokio::test] async fn cli_version_reader_runs_only_version_probe() { let spawner = Arc::new(RecordingSpawner { specs: Mutex::new(Vec::new()), }); let reader = ProcessCliVersionReader::new(spawner.clone()); let version = reader .read_cli_version(StructuredAdapter::Codex) .await .unwrap() .unwrap(); assert_eq!(version.raw, "codex-cli 0.45.1"); let specs = spawner.specs.lock().unwrap(); assert_eq!(specs.len(), 1); assert_eq!(specs[0].command, "codex"); assert_eq!(specs[0].args, vec!["--version"]); } #[test] fn embedded_matrix_is_valid() { let (matrix, warnings) = EmbeddedCompatibilityMatrix::new().compatibility_matrix(); assert!(warnings.is_empty()); assert!(matrix.codex.contains_key("gpt-5-codex")); assert!(matrix.claude.contains_key("claude-sonnet-5")); } }