feat(opencode): remplace le provider Ollama par llama.cpp

Le tool-calling local ne fonctionnait jamais via Ollama. Refonte du
support local d'OpenCode autour de llama.cpp: profil, catalogue,
matérialisation de la config OpenCode et surface first-run alignés sur
llama-server (backend + frontend).

QA vert (commandes réelles): domain 244, application 81+64, infra 263
(10 échecs = bind-port sandbox identiques sur develop, non-régression),
frontend 574, tsc propre. Réserve E2E live non bloquante faute de
llama-server joignable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 00:41:19 +02:00
parent eaba05d27d
commit 4e70631c40
12 changed files with 478 additions and 140 deletions

View File

@ -276,27 +276,68 @@ impl StructuredAdapter {
///
/// OpenCode reste une CLI locale pilotée par process, distincte de
/// [`StructuredAdapter::OpenAiCompatible`] qui est un client HTTP in-process.
/// Le modèle est optionnel : quand il est présent, IdeA l'écrit dans
/// `opencode.json`; quand il est absent, OpenCode applique son propre défaut ou
/// un override explicite porté par la commande utilisateur.
/// IdeA matérialise cette configuration dans un `opencode.json` minimal pour un
/// provider llama.cpp compatible OpenAI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeConfig {
/// Modèle OpenCode complet, par exemple `ollama/qwen3-coder:30b`.
/// Endpoint `/v1` du serveur llama.cpp compatible OpenAI.
#[serde(rename = "baseURL")]
pub base_url: String,
/// Clé API optionnelle transmise au provider OpenCode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub api_key: Option<String>,
/// Nom nu du modèle servi par llama-server, par exemple `qwen3-coder-30b`.
pub model: String,
/// Active le mode reasoning côté modèle OpenCode. Défaut effectif : `true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment: Option<bool>,
}
impl OpenCodeConfig {
/// Construit une configuration OpenCode validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `model` est présent mais vide.
pub fn new(model: Option<String>) -> Result<Self, DomainError> {
if let Some(model) = &model {
crate::validation::non_empty(model, "opencode.model")?;
/// Renvoie une erreur domaine si `base_url` n'est pas HTTP(S) ou si
/// `base_url`/`model` est vide.
pub fn new(
base_url: impl Into<String>,
api_key: Option<String>,
model: impl Into<String>,
reasoning: Option<bool>,
attachment: Option<bool>,
) -> Result<Self, DomainError> {
let base_url = base_url.into();
let model = model.into();
crate::validation::non_empty(&base_url, "opencode.baseURL")?;
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return Err(DomainError::Invariant(
"opencode.baseURL must start with http:// or https://".to_owned(),
));
}
Ok(Self { model })
crate::validation::non_empty(&model, "opencode.model")?;
Ok(Self {
base_url,
api_key,
model,
reasoning,
attachment,
})
}
/// Valeur effective de `reasoning` quand le profil ne la porte pas.
#[must_use]
pub fn reasoning_enabled(&self) -> bool {
self.reasoning.unwrap_or(true)
}
/// Valeur effective de `attachment` quand le profil ne la porte pas.
#[must_use]
pub fn attachment_enabled(&self) -> bool {
self.attachment.unwrap_or(false)
}
}
@ -1145,7 +1186,16 @@ mod mcp_tests {
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())).unwrap())
.with_opencode(
OpenCodeConfig::new(
"http://localhost:8080/v1",
Some("sk-no-key".to_owned()),
"qwen3-coder-30b",
None,
None,
)
.unwrap(),
)
.with_mcp(McpCapability::new(
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
McpTransport::Stdio,
@ -1153,6 +1203,28 @@ mod mcp_tests {
assert!(profile.materializes_idea_bridge());
}
#[test]
fn opencode_config_validates_llamacpp_endpoint_and_model() {
let config = OpenCodeConfig::new(
"https://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
Some(true),
)
.unwrap();
assert_eq!(config.base_url, "https://localhost:8080/v1");
assert_eq!(config.model, "qwen3-coder-30b");
assert!(config.reasoning_enabled());
assert!(config.attachment_enabled());
assert!(OpenCodeConfig::new("", None, "qwen3-coder-30b", None, None).is_err());
assert!(
OpenCodeConfig::new("file:///tmp/v1", None, "qwen3-coder-30b", None, None).is_err()
);
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
}
#[test]
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
let profile = profile_without_mcp()