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