feat: catalogue dynamique modèles Codex/Claude avec compatibilité CLI locale

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
This commit is contained in:
2026-07-26 16:09:10 +02:00
parent e7bf1d3666
commit ca70ec75f4
25 changed files with 1582 additions and 167 deletions

View File

@ -26,6 +26,7 @@ pub mod input;
pub mod inspector;
pub mod issues;
pub mod mailbox;
pub mod model_catalogue;
pub mod model_server;
pub mod orchestrator;
pub mod pair_attempt_limiter;
@ -68,6 +69,9 @@ pub use inspector::{
};
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use mailbox::InMemoryMailbox;
pub use model_catalogue::{
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
};
pub use model_server::{
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
LocalManagedProcess,

View File

@ -0,0 +1,331 @@
//! 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"));
}
}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"claude": {
"claude-sonnet-5": "1.0.0",
"claude-opus-4-8": "1.0.0",
"claude-haiku-4-5-20251001": "1.0.0"
},
"codex": {
"gpt-5-codex": "0.1.0",
"gpt-5": "0.1.0",
"gpt-5-mini": "0.1.0"
}
}