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:
@ -97,8 +97,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
reference_id("opencode-ollama"),
|
||||
"OpenCode + Ollama",
|
||||
reference_id("opencode-llamacpp"),
|
||||
"OpenCode + llama.cpp",
|
||||
"opencode",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md")
|
||||
@ -110,8 +110,14 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("OpenCode reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(
|
||||
OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned()))
|
||||
.expect("OpenCode config is valid"),
|
||||
OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
Some("sk-no-key".to_owned()),
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("OpenCode config is valid"),
|
||||
)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::open_code_config("opencode.json")
|
||||
@ -225,8 +231,8 @@ mod mcp_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_ollama_seed_replaces_http_ollama_profile() {
|
||||
let profile = profile("opencode-ollama");
|
||||
fn opencode_llamacpp_seed_replaces_http_ollama_profile() {
|
||||
let profile = profile("opencode-llamacpp");
|
||||
assert_eq!(
|
||||
profile.structured_adapter,
|
||||
Some(StructuredAdapter::OpenCode)
|
||||
@ -237,8 +243,22 @@ mod mcp_tests {
|
||||
profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.and_then(|config| config.model.as_deref()),
|
||||
Some("ollama/qwen3-coder:30b")
|
||||
.map(|config| config.model.as_str()),
|
||||
Some("qwen3-coder-30b")
|
||||
);
|
||||
assert_eq!(
|
||||
profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.map(|config| config.base_url.as_str()),
|
||||
Some("http://localhost:8080/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.and_then(|config| config.api_key.as_deref()),
|
||||
Some("sk-no-key")
|
||||
);
|
||||
assert!(profile.mcp.is_some());
|
||||
assert!(profile.materializes_idea_bridge());
|
||||
|
||||
@ -2292,6 +2292,9 @@ impl LaunchAgent {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
return;
|
||||
}
|
||||
let Some(opencode) = profile.opencode.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let config_path = join(run_dir, target);
|
||||
let opencode_home = join(run_dir, ".opencode");
|
||||
let xdg_config = format!("{opencode_home}/config");
|
||||
@ -2307,7 +2310,7 @@ impl LaunchAgent {
|
||||
.await;
|
||||
}
|
||||
let body =
|
||||
opencode_config_json(profile, project_root.as_str(), runtime).to_string();
|
||||
opencode_config_json(opencode, project_root.as_str(), runtime).to_string();
|
||||
let _ = self
|
||||
.fs
|
||||
.write(&RemotePath::new(config_path.clone()), body.as_bytes())
|
||||
@ -2474,43 +2477,52 @@ fn mcp_server_wiring(
|
||||
/// The MCP server is named `idea`; OpenCode therefore exposes IdeA tools as
|
||||
/// `idea_idea_*` (`<serverName>_<toolName>`), matching the observed contract.
|
||||
fn opencode_config_json(
|
||||
profile: &AgentProfile,
|
||||
config: &domain::profile::OpenCodeConfig,
|
||||
project_root: &str,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> serde_json::Value {
|
||||
let model = profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.and_then(|config| config.model.as_deref());
|
||||
let model = config.model.as_str();
|
||||
let opencode_model = format!("llamacpp/{model}");
|
||||
let mut root = serde_json::Map::new();
|
||||
root.insert(
|
||||
"$schema".to_owned(),
|
||||
serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
|
||||
);
|
||||
if let Some(model) = model {
|
||||
root.insert(
|
||||
"model".to_owned(),
|
||||
serde_json::Value::String(model.to_owned()),
|
||||
root.insert(
|
||||
"model".to_owned(),
|
||||
serde_json::Value::String(opencode_model.clone()),
|
||||
);
|
||||
|
||||
let mut options = serde_json::Map::new();
|
||||
options.insert(
|
||||
"baseURL".to_owned(),
|
||||
serde_json::Value::String(config.base_url.clone()),
|
||||
);
|
||||
if let Some(api_key) = config.api_key.as_ref() {
|
||||
options.insert(
|
||||
"apiKey".to_owned(),
|
||||
serde_json::Value::String(api_key.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut models = serde_json::Map::new();
|
||||
if let Some(model) = model {
|
||||
models.insert(
|
||||
model.trim_start_matches("ollama/").to_owned(),
|
||||
serde_json::json!({ "name": model }),
|
||||
);
|
||||
}
|
||||
models.insert(
|
||||
model.to_owned(),
|
||||
serde_json::json!({
|
||||
"name": opencode_model,
|
||||
"tool_call": true,
|
||||
"reasoning": config.reasoning_enabled(),
|
||||
"attachment": config.attachment_enabled()
|
||||
}),
|
||||
);
|
||||
|
||||
root.insert(
|
||||
"provider".to_owned(),
|
||||
serde_json::json!({
|
||||
"ollama": {
|
||||
"llamacpp": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama (local)",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"name": "llama.cpp",
|
||||
"options": options,
|
||||
"models": models
|
||||
}
|
||||
}),
|
||||
@ -2556,7 +2568,7 @@ fn opencode_config_json(
|
||||
);
|
||||
root.insert(
|
||||
"disabled_providers".to_owned(),
|
||||
serde_json::json!(["anthropic", "openai", "gemini"]),
|
||||
serde_json::json!(["anthropic", "openai", "gemini", "ollama"]),
|
||||
);
|
||||
serde_json::Value::Object(root)
|
||||
}
|
||||
|
||||
@ -1587,7 +1587,16 @@ fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
|
||||
fn opencode_profile(id: ProfileId) -> AgentProfile {
|
||||
profile(id, ContextInjection::convention_file("AGENTS.md").unwrap())
|
||||
.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,
|
||||
@ -1730,10 +1739,34 @@ async fn launch_opencode_writes_isolated_config_and_env() {
|
||||
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
|
||||
assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json"));
|
||||
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
|
||||
assert_eq!(body["model"], "ollama/qwen3-coder:30b");
|
||||
assert_eq!(body["model"], "llamacpp/qwen3-coder-30b");
|
||||
assert_eq!(
|
||||
body["provider"]["ollama"]["options"]["baseURL"],
|
||||
"http://localhost:11434/v1"
|
||||
body["provider"]["llamacpp"]["options"]["baseURL"],
|
||||
"http://localhost:8080/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
body["provider"]["llamacpp"]["options"]["apiKey"],
|
||||
"sk-no-key"
|
||||
);
|
||||
assert_eq!(
|
||||
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["name"],
|
||||
"llamacpp/qwen3-coder-30b"
|
||||
);
|
||||
assert_eq!(
|
||||
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["tool_call"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["reasoning"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["attachment"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
body["disabled_providers"],
|
||||
serde_json::json!(["anthropic", "openai", "gemini", "ollama"])
|
||||
);
|
||||
assert_eq!(body["mcp"]["idea"]["type"], "local");
|
||||
assert_eq!(body["mcp"]["idea"]["command"][0], "idea");
|
||||
@ -2496,6 +2529,50 @@ async fn launch_with_invalid_uuid_conversation_id_omits_resume_section() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_opencode_omits_api_key_when_profile_has_none() {
|
||||
let profile = profile(pid(91), ContextInjection::convention_file("AGENTS.md").unwrap())
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(
|
||||
OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let opencode = writes_ending_with(&fs, "/opencode.json");
|
||||
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
|
||||
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
|
||||
assert_eq!(
|
||||
body["provider"]["llamacpp"]["options"]["baseURL"],
|
||||
"http://localhost:8080/v1"
|
||||
);
|
||||
assert!(
|
||||
body["provider"]["llamacpp"]["options"]
|
||||
.as_object()
|
||||
.expect("llamacpp options are an object")
|
||||
.get("apiKey")
|
||||
.is_none(),
|
||||
"apiKey must be omitted, not serialized as null"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_with_store_without_handoff_omits_resume_section() {
|
||||
// Provider wired, valid conversation_id, but the store holds NO handoff for it
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
//! [`OpenCodeSession`] — adapter structuré OpenCode + Ollama.
|
||||
//! [`OpenCodeSession`] — adapter structuré OpenCode + llama.cpp.
|
||||
//!
|
||||
//! OpenCode est piloté comme host process local : IdeA génère un `opencode.json`
|
||||
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
|
||||
|
||||
Reference in New Issue
Block a user