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

1
Cargo.lock generated
View File

@ -1942,6 +1942,7 @@ dependencies = [
"async-trait", "async-trait",
"domain", "domain",
"fastembed", "fastembed",
"futures-util",
"git2", "git2",
"landlock", "landlock",
"notify", "notify",

View File

@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"
async-trait = "0.1" async-trait = "0.1"
futures-util = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
# only local operations (status/commit/branch/checkout/log) are in scope; remote # only local operations (status/commit/branch/checkout/log) are in scope; remote

View File

@ -19,6 +19,7 @@ pub mod dto;
pub mod events; pub mod events;
pub mod mcp_bridge; pub mod mcp_bridge;
pub mod mcp_endpoint; pub mod mcp_endpoint;
pub mod openai_tools;
pub mod pty; pub mod pty;
pub mod state; pub mod state;
pub mod tickets; pub mod tickets;

View File

@ -0,0 +1,149 @@
//! ToolInvoker app-tauri pour l'adapter OpenAI-compatible.
//!
//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que
//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même
//! `OrchestratorService::dispatch`.
use std::sync::{Arc, Mutex};
use application::OrchestratorService;
use async_trait::async_trait;
use domain::ports::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec};
use serde_json::Value;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
const REQUESTER_ARG: &str = "__ideaRequester";
/// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif.
pub struct AppOpenAiToolInvoker {
orchestrator: Arc<OrchestratorService>,
projects: Arc<dyn ProjectStore>,
}
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
/// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime.
#[derive(Default)]
pub struct LateBoundOpenAiToolInvoker {
inner: Mutex<Option<Arc<dyn ToolInvoker>>>,
}
impl LateBoundOpenAiToolInvoker {
/// Construit un proxy vide.
#[must_use]
pub fn new() -> Self {
Self {
inner: Mutex::new(None),
}
}
/// Lie l'implémentation réelle. Appelé une fois par la composition root.
pub fn bind(&self, inner: Arc<dyn ToolInvoker>) {
*self.inner.lock().expect("mutex sain") = Some(inner);
}
}
#[async_trait]
impl ToolInvoker for LateBoundOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
self.inner
.lock()
.expect("mutex sain")
.as_ref()
.map_or_else(Vec::new, |inner| inner.tools())
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let inner = self
.inner
.lock()
.expect("mutex sain")
.clone()
.ok_or_else(|| {
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
})?;
inner.call(name, args_json).await
}
}
impl AppOpenAiToolInvoker {
/// Construit l'invoker depuis le service orchestrateur et le store projet.
#[must_use]
pub fn new(orchestrator: Arc<OrchestratorService>, projects: Arc<dyn ProjectStore>) -> Self {
Self {
orchestrator,
projects,
}
}
}
#[async_trait]
impl ToolInvoker for AppOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
})
.collect()
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
let args = value.as_object().ok_or_else(|| {
ToolInvocationError::InvalidArguments(
"les arguments d'outil doivent être un objet JSON".to_owned(),
)
})?;
let project_root = args
.get(PROJECT_ROOT_ARG)
.and_then(Value::as_str)
.ok_or_else(|| {
ToolInvocationError::InvalidArguments(
"contexte projet interne absent pour l'outil".to_owned(),
)
})?;
let requester = args
.get(REQUESTER_ARG)
.and_then(Value::as_str)
.ok_or_else(|| {
ToolInvocationError::InvalidArguments(
"identité requester interne absente pour l'outil".to_owned(),
)
})?;
let project = self
.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})?;
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester)
.map_err(|e| match e {
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
ToolInvocationError::NotFound(tool)
}
infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => {
ToolInvocationError::InvalidArguments(format!(
"arguments invalides pour `{tool}`"
))
}
infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => {
ToolInvocationError::InvalidArguments(err.to_string())
}
})?;
let outcome = self
.orchestrator
.dispatch(&project, command)
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?;
Ok(outcome.reply.unwrap_or(outcome.detail))
}
}

View File

@ -49,7 +49,7 @@ use domain::ports::{
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
WakeError, WakeReason, ToolInvoker, WakeError, WakeReason,
}; };
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -81,6 +81,7 @@ use infrastructure::{
use crate::chat::ChatBridge; use crate::chat::ChatBridge;
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
use crate::tickets::AppTicketToolProvider; use crate::tickets::AppTicketToolProvider;
@ -1084,9 +1085,12 @@ impl AppState {
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
// ChangeAgentProfile (shutdown polymorphe au hot-swap). // ChangeAgentProfile (shutdown polymorphe au hot-swap).
let structured_sessions = Arc::new(StructuredSessions::new()); let structured_sessions = Arc::new(StructuredSessions::new());
let openai_tool_invoker = Arc::new(LateBoundOpenAiToolInvoker::new());
let openai_tool_invoker_port = Arc::clone(&openai_tool_invoker) as Arc<dyn ToolInvoker>;
let session_factory = Arc::new( let session_factory = Arc::new(
StructuredSessionFactory::new() StructuredSessionFactory::new()
.with_sandbox_enforcer(infrastructure::default_enforcer()), .with_sandbox_enforcer(infrastructure::default_enforcer())
.with_tool_invoker(openai_tool_invoker_port),
) as Arc<dyn AgentSessionFactory>; ) as Arc<dyn AgentSessionFactory>;
let open_terminal = Arc::new(OpenTerminal::new( let open_terminal = Arc::new(OpenTerminal::new(
@ -2146,6 +2150,10 @@ impl AppState {
// même use case que la commande Tauri, sans aller-retour frontend. // même use case que la commande Tauri, sans aller-retour frontend.
.with_spawn_background_command(Arc::clone(&spawn_background_command)), .with_spawn_background_command(Arc::clone(&spawn_background_command)),
); );
openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
Arc::clone(&orchestrator_service),
Arc::clone(&store_port),
)) as Arc<dyn ToolInvoker>);
let stop_live_agent = Arc::new( let stop_live_agent = Arc::new(
StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))

View File

@ -20,7 +20,7 @@
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::permission::ProjectorKey; use domain::permission::ProjectorKey;
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter, StructuredAdapter,
}; };
@ -96,6 +96,30 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"), .expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
McpTransport::Stdio, McpTransport::Stdio,
)), )),
AgentProfile::new(
reference_id("ollama-openai-compatible"),
"Ollama / OpenAI-compatible local model",
"openai-compatible",
Vec::new(),
ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"),
None,
"{agentRunDir}",
None,
)
.expect("OpenAI-compatible reference profile is valid")
.with_structured_adapter(StructuredAdapter::OpenAiCompatible)
.with_chat_http(
HttpChatConfig::new(
"http://localhost:11434/v1",
"qwen2.5-coder",
None,
Some(120_000),
Some(5_000),
Some(HttpChatConfig::DEFAULT_MAX_TOOL_ITERATIONS),
)
.expect("OpenAI-compatible HTTP config is valid"),
),
AgentProfile::new( AgentProfile::new(
reference_id("gemini"), reference_id("gemini"),
"Gemini CLI", "Gemini CLI",
@ -129,8 +153,9 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
/// ///
/// A profile is selectable iff it can be driven in **structured** mode /// A profile is selectable iff it can be driven in **structured** mode
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today /// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data /// that is Claude + Codex + OpenAI-compatible; Gemini/Aider stay in
/// catalogue is untouched) but are **not** proposed for selection. There is no /// [`reference_profiles`] (the data catalogue is untouched) but are **not**
/// proposed for selection. There is no
/// custom-profile entry here either: the selection path offers only profiles we /// custom-profile entry here either: the selection path offers only profiles we
/// know how to pilot. /// know how to pilot.
/// ///
@ -201,6 +226,20 @@ mod mcp_tests {
); );
} }
#[test]
fn openai_compatible_seed_is_selectable_http_profile_without_mcp() {
let profile = profile("ollama-openai-compatible");
assert_eq!(
profile.structured_adapter,
Some(StructuredAdapter::OpenAiCompatible)
);
let chat = profile.chat_http.as_ref().expect("chatHttp present");
assert_eq!(chat.endpoint, "http://localhost:11434/v1");
assert_eq!(chat.model, "qwen2.5-coder");
assert!(profile.mcp.is_none());
assert!(profile.is_selectable());
}
#[test] #[test]
fn gemini_and_aider_have_no_mcp_capability() { fn gemini_and_aider_have_no_mcp_capability() {
for slug in ["gemini", "aider"] { for slug in ["gemini", "aider"] {

View File

@ -217,10 +217,11 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
let out = uc.execute().await.unwrap(); let out = uc.execute().await.unwrap();
assert!(out.is_first_run); assert!(out.is_first_run);
// §17.3/D7: the wizard is seeded only with the *selectable* (structured- // §17.3/D7: the wizard is seeded only with the *selectable* (structured-
// drivable) profiles — Claude + Codex — not the full 4-profile catalogue. // drivable) profiles — Claude + Codex + OpenAI-compatible — not the full
// catalogue.
assert_eq!( assert_eq!(
out.reference_profiles.len(), out.reference_profiles.len(),
2, 3,
"selectable catalogue seeded" "selectable catalogue seeded"
); );
let commands: Vec<&str> = out let commands: Vec<&str> = out
@ -230,8 +231,8 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
.collect(); .collect();
assert_eq!( assert_eq!(
commands, commands,
vec!["claude", "codex"], vec!["claude", "codex", "openai-compatible"],
"only Claude/Codex offered" "only structured profiles offered"
); );
// Every seeded profile is selectable (the gate the menu relies on). // Every seeded profile is selectable (the gate the menu relies on).
assert!( assert!(
@ -299,32 +300,57 @@ async fn delete_unknown_is_not_found_error() {
#[tokio::test] #[tokio::test]
async fn reference_profiles_use_case_returns_only_selectable() { async fn reference_profiles_use_case_returns_only_selectable() {
// §17.3/D7: the selection use case exposes only structured-drivable profiles // §17.3/D7: the selection use case exposes only structured-drivable profiles
// (Claude + Codex). Gemini/Aider stay in the raw catalogue (see // (Claude + Codex + OpenAI-compatible). Gemini/Aider stay in the raw catalogue (see
// `catalogue_*` tests below) but are not offered to selection/creation. // `catalogue_*` tests below) but are not offered to selection/creation.
let out = ReferenceProfiles::new().execute().await.unwrap(); let out = ReferenceProfiles::new().execute().await.unwrap();
assert_eq!(out.profiles.len(), 2); assert_eq!(out.profiles.len(), 3);
let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect(); let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect();
assert_eq!(commands, vec!["claude", "codex"]); assert_eq!(commands, vec!["claude", "codex", "openai-compatible"]);
assert!(out.profiles.iter().all(AgentProfile::is_selectable)); assert!(out.profiles.iter().all(AgentProfile::is_selectable));
} }
/// §17.3/D7 — non-regression: the *raw* catalogue still carries the four profiles /// §17.3/D7 — non-regression: the *raw* catalogue still carries all reference profiles
/// (data intact). Only the selection-facing use case is filtered. /// (data intact). Only the selection-facing use case is filtered.
#[test] #[test]
fn raw_catalogue_still_has_all_four_profiles() { fn raw_catalogue_still_has_all_profiles() {
let commands: Vec<String> = reference_profiles() let commands: Vec<String> = reference_profiles()
.iter() .iter()
.map(|p| p.command.clone()) .map(|p| p.command.clone())
.collect(); .collect();
assert_eq!(commands, vec!["claude", "codex", "gemini", "aider"]); assert_eq!(
commands,
vec!["claude", "codex", "openai-compatible", "gemini", "aider"]
);
} }
/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the two #[test]
fn claude_and_codex_reference_profiles_roundtrip_with_byte_identity() {
let profiles = reference_profiles();
let by_command: HashMap<&str, &AgentProfile> =
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
for command in ["claude", "codex"] {
let before = serde_json::to_vec(by_command[command]).expect("serialize reference profile");
let back: AgentProfile =
serde_json::from_slice(&before).expect("deserialize reference profile");
let after = serde_json::to_vec(&back).expect("serialize round-tripped profile");
assert_eq!(
after, before,
"{command} profile JSON bytes must be strictly stable across a serde round-trip"
);
assert!(
!String::from_utf8_lossy(&after).contains("\"chatHttp\""),
"{command} is a historical non-HTTP profile and must not grow chatHttp"
);
}
}
/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the
/// structured-drivable profiles, false for the two PTY-only ones. This assertion /// structured-drivable profiles, false for the two PTY-only ones. This assertion
/// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex /// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex
/// lost theirs — i.e. it actually constrains behaviour. /// lost theirs — i.e. it actually constrains behaviour.
#[test] #[test]
fn is_selectable_is_true_only_for_claude_and_codex() { fn is_selectable_is_true_only_for_structured_profiles() {
let profiles = reference_profiles(); let profiles = reference_profiles();
let by_command: HashMap<&str, &AgentProfile> = let by_command: HashMap<&str, &AgentProfile> =
profiles.iter().map(|p| (p.command.as_str(), p)).collect(); profiles.iter().map(|p| (p.command.as_str(), p)).collect();
@ -336,6 +362,10 @@ fn is_selectable_is_true_only_for_claude_and_codex() {
by_command["codex"].is_selectable(), by_command["codex"].is_selectable(),
"Codex carries a structured adapter ⇒ selectable" "Codex carries a structured adapter ⇒ selectable"
); );
assert!(
by_command["openai-compatible"].is_selectable(),
"OpenAI-compatible carries a structured adapter ⇒ selectable"
);
assert!( assert!(
!by_command["gemini"].is_selectable(), !by_command["gemini"].is_selectable(),
"Gemini has no adapter ⇒ not selectable" "Gemini has no adapter ⇒ not selectable"

View File

@ -9,9 +9,9 @@ description = "IdeA — pure domain layer: entities, value objects, ports (trait
[dependencies] [dependencies]
uuid = { workspace = true } uuid = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
[dev-dependencies] [dev-dependencies]
serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View File

@ -25,6 +25,7 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error; use thiserror::Error;
use crate::agent::AgentManifest; use crate::agent::AgentManifest;
@ -368,6 +369,48 @@ pub enum ReplyEvent {
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux. /// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>; 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 /// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
/// [`Scheduler`] (ARCHITECTURE §21.4). /// [`Scheduler`] (ARCHITECTURE §21.4).
/// ///

View File

@ -246,6 +246,8 @@ pub enum StructuredAdapter {
Claude, Claude,
/// Piloté par `CodexExecSession` (`codex exec` structuré). /// Piloté par `CodexExecSession` (`codex exec` structuré).
Codex, Codex,
/// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
OpenAiCompatible,
} }
impl StructuredAdapter { impl StructuredAdapter {
@ -262,6 +264,98 @@ impl StructuredAdapter {
match self { match self {
Self::Claude => "claude", Self::Claude => "claude",
Self::Codex => "codex", 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. /// sérialisation : un profil sans adapter sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_adapter: Option<StructuredAdapter>, 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). /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
@ -772,6 +870,7 @@ impl AgentProfile {
cwd_template, cwd_template,
session, session,
structured_adapter: None, structured_adapter: None,
chat_http: None,
mcp: None, mcp: None,
liveness: None, liveness: None,
rate_limit_pattern: None, rate_limit_pattern: None,
@ -790,6 +889,13 @@ impl AgentProfile {
self 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 /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas. /// profils sans MCP ne l'appellent simplement pas.
@ -886,6 +992,7 @@ impl AgentProfile {
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => { (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
true true
} }
(Some(StructuredAdapter::OpenAiCompatible), _) => false,
_ => false, _ => false,
} }
} }
@ -934,6 +1041,70 @@ mod mcp_tests {
assert!(back.mcp.is_none()); 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] #[test]
fn legacy_json_without_mcp_field_deserialises_to_none() { fn legacy_json_without_mcp_field_deserialises_to_none() {
// JSON produced before the `mcp` field existed: no `mcp` key at all. // JSON produced before the `mcp` field existed: no `mcp` key at all.

View File

@ -16,6 +16,7 @@ application = { workspace = true }
tokio = { workspace = true, features = ["process", "time"] } tokio = { workspace = true, features = ["process", "time"] }
uuid = { workspace = true } uuid = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
futures-util = { workspace = true }
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors). # Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
thiserror = { workspace = true } thiserror = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@ -38,7 +39,7 @@ notify = "6"
# §14.5.3). `default-features = false` + `rustls-tls` keeps it OpenSSL-free so the # §14.5.3). `default-features = false` + `rustls-tls` keeps it OpenSSL-free so the
# AppImage stays portable; pulled in *only* under the `vector-http` feature so the # AppImage stays portable; pulled in *only* under the `vector-http` feature so the
# zero-dependency default (`none` strategy) compiles nothing extra. # zero-dependency default (`none` strategy) compiles nothing extra.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
# Optional in-process ONNX embedder (LOT C1b, §14.5.3). rustls all the way down # Optional in-process ONNX embedder (LOT C1b, §14.5.3). rustls all the way down
# (HF model download + ort binaries fetched at build time, NOT load-dynamic) so the # (HF model download + ort binaries fetched at build time, NOT load-dynamic) so the
# AppImage stays self-contained and OpenSSL-free. Pulled in *only* under the # AppImage stays self-contained and OpenSSL-free. Pulled in *only* under the
@ -51,6 +52,6 @@ landlock = "0.4.5"
[features] [features]
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…). # Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency. # OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
vector-http = ["dep:reqwest"] vector-http = []
# Real in-process ONNX embedder (`localOnnx`). OFF by default, same posture. # Real in-process ONNX embedder (`localOnnx`). OFF by default, same posture.
vector-onnx = ["dep:fastembed"] vector-onnx = ["dep:fastembed"]

View File

@ -10,9 +10,11 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value;
use domain::ports::{ use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan, AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
ToolInvocationError, ToolInvoker, ToolSpec,
}; };
use domain::profile::{AgentProfile, StructuredAdapter}; use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath; use domain::project::ProjectPath;
@ -21,6 +23,46 @@ use domain::SessionId;
use super::claude::ClaudeSdkSession; use super::claude::ClaudeSdkSession;
use super::codex::CodexExecSession; use super::codex::CodexExecSession;
use super::openai_compat::OpenAiCompatibleSession;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
const REQUESTER_ARG: &str = "__ideaRequester";
struct ProjectScopedToolInvoker {
inner: Arc<dyn ToolInvoker>,
project_root: String,
requester: String,
}
#[async_trait]
impl ToolInvoker for ProjectScopedToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
self.inner.tools()
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let mut value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
match &mut value {
Value::Object(map) => {
map.insert(
PROJECT_ROOT_ARG.to_owned(),
Value::String(self.project_root.clone()),
);
map.insert(
REQUESTER_ARG.to_owned(),
Value::String(self.requester.clone()),
);
}
_ => {
return Err(ToolInvocationError::InvalidArguments(
"les arguments d'outil doivent être un objet JSON".to_owned(),
));
}
}
self.inner.call(name, &value.to_string()).await
}
}
/// Fabrique infra des sessions structurées, sélectionnée par le profil. /// Fabrique infra des sessions structurées, sélectionnée par le profil.
/// ///
@ -35,6 +77,8 @@ pub struct StructuredSessionFactory {
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun /// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
/// sandboxing (chemin natif inchangé, zéro régression). /// sandboxing (chemin natif inchangé, zéro régression).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>, sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
/// Invoker outil optionnel pour les adapters sans client MCP natif.
tool_invoker: Option<Arc<dyn ToolInvoker>>,
} }
impl StructuredSessionFactory { impl StructuredSessionFactory {
@ -43,6 +87,7 @@ impl StructuredSessionFactory {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
sandbox_enforcer: None, sandbox_enforcer: None,
tool_invoker: None,
} }
} }
@ -55,6 +100,14 @@ impl StructuredSessionFactory {
self.sandbox_enforcer = Some(enforcer); self.sandbox_enforcer = Some(enforcer);
self self
} }
/// Builder additif : câble l'invocation d'outils `idea_*` pour les moteurs HTTP
/// sans client MCP natif. `None` (défaut) ⇒ chat nu.
#[must_use]
pub fn with_tool_invoker(mut self, invoker: Arc<dyn ToolInvoker>) -> Self {
self.tool_invoker = Some(invoker);
self
}
} }
/// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`] /// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`]
@ -95,6 +148,11 @@ impl AgentSessionFactory for StructuredSessionFactory {
let command = profile.command.clone(); let command = profile.command.clone();
let cwd = cwd.as_str().to_owned(); let cwd = cwd.as_str().to_owned();
let seed = seed_conversation_id(session); let seed = seed_conversation_id(session);
let requester = std::path::Path::new(&cwd)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned();
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
@ -121,6 +179,27 @@ impl AgentSessionFactory for StructuredSessionFactory {
plan, plan,
enforcer, enforcer,
)), )),
StructuredAdapter::OpenAiCompatible => {
let config = profile.chat_http.clone().ok_or_else(|| {
AgentSessionError::Start(format!(
"le profil « {} » n'a pas de configuration chatHttp",
profile.name
))
})?;
Arc::new(OpenAiCompatibleSession::new(
id,
config,
&cwd,
ctx.content.as_str(),
self.tool_invoker.clone().map(|inner| {
Arc::new(ProjectScopedToolInvoker {
inner,
project_root: ctx.project_root.clone(),
requester: requester.clone(),
}) as Arc<dyn ToolInvoker>
}),
)?)
}
}; };
Ok(session) Ok(session)
} }

View File

@ -23,6 +23,7 @@ pub mod claude;
pub mod codex; pub mod codex;
pub mod conformance; pub mod conformance;
pub mod factory; pub mod factory;
pub mod openai_compat;
pub mod process; pub mod process;
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4), /// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
@ -34,18 +35,22 @@ pub use claude::ClaudeSdkSession;
pub use codex::CodexExecSession; pub use codex::CodexExecSession;
pub use conformance::FakeCli; pub use conformance::FakeCli;
pub use factory::StructuredSessionFactory; pub use factory::StructuredSessionFactory;
pub use openai_compat::OpenAiCompatibleSession;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::ports::{ use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan, AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
PreparedContext, ReplyEvent, SessionPlan, PreparedContext, ReplyEvent, SessionPlan,
}; };
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::profile::{AgentProfile, ContextInjection, HttpChatConfig, StructuredAdapter};
use domain::project::ProjectPath; use domain::project::ProjectPath;
use domain::{MarkdownDoc, SessionId}; use domain::{MarkdownDoc, SessionId};
@ -70,8 +75,17 @@ mod tests {
ProjectPath::new("/").expect("cwd valide") ProjectPath::new("/").expect("cwd valide")
} }
fn temp_cwd(name: &str) -> ProjectPath {
let path = std::env::temp_dir().join(format!(
"idea-structured-session-{name}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&path).expect("temp cwd");
ProjectPath::new(path.to_string_lossy().into_owned()).expect("temp cwd path")
}
fn structured_profile(adapter: StructuredAdapter, command: &str) -> AgentProfile { fn structured_profile(adapter: StructuredAdapter, command: &str) -> AgentProfile {
AgentProfile::new( let profile = AgentProfile::new(
ProfileId::new_random(), ProfileId::new_random(),
"Profil structuré", "Profil structuré",
command, command,
@ -82,7 +96,57 @@ mod tests {
None, None,
) )
.expect("profil valide") .expect("profil valide")
.with_structured_adapter(adapter) .with_structured_adapter(adapter);
if adapter == StructuredAdapter::OpenAiCompatible {
profile.with_chat_http(
HttpChatConfig::new(
"http://127.0.0.1:9/v1",
"local-model",
None,
Some(1_000),
Some(100),
Some(1),
)
.expect("valid http config"),
)
} else {
profile
}
}
async fn one_shot_chat_server(content: &'static str) -> (String, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let handle = tokio::spawn(async move {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let n = socket.read(&mut chunk).await.expect("read request");
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let body = format!(
r#"{{"choices":[{{"message":{{"role":"assistant","content":"{content}"}}}}]}}"#
);
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
socket
.write_all(response.as_bytes())
.await
.expect("write response");
});
(format!("http://{addr}/v1"), handle)
} }
// -- Machinerie de process (paramétrable, fake CLI) ------------------- // -- Machinerie de process (paramétrable, fake CLI) -------------------
@ -404,13 +468,14 @@ mod tests {
let mut expected: HashMap<&str, bool> = HashMap::new(); let mut expected: HashMap<&str, bool> = HashMap::new();
expected.insert("claude", true); expected.insert("claude", true);
expected.insert("codex", true); expected.insert("codex", true);
expected.insert("openai-compatible", true);
expected.insert("gemini", false); expected.insert("gemini", false);
expected.insert("aider", false); expected.insert("aider", false);
assert_eq!( assert_eq!(
profiles.len(), profiles.len(),
4, 5,
"catalogue has the four reference profiles" "catalogue has the five reference profiles"
); );
for profile in &profiles { for profile in &profiles {
let selectable = profile.is_selectable(); let selectable = profile.is_selectable();
@ -454,6 +519,43 @@ mod tests {
assert_eq!(content_cx, "réponse Codex"); assert_eq!(content_cx, "réponse Codex");
} }
#[tokio::test]
async fn factory_routes_openai_compatible_to_http_session() {
let factory = StructuredSessionFactory::new();
let (endpoint, handle) = one_shot_chat_server("réponse HTTP").await;
let openai = structured_profile(StructuredAdapter::OpenAiCompatible, "openai-compatible")
.with_chat_http(
HttpChatConfig::new(
endpoint,
"local-model",
None,
Some(10_000),
Some(1_000),
None,
)
.expect("valid http config"),
);
let session = factory
.start(
&openai,
&prepared_ctx(),
&temp_cwd("factory-openai"),
&SessionPlan::None,
None,
)
.await
.expect("start OpenAI-compatible ok");
assert_eq!(
session.conversation_id(),
None,
"OpenAI-compatible route must not expose a provider conversation id"
);
let content = drain_final(session.as_ref()).await;
assert_eq!(content, "réponse HTTP");
handle.abort();
}
#[tokio::test] #[tokio::test]
async fn factory_passes_project_root_to_codex_add_dir() { async fn factory_passes_project_root_to_codex_add_dir() {
let factory = StructuredSessionFactory::new(); let factory = StructuredSessionFactory::new();

File diff suppressed because it is too large Load Diff