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

@ -16,7 +16,8 @@ use std::sync::Arc;
use domain::ids::ProfileId;
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore};
use domain::profile::{
AgentProfile, CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
AgentProfile, ClaudeProviderConfig, CodexCustomProviderConfig, CodexProviderConfig,
CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
};
use crate::error::AppError;
@ -324,6 +325,149 @@ pub struct SaveOpenCodeProviderProfileOutput {
pub profile: AgentProfile,
}
/// Input for [`SaveCodexProviderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveCodexProviderProfileInput {
/// The profile to create or replace (by id).
pub profile: AgentProfile,
/// Provider id used as Codex's `model_provider`.
pub provider_id: String,
/// Model name served by this provider.
pub model: String,
/// Literal API key, sealed into the `SecretStore`.
pub api_key: String,
/// Optional custom-provider endpoint configuration.
pub custom: Option<CodexCustomProviderConfig>,
}
/// Output of [`SaveCodexProviderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveCodexProviderProfileOutput {
/// The saved profile (echoed back), with `codex_provider` set.
pub profile: AgentProfile,
}
/// Persists a Codex profile backed by a provider/model pair (ticket #99).
pub struct SaveCodexProviderProfile {
profile_store: Arc<dyn ProfileStore>,
secret_store: Arc<dyn SecretStore>,
ids: Arc<dyn IdGenerator>,
}
impl SaveCodexProviderProfile {
/// Builds the use case from the profile store, secret store and id generator
/// ports.
#[must_use]
pub fn new(
profile_store: Arc<dyn ProfileStore>,
secret_store: Arc<dyn SecretStore>,
ids: Arc<dyn IdGenerator>,
) -> Self {
Self {
profile_store,
secret_store,
ids,
}
}
/// Seals `input.api_key` under a [`SecretRef`] and persists the profile with
/// `codex_provider` set.
///
/// # Errors
/// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`]
/// on secret or profile persistence failure.
pub async fn execute(
&self,
input: SaveCodexProviderProfileInput,
) -> Result<SaveCodexProviderProfileOutput, AppError> {
let secret_ref = input
.profile
.codex_provider
.as_ref()
.map(|config| config.api_key_ref.clone())
.unwrap_or_else(|| SecretRef::new(self.ids.new_uuid().to_string()));
self.secret_store.put(&secret_ref, &input.api_key).await?;
let mut provider = CodexProviderConfig::new(input.provider_id, input.model, secret_ref)
.map_err(|e| AppError::Invalid(e.to_string()))?;
if let Some(custom) = input.custom {
provider = provider.with_custom(custom);
}
let profile = input.profile.with_codex_provider(provider);
self.profile_store.save(&profile).await?;
Ok(SaveCodexProviderProfileOutput { profile })
}
}
/// Input for [`SaveClaudeProviderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveClaudeProviderProfileInput {
/// The profile to create or replace (by id).
pub profile: AgentProfile,
/// Provider id. V1 backend expects `"anthropic"`.
pub provider_id: String,
/// Model name served by this provider.
pub model: String,
/// Literal API key, sealed into the `SecretStore`.
pub api_key: String,
}
/// Output of [`SaveClaudeProviderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveClaudeProviderProfileOutput {
/// The saved profile (echoed back), with `claude_provider` set.
pub profile: AgentProfile,
}
/// Persists a Claude profile backed by a provider/model pair (ticket #99).
pub struct SaveClaudeProviderProfile {
profile_store: Arc<dyn ProfileStore>,
secret_store: Arc<dyn SecretStore>,
ids: Arc<dyn IdGenerator>,
}
impl SaveClaudeProviderProfile {
/// Builds the use case from the profile store, secret store and id generator
/// ports.
#[must_use]
pub fn new(
profile_store: Arc<dyn ProfileStore>,
secret_store: Arc<dyn SecretStore>,
ids: Arc<dyn IdGenerator>,
) -> Self {
Self {
profile_store,
secret_store,
ids,
}
}
/// Seals `input.api_key` under a [`SecretRef`] and persists the profile with
/// `claude_provider` set.
///
/// # Errors
/// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`]
/// on secret or profile persistence failure.
pub async fn execute(
&self,
input: SaveClaudeProviderProfileInput,
) -> Result<SaveClaudeProviderProfileOutput, AppError> {
let secret_ref = input
.profile
.claude_provider
.as_ref()
.map(|config| config.api_key_ref.clone())
.unwrap_or_else(|| SecretRef::new(self.ids.new_uuid().to_string()));
self.secret_store.put(&secret_ref, &input.api_key).await?;
let provider = ClaudeProviderConfig::new(input.provider_id, input.model, secret_ref)
.map_err(|e| AppError::Invalid(e.to_string()))?;
let profile = input.profile.with_claude_provider(provider);
self.profile_store.save(&profile).await?;
Ok(SaveClaudeProviderProfileOutput { profile })
}
}
/// Persists an OpenCode profile backed by a **cloud** provider (ticket #92, lot
/// B3), keeping the literal API key out of `profiles.json`: it is sealed into the
/// [`SecretStore`] under an opaque [`SecretRef`], and only the ref is persisted on
@ -436,6 +580,12 @@ impl DeleteProfile {
if let Some(config) = &profile.opencode_provider {
self.secret_store.delete(&config.api_key_ref).await?;
}
if let Some(config) = &profile.codex_provider {
self.secret_store.delete(&config.api_key_ref).await?;
}
if let Some(config) = &profile.claude_provider {
self.secret_store.delete(&config.api_key_ref).await?;
}
}
self.store.delete(input.id).await?;
Ok(())