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:
2026-07-26 13:38:18 +02:00
parent b5d7e16501
commit 0821739924
23 changed files with 240 additions and 1846 deletions

View File

@ -40,27 +40,26 @@ use crate::dto::{
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
ChangeAgentProfileRequestDto, ClaudeProviderListDto, CloneOpenCodeProfileFromSeedRequestDto,
CodexProviderListDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveClaudeProviderProfileRequestDto,
SaveCodexProviderProfileRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
@ -1189,22 +1188,6 @@ pub async fn list_opencode_providers(
Ok(state.list_opencode_providers.execute().into())
}
/// `list_codex_providers` — static catalogue of Codex providers (ticket #99).
#[tauri::command]
pub async fn list_codex_providers(
state: State<'_, AppState>,
) -> Result<CodexProviderListDto, ErrorDto> {
Ok(state.list_codex_providers.execute().into())
}
/// `list_claude_providers` — static catalogue of Claude providers (ticket #99).
#[tauri::command]
pub async fn list_claude_providers(
state: State<'_, AppState>,
) -> Result<ClaudeProviderListDto, ErrorDto> {
Ok(state.list_claude_providers.execute().into())
}
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
@ -1225,36 +1208,6 @@ pub async fn save_opencode_provider_profile(
.map_err(ErrorDto::from)
}
/// `save_codex_provider_profile` — create or replace a Codex profile backed by
/// a provider/model. The literal API key is sealed into the `SecretStore`.
#[tauri::command]
pub async fn save_codex_provider_profile(
request: SaveCodexProviderProfileRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
state
.save_codex_provider_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)
}
/// `save_claude_provider_profile` — create or replace a Claude profile backed by
/// a provider/model. The literal API key is sealed into the `SecretStore`.
#[tauri::command]
pub async fn save_claude_provider_profile(
request: SaveClaudeProviderProfileRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
state
.save_claude_provider_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)
}
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
/// from the canonical `opencode-llamacpp` seed/template.
///

View File

@ -255,10 +255,6 @@ pub fn run() {
commands::save_profile,
commands::save_opencode_provider_profile,
commands::list_opencode_providers,
commands::save_codex_provider_profile,
commands::list_codex_providers,
commands::save_claude_provider_profile,
commands::list_claude_providers,
commands::clone_opencode_profile_from_seed,
commands::delete_profile,
commands::configure_profiles,

View File

@ -20,10 +20,7 @@ use domain::ports::{
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{
ClaudeProviderConfig, CodexProviderConfig, McpConfigStrategy, OpenCodeProviderConfig,
StructuredAdapter,
};
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
@ -1134,35 +1131,7 @@ 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")
profile.model.as_deref()
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
@ -1815,8 +1784,6 @@ 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 /
@ -2246,9 +2213,6 @@ impl LaunchAgent {
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);
@ -2483,6 +2447,7 @@ impl LaunchAgent {
&declaration,
run_dir.as_str(),
project_root.as_str(),
profile.model.as_deref(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
@ -2494,6 +2459,7 @@ impl LaunchAgent {
&declaration,
run_dir.as_str(),
project_root.as_str(),
profile.model.as_deref(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
@ -2602,64 +2568,6 @@ 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,
@ -3148,6 +3056,11 @@ fn toml_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
}
fn set_top_level_toml_value(input: &str, key: &str, value: &str) -> String {
let line = format!("{key} = {}", toml_string(value));
set_top_level_toml_line(input, key, &line)
}
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
@ -3159,8 +3072,12 @@ fn codex_config_toml(
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
model: Option<&str>,
) -> String {
let mut text = existing.unwrap_or_default().to_owned();
if let Some(model) = model {
text = set_top_level_toml_value(&text, "model", model);
}
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = ensure_codex_trust(&text, run_dir);
text = ensure_codex_trust(&text, project_root);
@ -4585,6 +4502,7 @@ command = "other"
"[mcp_servers.idea]\ncommand = \"new\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
Some("gpt-5-codex"),
);
// MCP table + trust entries are the only things this function touches.
@ -4593,6 +4511,7 @@ command = "other"
assert!(rendered.contains("approval_policy = \"nested\""));
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
assert!(rendered.contains("model = \"gpt-5-codex\""));
assert!(!rendered.contains("command = \"old\""));
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
@ -4607,8 +4526,10 @@ command = "other"
"[mcp_servers.idea]\ncommand = \"idea-mcp\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
None,
);
assert!(!rendered.contains("model ="));
assert!(!rendered.contains("approval_policy ="));
assert!(!rendered.contains("sandbox_mode ="));
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));

View File

@ -40,10 +40,8 @@ pub use lifecycle::{
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use provider_catalogue::{
claude_provider_catalogue, codex_provider_catalogue, opencode_models_cache_path,
opencode_provider_catalogue, ClaudeProviderCatalogEntry, CodexProviderCatalogEntry,
ListClaudeProviders, ListClaudeProvidersOutput, ListCodexProviders, ListCodexProvidersOutput,
ListOpenCodeProviders, ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
@ -54,8 +52,6 @@ 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,
};

View File

@ -41,60 +41,6 @@ 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> {
@ -339,70 +285,6 @@ 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::*;

View File

@ -16,8 +16,7 @@ use std::sync::Arc;
use domain::ids::ProfileId;
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore};
use domain::profile::{
AgentProfile, ClaudeProviderConfig, CodexCustomProviderConfig, CodexProviderConfig,
CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
AgentProfile, CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
};
use crate::error::AppError;
@ -325,149 +324,6 @@ 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
@ -580,12 +436,6 @@ 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(())

View File

@ -44,26 +44,23 @@ pub use agent::{
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
ClaudeProviderCatalogEntry, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, CodexProviderCatalogEntry, ConfigureProfiles,
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
HandoffProvider, InjectedLiveRow, InspectConversation, InspectConversationInput,
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
ListAgentsInput, ListAgentsOutput, ListClaudeProviders, ListClaudeProvidersOutput,
ListCodexProviders, ListCodexProvidersOutput, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveClaudeProviderProfile,
SaveClaudeProviderProfileInput, SaveClaudeProviderProfileOutput, SaveCodexProviderProfile,
SaveCodexProviderProfileInput, SaveCodexProviderProfileOutput, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,

View File

@ -27,9 +27,8 @@ use application::{
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
ReferenceProfiles, SaveClaudeProviderProfile, SaveClaudeProviderProfileInput,
SaveCodexProviderProfile, SaveCodexProviderProfileInput, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -602,70 +601,25 @@ async fn delete_profile_with_opencode_provider_purges_its_secret() {
}
#[tokio::test]
async fn save_codex_provider_profile_seals_key_and_sets_backend_config() {
async fn save_profile_persists_codex_claude_model_without_provider_or_secret_surface() {
let store = FakeProfileStore::default();
let secrets = FakeSecretStore::default();
let save = SaveCodexProviderProfile::new(
Arc::new(store.clone()),
Arc::new(secrets.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9901)])),
);
let save = SaveProfile::new(Arc::new(store.clone()));
let profile = profile(991, "Codex GPT-5", "codex").with_model("gpt-5-codex");
let out = save
.execute(SaveCodexProviderProfileInput {
profile: profile(991, "Codex GPT-5", "codex"),
provider_id: "openai".to_owned(),
model: "gpt-5".to_owned(),
api_key: "sk-openai-literal".to_owned(),
custom: None,
.execute(SaveProfileInput {
profile: profile.clone(),
})
.await
.unwrap();
let provider = out.profile.codex_provider.as_ref().unwrap();
assert_eq!(provider.provider_id, "openai");
assert_eq!(provider.model, "gpt-5");
assert_ne!(provider.api_key_ref.as_str(), "sk-openai-literal");
assert!(!serde_json::to_string(&out.profile)
.unwrap()
.contains("sk-openai-literal"));
assert_eq!(
secrets.get(&provider.api_key_ref).await.unwrap(),
Some("sk-openai-literal".to_owned())
);
}
#[tokio::test]
async fn save_claude_provider_profile_seals_key_and_sets_backend_config() {
let store = FakeProfileStore::default();
let secrets = FakeSecretStore::default();
let save = SaveClaudeProviderProfile::new(
Arc::new(store.clone()),
Arc::new(secrets.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9902)])),
);
let out = save
.execute(SaveClaudeProviderProfileInput {
profile: profile(992, "Claude Sonnet", "claude"),
provider_id: "anthropic".to_owned(),
model: "claude-sonnet-4-5".to_owned(),
api_key: "sk-anthropic-literal".to_owned(),
})
.await
.unwrap();
let provider = out.profile.claude_provider.as_ref().unwrap();
assert_eq!(provider.provider_id, "anthropic");
assert_eq!(provider.model, "claude-sonnet-4-5");
assert_ne!(provider.api_key_ref.as_str(), "sk-anthropic-literal");
assert!(!serde_json::to_string(&out.profile)
.unwrap()
.contains("sk-anthropic-literal"));
assert_eq!(
secrets.get(&provider.api_key_ref).await.unwrap(),
Some("sk-anthropic-literal".to_owned())
);
assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
let json = serde_json::to_string(&out.profile).unwrap();
assert!(!json.contains("codexProvider"), "got: {json}");
assert!(!json.contains("claudeProvider"), "got: {json}");
assert!(!json.contains("apiKey"), "got: {json}");
assert!(!json.contains("providerId"), "got: {json}");
assert_eq!(store.list().await.unwrap(), vec![profile]);
}
#[tokio::test]

View File

@ -999,13 +999,10 @@ use application::{
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
SaveClaudeProviderProfileInput, SaveClaudeProviderProfileOutput, SaveCodexProviderProfileInput,
SaveCodexProviderProfileOutput, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfileInput, SaveProfileOutput,
};
use domain::profile::{
AgentProfile, CodexCustomProviderConfig, CustomProviderConfig, OpenCodeConfig,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput,
SaveProfileOutput,
};
use domain::profile::{AgentProfile, CustomProviderConfig, OpenCodeConfig};
use domain::ProfileId;
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
@ -1072,68 +1069,6 @@ impl From<application::OpenCodeProviderCatalogEntry> for OpenCodeProviderDto {
}
}
/// One entry of the static Codex provider catalogue (ticket #99).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexProviderDto {
pub provider_id: String,
pub display_name: String,
pub models: Vec<String>,
pub custom_supported: bool,
}
impl From<application::CodexProviderCatalogEntry> for CodexProviderDto {
fn from(entry: application::CodexProviderCatalogEntry) -> Self {
Self {
provider_id: entry.provider_id,
display_name: entry.display_name,
models: entry.models,
custom_supported: entry.custom_supported,
}
}
}
/// A list of Codex provider catalogue entries.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct CodexProviderListDto(pub Vec<CodexProviderDto>);
impl From<application::ListCodexProvidersOutput> for CodexProviderListDto {
fn from(out: application::ListCodexProvidersOutput) -> Self {
Self(out.providers.into_iter().map(Into::into).collect())
}
}
/// One entry of the static Claude provider catalogue (ticket #99).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeProviderDto {
pub provider_id: String,
pub display_name: String,
pub models: Vec<String>,
}
impl From<application::ClaudeProviderCatalogEntry> for ClaudeProviderDto {
fn from(entry: application::ClaudeProviderCatalogEntry) -> Self {
Self {
provider_id: entry.provider_id,
display_name: entry.display_name,
models: entry.models,
}
}
}
/// A list of Claude provider catalogue entries.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct ClaudeProviderListDto(pub Vec<ClaudeProviderDto>);
impl From<application::ListClaudeProvidersOutput> for ClaudeProviderListDto {
fn from(out: application::ListClaudeProvidersOutput) -> Self {
Self(out.providers.into_iter().map(Into::into).collect())
}
}
/// A list of OpenCode cloud-provider catalogue entries (camelCase array on the
/// wire).
#[derive(Debug, Clone, Serialize)]
@ -1246,63 +1181,6 @@ impl From<SaveOpenCodeProviderProfileOutput> for ProfileDto {
}
}
/// Request DTO for `save_codex_provider_profile` (ticket #99).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveCodexProviderProfileRequestDto {
pub profile: AgentProfile,
pub provider_id: String,
pub model: String,
pub api_key: String,
#[serde(default)]
pub custom: Option<CodexCustomProviderConfig>,
}
impl From<SaveCodexProviderProfileRequestDto> for SaveCodexProviderProfileInput {
fn from(dto: SaveCodexProviderProfileRequestDto) -> Self {
Self {
profile: dto.profile,
provider_id: dto.provider_id,
model: dto.model,
api_key: dto.api_key,
custom: dto.custom,
}
}
}
impl From<SaveCodexProviderProfileOutput> for ProfileDto {
fn from(out: SaveCodexProviderProfileOutput) -> Self {
Self(out.profile)
}
}
/// Request DTO for `save_claude_provider_profile` (ticket #99).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveClaudeProviderProfileRequestDto {
pub profile: AgentProfile,
pub provider_id: String,
pub model: String,
pub api_key: String,
}
impl From<SaveClaudeProviderProfileRequestDto> for SaveClaudeProviderProfileInput {
fn from(dto: SaveClaudeProviderProfileRequestDto) -> Self {
Self {
profile: dto.profile,
provider_id: dto.provider_id,
model: dto.model,
api_key: dto.api_key,
}
}
}
impl From<SaveClaudeProviderProfileOutput> for ProfileDto {
fn from(out: SaveClaudeProviderProfileOutput) -> Self {
Self(out.profile)
}
}
/// Request DTO for `clone_opencode_profile_from_seed`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -26,13 +26,13 @@ use application::{
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
LinkIssues, ListAgents, ListAgentsInput, ListClaudeProviders, ListCodexProviders, ListDevices,
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues,
ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
@ -40,17 +40,17 @@ use application::{
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveClaudeProviderProfile,
SaveCodexProviderProfile, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile,
SaveProfile, SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@ -938,18 +938,8 @@ pub struct BackendCore {
/// Save (upsert) an OpenCode profile backed by a cloud provider, sealing its
/// literal API key into the [`SecretStore`] (ticket #92, lot B3).
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
/// Save (upsert) a Codex profile backed by a provider/model, sealing its
/// literal API key into the [`SecretStore`] (ticket #99).
pub save_codex_provider_profile: Arc<SaveCodexProviderProfile>,
/// Save (upsert) a Claude profile backed by a provider/model, sealing its
/// literal API key into the [`SecretStore`] (ticket #99).
pub save_claude_provider_profile: Arc<SaveClaudeProviderProfile>,
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
/// Static catalogue of Codex providers (ticket #99).
pub list_codex_providers: Arc<ListCodexProviders>,
/// Static catalogue of Claude providers (ticket #99).
pub list_claude_providers: Arc<ListClaudeProviders>,
/// Create a new OpenCode profile instance from the canonical seed.
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
/// Delete a profile.
@ -1477,19 +1467,7 @@ impl BackendCore {
Arc::clone(&secret_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let save_codex_provider_profile = Arc::new(SaveCodexProviderProfile::new(
Arc::clone(&profile_store_port),
Arc::clone(&secret_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let save_claude_provider_profile = Arc::new(SaveClaudeProviderProfile::new(
Arc::clone(&profile_store_port),
Arc::clone(&secret_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
let list_codex_providers = Arc::new(ListCodexProviders::new());
let list_claude_providers = Arc::new(ListClaudeProviders::new());
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
Arc::clone(&profile_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
@ -2682,11 +2660,7 @@ impl BackendCore {
list_profiles,
save_profile,
save_opencode_provider_profile,
save_codex_provider_profile,
save_claude_provider_profile,
list_opencode_providers,
list_codex_providers,
list_claude_providers,
clone_opencode_profile_from_seed,
delete_profile,
configure_profiles,

View File

@ -853,12 +853,6 @@ pub struct ProjectionContext<'a> {
/// 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,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]

View File

@ -235,6 +235,7 @@ impl TicketAssistantEnvironmentPreparer {
&declaration,
cwd.as_str(),
project.root.as_str(),
profile.model.as_deref(),
);
self.write_file(&path, rendered.as_bytes()).await?;
env.push((home_env.clone(), parent_dir(cwd, target)));
@ -511,12 +512,13 @@ fn codex_config_toml(
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
model: Option<&str>,
) -> String {
let mut text = replace_toml_table_block(
existing.unwrap_or_default(),
"mcp_servers.idea",
mcp_declaration.trim_end(),
);
let mut text = existing.unwrap_or_default().to_owned();
if let Some(model) = model {
text = set_top_level_toml_value(&text, "model", model);
}
text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = ensure_codex_project_trust(&text, run_dir);
text = ensure_codex_project_trust(&text, project_root);
if !text.ends_with('\n') {
@ -525,6 +527,33 @@ fn codex_config_toml(
text
}
fn set_top_level_toml_value(input: &str, key: &str, value: &str) -> String {
let line = format!("{key} = {}", toml_quoted(value));
set_top_level_toml_line(input, key, &line)
}
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
let needle = format!("{key} =");
let mut out = Vec::new();
let mut replaced = false;
for line in input.lines() {
let trimmed = line.trim_start();
if !replaced && trimmed.starts_with(&needle) {
out.push(replacement.to_owned());
replaced = true;
} else {
out.push(line.to_owned());
}
}
if !replaced {
if !out.is_empty() && !out.first().is_some_and(|line| line.trim().starts_with('[')) {
out.push(String::new());
}
out.insert(0, replacement.to_owned());
}
out.join("\n")
}
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
let header = format!("[{table}]");
let mut out = Vec::new();

View File

@ -228,9 +228,6 @@ mod tests {
project_root: root,
run_dir,
model: None,
model_provider: None,
model_provider_base_url: None,
model_provider_env_key: None,
}
}
@ -285,9 +282,6 @@ mod tests {
project_root: "/proj",
run_dir: "/run",
model: Some("claude-sonnet-4-5"),
model_provider: None,
model_provider_base_url: None,
model_provider_env_key: None,
};
let proj = ClaudePermissionProjector.project(None, None, &ctx);
assert!(proj.args.is_empty());

View File

@ -133,49 +133,17 @@ fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec
if ctx.model.is_some() {
keys.push("model".to_owned());
}
if ctx.model_provider.is_some() {
keys.push("model_provider".to_owned());
}
keys
}
fn codex_managed_tables(ctx: &ProjectionContext) -> Vec<String> {
let mut tables = vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()];
if let Some(provider) = ctx.model_provider {
if ctx.model_provider_base_url.is_some() {
tables.push(codex_model_provider_table(provider));
}
}
tables
fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec<String> {
vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
}
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
if let Some(model) = ctx.model {
contents.push_str(&format!("model = {}\n", toml_string(model)));
}
let Some(provider) = ctx.model_provider else {
return;
};
contents.push_str(&format!("model_provider = {}\n", toml_string(provider)));
if let Some(base_url) = ctx.model_provider_base_url {
let table = codex_model_provider_table(provider);
let name = provider_display_name(ctx);
let env_key = ctx.model_provider_env_key.unwrap_or("OPENAI_API_KEY");
contents.push_str(&format!(
"\n[{table}]\nname = {}\nbase_url = {}\nenv_key = {}\nwire_api = \"responses\"\n",
toml_string(name),
toml_string(base_url),
toml_string(env_key),
));
}
}
fn provider_display_name<'a>(ctx: &'a ProjectionContext<'a>) -> &'a str {
ctx.model_provider.unwrap_or("custom")
}
fn codex_model_provider_table(provider: &str) -> String {
format!("model_providers.{}", toml_string(provider))
}
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
@ -221,9 +189,6 @@ mod tests {
project_root: "/proj",
run_dir: "/run/agent",
model: None,
model_provider: None,
model_provider_base_url: None,
model_provider_env_key: None,
}
}
@ -273,14 +238,11 @@ mod tests {
}
#[test]
fn model_projection_without_permissions_writes_model_provider_and_custom_table() {
fn model_projection_without_permissions_writes_only_model() {
let ctx = ProjectionContext {
project_root: "/proj",
run_dir: "/run/agent",
model: Some("gpt-5"),
model_provider: Some("openai-compatible"),
model_provider_base_url: Some("https://models.example.test/v1"),
model_provider_env_key: Some("OPENAI_API_KEY"),
};
let proj = CodexPermissionProjector.project(None, None, &ctx);
assert!(proj.args.is_empty());
@ -292,27 +254,16 @@ mod tests {
..
} => {
assert!(managed_keys.contains(&"model".to_owned()));
assert!(managed_keys.contains(&"model_provider".to_owned()));
assert!(
managed_tables.contains(&"model_providers.\"openai-compatible\"".to_owned())
assert!(!managed_keys.contains(&"model_provider".to_owned()));
assert_eq!(
managed_tables,
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
);
assert!(contents.contains("model = \"gpt-5\""), "{contents}");
assert!(
contents.contains("model_provider = \"openai-compatible\""),
"{contents}"
);
assert!(
contents.contains("[model_providers.\"openai-compatible\"]"),
"{contents}"
);
assert!(
contents.contains("base_url = \"https://models.example.test/v1\""),
"{contents}"
);
assert!(
contents.contains("env_key = \"OPENAI_API_KEY\""),
"{contents}"
);
assert!(!contents.contains("model_provider"), "{contents}");
assert!(!contents.contains("model_providers"), "{contents}");
assert!(!contents.contains("base_url"), "{contents}");
assert!(!contents.contains("env_key"), "{contents}");
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}

View File

@ -69,29 +69,28 @@ use backend::dto::{
parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id,
sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto,
AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ClaudeProviderListDto,
CloneOpenCodeProfileFromSeedRequestDto, CodexProviderListDto, ConfigureProfilesRequestDto,
ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto,
CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveClaudeProviderProfileRequestDto,
SaveCodexProviderProfileRequestDto, SaveEmbedderProfileRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto,
CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
@ -2357,14 +2356,6 @@ async fn invoke(
"save_opencode_provider_profile" => {
invoke_save_opencode_provider_profile(&request.args, &state.app).await
}
"list_codex_providers" => invoke_list_codex_providers(&state.app),
"save_codex_provider_profile" => {
invoke_save_codex_provider_profile(&request.args, &state.app).await
}
"list_claude_providers" => invoke_list_claude_providers(&state.app),
"save_claude_provider_profile" => {
invoke_save_claude_provider_profile(&request.args, &state.app).await
}
"delete_profile" => invoke_delete_profile(&request.args, &state.app).await,
"configure_profiles" => invoke_configure_profiles(&request.args, &state.app).await,
"clone_opencode_profile_from_seed" => {
@ -2632,50 +2623,6 @@ async fn invoke_save_opencode_provider_profile(
serde_json::to_value(output).map_err(serialization_error)
}
fn invoke_list_codex_providers(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: CodexProviderListDto = state.list_codex_providers.execute().into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_codex_provider_profile(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<SaveCodexProviderProfileRequestDto>(
"save_codex_provider_profile",
args,
)?;
let output = state
.save_codex_provider_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
fn invoke_list_claude_providers(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: ClaudeProviderListDto = state.list_claude_providers.execute().into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_claude_provider_profile(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<SaveClaudeProviderProfileRequestDto>(
"save_claude_provider_profile",
args,
)?;
let output = state
.save_claude_provider_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_delete_profile(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let profile_id = string_arg(args, "profileId", "delete_profile")?;
let input = backend::dto::parse_delete_profile(profile_id)?;
@ -7707,10 +7654,6 @@ mod tests {
"save_profile",
"list_opencode_providers",
"save_opencode_provider_profile",
"list_codex_providers",
"save_codex_provider_profile",
"list_claude_providers",
"save_claude_provider_profile",
"delete_profile",
"configure_profiles",
"clone_opencode_profile_from_seed",