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:
@ -16,6 +16,7 @@ application = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "time"] }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
|
||||
thiserror = { 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
|
||||
# AppImage stays portable; pulled in *only* under the `vector-http` feature so the
|
||||
# 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
|
||||
# (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
|
||||
@ -51,6 +52,6 @@ landlock = "0.4.5"
|
||||
[features]
|
||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||
# 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.
|
||||
vector-onnx = ["dep:fastembed"]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod conformance;
|
||||
pub mod factory;
|
||||
pub mod openai_compat;
|
||||
pub mod process;
|
||||
|
||||
/// 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 conformance::FakeCli;
|
||||
pub use factory::StructuredSessionFactory;
|
||||
pub use openai_compat::OpenAiCompatibleSession;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
|
||||
PreparedContext, ReplyEvent, SessionPlan,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::profile::{AgentProfile, ContextInjection, HttpChatConfig, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{MarkdownDoc, SessionId};
|
||||
|
||||
@ -70,8 +75,17 @@ mod tests {
|
||||
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 {
|
||||
AgentProfile::new(
|
||||
let profile = AgentProfile::new(
|
||||
ProfileId::new_random(),
|
||||
"Profil structuré",
|
||||
command,
|
||||
@ -82,7 +96,57 @@ mod tests {
|
||||
None,
|
||||
)
|
||||
.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) -------------------
|
||||
@ -404,13 +468,14 @@ mod tests {
|
||||
let mut expected: HashMap<&str, bool> = HashMap::new();
|
||||
expected.insert("claude", true);
|
||||
expected.insert("codex", true);
|
||||
expected.insert("openai-compatible", true);
|
||||
expected.insert("gemini", false);
|
||||
expected.insert("aider", false);
|
||||
|
||||
assert_eq!(
|
||||
profiles.len(),
|
||||
4,
|
||||
"catalogue has the four reference profiles"
|
||||
5,
|
||||
"catalogue has the five reference profiles"
|
||||
);
|
||||
for profile in &profiles {
|
||||
let selectable = profile.is_selectable();
|
||||
@ -454,6 +519,43 @@ mod tests {
|
||||
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]
|
||||
async fn factory_passes_project_root_to_codex_add_dir() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
|
||||
1144
crates/infrastructure/src/session/openai_compat.rs
Normal file
1144
crates/infrastructure/src/session/openai_compat.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user