From ca70ec75f4acc061fbbf6e6c1b0dfadcc41f9e56 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 16:09:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20catalogue=20dynamique=20mod=C3=A8les=20?= =?UTF-8?q?Codex/Claude=20avec=20compatibilit=C3=A9=20CLI=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .ideai/memory/MEMORY.md | 1 + .../memory/model-catalogue-compat-cadrage.md | 25 ++ crates/app-tauri/src/commands.rs | 8 +- crates/app-tauri/tests/dto_profiles.rs | 35 +- .../application/src/agent/model_catalogue.rs | 369 ++++++++++++++++-- crates/application/src/lib.rs | 10 +- crates/application/tests/profile_usecases.rs | 16 +- crates/backend/src/dto.rs | 31 +- crates/backend/src/lib.rs | 44 ++- crates/domain/src/lib.rs | 27 +- crates/domain/src/model_catalogue.rs | 216 ++++++++++ crates/domain/src/ports.rs | 34 +- crates/infrastructure/src/lib.rs | 4 + crates/infrastructure/src/model_catalogue.rs | 331 ++++++++++++++++ .../src/model_compatibility_matrix.json | 13 + crates/web-server/src/lib.rs | 38 +- .../adapters/http/requestResponseGateways.ts | 11 +- frontend/src/adapters/mock/index.ts | 29 +- frontend/src/adapters/profile.ts | 11 +- frontend/src/adapters/profileCatalog.test.ts | 56 +++ frontend/src/adapters/profileCatalog.ts | 63 +++ frontend/src/domain/index.ts | 22 ++ .../first-run/ProfilesSettings.test.tsx | 55 ++- .../features/first-run/ProfilesSettings.tsx | 290 ++++++++++++-- frontend/src/ports/index.ts | 10 +- 25 files changed, 1582 insertions(+), 167 deletions(-) create mode 100644 .ideai/memory/model-catalogue-compat-cadrage.md create mode 100644 crates/domain/src/model_catalogue.rs create mode 100644 crates/infrastructure/src/model_catalogue.rs create mode 100644 crates/infrastructure/src/model_compatibility_matrix.json create mode 100644 frontend/src/adapters/profileCatalog.test.ts create mode 100644 frontend/src/adapters/profileCatalog.ts diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 9895848..5d8a66b 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -71,3 +71,4 @@ - [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA. - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix - [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping +- [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. diff --git a/.ideai/memory/model-catalogue-compat-cadrage.md b/.ideai/memory/model-catalogue-compat-cadrage.md new file mode 100644 index 0000000..6aa85c9 --- /dev/null +++ b/.ideai/memory/model-catalogue-compat-cadrage.md @@ -0,0 +1,25 @@ +--- +name: model-catalogue-compat-cadrage +description: Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. +metadata: + type: reference +--- +Évolution de `ListClaudeModels`/`ListCodexModels` (catalogue statique `application/src/agent/model_catalogue.rs`) vers un catalogue enrichi par compatibilité CLI. + +**Décisions tranchées :** +- JAMAIS scraper les TUI `/model`, JAMAIS exécuter les CLIs pour énumérer les modèles. Seule exécution CLI autorisée : ` --version` (pattern existant `infrastructure/runtime::detection_spec` + port `ProcessSpawner`). Saisie libre toujours ouverte. +- 3 sources non bloquantes à dégradation indépendante : API provider `/v1/models` (best-effort, uniquement si clé env/SecretStore présente, sinon skip), catalogue statique seed, matrice de compat. + +**Hexagonal :** +- Domaine (pur) : VO `CliVersion` (Ord), enum `ModelCompatibility {Compatible|Unknown|LikelyTooRecent}` (miroir des 3 états produit), VO `CompatibilityMatrix` (forme seule), fonction pure `evaluate_compatibility(matrix, adapter, model_id, Option)` — version None ⇒ Unknown, modèle absent ⇒ Unknown, min<=ver ⇒ Compatible, min>ver ⇒ LikelyTooRecent. +- Nouveaux ports : `CliVersionReader`, `ProviderModelCatalogue` (Ok(vec![]) si pas de clé), `CompatibilityMatrixSource` (infaillible). +- Application : use case unique `ResolveModelCatalogue{adapter}` async, jamais de hard-error sur échec source (warnings + fallback). `ListClaude/CodexModels` deviennent des façades. +- Infra : `ProcessCliVersionReader`, `HttpProviderModelCatalogue` (reqwest), `EmbeddedCompatibilityMatrix`. + +**Matrice de compat = DONNÉE, pas code** : JSON versionné maintenu dans IdeA, bundlé via `include_str!` (seed infaillible) + override optionnel `app_data_dir/IdeA/model-compat.json`. Ajouter un modèle = éditer le JSON, zéro code (Open/Closed). + +**DTO (rupture front)** : `ProfileModelCatalogDto` passe de `transparent Vec` à `{ models:[{...,compatibility,source}], cliVersion:string|null, warnings:string[] }`. Répercuter ports TS + 2 adapters + mock + ProfilesSettings. + +**Découpage** : B1 domaine pur, B2 use case ports mockés, B3 infra ; F1 contrat, F2 ProfilesSettings 3 badges. UX passe avant F2 (libellés des 3 états, warnings, cliVersion null). + +**Point ouvert produit** : récup clé provider — proposé best-effort sur clé env/SecretStore existante, pas de prompt dédié. \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index c14d689..82fe758 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1189,20 +1189,20 @@ pub async fn list_opencode_providers( Ok(state.list_opencode_providers.execute().into()) } -/// `list_claude_models` — static curated Claude model catalogue. +/// `list_claude_models` — enriched Claude model catalogue. #[tauri::command] pub async fn list_claude_models( state: State<'_, AppState>, ) -> Result { - Ok(state.list_claude_models.execute().into()) + Ok(state.list_claude_models.execute().await.into()) } -/// `list_codex_models` — static curated Codex model catalogue. +/// `list_codex_models` — enriched Codex model catalogue. #[tauri::command] pub async fn list_codex_models( state: State<'_, AppState>, ) -> Result { - Ok(state.list_codex_models.execute().into()) + Ok(state.list_codex_models.execute().await.into()) } /// `save_opencode_provider_profile` — create or replace an OpenCode profile diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index 81cb35e..7d583e2 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -141,21 +141,30 @@ fn clone_profile_from_seed_request_deserialises_camelcase_overrides() { #[test] fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() { - let dto = ProfileModelCatalogDto(vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto { - adapter: StructuredAdapter::Codex, - model_id: "gpt-5-codex".to_owned(), - display_name: "GPT-5 Codex".to_owned(), - aliases: vec!["codex".to_owned()], - recommended: true, - }]); + let dto = ProfileModelCatalogDto { + models: vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto { + adapter: StructuredAdapter::Codex, + model_id: "gpt-5-codex".to_owned(), + display_name: "GPT-5 Codex".to_owned(), + aliases: vec!["codex".to_owned()], + recommended: true, + compatibility: domain::ModelCompatibility::Compatible, + source: domain::ModelCatalogSource::Catalogue, + }], + cli_version: Some("0.45.1".to_owned()), + warnings: vec!["provider unavailable".to_owned()], + }; let value = serde_json::to_value(&dto).unwrap(); - let arr = value.as_array().expect("transparent array"); - assert_eq!(arr[0]["adapter"], "codex"); - assert_eq!(arr[0]["modelId"], "gpt-5-codex"); - assert_eq!(arr[0]["displayName"], "GPT-5 Codex"); - assert_eq!(arr[0]["aliases"], json!(["codex"])); - assert_eq!(arr[0]["recommended"], true); + assert_eq!(value["cliVersion"], "0.45.1"); + assert_eq!(value["warnings"], json!(["provider unavailable"])); + assert_eq!(value["models"][0]["adapter"], "codex"); + assert_eq!(value["models"][0]["modelId"], "gpt-5-codex"); + assert_eq!(value["models"][0]["displayName"], "GPT-5 Codex"); + assert_eq!(value["models"][0]["aliases"], json!(["codex"])); + assert_eq!(value["models"][0]["recommended"], true); + assert_eq!(value["models"][0]["compatibility"], "compatible"); + assert_eq!(value["models"][0]["source"], "catalogue"); } #[test] diff --git a/crates/application/src/agent/model_catalogue.rs b/crates/application/src/agent/model_catalogue.rs index f4d2fb1..e5399b8 100644 --- a/crates/application/src/agent/model_catalogue.rs +++ b/crates/application/src/agent/model_catalogue.rs @@ -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 +//! ` --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, /// 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 { ] } -/// 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, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} /// Output of [`ListClaudeModels::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListClaudeModelsOutput { /// The catalogue entries. pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, } -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 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, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, } -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 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, + provider_catalogue: Arc, + matrix_source: Arc, +} + +impl ResolveModelCatalogue { + /// Builds the use case from hexagonal ports. + #[must_use] + pub fn new( + cli_versions: Arc, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> 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::>(); + 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, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> 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, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> 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, String>>); + + #[async_trait] + impl CliVersionReader for FakeCliVersionReader { + async fn read_cli_version( + &self, + _adapter: StructuredAdapter, + ) -> Result, String> { + self.0 + .clone() + .unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap()))) + } + } + + struct FakeProvider(Vec, Option); + + #[async_trait] + impl ProviderModelCatalogue for FakeProvider { + async fn list_provider_models( + &self, + _adapter: StructuredAdapter, + ) -> Result, String> { + if let Some(warning) = &self.1 { + Err(warning.clone()) + } else { + Ok(self.0.clone()) + } + } + } + + struct FakeMatrixSource(CompatibilityMatrix, Vec); + + impl CompatibilityMatrixSource for FakeMatrixSource { + fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec) { + (self.0.clone(), self.1.clone()) + } + } + + fn resolver( + version: Option, String>>, + provider: Vec, + provider_warning: Option, + ) -> 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); + } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 307c821..43800f3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 0986e9f..cf64afc 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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() diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 5da2b89..50b844b 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1067,6 +1067,10 @@ pub struct ProfileModelCatalogEntryDto { pub aliases: Vec, /// Whether this entry is the conservative default suggestion. pub recommended: bool, + /// Compatibility state against the locally detected CLI version. + pub compatibility: domain::ModelCompatibility, + /// Source that contributed the model entry. + pub source: domain::ModelCatalogSource, } impl From for ProfileModelCatalogEntryDto { @@ -1077,24 +1081,41 @@ impl From for ProfileModelCatalogEntryDto display_name: entry.display_name, aliases: entry.aliases, recommended: entry.recommended, + compatibility: entry.compatibility, + source: entry.source, } } } -/// A list of curated structured-profile models. +/// Enriched structured-profile model catalogue. #[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ProfileModelCatalogDto(pub Vec); +#[serde(rename_all = "camelCase")] +pub struct ProfileModelCatalogDto { + /// The catalogue entries. + pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} impl From for ProfileModelCatalogDto { fn from(out: application::ListClaudeModelsOutput) -> Self { - Self(out.models.into_iter().map(Into::into).collect()) + Self { + models: out.models.into_iter().map(Into::into).collect(), + cli_version: out.cli_version.map(|version| version.raw), + warnings: out.warnings, + } } } impl From for ProfileModelCatalogDto { fn from(out: application::ListCodexModelsOutput) -> Self { - Self(out.models.into_iter().map(Into::into).collect()) + Self { + models: out.models.into_iter().map(Into::into).collect(), + cli_version: out.cli_version.map(|version| version.raw), + warnings: out.warnings, + } } } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 95f4711..7a9a199 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -80,19 +80,20 @@ use uuid::Uuid; use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, - CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, - FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, - FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, - FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, - FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, - FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, - NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe, - RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, + CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, + EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, + FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, + FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, + FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, + FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, + FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, + FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, + HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, + HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, @@ -1474,8 +1475,21 @@ impl BackendCore { Arc::clone(&ids) as Arc, )); let list_opencode_providers = Arc::new(ListOpenCodeProviders::new()); - let list_claude_models = Arc::new(ListClaudeModels::new()); - let list_codex_models = Arc::new(ListCodexModels::new()); + let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port))); + let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new()); + let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir( + app_data_dir.clone(), + )); + let list_claude_models = Arc::new(ListClaudeModels::new( + Arc::clone(&cli_version_reader) as Arc, + Arc::clone(&provider_model_catalogue) as Arc, + Arc::clone(&compatibility_matrix) as Arc, + )); + let list_codex_models = Arc::new(ListCodexModels::new( + cli_version_reader as Arc, + provider_model_catalogue as Arc, + compatibility_matrix as Arc, + )); let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new( Arc::clone(&profile_store_port), Arc::clone(&ids) as Arc, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index d3c5a4e..fe3c8f5 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -51,6 +51,7 @@ pub mod markdown; pub mod mcp_tool_permissions; pub mod memory; pub mod memory_harvest; +pub mod model_catalogue; pub mod model_server; pub mod orchestrator; pub mod permission; @@ -167,6 +168,11 @@ pub use memory_harvest::{ MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS, }; +pub use model_catalogue::{ + evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource, + ModelCatalogueError, ModelCompatibility, +}; + pub use model_server::{ validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint, @@ -226,16 +232,17 @@ pub use ports::{ AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError, AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, - BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector, - EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, - EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, - GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore, - IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, - MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, - ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, - PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, - PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, - PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, + BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan, + DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, + EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, + ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, + IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath, + McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, + ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, + Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, + PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, + PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError, + ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, diff --git a/crates/domain/src/model_catalogue.rs b/crates/domain/src/model_catalogue.rs new file mode 100644 index 0000000..88bee67 --- /dev/null +++ b/crates/domain/src/model_catalogue.rs @@ -0,0 +1,216 @@ +//! Pure model-catalogue compatibility types. + +use core::cmp::Ordering; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::profile::StructuredAdapter; + +/// Parsed CLI version used for local compatibility checks. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliVersion { + /// Original version string reported by the CLI. + pub raw: String, + parts: Vec, +} + +impl CliVersion { + /// Parses a version from a string containing at least one digit. + /// + /// # Errors + /// Returns [`ModelCatalogueError::InvalidVersion`] when no numeric version + /// segment can be found. + pub fn parse(raw: impl Into) -> Result { + let raw = raw.into(); + let start = raw + .char_indices() + .find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx)) + .ok_or_else(|| ModelCatalogueError::InvalidVersion(raw.clone()))?; + let version = raw[start..] + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '.') + .collect::(); + let parts = version + .split('.') + .filter(|part| !part.is_empty()) + .map(str::parse::) + .collect::, _>>() + .map_err(|_| ModelCatalogueError::InvalidVersion(raw.clone()))?; + if parts.is_empty() { + return Err(ModelCatalogueError::InvalidVersion(raw)); + } + Ok(Self { raw, parts }) + } +} + +impl PartialOrd for CliVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CliVersion { + fn cmp(&self, other: &Self) -> Ordering { + let max_len = self.parts.len().max(other.parts.len()); + for idx in 0..max_len { + match self + .parts + .get(idx) + .copied() + .unwrap_or(0) + .cmp(&other.parts.get(idx).copied().unwrap_or(0)) + { + Ordering::Equal => {} + ordering => return ordering, + } + } + Ordering::Equal + } +} + +/// Compatibility state between a local CLI version and a model id. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelCompatibility { + /// The known minimum CLI version is satisfied. + Compatible, + /// The CLI version is absent, or the model is not covered by the matrix. + Unknown, + /// The model is covered by the matrix but appears newer than the local CLI. + LikelyTooRecent, +} + +/// Origin of a model-catalogue entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelCatalogSource { + /// Curated static seed maintained by IdeA. + Catalogue, + /// Best-effort provider API discovery. + Provider, +} + +/// Matrix mapping adapter/model ids to their minimum known CLI version. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompatibilityMatrix { + /// Matrix schema/data version. + pub version: u32, + /// Claude Code entries keyed by model id. + #[serde(default)] + pub claude: HashMap, + /// Codex CLI entries keyed by model id. + #[serde(default)] + pub codex: HashMap, +} + +impl CompatibilityMatrix { + /// Looks up the minimum CLI version for an adapter/model pair. + #[must_use] + pub fn minimum_version(&self, adapter: StructuredAdapter, model_id: &str) -> Option<&str> { + match adapter { + StructuredAdapter::Claude => self.claude.get(model_id).map(String::as_str), + StructuredAdapter::Codex => self.codex.get(model_id).map(String::as_str), + StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => None, + } + } +} + +/// Evaluates local compatibility using only pure matrix data. +#[must_use] +pub fn evaluate_compatibility( + matrix: &CompatibilityMatrix, + adapter: StructuredAdapter, + model_id: &str, + cli_version: Option<&CliVersion>, +) -> ModelCompatibility { + let Some(cli_version) = cli_version else { + return ModelCompatibility::Unknown; + }; + let Some(minimum) = matrix.minimum_version(adapter, model_id) else { + return ModelCompatibility::Unknown; + }; + let Ok(minimum) = CliVersion::parse(minimum.to_owned()) else { + return ModelCompatibility::Unknown; + }; + if &minimum <= cli_version { + ModelCompatibility::Compatible + } else { + ModelCompatibility::LikelyTooRecent + } +} + +/// Errors from pure model-catalogue parsing. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ModelCatalogueError { + /// Version strings must contain at least one numeric segment. + #[error("invalid CLI version: {0}")] + InvalidVersion(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matrix() -> CompatibilityMatrix { + CompatibilityMatrix { + version: 1, + claude: HashMap::from([("claude-sonnet-5".to_owned(), "1.2.0".to_owned())]), + codex: HashMap::from([("gpt-5-codex".to_owned(), "0.44.0".to_owned())]), + } + } + + #[test] + fn cli_versions_compare_by_numeric_parts() { + assert!(CliVersion::parse("codex 0.10.0").unwrap() > CliVersion::parse("0.9.9").unwrap()); + assert_eq!( + CliVersion::parse("1.2") + .unwrap() + .cmp(&CliVersion::parse("1.2.0").unwrap()), + Ordering::Equal + ); + } + + #[test] + fn compatibility_is_unknown_without_version_or_matrix_entry() { + let matrix = matrix(); + assert_eq!( + evaluate_compatibility(&matrix, StructuredAdapter::Codex, "gpt-5-codex", None), + ModelCompatibility::Unknown + ); + assert_eq!( + evaluate_compatibility( + &matrix, + StructuredAdapter::Codex, + "future-model", + Some(&CliVersion::parse("999.0.0").unwrap()) + ), + ModelCompatibility::Unknown + ); + } + + #[test] + fn compatibility_detects_supported_and_too_recent_models() { + let matrix = matrix(); + assert_eq!( + evaluate_compatibility( + &matrix, + StructuredAdapter::Codex, + "gpt-5-codex", + Some(&CliVersion::parse("0.44.0").unwrap()) + ), + ModelCompatibility::Compatible + ); + assert_eq!( + evaluate_compatibility( + &matrix, + StructuredAdapter::Claude, + "claude-sonnet-5", + Some(&CliVersion::parse("1.1.9").unwrap()) + ), + ModelCompatibility::LikelyTooRecent + ); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index d4e5214..69f8ba4 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -45,6 +45,7 @@ use crate::issue::{ use crate::markdown::MarkdownDoc; use crate::mcp_tool_permissions::ProjectMcpToolPermissions; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; +use crate::model_catalogue::{CliVersion, CompatibilityMatrix}; use crate::model_server::{ HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus, }; @@ -54,7 +55,7 @@ use crate::plugin::{ PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome, StagedPluginPackage, }; -use crate::profile::{AgentProfile, EmbedderProfile}; +use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; use crate::skill::{Skill, SkillScope}; @@ -1245,6 +1246,37 @@ pub trait ProcessSpawner: Send + Sync { async fn run(&self, spec: SpawnSpec) -> Result; } +/// Read a local structured CLI version using only the allowed `--version` probe. +#[async_trait] +pub trait CliVersionReader: Send + Sync { + /// Best-effort local CLI version lookup. + /// + /// # Errors + /// Returns a string suitable for non-blocking catalogue warnings. + async fn read_cli_version( + &self, + adapter: StructuredAdapter, + ) -> Result, String>; +} + +/// Best-effort provider API model catalogue. +#[async_trait] +pub trait ProviderModelCatalogue: Send + Sync { + /// Lists provider model ids for an adapter. `Ok(Vec::new())` means no key or + /// unsupported provider and is not a warning-worthy failure. + /// + /// # Errors + /// Returns a string suitable for non-blocking catalogue warnings. + async fn list_provider_models(&self, adapter: StructuredAdapter) + -> Result, String>; +} + +/// Source of the versioned compatibility matrix. +pub trait CompatibilityMatrixSource: Send + Sync { + /// Returns matrix data and any fallback warnings. + fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec); +} + /// Probe readiness of an OpenAI-compatible model server. #[async_trait] pub trait ModelServerProbe: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 18963df..eb89443 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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, diff --git a/crates/infrastructure/src/model_catalogue.rs b/crates/infrastructure/src/model_catalogue.rs new file mode 100644 index 0000000..8759f62 --- /dev/null +++ b/crates/infrastructure/src/model_catalogue.rs @@ -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, +} + +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")); + } +} diff --git a/crates/infrastructure/src/model_compatibility_matrix.json b/crates/infrastructure/src/model_compatibility_matrix.json new file mode 100644 index 0000000..a0af0a8 --- /dev/null +++ b/crates/infrastructure/src/model_compatibility_matrix.json @@ -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" + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index dc79106..e14ca7c 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -79,18 +79,18 @@ use backend::dto::{ GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, - ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, - ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, - ReadConversationPageRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto, - ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, - ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, - SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, - SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, - SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, - TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto, - TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, - TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, + ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto, + ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto, + SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, + SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, + TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, + TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, @@ -2353,6 +2353,8 @@ async fn invoke( "list_profiles" => invoke_list_profiles(&state.app).await, "save_profile" => invoke_save_profile(&request.args, &state.app).await, "list_opencode_providers" => invoke_list_opencode_providers(&state.app), + "list_claude_models" => invoke_list_claude_models(&state.app).await, + "list_codex_models" => invoke_list_codex_models(&state.app).await, "save_opencode_provider_profile" => { invoke_save_opencode_provider_profile(&request.args, &state.app).await } @@ -2606,6 +2608,16 @@ fn invoke_list_opencode_providers(state: &BackendCore) -> Result Result { + let output: ProfileModelCatalogDto = state.list_claude_models.execute().await.into(); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_codex_models(state: &BackendCore) -> Result { + let output: ProfileModelCatalogDto = state.list_codex_models.execute().await.into(); + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_save_opencode_provider_profile( args: &Value, state: &BackendCore, @@ -7653,6 +7665,8 @@ mod tests { "list_profiles", "save_profile", "list_opencode_providers", + "list_claude_models", + "list_codex_models", "save_opencode_provider_profile", "delete_profile", "configure_profiles", diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index c44c5e0..5fca750 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -42,7 +42,7 @@ import type { ProjectWorkState, ProjectSystemPermissions, ProfileAvailability, - ProfileModelCatalogEntry, + ProfileModelCatalog, ResolvedAgentSystemPermissions, SystemPermissionSet, Skill, @@ -74,6 +74,7 @@ import type { } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; import { normalizeTurnPage } from "../conversationNormalization"; +import { normalizeProfileModelCatalog } from "../profileCatalog"; import type { HttpInvoker } from "./httpInvoker"; export class HttpProjectGateway implements ProjectGateway { @@ -183,11 +184,11 @@ export class HttpProfileGateway implements ProfileGateway { request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model }, }); } - listClaudeModels(): Promise { - return this.http.invoke("list_claude_models"); + async listClaudeModels(): Promise { + return normalizeProfileModelCatalog(await this.http.invoke("list_claude_models")); } - listCodexModels(): Promise { - return this.http.invoke("list_codex_models"); + async listCodexModels(): Promise { + return normalizeProfileModelCatalog(await this.http.invoke("list_codex_models")); } configureProfiles(profiles: AgentProfile[]): Promise { return this.http.invoke("configure_profiles", { request: { profiles } }); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 31b62c7..88f2a27 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -35,6 +35,7 @@ import type { McpToolCatalogue, McpToolPolicy, OpenCodeProviderCatalogEntry, + ProfileModelCatalog, ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, @@ -1302,6 +1303,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ displayName: "Claude Sonnet 5", aliases: ["sonnet"], recommended: true, + compatibility: "compatible", + source: "catalogue", }, { adapter: "claude", @@ -1309,6 +1312,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ displayName: "Claude Opus 4.8", aliases: ["opus"], recommended: false, + compatibility: "unknown", + source: "catalogue", }, { adapter: "claude", @@ -1316,6 +1321,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ displayName: "Claude Haiku 4.5", aliases: ["haiku"], recommended: false, + compatibility: "likelyTooRecent", + source: "provider", }, ]; @@ -1326,6 +1333,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ displayName: "GPT-5 Codex", aliases: ["codex"], recommended: true, + compatibility: "compatible", + source: "catalogue", }, { adapter: "codex", @@ -1333,6 +1342,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ displayName: "GPT-5", aliases: ["general"], recommended: false, + compatibility: "unknown", + source: "catalogue", }, { adapter: "codex", @@ -1340,6 +1351,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ displayName: "GPT-5 mini", aliases: ["mini", "fast"], recommended: false, + compatibility: "likelyTooRecent", + source: "provider", }, ]; @@ -1420,12 +1433,20 @@ export class MockProfileGateway implements ProfileGateway { return structuredClone(cloned); } - async listClaudeModels(): Promise { - return structuredClone(MOCK_CLAUDE_MODELS); + async listClaudeModels(): Promise { + return { + models: structuredClone(MOCK_CLAUDE_MODELS), + cliVersion: "2.1.220", + warnings: [], + }; } - async listCodexModels(): Promise { - return structuredClone(MOCK_CODEX_MODELS); + async listCodexModels(): Promise { + return { + models: structuredClone(MOCK_CODEX_MODELS), + cliVersion: "0.145.0", + warnings: ["Catalogue provider partiellement estime depuis les donnees locales."], + }; } async cloneOpenCodeProfileFromSeed( diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index b68cee5..f5b4be1 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -12,7 +12,7 @@ import type { AgentProfile, FirstRunState, OpenCodeProviderCatalogEntry, - ProfileModelCatalogEntry, + ProfileModelCatalog, ProfileAvailability, } from "@/domain"; import type { @@ -21,6 +21,7 @@ import type { ProfileGateway, SaveOpenCodeProviderProfileInput, } from "@/ports"; +import { normalizeProfileModelCatalog } from "./profileCatalog"; export class TauriProfileGateway implements ProfileGateway { firstRunState(): Promise { @@ -59,12 +60,12 @@ export class TauriProfileGateway implements ProfileGateway { }); } - listClaudeModels(): Promise { - return invoke("list_claude_models"); + async listClaudeModels(): Promise { + return normalizeProfileModelCatalog(await invoke("list_claude_models")); } - listCodexModels(): Promise { - return invoke("list_codex_models"); + async listCodexModels(): Promise { + return normalizeProfileModelCatalog(await invoke("list_codex_models")); } configureProfiles(profiles: AgentProfile[]): Promise { diff --git a/frontend/src/adapters/profileCatalog.test.ts b/frontend/src/adapters/profileCatalog.test.ts new file mode 100644 index 0000000..3c9066e --- /dev/null +++ b/frontend/src/adapters/profileCatalog.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProfileModelCatalog } from "./profileCatalog"; + +describe("normalizeProfileModelCatalog", () => { + it("wraps the legacy bare array response with unknown compatibility", () => { + expect( + normalizeProfileModelCatalog([ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + }, + ]), + ).toEqual({ + models: [ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + compatibility: "unknown", + source: "catalogue", + }, + ], + cliVersion: null, + warnings: [], + }); + }); + + it("defaults missing enriched fields without dropping warnings", () => { + expect( + normalizeProfileModelCatalog({ + models: [{ adapter: "claude", modelId: "claude-sonnet-5" }], + warnings: ["version inconnue"], + }), + ).toEqual({ + models: [ + { + adapter: "claude", + modelId: "claude-sonnet-5", + displayName: "claude-sonnet-5", + aliases: [], + recommended: false, + compatibility: "unknown", + source: "catalogue", + }, + ], + cliVersion: null, + warnings: ["version inconnue"], + }); + }); +}); diff --git a/frontend/src/adapters/profileCatalog.ts b/frontend/src/adapters/profileCatalog.ts new file mode 100644 index 0000000..42adf9e --- /dev/null +++ b/frontend/src/adapters/profileCatalog.ts @@ -0,0 +1,63 @@ +import type { + ModelCatalogSource, + ModelCompatibility, + ProfileModelCatalog, + ProfileModelCatalogEntry, +} from "@/domain"; + +type PartialCatalogEntry = Partial & { + adapter?: "claude" | "codex"; + modelId?: string; + displayName?: string; +}; + +function compatibilityOf(value: unknown): ModelCompatibility { + return value === "compatible" || + value === "unknown" || + value === "likelyTooRecent" + ? value + : "unknown"; +} + +function sourceOf(value: unknown): ModelCatalogSource { + return value === "provider" ? "provider" : "catalogue"; +} + +function normalizeEntry(raw: PartialCatalogEntry): ProfileModelCatalogEntry { + const modelId = raw.modelId ?? ""; + return { + adapter: raw.adapter ?? "codex", + modelId, + displayName: raw.displayName ?? modelId, + aliases: Array.isArray(raw.aliases) ? raw.aliases : [], + recommended: Boolean(raw.recommended), + compatibility: compatibilityOf(raw.compatibility), + source: sourceOf(raw.source), + }; +} + +export function normalizeProfileModelCatalog(raw: unknown): ProfileModelCatalog { + if (Array.isArray(raw)) { + return { + models: raw.map((entry) => normalizeEntry(entry as PartialCatalogEntry)), + cliVersion: null, + warnings: [], + }; + } + + const catalog = + raw && typeof raw === "object" + ? (raw as Partial) + : {}; + + return { + models: Array.isArray(catalog.models) + ? catalog.models.map((entry) => normalizeEntry(entry as PartialCatalogEntry)) + : [], + cliVersion: + typeof catalog.cliVersion === "string" ? catalog.cliVersion : null, + warnings: Array.isArray(catalog.warnings) + ? catalog.warnings.map(String) + : [], + }; +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 7e69149..2f14f34 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1096,6 +1096,15 @@ export interface OpenCodeProviderCatalogEntry { models: string[]; } +/** Estimated compatibility for a Codex/Claude model against the detected local CLI. */ +export type ModelCompatibility = + | "compatible" + | "unknown" + | "likelyTooRecent"; + +/** Origin of a model catalogue entry. */ +export type ModelCatalogSource = "catalogue" | "provider"; + /** One searchable model from the Codex/Claude structured-profile catalogues. */ export interface ProfileModelCatalogEntry { /** Structured adapter this model belongs to. */ @@ -1108,6 +1117,19 @@ export interface ProfileModelCatalogEntry { aliases: string[]; /** Whether this entry is the conservative default suggestion. */ recommended: boolean; + /** Best-effort compatibility estimate for the locally detected CLI version. */ + compatibility: ModelCompatibility; + /** Whether the entry comes from IdeA's catalogue or a provider-derived source. */ + source: ModelCatalogSource; +} + +/** Enriched Codex/Claude model catalogue. Manual model entry remains supported. */ +export interface ProfileModelCatalog { + models: ProfileModelCatalogEntry[]; + /** Detected local CLI version, or null when unavailable. */ + cliVersion: string | null; + /** Non-fatal catalogue/version diagnostics. */ + warnings: string[]; } /** diff --git a/frontend/src/features/first-run/ProfilesSettings.test.tsx b/frontend/src/features/first-run/ProfilesSettings.test.tsx index 75a50fb..9bbf952 100644 --- a/frontend/src/features/first-run/ProfilesSettings.test.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { DIProvider } from "@/app/di"; import { MockProfileGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; -import type { ProfileModelCatalogEntry } from "@/domain"; +import type { ProfileModelCatalog } from "@/domain"; import { ProfilesSettings } from "./ProfilesSettings"; function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) { @@ -94,14 +94,14 @@ describe("ProfilesSettings", () => { it("keeps manual model entry available when the catalogue fails", async () => { class CatalogueDownProfileGateway extends MockProfileGateway { - listCodexModels(): Promise { + listCodexModels(): Promise { return Promise.reject(new Error("catalogue down")); } } renderSettings(new CatalogueDownProfileGateway()); await waitReady(); - expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy(); + expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy(); await createProfile(); const model = within(screen.getAllByRole("listitem")[0]).getByLabelText( @@ -109,5 +109,54 @@ describe("ProfilesSettings", () => { ) as HTMLInputElement; fireEvent.change(model, { target: { value: "future-codex-model" } }); expect(model.value).toBe("future-codex-model"); + expect(screen.getAllByText(/Catalogue provider indisponible/).length).toBeGreaterThan(0); + }); + + it("shows compatibility states in suggestions and contextual help", async () => { + renderSettings(); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement; + fireEvent.focus(model); + + expect(await within(row).findByText("Compatible")).toBeTruthy(); + expect( + within(row).getByText( + /Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./, + ), + ).toBeTruthy(); + + fireEvent.change(model, { target: { value: "" } }); + expect(within(row).getAllByText("Inconnu").length).toBeGreaterThan(0); + expect(within(row).getByText("Probablement trop récent")).toBeTruthy(); + + fireEvent.change(model, { target: { value: "future-codex-model" } }); + expect(within(row).getByText("Inconnu")).toBeTruthy(); + expect( + within(row).getByText( + /Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./, + ), + ).toBeTruthy(); + }); + + it("saves likely-too-recent models and shows a non-blocking warning", async () => { + const { profile } = renderSettings(); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement; + fireEvent.change(model, { target: { value: "gpt-5-mini" } }); + fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.some((p) => p.model === "gpt-5-mini")).toBe(true); + }); + expect( + await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/), + ).toBeTruthy(); }); }); diff --git a/frontend/src/features/first-run/ProfilesSettings.tsx b/frontend/src/features/first-run/ProfilesSettings.tsx index ab601d0..d3ad78d 100644 --- a/frontend/src/features/first-run/ProfilesSettings.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.tsx @@ -8,12 +8,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentProfile, GatewayError, + ModelCompatibility, + ProfileModelCatalog, ProfileModelCatalogEntry, } from "@/domain"; import { useGateways } from "@/app/di"; import { Button, Input, Panel, cn } from "@/shared"; type ProfileTab = "codex" | "claude" | "openCode"; +type ModelTab = "codex" | "claude"; +type CatalogState = Record; const TABS: Array<{ id: ProfileTab; label: string }> = [ { id: "codex", label: "Codex" }, @@ -21,9 +25,16 @@ const TABS: Array<{ id: ProfileTab; label: string }> = [ { id: "openCode", label: "OpenCode-local" }, ]; -const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = { - codex: [], - claude: [], +const EMPTY_MODEL_CATALOG: ProfileModelCatalog & { unavailable: boolean } = { + models: [], + cliVersion: null, + warnings: [], + unavailable: false, +}; + +const EMPTY_CATALOGUE: CatalogState = { + codex: EMPTY_MODEL_CATALOG, + claude: EMPTY_MODEL_CATALOG, }; function describe(e: unknown): string { @@ -66,6 +77,185 @@ function optionLabel(entry: ProfileModelCatalogEntry): string { : `${entry.displayName} (${entry.modelId})`; } +function compatibilityLabel(compatibility: ModelCompatibility): string { + if (compatibility === "compatible") return "Compatible"; + if (compatibility === "likelyTooRecent") return "Probablement trop récent"; + return "Inconnu"; +} + +function engineLabel(tab: ModelTab): string { + return tab === "codex" ? "Codex CLI" : "Claude CLI"; +} + +function attentionBadge( + compatibility: ModelCompatibility, + cliVersion: string | null, +): string | null { + if (!cliVersion) return "CLI non détecté"; + if (compatibility === "compatible") return null; + return compatibilityLabel(compatibility); +} + +function catalogEntryFor( + model: string, + models: ProfileModelCatalogEntry[], +): ProfileModelCatalogEntry | null { + const normalized = model.trim().toLowerCase(); + if (!normalized) return null; + return models.find((entry) => entry.modelId.toLowerCase() === normalized) ?? null; +} + +function compatibilityFor( + model: string, + models: ProfileModelCatalogEntry[], +): ModelCompatibility { + return catalogEntryFor(model, models)?.compatibility ?? "unknown"; +} + +function modelHelp( + tab: ModelTab, + model: string, + catalog: ProfileModelCatalog & { unavailable: boolean }, +): string { + const cli = engineLabel(tab); + if (catalog.unavailable) { + return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement."; + } + if (!catalog.cliVersion) { + return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`; + } + const compatibility = compatibilityFor(model, catalog.models); + if (compatibility === "compatible") { + return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`; + } + if (compatibility === "likelyTooRecent") { + return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`; + } + return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`; +} + +function saveWarningText(tab: ModelTab, cliVersion: string | null): string { + const cli = engineLabel(tab); + const version = cliVersion ? ` ${cliVersion}` : ""; + return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`; +} + +function matchingSuggestions( + model: string, + models: ProfileModelCatalogEntry[], +): ProfileModelCatalogEntry[] { + const q = model.trim().toLowerCase(); + const filtered = q + ? models.filter((entry) => + [entry.modelId, entry.displayName, ...entry.aliases] + .join(" ") + .toLowerCase() + .includes(q), + ) + : models; + return filtered.slice(0, 5); +} + +function ModelField({ + profileId, + profileName, + tab, + model, + catalog, + onChange, +}: { + profileId: string; + profileName: string; + tab: ModelTab; + model: string; + catalog: ProfileModelCatalog & { unavailable: boolean }; + onChange: (model: string) => void; +}) { + const [focused, setFocused] = useState(false); + const inputId = `profile-model-${tab}-${profileId}`; + const compatibility = compatibilityFor(model, catalog.models); + const badge = attentionBadge(compatibility, catalog.cliVersion); + const suggestions = matchingSuggestions(model, catalog.models); + + return ( +
+ + 0 + ? "Choisir ou saisir un modèle" + : "Saisir un modèle" + } + value={model} + onFocus={() => setFocused(true)} + onBlur={() => window.setTimeout(() => setFocused(false), 120)} + onChange={(e) => onChange(e.target.value)} + /> + {modelHelp(tab, model, catalog)} + {focused && suggestions.length > 0 && ( +
+ {suggestions.map((entry) => ( + + ))} +
+ )} +
+ ); +} + export function ProfilesSettings() { const { profile } = useGateways(); const [profiles, setProfiles] = useState([]); @@ -75,6 +265,7 @@ export function ProfilesSettings() { const [drafts, setDrafts] = useState>({}); const [error, setError] = useState(null); const [catalogueWarning, setCatalogueWarning] = useState(null); + const [saveWarnings, setSaveWarnings] = useState>({}); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { @@ -106,12 +297,18 @@ export function ProfilesSettings() { ]); if (cancelled) return; setCatalogue({ - codex: codex.status === "fulfilled" ? codex.value : [], - claude: claude.status === "fulfilled" ? claude.value : [], + codex: + codex.status === "fulfilled" + ? { ...codex.value, unavailable: false } + : { ...EMPTY_MODEL_CATALOG, unavailable: true }, + claude: + claude.status === "fulfilled" + ? { ...claude.value, unavailable: false } + : { ...EMPTY_MODEL_CATALOG, unavailable: true }, }); if (codex.status === "rejected" || claude.status === "rejected") { setCatalogueWarning( - "Catalogue de modeles indisponible: saisie manuelle active.", + "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.", ); } } @@ -146,7 +343,7 @@ export function ProfilesSettings() { try { const models = activeTab === "codex" || activeTab === "claude" - ? catalogue[activeTab] + ? catalogue[activeTab].models : []; const recommended = models.find((m) => m.recommended)?.modelId; await profile.cloneProfileFromSeed({ @@ -167,9 +364,24 @@ export function ProfilesSettings() { if (!draft) return; setBusy(true); setError(null); + setSaveWarnings((prev) => { + const { [id]: _ignored, ...rest } = prev; + return rest; + }); try { + const tab = tabFor(draft); + const warning = + tab === "codex" || tab === "claude" + ? compatibilityFor(modelOf(draft), catalogue[tab].models) === + "likelyTooRecent" + ? saveWarningText(tab, catalogue[tab].cliVersion) + : null + : null; await profile.saveProfile(draft); await refresh(); + if (warning) { + setSaveWarnings((prev) => ({ ...prev, [id]: warning })); + } } catch (e) { setError(describe(e)); } finally { @@ -212,7 +424,9 @@ export function ProfilesSettings() { } const modelOptions = - activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : []; + activeTab === "codex" || activeTab === "claude" + ? catalogue[activeTab].models + : []; return ( {catalogueWarning}

)} - - {modelOptions.map((entry) => ( - + {(activeTab === "codex" || activeTab === "claude") && + catalogue[activeTab].warnings.map((warning) => ( +

+ {warning} +

))} -
{visibleProfiles.length === 0 ? (

@@ -296,25 +509,42 @@ export function ProfilesSettings() { /> - + ) : ( + + )} + {saveWarnings[saved.id] && ( +

+ {saveWarnings[saved.id]} +

+ )}
diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index cc4815f..4a1af1c 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -37,7 +37,7 @@ import type { McpToolPolicy, OpenCodeConfig, OpenCodeProviderCatalogEntry, - ProfileModelCatalogEntry, + ProfileModelCatalog, EffectivePermissions, PairedDevice, PairingCode, @@ -670,10 +670,10 @@ export interface ProfileGateway { * Used by Settings duplication for Codex/Claude/OpenCode identity copies. */ cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise; - /** Curated Claude Code model catalogue. Manual model entry remains supported. */ - listClaudeModels(): Promise; - /** Curated Codex CLI model catalogue. Manual model entry remains supported. */ - listCodexModels(): Promise; + /** Enriched Claude Code model catalogue. Manual model entry remains supported. */ + listClaudeModels(): Promise; + /** Enriched Codex CLI model catalogue. Manual model entry remains supported. */ + listCodexModels(): Promise; /** Persists the batch of chosen profiles, closing the first run. */ configureProfiles(profiles: AgentProfile[]): Promise; /**