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

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

View File

@ -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)
}

View File

@ -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