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

@ -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();