feat: implémentation et QA ticket99 - agent model configuration v2

- backend A-C: VO Codex/Claude, renderers/projecteurs modèle, SecretRef/env, catalogues/use cases/Tauri commands
- frontend D: profils Codex/Claude provider->model->secret, validations, conservation SecretRef
- fix: app-tauri embedded_server isolant IDEA_WEB_ROOT

QA: backend 57/57 tests, frontend 74/74 tests OK
This commit is contained in:
2026-07-26 11:56:25 +02:00
parent 13fb538880
commit dae07d35bb
27 changed files with 2358 additions and 143 deletions

View File

@ -850,6 +850,15 @@ pub struct ProjectionContext<'a> {
pub project_root: &'a str,
/// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
pub run_dir: &'a str,
/// Optional model selected by the agent profile. Orthogonal to permissions:
/// projectors may still materialise it even when `eff == None`.
pub model: Option<&'a str>,
/// Optional Codex `model_provider` value selected by the profile.
pub model_provider: Option<&'a str>,
/// Optional base URL for a custom Codex model provider.
pub model_provider_base_url: Option<&'a str>,
/// Optional API-key environment variable name for a model provider.
pub model_provider_env_key: Option<&'a str>,
}
/// One file a projector wants materialised at launch, tagged by **ownership**.

View File

@ -423,6 +423,125 @@ impl OpenCodeProviderConfig {
}
}
/// Configuration additive d'un provider Codex personnalisé (endpoint
/// OpenAI-compatible arbitraire, hors providers natifs Codex).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCustomProviderConfig {
/// URL de base de l'endpoint OpenAI-compatible.
pub base_url: String,
/// Libellé optionnel écrit dans la table `model_providers`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
impl CodexCustomProviderConfig {
/// Construit une configuration validée (parse-don't-validate).
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `base_url`, ou un `display_name`
/// fourni non vide après trim, est vide.
pub fn new(
base_url: impl Into<String>,
display_name: Option<String>,
) -> Result<Self, DomainError> {
let base_url = base_url.into();
crate::validation::non_empty(&base_url, "codexProvider.custom.baseUrl")?;
if let Some(name) = &display_name {
crate::validation::non_empty(name, "codexProvider.custom.displayName")?;
}
Ok(Self {
base_url,
display_name,
})
}
}
/// Configuration déclarative d'un profil Codex contrôlé par IdeA.
///
/// Le modèle est rendu dans le `$CODEX_HOME/config.toml` isolé du run dir, et la
/// clé API réelle reste dans le [`crate::ports::SecretStore`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexProviderConfig {
/// Identifiant du provider Codex (`"openai"` ou un provider custom).
pub provider_id: String,
/// Nom du modèle servi par ce provider.
pub model: String,
/// Référence opaque vers la clé API réelle, jamais persistée en clair.
pub api_key_ref: crate::ports::SecretRef,
/// Configuration additive d'un provider personnalisé.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<CodexCustomProviderConfig>,
}
impl CodexProviderConfig {
/// Construit une configuration validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
pub fn new(
provider_id: impl Into<String>,
model: impl Into<String>,
api_key_ref: crate::ports::SecretRef,
) -> Result<Self, DomainError> {
let provider_id = provider_id.into();
let model = model.into();
crate::validation::non_empty(&provider_id, "codexProvider.providerId")?;
crate::validation::non_empty(&model, "codexProvider.model")?;
Ok(Self {
provider_id,
model,
api_key_ref,
custom: None,
})
}
/// Attache une configuration de provider personnalisé.
#[must_use]
pub fn with_custom(mut self, custom: CodexCustomProviderConfig) -> Self {
self.custom = Some(custom);
self
}
}
/// Configuration déclarative d'un profil Claude contrôlé par IdeA.
///
/// Le modèle est rendu dans le `.claude/settings.local.json` isolé du run dir, et
/// la clé API réelle reste dans le [`crate::ports::SecretStore`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeProviderConfig {
/// Identifiant du provider Claude. V1 backend: `"anthropic"`.
pub provider_id: String,
/// Nom du modèle Claude à poser au lancement.
pub model: String,
/// Référence opaque vers la clé API réelle, jamais persistée en clair.
pub api_key_ref: crate::ports::SecretRef,
}
impl ClaudeProviderConfig {
/// Construit une configuration validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
pub fn new(
provider_id: impl Into<String>,
model: impl Into<String>,
api_key_ref: crate::ports::SecretRef,
) -> Result<Self, DomainError> {
let provider_id = provider_id.into();
let model = model.into();
crate::validation::non_empty(&provider_id, "claudeProvider.providerId")?;
crate::validation::non_empty(&model, "claudeProvider.model")?;
Ok(Self {
provider_id,
model,
api_key_ref,
})
}
}
/// Configuration additive d'un provider OpenCode **personnalisé** (endpoint
/// OpenAI-compatible arbitraire, hors catalogue OpenCode), portée par
/// [`OpenCodeProviderConfig::custom`].
@ -896,6 +1015,16 @@ pub struct AgentProfile {
/// avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_provider: Option<OpenCodeProviderConfig>,
/// Configuration Codex provider/modèle (ticket #99). `None` pour les profils
/// non-Codex et les profils Codex historiques qui gardent le défaut natif de
/// la CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider: Option<CodexProviderConfig>,
/// Configuration Claude provider/modèle (ticket #99). `None` pour les profils
/// non-Claude et les profils Claude historiques qui gardent le défaut natif de
/// la CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_provider: Option<ClaudeProviderConfig>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
@ -1102,6 +1231,8 @@ impl AgentProfile {
chat_http: None,
opencode: None,
opencode_provider: None,
codex_provider: None,
claude_provider: None,
mcp: None,
liveness: None,
rate_limit_pattern: None,
@ -1152,6 +1283,20 @@ impl AgentProfile {
self
}
/// Builder : fixe la configuration Codex provider/modèle (ticket #99).
#[must_use]
pub fn with_codex_provider(mut self, config: CodexProviderConfig) -> Self {
self.codex_provider = Some(config);
self
}
/// Builder : fixe la configuration Claude provider/modèle (ticket #99).
#[must_use]
pub fn with_claude_provider(mut self, config: ClaudeProviderConfig) -> Self {
self.claude_provider = Some(config);
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas.
@ -1462,6 +1607,53 @@ mod mcp_tests {
assert_eq!(back.opencode_provider, Some(provider));
}
#[test]
fn codex_and_claude_provider_configs_round_trip_camelcase() {
let codex = CodexProviderConfig::new(
"openai",
"gpt-5",
crate::ports::SecretRef::new("secret-openai"),
)
.unwrap()
.with_custom(
CodexCustomProviderConfig::new(
"https://models.example.test/v1",
Some("Example".to_owned()),
)
.unwrap(),
);
let claude = ClaudeProviderConfig::new(
"anthropic",
"claude-sonnet-4-5",
crate::ports::SecretRef::new("secret-anthropic"),
)
.unwrap();
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Codex)
.with_codex_provider(codex.clone())
.with_claude_provider(claude.clone());
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("\"codexProvider\""), "got: {json}");
assert!(json.contains("\"claudeProvider\""), "got: {json}");
assert!(json.contains("\"providerId\":\"openai\""), "got: {json}");
assert!(json.contains("\"baseUrl\""), "got: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.codex_provider, Some(codex));
assert_eq!(back.claude_provider, Some(claude));
}
#[test]
fn codex_and_claude_provider_configs_reject_empty_fields() {
let secret_ref = crate::ports::SecretRef::new("secret-1");
assert!(CodexProviderConfig::new("", "gpt-5", secret_ref.clone()).is_err());
assert!(CodexProviderConfig::new("openai", "", secret_ref.clone()).is_err());
assert!(ClaudeProviderConfig::new("", "claude-sonnet-4-5", secret_ref.clone()).is_err());
assert!(ClaudeProviderConfig::new("anthropic", "", secret_ref).is_err());
assert!(CodexCustomProviderConfig::new("", None).is_err());
}
#[test]
fn opencode_backend_consistency_rejects_both_configs_set() {
let local = OpenCodeConfig::new(