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

@ -10,9 +10,11 @@
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
ToolInvocationError, ToolInvoker, ToolSpec,
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
@ -21,6 +23,46 @@ use domain::SessionId;
use super::claude::ClaudeSdkSession;
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.
///
@ -35,6 +77,8 @@ pub struct StructuredSessionFactory {
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
/// sandboxing (chemin natif inchangé, zéro régression).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
/// Invoker outil optionnel pour les adapters sans client MCP natif.
tool_invoker: Option<Arc<dyn ToolInvoker>>,
}
impl StructuredSessionFactory {
@ -43,6 +87,7 @@ impl StructuredSessionFactory {
pub fn new() -> Self {
Self {
sandbox_enforcer: None,
tool_invoker: None,
}
}
@ -55,6 +100,14 @@ impl StructuredSessionFactory {
self.sandbox_enforcer = Some(enforcer);
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`]
@ -95,6 +148,11 @@ impl AgentSessionFactory for StructuredSessionFactory {
let command = profile.command.clone();
let cwd = cwd.as_str().to_owned();
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
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
@ -121,6 +179,27 @@ impl AgentSessionFactory for StructuredSessionFactory {
plan,
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)
}