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:
@ -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,
|
||||
|
||||
331
crates/infrastructure/src/model_catalogue.rs
Normal file
331
crates/infrastructure/src/model_catalogue.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
13
crates/infrastructure/src/model_compatibility_matrix.json
Normal file
13
crates/infrastructure/src/model_compatibility_matrix.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user