feat: finalise ticket99 - implémentation agent model configuration v2
- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
This commit is contained in:
@ -423,125 +423,6 @@ 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`].
|
||||
@ -1015,16 +896,11 @@ 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.
|
||||
/// Modèle explicitement configuré pour les CLIs qui supportent un réglage
|
||||
/// déclaratif direct (Codex/Claude). `None` conserve le défaut natif de la
|
||||
/// CLI. OpenCode garde ses champs dédiés.
|
||||
#[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>,
|
||||
pub model: Option<String>,
|
||||
/// 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
|
||||
@ -1231,8 +1107,7 @@ impl AgentProfile {
|
||||
chat_http: None,
|
||||
opencode: None,
|
||||
opencode_provider: None,
|
||||
codex_provider: None,
|
||||
claude_provider: None,
|
||||
model: None,
|
||||
mcp: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
@ -1283,17 +1158,10 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la configuration Codex provider/modèle (ticket #99).
|
||||
/// Builder : fixe le modèle CLI direct (Codex/Claude, 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);
|
||||
pub fn with_model(mut self, model: impl Into<String>) -> Self {
|
||||
self.model = Some(model.into());
|
||||
self
|
||||
}
|
||||
|
||||
@ -1608,50 +1476,20 @@ mod mcp_tests {
|
||||
}
|
||||
|
||||
#[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();
|
||||
fn profile_model_round_trips_without_codex_or_claude_provider_config() {
|
||||
let profile = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_codex_provider(codex.clone())
|
||||
.with_claude_provider(claude.clone());
|
||||
.with_model("gpt-5-codex");
|
||||
|
||||
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}");
|
||||
assert!(json.contains("\"model\":\"gpt-5-codex\""), "got: {json}");
|
||||
assert!(!json.contains("codexProvider"), "got: {json}");
|
||||
assert!(!json.contains("claudeProvider"), "got: {json}");
|
||||
assert!(!json.contains("apiKeyRef"), "got: {json}");
|
||||
assert!(!json.contains("providerId"), "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());
|
||||
assert_eq!(back.model.as_deref(), Some("gpt-5-codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user