feat(session): adapter HTTP OpenAI-compatible pour profils locaux/LAN (#14)

Ajoute un adapter de session HTTP OpenAI-compatible, purement additif,
permettant d'intégrer des modèles locaux/LAN comme profils IdeA canoniques
avec parité tool-calling/MCP.

- domain: extension du profil et des ports pour l'adapter OpenAI-compatible
- infrastructure: adapter openai_compat + routage factory
- app-tauri: mapping des outils OpenAI (openai_tools) + wiring state/lib
- application: catalogue d'agents + tests de use-cases profils

Validé QA (backend GO): round-trip byte-identique Claude/Codex, mapping
erreurs, dégradation tools, conversation_id None, conformance un seul Final,
routage factory. Suites vertes domain 467 / application 530 /
infrastructure 523 / app-tauri 247, 0 échec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:09:16 +02:00
parent cb20fabdf4
commit aab4bcafb6
14 changed files with 1794 additions and 25 deletions

View File

@ -25,6 +25,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
use crate::agent::AgentManifest;
@ -368,6 +369,48 @@ pub enum ReplyEvent {
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
/// Description pure d'un outil exposé à un modèle OpenAI-compatible.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolSpec {
/// Nom stable de l'outil (ex. `idea_ask_agent`).
pub name: String,
/// Description destinée au modèle.
pub description: String,
/// Schéma JSON d'entrée de l'outil.
pub input_schema: Value,
}
/// Erreurs du port d'invocation d'outils agentiques.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ToolInvocationError {
/// Outil inconnu ou non exposé par l'orchestrateur.
#[error("tool not found: {0}")]
NotFound(String),
/// Arguments invalides ou non conformes au schéma attendu.
#[error("tool invalid arguments: {0}")]
InvalidArguments(String),
/// Refus par gating produit/permissions.
#[error("tool rejected: {0}")]
Rejected(String),
/// Échec d'exécution de l'outil.
#[error("tool execution failed: {0}")]
Execution(String),
}
/// Port pur d'invocation des outils `idea_*` pour les modèles qui n'ont pas de
/// client MCP natif. L'impl concrète vit hors domaine.
#[async_trait]
pub trait ToolInvoker: Send + Sync {
/// Liste des outils exposés au modèle.
fn tools(&self) -> Vec<ToolSpec>;
/// Appelle un outil avec ses arguments JSON bruts.
///
/// # Errors
/// [`ToolInvocationError`] si l'outil est refusé, introuvable ou échoue.
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError>;
}
/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
/// [`Scheduler`] (ARCHITECTURE §21.4).
///

View File

@ -246,6 +246,8 @@ pub enum StructuredAdapter {
Claude,
/// Piloté par `CodexExecSession` (`codex exec` structuré).
Codex,
/// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
OpenAiCompatible,
}
impl StructuredAdapter {
@ -262,6 +264,98 @@ impl StructuredAdapter {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenAiCompatible => "openai-compatible",
}
}
}
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
///
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
/// et `api_key_env` porte uniquement le NOM de variable d'environnement à résoudre
/// côté infrastructure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpChatConfig {
/// Endpoint racine ou endpoint `/chat/completions` du serveur HTTP.
pub endpoint: String,
/// Nom du modèle envoyé dans le payload `/chat/completions`.
pub model: String,
/// Nom optionnel de la variable d'environnement contenant la clé API.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
/// Timeout de requête par tour, en millisecondes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_timeout_ms: Option<u32>,
/// Timeout de connexion, en millisecondes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u32>,
/// Plafond de rebouclage tool-calling pour un tour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_iterations: Option<u16>,
}
impl HttpChatConfig {
/// Défaut produit du garde-fou tool-calling local/LAN.
pub const DEFAULT_MAX_TOOL_ITERATIONS: u16 = 16;
/// Construit une configuration validée (parse-don't-validate, sans I/O).
///
/// # Errors
/// Renvoie une erreur domaine si l'endpoint n'est pas HTTP(S), si le modèle est
/// vide, si `api_key_env` n'est pas un nom d'env valide, ou si un timeout/plafond
/// vaut zéro.
pub fn new(
endpoint: impl Into<String>,
model: impl Into<String>,
api_key_env: Option<String>,
request_timeout_ms: Option<u32>,
connect_timeout_ms: Option<u32>,
max_tool_iterations: Option<u16>,
) -> Result<Self, DomainError> {
let endpoint = endpoint.into();
let model = model.into();
crate::validation::non_empty(&endpoint, "chatHttp.endpoint")?;
if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
return Err(DomainError::Invariant(
"chatHttp.endpoint must start with http:// or https://".to_owned(),
));
}
crate::validation::non_empty(&model, "chatHttp.model")?;
if let Some(var) = &api_key_env {
crate::validation::valid_env_var(var)?;
}
if let Some(0) = request_timeout_ms {
return Err(DomainError::EmptyField {
field: "chatHttp.requestTimeoutMs",
});
}
if let Some(0) = connect_timeout_ms {
return Err(DomainError::EmptyField {
field: "chatHttp.connectTimeoutMs",
});
}
if let Some(0) = max_tool_iterations {
return Err(DomainError::EmptyField {
field: "chatHttp.maxToolIterations",
});
}
Ok(Self {
endpoint,
model,
api_key_env,
request_timeout_ms,
connect_timeout_ms,
max_tool_iterations,
})
}
/// Plafond effectif de rebouclage tool-calling.
#[must_use]
pub const fn effective_max_tool_iterations(&self) -> u16 {
match self.max_tool_iterations {
Some(value) => value,
None => Self::DEFAULT_MAX_TOOL_ITERATIONS,
}
}
}
@ -569,6 +663,10 @@ pub struct AgentProfile {
/// sérialisation : un profil sans adapter sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_adapter: Option<StructuredAdapter>,
/// Configuration HTTP pour [`StructuredAdapter::OpenAiCompatible`]. `None` pour
/// tous les profils historiques et tous les adapters non HTTP.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_http: Option<HttpChatConfig>,
/// 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
@ -772,6 +870,7 @@ impl AgentProfile {
cwd_template,
session,
structured_adapter: None,
chat_http: None,
mcp: None,
liveness: None,
rate_limit_pattern: None,
@ -790,6 +889,13 @@ impl AgentProfile {
self
}
/// Builder : fixe la configuration HTTP chat OpenAI-compatible.
#[must_use]
pub fn with_chat_http(mut self, config: HttpChatConfig) -> Self {
self.chat_http = 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.
@ -886,6 +992,7 @@ impl AgentProfile {
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
true
}
(Some(StructuredAdapter::OpenAiCompatible), _) => false,
_ => false,
}
}
@ -934,6 +1041,70 @@ mod mcp_tests {
assert!(back.mcp.is_none());
}
#[test]
fn profile_without_chat_http_round_trips_identically() {
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
McpTransport::Stdio,
));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
!json.contains("\"chatHttp\""),
"a non-HTTP profile must NOT serialise `chatHttp`: {json}"
);
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert!(back.chat_http.is_none());
}
#[test]
fn openai_compatible_adapter_serialises_camel_case_and_provider_key_is_stable() {
let adapter = StructuredAdapter::OpenAiCompatible;
assert_eq!(adapter.provider_key(), "openai-compatible");
let json = serde_json::to_string(&adapter).expect("serialise");
assert_eq!(json, "\"openAiCompatible\"");
let back: StructuredAdapter = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, adapter);
}
#[test]
fn http_chat_config_validates_without_io_and_round_trips() {
let config = HttpChatConfig::new(
"http://127.0.0.1:11434/v1",
"qwen2.5-coder",
Some("OLLAMA_API_KEY".to_owned()),
Some(30_000),
Some(3_000),
Some(8),
)
.expect("valid");
assert_eq!(config.effective_max_tool_iterations(), 8);
let json = serde_json::to_string(&config).expect("serialise");
assert!(json.contains("\"apiKeyEnv\":\"OLLAMA_API_KEY\""));
let back: HttpChatConfig = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, config);
}
#[test]
fn http_chat_config_rejects_invalid_values() {
assert!(HttpChatConfig::new("ftp://host", "model", None, None, None, None).is_err());
assert!(HttpChatConfig::new("http://host", " ", None, None, None, None).is_err());
assert!(HttpChatConfig::new(
"http://host",
"model",
Some("not-valid".to_owned()),
None,
None,
None
)
.is_err());
assert!(HttpChatConfig::new("http://host", "model", None, Some(0), None, None).is_err());
assert!(HttpChatConfig::new("http://host", "model", None, None, Some(0), None).is_err());
assert!(HttpChatConfig::new("http://host", "model", None, None, None, Some(0)).is_err());
}
#[test]
fn legacy_json_without_mcp_field_deserialises_to_none() {
// JSON produced before the `mcp` field existed: no `mcp` key at all.