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:
@ -1,10 +1,17 @@
|
||||
//! Static curated model catalogues for structured Claude/Codex profiles.
|
||||
//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
|
||||
//!
|
||||
//! The CLIs do not expose a stable machine-readable model catalogue. These lists
|
||||
//! are therefore intentionally small, static and infallible; the UI must still
|
||||
//! keep manual entry as a fallback for models not listed here.
|
||||
//! The CLIs do not expose a stable machine-readable model catalogue and must not
|
||||
//! be asked to enumerate models. The only local probe allowed here is
|
||||
//! `<cli> --version`; provider APIs are optional and failures degrade to the
|
||||
//! static seed plus warnings.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::model_catalogue::{evaluate_compatibility, CliVersion};
|
||||
use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue};
|
||||
use domain::profile::StructuredAdapter;
|
||||
use domain::{ModelCatalogSource, ModelCompatibility};
|
||||
|
||||
/// One searchable model entry for a structured profile adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -19,6 +26,10 @@ pub struct ProfileModelCatalogEntry {
|
||||
pub aliases: Vec<String>,
|
||||
/// Whether this entry is the conservative default suggestion.
|
||||
pub recommended: bool,
|
||||
/// Compatibility state against the locally detected CLI version.
|
||||
pub compatibility: ModelCompatibility,
|
||||
/// Source that contributed the model entry.
|
||||
pub source: ModelCatalogSource,
|
||||
}
|
||||
|
||||
fn entry(
|
||||
@ -34,6 +45,20 @@ fn entry(
|
||||
display_name: display_name.to_owned(),
|
||||
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
|
||||
recommended,
|
||||
compatibility: ModelCompatibility::Unknown,
|
||||
source: ModelCatalogSource::Catalogue,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry {
|
||||
ProfileModelCatalogEntry {
|
||||
adapter,
|
||||
display_name: model_id.clone(),
|
||||
model_id,
|
||||
aliases: Vec::new(),
|
||||
recommended: false,
|
||||
compatibility: ModelCompatibility::Unknown,
|
||||
source: ModelCatalogSource::Provider,
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,73 +118,254 @@ pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Use case exposing the static Claude model catalogue.
|
||||
pub struct ListClaudeModels;
|
||||
/// Output of structured model-catalogue resolution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<CliVersion>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Output of [`ListClaudeModels::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListClaudeModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<CliVersion>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ListClaudeModels {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists curated Claude models. Infallible.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListClaudeModelsOutput {
|
||||
ListClaudeModelsOutput {
|
||||
models: claude_model_catalogue(),
|
||||
impl From<ListModelsOutput> for ListClaudeModelsOutput {
|
||||
fn from(out: ListModelsOutput) -> Self {
|
||||
Self {
|
||||
models: out.models,
|
||||
cli_version: out.cli_version,
|
||||
warnings: out.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListClaudeModels {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the static Codex model catalogue.
|
||||
pub struct ListCodexModels;
|
||||
|
||||
/// Output of [`ListCodexModels::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListCodexModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<CliVersion>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ListCodexModels {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists curated Codex models. Infallible.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListCodexModelsOutput {
|
||||
ListCodexModelsOutput {
|
||||
models: codex_model_catalogue(),
|
||||
impl From<ListModelsOutput> for ListCodexModelsOutput {
|
||||
fn from(out: ListModelsOutput) -> Self {
|
||||
Self {
|
||||
models: out.models,
|
||||
cli_version: out.cli_version,
|
||||
warnings: out.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListCodexModels {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
/// Use case resolving an enriched structured model catalogue.
|
||||
pub struct ResolveModelCatalogue {
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
}
|
||||
|
||||
impl ResolveModelCatalogue {
|
||||
/// Builds the use case from hexagonal ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cli_versions,
|
||||
provider_catalogue,
|
||||
matrix_source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves models for one structured adapter. Infallible by design.
|
||||
pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput {
|
||||
let mut warnings = Vec::new();
|
||||
let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix();
|
||||
warnings.extend(matrix_warnings);
|
||||
|
||||
let cli_version = match self.cli_versions.read_cli_version(adapter).await {
|
||||
Ok(version) => version,
|
||||
Err(warning) => {
|
||||
warnings.push(warning);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut models = match adapter {
|
||||
StructuredAdapter::Claude => claude_model_catalogue(),
|
||||
StructuredAdapter::Codex => codex_model_catalogue(),
|
||||
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(),
|
||||
};
|
||||
|
||||
match self.provider_catalogue.list_provider_models(adapter).await {
|
||||
Ok(provider_models) => {
|
||||
let existing = models
|
||||
.iter()
|
||||
.map(|model| model.model_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
models.extend(
|
||||
provider_models
|
||||
.into_iter()
|
||||
.filter(|model_id| !existing.contains(model_id))
|
||||
.map(|model_id| provider_entry(adapter, model_id)),
|
||||
);
|
||||
}
|
||||
Err(warning) => warnings.push(warning),
|
||||
}
|
||||
|
||||
for model in &mut models {
|
||||
model.compatibility =
|
||||
evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref());
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
b.recommended
|
||||
.cmp(&a.recommended)
|
||||
.then_with(|| a.display_name.cmp(&b.display_name))
|
||||
.then_with(|| a.model_id.cmp(&b.model_id))
|
||||
});
|
||||
|
||||
ListModelsOutput {
|
||||
models,
|
||||
cli_version,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the Claude model catalogue.
|
||||
pub struct ListClaudeModels {
|
||||
resolver: ResolveModelCatalogue,
|
||||
}
|
||||
|
||||
impl ListClaudeModels {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists Claude models. Infallible.
|
||||
pub async fn execute(&self) -> ListClaudeModelsOutput {
|
||||
self.resolver
|
||||
.execute(StructuredAdapter::Claude)
|
||||
.await
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the Codex model catalogue.
|
||||
pub struct ListCodexModels {
|
||||
resolver: ResolveModelCatalogue,
|
||||
}
|
||||
|
||||
impl ListCodexModels {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists Codex models. Infallible.
|
||||
pub async fn execute(&self) -> ListCodexModelsOutput {
|
||||
self.resolver.execute(StructuredAdapter::Codex).await.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{CliVersion, CompatibilityMatrix};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct FakeCliVersionReader(Option<Result<Option<CliVersion>, String>>);
|
||||
|
||||
#[async_trait]
|
||||
impl CliVersionReader for FakeCliVersionReader {
|
||||
async fn read_cli_version(
|
||||
&self,
|
||||
_adapter: StructuredAdapter,
|
||||
) -> Result<Option<CliVersion>, String> {
|
||||
self.0
|
||||
.clone()
|
||||
.unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap())))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProvider(Vec<String>, Option<String>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderModelCatalogue for FakeProvider {
|
||||
async fn list_provider_models(
|
||||
&self,
|
||||
_adapter: StructuredAdapter,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if let Some(warning) = &self.1 {
|
||||
Err(warning.clone())
|
||||
} else {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeMatrixSource(CompatibilityMatrix, Vec<String>);
|
||||
|
||||
impl CompatibilityMatrixSource for FakeMatrixSource {
|
||||
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
|
||||
(self.0.clone(), self.1.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolver(
|
||||
version: Option<Result<Option<CliVersion>, String>>,
|
||||
provider: Vec<String>,
|
||||
provider_warning: Option<String>,
|
||||
) -> ResolveModelCatalogue {
|
||||
ResolveModelCatalogue::new(
|
||||
Arc::new(FakeCliVersionReader(version)),
|
||||
Arc::new(FakeProvider(provider, provider_warning)),
|
||||
Arc::new(FakeMatrixSource(
|
||||
CompatibilityMatrix {
|
||||
version: 1,
|
||||
claude: HashMap::from([
|
||||
("claude-sonnet-5".to_owned(), "1.0.0".to_owned()),
|
||||
("claude-opus-4-8".to_owned(), "2.0.0".to_owned()),
|
||||
]),
|
||||
codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]),
|
||||
},
|
||||
vec![],
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
|
||||
@ -177,7 +383,86 @@ mod tests {
|
||||
assert_eq!(model.adapter, adapter);
|
||||
assert!(!model.model_id.trim().is_empty());
|
||||
assert!(!model.display_name.trim().is_empty());
|
||||
assert_eq!(model.compatibility, ModelCompatibility::Unknown);
|
||||
assert_eq!(model.source, ModelCatalogSource::Catalogue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_enriches_static_catalogue_with_cli_compatibility() {
|
||||
let out = resolver(
|
||||
Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))),
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.execute(StructuredAdapter::Claude)
|
||||
.await;
|
||||
|
||||
let sonnet = out
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "claude-sonnet-5")
|
||||
.unwrap();
|
||||
let opus = out
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "claude-opus-4-8")
|
||||
.unwrap();
|
||||
assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible);
|
||||
assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent);
|
||||
assert_eq!(out.cli_version.unwrap().raw, "1.0.0");
|
||||
assert!(out.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_keeps_provider_and_cli_failures_non_blocking() {
|
||||
let out = resolver(
|
||||
Some(Err("codex version unavailable".to_owned())),
|
||||
vec![],
|
||||
Some("provider unavailable".to_owned()),
|
||||
)
|
||||
.execute(StructuredAdapter::Codex)
|
||||
.await;
|
||||
|
||||
assert!(!out.models.is_empty());
|
||||
assert_eq!(out.cli_version, None);
|
||||
assert_eq!(
|
||||
out.warnings,
|
||||
vec![
|
||||
"codex version unavailable".to_owned(),
|
||||
"provider unavailable".to_owned()
|
||||
]
|
||||
);
|
||||
assert!(out
|
||||
.models
|
||||
.iter()
|
||||
.all(|model| model.compatibility == ModelCompatibility::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() {
|
||||
let out = resolver(
|
||||
None,
|
||||
vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()],
|
||||
None,
|
||||
)
|
||||
.execute(StructuredAdapter::Codex)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
out.models
|
||||
.iter()
|
||||
.filter(|model| model.model_id == "gpt-5-codex")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let provider = out
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "gpt-5-provider")
|
||||
.unwrap();
|
||||
assert_eq!(provider.source, ModelCatalogSource::Provider);
|
||||
assert_eq!(provider.compatibility, ModelCompatibility::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,11 +40,11 @@ pub mod window;
|
||||
pub mod workstate;
|
||||
|
||||
pub use agent::{
|
||||
drain_reply_stream_with_readiness, drain_with_readiness,
|
||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
claude_model_catalogue, codex_model_catalogue, drain_reply_stream_with_readiness,
|
||||
drain_with_readiness, drain_with_readiness_and_announcements, drain_with_readiness_outcome,
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
||||
AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput,
|
||||
ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||
|
||||
@ -24,12 +24,12 @@ use domain::profile::{
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, AppError, CloneOpenCodeProfileFromSeed,
|
||||
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, FirstRunState, ListClaudeModels, ListCodexModels, ListProfiles,
|
||||
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
|
||||
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
claude_model_catalogue, codex_model_catalogue, reference_profile_id, reference_profiles,
|
||||
AppError, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneProfileFromSeed, CloneProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1161,8 +1161,8 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
|
||||
let claude = ListClaudeModels::new().execute().models;
|
||||
let codex = ListCodexModels::new().execute().models;
|
||||
let claude = claude_model_catalogue();
|
||||
let codex = codex_model_catalogue();
|
||||
|
||||
assert!(claude
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user