merge feature/multi-profiles-codex-clarification-toast-notices dans develop

Résout le conflit d'imports/helpers de tests dans
crates/application/tests/model_server.rs par union des deux côtés
(imports application/domain + helpers aid/nid/sess).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 13:06:35 +02:00
63 changed files with 5674 additions and 993 deletions

View File

@ -9,6 +9,7 @@
mod catalogue;
mod inspect;
mod lifecycle;
mod model_catalogue;
mod provider_catalogue;
mod resume;
mod session_limit;
@ -39,6 +40,10 @@ pub use lifecycle::{
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use model_catalogue::{
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry,
};
pub use provider_catalogue::{
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
@ -48,10 +53,11 @@ pub use resume::{
};
pub use usecases::{
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability,
ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput,
};

View File

@ -0,0 +1,468 @@
//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
//!
//! 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)]
pub struct ProfileModelCatalogEntry {
/// Structured adapter this model belongs to.
pub adapter: StructuredAdapter,
/// Exact model identifier to persist on [`domain::profile::AgentProfile::model`].
pub model_id: String,
/// Human-readable label for picker display.
pub display_name: String,
/// Extra search tokens useful to the frontend.
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(
adapter: StructuredAdapter,
model_id: &str,
display_name: &str,
aliases: &[&str],
recommended: bool,
) -> ProfileModelCatalogEntry {
ProfileModelCatalogEntry {
adapter,
model_id: model_id.to_owned(),
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,
}
}
/// Static Claude Code model catalogue.
#[must_use]
pub fn claude_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
vec![
entry(
StructuredAdapter::Claude,
"claude-sonnet-5",
"Claude Sonnet 5",
&["sonnet"],
true,
),
entry(
StructuredAdapter::Claude,
"claude-opus-4-8",
"Claude Opus 4.8",
&["opus"],
false,
),
entry(
StructuredAdapter::Claude,
"claude-haiku-4-5-20251001",
"Claude Haiku 4.5",
&["haiku"],
false,
),
]
}
/// Static OpenAI Codex CLI model catalogue.
#[must_use]
pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
vec![
entry(
StructuredAdapter::Codex,
"gpt-5-codex",
"GPT-5 Codex",
&["codex"],
true,
),
entry(
StructuredAdapter::Codex,
"gpt-5",
"GPT-5",
&["general"],
false,
),
entry(
StructuredAdapter::Codex,
"gpt-5-mini",
"GPT-5 mini",
&["mini", "fast"],
false,
),
]
}
/// 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 From<ListModelsOutput> for ListClaudeModelsOutput {
fn from(out: ListModelsOutput) -> Self {
Self {
models: out.models,
cli_version: out.cli_version,
warnings: out.warnings,
}
}
}
/// 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 From<ListModelsOutput> for ListCodexModelsOutput {
fn from(out: ListModelsOutput) -> Self {
Self {
models: out.models,
cli_version: out.cli_version,
warnings: out.warnings,
}
}
}
/// 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() {
for (adapter, models) in [
(StructuredAdapter::Claude, claude_model_catalogue()),
(StructuredAdapter::Codex, codex_model_catalogue()),
] {
assert!(!models.is_empty());
assert_eq!(
models.iter().filter(|model| model.recommended).count(),
1,
"{adapter:?} should expose one default suggestion"
);
for model in models {
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);
}
}

View File

@ -146,6 +146,92 @@ pub struct SaveProfileOutput {
pub profile: AgentProfile,
}
// ---------------------------------------------------------------------------
// CloneProfileFromSeed
// ---------------------------------------------------------------------------
/// Input for [`CloneProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneProfileFromSeedInput {
/// Id of the persisted or reference profile to clone.
pub seed_profile_id: ProfileId,
/// Optional display name for the cloned profile. When absent, a copy label is
/// derived from the seed name.
pub name: Option<String>,
/// Optional model override. When absent, the seed model is copied as-is.
pub model: Option<String>,
}
/// Output of [`CloneProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneProfileFromSeedOutput {
/// The newly persisted profile.
pub profile: AgentProfile,
}
/// Creates a new profile instance from an existing persisted/reference seed.
///
/// Persisted profiles are preferred over reference seeds so user edits to the
/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the
/// backend [`IdGenerator`]; callers can override the display name and model
/// without minting ids client-side.
pub struct CloneProfileFromSeed {
store: Arc<dyn ProfileStore>,
ids: Arc<dyn IdGenerator>,
}
impl CloneProfileFromSeed {
/// Builds the use case from the profile store and id generator ports.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { store, ids }
}
/// Clones the requested seed into a new persisted profile.
///
/// # Errors
/// [`AppError::NotFound`] if no persisted/reference profile has the seed id,
/// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`]
/// on persistence failure.
pub async fn execute(
&self,
input: CloneProfileFromSeedInput,
) -> Result<CloneProfileFromSeedOutput, AppError> {
let existing = self.store.list().await?;
let seed = existing
.iter()
.find(|profile| profile.id == input.seed_profile_id)
.cloned()
.or_else(|| {
reference_profiles()
.into_iter()
.find(|profile| profile.id == input.seed_profile_id)
})
.ok_or(AppError::NotFound("profile seed not found".into()))?;
let mut profile = seed;
profile.id = fresh_profile_id(&*self.ids, &existing)?;
profile.name = match input.name {
Some(name) => {
if name.trim().is_empty() {
return Err(AppError::Invalid("profile.name must not be empty".into()));
}
name
}
None => format!("{} copy", profile.name),
};
if let Some(model) = input.model {
if model.trim().is_empty() {
return Err(AppError::Invalid("profile.model must not be empty".into()));
}
profile.model = Some(model);
}
self.store.save(&profile).await?;
Ok(CloneProfileFromSeedOutput { profile })
}
}
// ---------------------------------------------------------------------------
// CloneOpenCodeProfileFromSeed
// ---------------------------------------------------------------------------