Ajout du catalogue enrichi pour les modèles Codex et Claude avec: - Compatibilité estimée avec la version CLI locale détectée - Source d'origine (catalogue/Provider) pour chaque entrée - Support du catalogue Provider API externe - Matrice de compatibilité embarquée dans l'application Frontend: - UI de configuration des modèles avec affichage des états de compatibilité - Suggestions dynamiques avec badges de compatibilité - Messages d'aide contextuels (compatible/unknown/likelyTooRecent) - Alertes non-bloquantes pour les modèles trop récents - Gestion des échecs de catalogue avec saisie manuelle conservée Backend: - Ports CliVersionReader, ProviderModelCatalogue, CompatibilityMatrixSource - Implémentations: ProcessCliVersionReader, HttpProviderModelCatalogue, EmbeddedCompatibilityMatrix - Enrichissement des DTOs avec compatibility, cli_version, warnings - Tests unitaires complets pour le resolver de catalogue
332 lines
10 KiB
Rust
332 lines
10 KiB
Rust
//! 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<dyn ProcessSpawner>,
|
|
}
|
|
|
|
impl ProcessCliVersionReader {
|
|
/// Builds the adapter from the process-spawner port.
|
|
#[must_use]
|
|
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
|
|
Self { spawner }
|
|
}
|
|
|
|
fn spec(adapter: StructuredAdapter) -> Option<SpawnSpec> {
|
|
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<Option<CliVersion>, 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<Vec<String>, String> {
|
|
#[derive(Deserialize)]
|
|
struct Response {
|
|
data: Vec<Model>,
|
|
}
|
|
#[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::<Response>()
|
|
.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<Vec<String>, String> {
|
|
#[derive(Deserialize)]
|
|
struct Response {
|
|
data: Vec<Model>,
|
|
}
|
|
#[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::<Response>()
|
|
.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<Vec<String>, 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<Item = String>) -> Vec<String> {
|
|
values
|
|
.map(|value| value.trim().to_owned())
|
|
.filter(|value| !value.is_empty())
|
|
.collect::<BTreeSet<_>>()
|
|
.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<PathBuf>,
|
|
}
|
|
|
|
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<PathBuf>) -> 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<String>) {
|
|
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::<CompatibilityMatrix>(&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<Vec<SpawnSpec>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProcessSpawner for RecordingSpawner {
|
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
|
|
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"));
|
|
}
|
|
}
|