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:
@ -1730,6 +1730,67 @@ pub trait WindowStateStore: Send + Sync {
|
||||
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
|
||||
}
|
||||
|
||||
/// Opaque lookup key for a value held in a [`SecretStore`] (ticket #92, lot B2).
|
||||
/// Carries no semantics beyond "a stable reference" (e.g. a UUID string minted by
|
||||
/// the caller) — the store never inspects or derives it. Attached to
|
||||
/// [`crate::profile::OpenCodeProviderConfig`] and persisted as part of
|
||||
/// `profiles.json`: safe, since it is only ever an opaque id, never the secret
|
||||
/// value itself (see [`ProfileStore`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SecretRef(pub String);
|
||||
|
||||
impl SecretRef {
|
||||
/// Wraps an opaque identifier string.
|
||||
#[must_use]
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
/// Returns the inner identifier.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from the [`SecretStore`] port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SecretStoreError {
|
||||
/// Underlying I/O error.
|
||||
#[error("secret store io failed: {0}")]
|
||||
Io(String),
|
||||
/// Encryption/decryption failure (corrupt ciphertext, key mismatch, …).
|
||||
#[error("secret store crypto failure: {0}")]
|
||||
Crypto(String),
|
||||
}
|
||||
|
||||
/// Port for at-rest storage of secret string values (ticket #92, lot B2, cadrage
|
||||
/// Architect §B2) — keeps literal API keys OUT of `profiles.json`, which is plain
|
||||
/// JSON with no encryption. Adapters implementing this port are the ONLY place
|
||||
/// allowed to hold the encryption key; callers only ever handle plaintext values
|
||||
/// and opaque [`SecretRef`]s.
|
||||
#[async_trait]
|
||||
pub trait SecretStore: Send + Sync {
|
||||
/// Stores (creates or replaces) the secret value under `key`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SecretStoreError`] on I/O or encryption failure.
|
||||
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError>;
|
||||
|
||||
/// Retrieves the secret value stored under `key`, or `None` if absent.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SecretStoreError`] on I/O or decryption failure.
|
||||
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError>;
|
||||
|
||||
/// Deletes the secret stored under `key`. Deleting an absent key is a no-op
|
||||
/// success (idempotent).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SecretStoreError`] on I/O failure.
|
||||
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError>;
|
||||
}
|
||||
|
||||
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
|
||||
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
|
||||
/// single generic [`AgentRuntime`] adapter (Open/Closed).
|
||||
|
||||
@ -354,6 +354,59 @@ impl OpenCodeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration déclarative d'un profil OpenCode piloté par un provider **cloud**
|
||||
/// natif du registre OpenCode (ticket #92, lot B1).
|
||||
///
|
||||
/// Distincte de [`OpenCodeConfig`] : celle-ci sert le provider custom `llamacpp`
|
||||
/// (endpoint local compatible OpenAI), celle-ci sert un provider BUILT-IN
|
||||
/// d'OpenCode (Anthropic, OpenRouter, …) authentifié par une clé API littérale.
|
||||
/// `AgentProfile::opencode_backend_is_consistent` garantit qu'un profil ne porte
|
||||
/// jamais les deux configurations à la fois.
|
||||
///
|
||||
/// Contrairement à [`OpenCodeConfig::api_key`] (optionnelle, mode local sans
|
||||
/// authentification), une clé est ici **obligatoire** : un provider cloud sans
|
||||
/// clé ne peut pas être appelé. Elle n'est cependant jamais portée en littéral —
|
||||
/// voir [`Self::api_key_ref`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// Identifiant du provider dans le registre OpenCode (ex. `"anthropic"`,
|
||||
/// `"openrouter"`). Doit correspondre à une entrée du catalogue (lot B3).
|
||||
pub provider_id: String,
|
||||
/// Nom du modèle servi par ce provider (ex. `"claude-sonnet-5"`).
|
||||
pub model: String,
|
||||
/// Référence opaque vers la clé API réelle, tenue par un
|
||||
/// [`crate::ports::SecretStore`] (jamais un littéral en clair — ce struct est
|
||||
/// persisté tel quel dans `profiles.json`, qui n'est pas chiffré). Seule une
|
||||
/// couche application autorisée à manipuler le littéral (mint du `SecretRef` +
|
||||
/// écriture dans le `SecretStore`) construit cette référence.
|
||||
pub api_key_ref: crate::ports::SecretRef,
|
||||
}
|
||||
|
||||
impl OpenCodeProviderConfig {
|
||||
/// Construit une configuration validée (parse-don't-validate, comme
|
||||
/// [`OpenCodeConfig::new`]). Ne prend jamais de clé littérale : seule une
|
||||
/// référence déjà mintée par l'application est accepté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, "opencodeProvider.providerId")?;
|
||||
crate::validation::non_empty(&model, "opencodeProvider.model")?;
|
||||
Ok(Self {
|
||||
provider_id,
|
||||
model,
|
||||
api_key_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
|
||||
///
|
||||
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
|
||||
@ -774,6 +827,17 @@ pub struct AgentProfile {
|
||||
/// HTTP OpenAI-compatible in-process.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<OpenCodeConfig>,
|
||||
/// Configuration OpenCode **cloud** pour [`StructuredAdapter::OpenCode`] (ticket
|
||||
/// #92, lot B1) : un provider BUILT-IN du registre OpenCode authentifié par clé
|
||||
/// API, plutôt que le provider custom `llamacpp` de [`Self::opencode`]. Un profil
|
||||
/// ne porte jamais les deux à la fois — voir
|
||||
/// [`AgentProfile::opencode_backend_is_consistent`].
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
|
||||
/// sérialisation : un profil sans provider cloud sérialise exactement comme
|
||||
/// avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_provider: Option<OpenCodeProviderConfig>,
|
||||
/// 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
|
||||
@ -979,6 +1043,7 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
chat_http: None,
|
||||
opencode: None,
|
||||
opencode_provider: None,
|
||||
mcp: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
@ -1011,6 +1076,14 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la configuration OpenCode **cloud** (provider BUILT-IN,
|
||||
/// ticket #92, lot B1).
|
||||
#[must_use]
|
||||
pub fn with_opencode_provider(mut self, config: OpenCodeProviderConfig) -> Self {
|
||||
self.opencode_provider = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans MCP ne l'appellent simplement pas.
|
||||
@ -1079,6 +1152,17 @@ impl AgentProfile {
|
||||
self.structured_adapter.is_some()
|
||||
}
|
||||
|
||||
/// Invariant transverse (ticket #92, lot B1) : un profil OpenCode ne porte
|
||||
/// **jamais** à la fois [`Self::opencode`] (provider custom `llamacpp`) et
|
||||
/// [`Self::opencode_provider`] (provider cloud BUILT-IN) — les deux configurent
|
||||
/// le même champ `opencode.json` `provider`/`model`, et n'ont aucun sens
|
||||
/// combinées. `true` quand au plus un des deux est `Some` (y compris quand
|
||||
/// aucun des deux n'est présent — profil non-OpenCode ou OpenCode non configuré).
|
||||
#[must_use]
|
||||
pub const fn opencode_backend_is_consistent(&self) -> bool {
|
||||
!(self.opencode.is_some() && self.opencode_provider.is_some())
|
||||
}
|
||||
|
||||
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
|
||||
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
|
||||
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
|
||||
@ -1258,6 +1342,95 @@ mod mcp_tests {
|
||||
assert_eq!(back.local_model_server_id, None);
|
||||
}
|
||||
|
||||
// -- Ticket #92, lot B1 : OpenCodeProviderConfig (provider cloud) -----------
|
||||
|
||||
#[test]
|
||||
fn opencode_provider_config_rejects_empty_fields() {
|
||||
let secret_ref = crate::ports::SecretRef::new("secret-1");
|
||||
assert!(
|
||||
OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err()
|
||||
);
|
||||
assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_provider_config_accepts_valid_fields() {
|
||||
let secret_ref = crate::ports::SecretRef::new("secret-1");
|
||||
let config =
|
||||
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", secret_ref.clone())
|
||||
.unwrap();
|
||||
assert_eq!(config.provider_id, "anthropic");
|
||||
assert_eq!(config.model, "claude-sonnet-5");
|
||||
assert_eq!(config.api_key_ref, secret_ref);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_opencode_provider_omits_key_in_json() {
|
||||
let profile = profile_without_mcp();
|
||||
assert!(profile.opencode_provider.is_none());
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("\"opencodeProvider\""),
|
||||
"a profile without an OpenCode cloud provider must NOT serialise \
|
||||
`opencodeProvider` (zero regression); got: {json}"
|
||||
);
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_with_opencode_provider_round_trips_camelcase() {
|
||||
let provider = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-anthropic")).unwrap();
|
||||
let profile = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode_provider(provider.clone());
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("\"opencodeProvider\""), "got: {json}");
|
||||
assert!(json.contains("\"providerId\":\"anthropic\""), "got: {json}");
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(back.opencode_provider, Some(provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_backend_consistency_rejects_both_configs_set() {
|
||||
let local = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
|
||||
|
||||
let only_local = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(local.clone());
|
||||
assert!(only_local.opencode_backend_is_consistent());
|
||||
|
||||
let only_cloud = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode_provider(cloud.clone());
|
||||
assert!(only_cloud.opencode_backend_is_consistent());
|
||||
|
||||
let neither = profile_without_mcp().with_structured_adapter(StructuredAdapter::OpenCode);
|
||||
assert!(neither.opencode_backend_is_consistent());
|
||||
|
||||
let both = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(local)
|
||||
.with_opencode_provider(cloud);
|
||||
assert!(!both.opencode_backend_is_consistent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_provider_config_serialises_no_local_model_server_id_leak() {
|
||||
let config = OpenCodeProviderConfig::new("openrouter", "some-model", crate::ports::SecretRef::new("secret-openrouter")).unwrap();
|
||||
let json = serde_json::to_string(&config).expect("serialise");
|
||||
assert!(!json.contains("localModelServerId"));
|
||||
assert!(!json.contains("baseURL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_serialises_local_model_server_id_camelcase() {
|
||||
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
|
||||
|
||||
Reference in New Issue
Block a user