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:
@ -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**.
|
||||
|
||||
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user