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

Ajout du catalogue enrichi pour les modèles Codex et Claude avec:
- Compatibilité estimée avec la version CLI locale détectée
- Source d'origine (catalogue/Provider) pour chaque entrée
- Support du catalogue Provider API externe
- Matrice de compatibilité embarquée dans l'application

Frontend:
- UI de configuration des modèles avec affichage des états de compatibilité
- Suggestions dynamiques avec badges de compatibilité
- Messages d'aide contextuels (compatible/unknown/likelyTooRecent)
- Alertes non-bloquantes pour les modèles trop récents
- Gestion des échecs de catalogue avec saisie manuelle conservée

Backend:
- Ports CliVersionReader, ProviderModelCatalogue, CompatibilityMatrixSource
- Implémentations: ProcessCliVersionReader, HttpProviderModelCatalogue, EmbeddedCompatibilityMatrix
- Enrichissement des DTOs avec compatibility, cli_version, warnings
- Tests unitaires complets pour le resolver de catalogue
This commit is contained in:
2026-07-26 16:09:10 +02:00
parent e7bf1d3666
commit ca70ec75f4
25 changed files with 1582 additions and 167 deletions

View File

@ -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,

View File

@ -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<u64>,
}
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<String>) -> Result<Self, ModelCatalogueError> {
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::<String>();
let parts = version
.split('.')
.filter(|part| !part.is_empty())
.map(str::parse::<u64>)
.collect::<Result<Vec<_>, _>>()
.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<Ordering> {
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<String, String>,
/// Codex CLI entries keyed by model id.
#[serde(default)]
pub codex: HashMap<String, String>,
}
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
);
}
}

View File

@ -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<Output, ProcessError>;
}
/// 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<Option<CliVersion>, 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<Vec<String>, 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<String>);
}
/// Probe readiness of an OpenAI-compatible model server.
#[async_trait]
pub trait ModelServerProbe: Send + Sync {