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:
@ -20,7 +20,10 @@ use domain::ports::{
|
||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
||||
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use domain::profile::{
|
||||
ClaudeProviderConfig, CodexProviderConfig, McpConfigStrategy, OpenCodeProviderConfig,
|
||||
StructuredAdapter,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||
@ -1130,6 +1133,38 @@ fn build_structured_launch_policy(
|
||||
})
|
||||
}
|
||||
|
||||
fn projection_model(profile: &AgentProfile) -> Option<&str> {
|
||||
profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.model.as_str())
|
||||
.or_else(|| {
|
||||
profile
|
||||
.claude_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.model.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn projection_model_provider(profile: &AgentProfile) -> Option<&str> {
|
||||
profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.provider_id.as_str())
|
||||
}
|
||||
|
||||
fn projection_model_provider_base_url(profile: &AgentProfile) -> Option<&str> {
|
||||
profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.and_then(|provider| provider.custom.as_ref())
|
||||
.map(|custom| custom.base_url.as_str())
|
||||
}
|
||||
|
||||
fn projection_model_provider_env_key(profile: &AgentProfile) -> Option<&str> {
|
||||
profile.codex_provider.as_ref().map(|_| "OPENAI_API_KEY")
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
||||
///
|
||||
@ -1780,6 +1815,8 @@ impl LaunchAgent {
|
||||
|
||||
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
|
||||
.await?;
|
||||
self.apply_profile_provider_env(&profile, &run_dir, &mut spec)
|
||||
.await?;
|
||||
|
||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||
@ -2208,6 +2245,10 @@ impl LaunchAgent {
|
||||
let ctx = ProjectionContext {
|
||||
project_root: project_root.as_str(),
|
||||
run_dir: run_dir.as_str(),
|
||||
model: projection_model(profile),
|
||||
model_provider: projection_model_provider(profile),
|
||||
model_provider_base_url: projection_model_provider_base_url(profile),
|
||||
model_provider_env_key: projection_model_provider_env_key(profile),
|
||||
};
|
||||
let projection = projector.project(permissions, network, &ctx);
|
||||
|
||||
@ -2467,7 +2508,7 @@ impl LaunchAgent {
|
||||
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
|
||||
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
|
||||
let home_dir = parent_dir(run_dir, target);
|
||||
spec.env.push((home_env.clone(), home_dir));
|
||||
upsert_env(&mut spec.env, home_env, &home_dir);
|
||||
}
|
||||
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
@ -2561,6 +2602,64 @@ impl LaunchAgent {
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_profile_provider_env(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(provider) = profile.codex_provider.as_ref() {
|
||||
let api_key = self.resolve_codex_provider_api_key(provider).await?;
|
||||
upsert_env(&mut spec.env, "OPENAI_API_KEY", &api_key);
|
||||
upsert_env(&mut spec.env, "CODEX_HOME", &join(run_dir, ".codex"));
|
||||
}
|
||||
if let Some(provider) = profile.claude_provider.as_ref() {
|
||||
let api_key = self.resolve_claude_provider_api_key(provider).await?;
|
||||
upsert_env(&mut spec.env, "ANTHROPIC_API_KEY", &api_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_codex_provider_api_key(
|
||||
&self,
|
||||
provider: &CodexProviderConfig,
|
||||
) -> Result<String, AppError> {
|
||||
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid("Codex provider profile requires a SecretStore, none injected".into())
|
||||
})?;
|
||||
secret_store
|
||||
.get(&provider.api_key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(format!(
|
||||
"no secret found for Codex provider `{}` (secret ref `{}`)",
|
||||
provider.provider_id,
|
||||
provider.api_key_ref.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_claude_provider_api_key(
|
||||
&self,
|
||||
provider: &ClaudeProviderConfig,
|
||||
) -> Result<String, AppError> {
|
||||
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"Claude provider profile requires a SecretStore, none injected".into(),
|
||||
)
|
||||
})?;
|
||||
secret_store
|
||||
.get(&provider.api_key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(format!(
|
||||
"no secret found for Claude provider `{}` (secret ref `{}`)",
|
||||
provider.provider_id,
|
||||
provider.api_key_ref.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_local_model_server_for_opencode(
|
||||
&self,
|
||||
agent: &Agent,
|
||||
|
||||
@ -40,8 +40,10 @@ pub use lifecycle::{
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use provider_catalogue::{
|
||||
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
|
||||
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||
claude_provider_catalogue, codex_provider_catalogue, opencode_models_cache_path,
|
||||
opencode_provider_catalogue, ClaudeProviderCatalogEntry, CodexProviderCatalogEntry,
|
||||
ListClaudeProviders, ListClaudeProvidersOutput, ListCodexProviders, ListCodexProvidersOutput,
|
||||
ListOpenCodeProviders, ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
@ -52,6 +54,8 @@ pub use usecases::{
|
||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
SaveClaudeProviderProfile, SaveClaudeProviderProfileInput, SaveClaudeProviderProfileOutput,
|
||||
SaveCodexProviderProfile, SaveCodexProviderProfileInput, SaveCodexProviderProfileOutput,
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
|
||||
@ -41,6 +41,60 @@ pub struct OpenCodeProviderCatalogEntry {
|
||||
pub(crate) model_labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One entry of a Codex provider catalogue (ticket #99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexProviderCatalogEntry {
|
||||
/// Provider identifier used as Codex's `model_provider`.
|
||||
pub provider_id: String,
|
||||
/// Human-readable label for the picker UI.
|
||||
pub display_name: String,
|
||||
/// Model identifiers this provider serves.
|
||||
pub models: Vec<String>,
|
||||
/// Whether the UI may save this provider with a custom endpoint.
|
||||
pub custom_supported: bool,
|
||||
}
|
||||
|
||||
/// One entry of a Claude provider catalogue (ticket #99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClaudeProviderCatalogEntry {
|
||||
/// Provider identifier. V1 backend exposes Anthropic.
|
||||
pub provider_id: String,
|
||||
/// Human-readable label for the picker UI.
|
||||
pub display_name: String,
|
||||
/// Model identifiers this provider serves.
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Returns the static Codex provider catalogue.
|
||||
#[must_use]
|
||||
pub fn codex_provider_catalogue() -> Vec<CodexProviderCatalogEntry> {
|
||||
vec![CodexProviderCatalogEntry {
|
||||
provider_id: "openai".to_owned(),
|
||||
display_name: "OpenAI".to_owned(),
|
||||
models: vec![
|
||||
"gpt-5".to_owned(),
|
||||
"gpt-5-mini".to_owned(),
|
||||
"gpt-5-codex".to_owned(),
|
||||
"o3".to_owned(),
|
||||
],
|
||||
custom_supported: true,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Returns the static Claude provider catalogue.
|
||||
#[must_use]
|
||||
pub fn claude_provider_catalogue() -> Vec<ClaudeProviderCatalogEntry> {
|
||||
vec![ClaudeProviderCatalogEntry {
|
||||
provider_id: "anthropic".to_owned(),
|
||||
display_name: "Anthropic".to_owned(),
|
||||
models: vec![
|
||||
"claude-sonnet-4-5".to_owned(),
|
||||
"claude-opus-4-1".to_owned(),
|
||||
"claude-haiku-3-5".to_owned(),
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/// The original lot-B3 catalogue: three well-known providers, used whenever
|
||||
/// the real OpenCode model cache can't be read or parsed.
|
||||
fn static_fallback_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
|
||||
@ -285,6 +339,70 @@ impl Default for ListOpenCodeProviders {
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the static Codex provider catalogue.
|
||||
pub struct ListCodexProviders;
|
||||
|
||||
/// Output of [`ListCodexProviders::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListCodexProvidersOutput {
|
||||
/// The catalogue entries.
|
||||
pub providers: Vec<CodexProviderCatalogEntry>,
|
||||
}
|
||||
|
||||
impl ListCodexProviders {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists the Codex provider catalogue.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListCodexProvidersOutput {
|
||||
ListCodexProvidersOutput {
|
||||
providers: codex_provider_catalogue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListCodexProviders {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the static Claude provider catalogue.
|
||||
pub struct ListClaudeProviders;
|
||||
|
||||
/// Output of [`ListClaudeProviders::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListClaudeProvidersOutput {
|
||||
/// The catalogue entries.
|
||||
pub providers: Vec<ClaudeProviderCatalogEntry>,
|
||||
}
|
||||
|
||||
impl ListClaudeProviders {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists the Claude provider catalogue.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListClaudeProvidersOutput {
|
||||
ListClaudeProvidersOutput {
|
||||
providers: claude_provider_catalogue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListClaudeProviders {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user