feat(backend): support des providers OpenCode cloud (#92)
Ajoute le catalogue statique de providers OpenCode (lot B3), le stockage sécurisé des secrets (SecretStore + adapter infrastructure), et les use cases SaveOpenCodeProviderProfile/DeleteProfile câblés en composition root. Couvre le fix B1 et les tests de régression demandés par QA. cargo build --workspace propre, cargo test --workspace -- --test-threads=1 intégralement vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,8 +14,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
|
||||
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore};
|
||||
use domain::profile::{AgentProfile, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -272,6 +272,95 @@ impl SaveProfile {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SaveOpenCodeProviderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SaveOpenCodeProviderProfile::execute`]: the profile to upsert
|
||||
/// (with [`AgentProfile::opencode_provider`] left as-is — this use case fills it
|
||||
/// in) plus the **literal** provider fields. The literal `api_key` never reaches
|
||||
/// [`ProfileStore`] — only [`SaveOpenCodeProviderProfile`] is allowed to touch it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveOpenCodeProviderProfileInput {
|
||||
/// The profile to create or replace (by id). Its `opencode_provider` field is
|
||||
/// overwritten by this use case; any value set on it is ignored.
|
||||
pub profile: AgentProfile,
|
||||
/// Provider id in the OpenCode registry (e.g. `"anthropic"`).
|
||||
pub provider_id: String,
|
||||
/// Model name served by this provider.
|
||||
pub model: String,
|
||||
/// Literal API key. Minted into a fresh [`SecretRef`] on first save, or
|
||||
/// re-sealed under the profile's existing `SecretRef` on edit — never
|
||||
/// persisted as a literal in `profiles.json`.
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Output of [`SaveOpenCodeProviderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveOpenCodeProviderProfileOutput {
|
||||
/// The saved profile (echoed back), with `opencode_provider` set.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [`domain::profile::OpenCodeProviderConfig`].
|
||||
pub struct SaveOpenCodeProviderProfile {
|
||||
profile_store: Arc<dyn ProfileStore>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl SaveOpenCodeProviderProfile {
|
||||
/// 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`] (minted fresh, or reused from
|
||||
/// the profile's existing `opencode_provider` when editing) and persists the
|
||||
/// profile with `opencode_provider` set to the resulting
|
||||
/// [`domain::profile::OpenCodeProviderConfig`].
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`]
|
||||
/// on secret or profile persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SaveOpenCodeProviderProfileInput,
|
||||
) -> Result<SaveOpenCodeProviderProfileOutput, AppError> {
|
||||
let secret_ref = input
|
||||
.profile
|
||||
.opencode_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 =
|
||||
OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
let mut profile = input.profile;
|
||||
profile.opencode_provider = Some(provider);
|
||||
|
||||
self.profile_store.save(&profile).await?;
|
||||
Ok(SaveOpenCodeProviderProfileOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -283,24 +372,37 @@ pub struct DeleteProfileInput {
|
||||
pub id: domain::ids::ProfileId,
|
||||
}
|
||||
|
||||
/// Deletes a profile by id.
|
||||
/// Deletes a profile by id. If the profile carries an
|
||||
/// [`domain::profile::OpenCodeProviderConfig`], its secret is removed from the
|
||||
/// [`SecretStore`] first, so no orphaned secret is left behind (ticket #92, lot
|
||||
/// B3).
|
||||
pub struct DeleteProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl DeleteProfile {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
/// Builds the use case from the [`ProfileStore`] and [`SecretStore`] ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
pub fn new(store: Arc<dyn ProfileStore>, secret_store: Arc<dyn SecretStore>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
secret_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the profile.
|
||||
/// Deletes the profile (and its secret, if any).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
|
||||
/// persistence failure.
|
||||
pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> {
|
||||
let profiles = self.store.list().await?;
|
||||
if let Some(profile) = profiles.into_iter().find(|p| p.id == input.id) {
|
||||
if let Some(config) = &profile.opencode_provider {
|
||||
self.secret_store.delete(&config.api_key_ref).await?;
|
||||
}
|
||||
}
|
||||
self.store.delete(input.id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user