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:
@ -20,7 +20,7 @@
|
||||
use domain::ids::ProfileId;
|
||||
use domain::permission::ProjectorKey;
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
|
||||
@ -96,6 +96,30 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
||||
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(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
@ -129,8 +153,9 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
///
|
||||
/// A profile is selectable iff it can be driven in **structured** mode
|
||||
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
|
||||
/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data
|
||||
/// catalogue is untouched) but are **not** proposed for selection. There is no
|
||||
/// that is Claude + Codex + OpenAI-compatible; Gemini/Aider stay in
|
||||
/// [`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
|
||||
/// 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]
|
||||
fn gemini_and_aider_have_no_mcp_capability() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
|
||||
@ -217,10 +217,11 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
let out = uc.execute().await.unwrap();
|
||||
assert!(out.is_first_run);
|
||||
// §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!(
|
||||
out.reference_profiles.len(),
|
||||
2,
|
||||
3,
|
||||
"selectable catalogue seeded"
|
||||
);
|
||||
let commands: Vec<&str> = out
|
||||
@ -230,8 +231,8 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["claude", "codex"],
|
||||
"only Claude/Codex offered"
|
||||
vec!["claude", "codex", "openai-compatible"],
|
||||
"only structured profiles offered"
|
||||
);
|
||||
// Every seeded profile is selectable (the gate the menu relies on).
|
||||
assert!(
|
||||
@ -299,32 +300,57 @@ async fn delete_unknown_is_not_found_error() {
|
||||
#[tokio::test]
|
||||
async fn reference_profiles_use_case_returns_only_selectable() {
|
||||
// §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.
|
||||
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();
|
||||
assert_eq!(commands, vec!["claude", "codex"]);
|
||||
assert_eq!(commands, vec!["claude", "codex", "openai-compatible"]);
|
||||
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.
|
||||
#[test]
|
||||
fn raw_catalogue_still_has_all_four_profiles() {
|
||||
fn raw_catalogue_still_has_all_profiles() {
|
||||
let commands: Vec<String> = reference_profiles()
|
||||
.iter()
|
||||
.map(|p| p.command.clone())
|
||||
.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
|
||||
/// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex
|
||||
/// lost theirs — i.e. it actually constrains behaviour.
|
||||
#[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 by_command: HashMap<&str, &AgentProfile> =
|
||||
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(),
|
||||
"Codex carries a structured adapter ⇒ selectable"
|
||||
);
|
||||
assert!(
|
||||
by_command["openai-compatible"].is_selectable(),
|
||||
"OpenAI-compatible carries a structured adapter ⇒ selectable"
|
||||
);
|
||||
assert!(
|
||||
!by_command["gemini"].is_selectable(),
|
||||
"Gemini has no adapter ⇒ not selectable"
|
||||
|
||||
Reference in New Issue
Block a user