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,
|
McpTransport::Stdio,
|
||||||
)),
|
)),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
reference_id("opencode-ollama"),
|
reference_id("opencode-llamacpp"),
|
||||||
"OpenCode + Ollama",
|
"OpenCode + llama.cpp",
|
||||||
"opencode",
|
"opencode",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
ContextInjection::convention_file("AGENTS.md")
|
ContextInjection::convention_file("AGENTS.md")
|
||||||
@ -110,7 +110,13 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect("OpenCode reference profile is valid")
|
.expect("OpenCode reference profile is valid")
|
||||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||||
.with_opencode(
|
.with_opencode(
|
||||||
OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned()))
|
OpenCodeConfig::new(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
Some("sk-no-key".to_owned()),
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.expect("OpenCode config is valid"),
|
.expect("OpenCode config is valid"),
|
||||||
)
|
)
|
||||||
.with_mcp(McpCapability::new(
|
.with_mcp(McpCapability::new(
|
||||||
@ -225,8 +231,8 @@ mod mcp_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opencode_ollama_seed_replaces_http_ollama_profile() {
|
fn opencode_llamacpp_seed_replaces_http_ollama_profile() {
|
||||||
let profile = profile("opencode-ollama");
|
let profile = profile("opencode-llamacpp");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
profile.structured_adapter,
|
profile.structured_adapter,
|
||||||
Some(StructuredAdapter::OpenCode)
|
Some(StructuredAdapter::OpenCode)
|
||||||
@ -237,8 +243,22 @@ mod mcp_tests {
|
|||||||
profile
|
profile
|
||||||
.opencode
|
.opencode
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|config| config.model.as_deref()),
|
.map(|config| config.model.as_str()),
|
||||||
Some("ollama/qwen3-coder:30b")
|
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.mcp.is_some());
|
||||||
assert!(profile.materializes_idea_bridge());
|
assert!(profile.materializes_idea_bridge());
|
||||||
|
|||||||
@ -2292,6 +2292,9 @@ impl LaunchAgent {
|
|||||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let Some(opencode) = profile.opencode.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let config_path = join(run_dir, target);
|
let config_path = join(run_dir, target);
|
||||||
let opencode_home = join(run_dir, ".opencode");
|
let opencode_home = join(run_dir, ".opencode");
|
||||||
let xdg_config = format!("{opencode_home}/config");
|
let xdg_config = format!("{opencode_home}/config");
|
||||||
@ -2307,7 +2310,7 @@ impl LaunchAgent {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let body =
|
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
|
let _ = self
|
||||||
.fs
|
.fs
|
||||||
.write(&RemotePath::new(config_path.clone()), body.as_bytes())
|
.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
|
/// The MCP server is named `idea`; OpenCode therefore exposes IdeA tools as
|
||||||
/// `idea_idea_*` (`<serverName>_<toolName>`), matching the observed contract.
|
/// `idea_idea_*` (`<serverName>_<toolName>`), matching the observed contract.
|
||||||
fn opencode_config_json(
|
fn opencode_config_json(
|
||||||
profile: &AgentProfile,
|
config: &domain::profile::OpenCodeConfig,
|
||||||
project_root: &str,
|
project_root: &str,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let model = profile
|
let model = config.model.as_str();
|
||||||
.opencode
|
let opencode_model = format!("llamacpp/{model}");
|
||||||
.as_ref()
|
|
||||||
.and_then(|config| config.model.as_deref());
|
|
||||||
let mut root = serde_json::Map::new();
|
let mut root = serde_json::Map::new();
|
||||||
root.insert(
|
root.insert(
|
||||||
"$schema".to_owned(),
|
"$schema".to_owned(),
|
||||||
serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
|
serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
|
||||||
);
|
);
|
||||||
if let Some(model) = model {
|
|
||||||
root.insert(
|
root.insert(
|
||||||
"model".to_owned(),
|
"model".to_owned(),
|
||||||
serde_json::Value::String(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();
|
let mut models = serde_json::Map::new();
|
||||||
if let Some(model) = model {
|
|
||||||
models.insert(
|
models.insert(
|
||||||
model.trim_start_matches("ollama/").to_owned(),
|
model.to_owned(),
|
||||||
serde_json::json!({ "name": model }),
|
serde_json::json!({
|
||||||
|
"name": opencode_model,
|
||||||
|
"tool_call": true,
|
||||||
|
"reasoning": config.reasoning_enabled(),
|
||||||
|
"attachment": config.attachment_enabled()
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
root.insert(
|
root.insert(
|
||||||
"provider".to_owned(),
|
"provider".to_owned(),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"ollama": {
|
"llamacpp": {
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
"name": "Ollama (local)",
|
"name": "llama.cpp",
|
||||||
"options": {
|
"options": options,
|
||||||
"baseURL": "http://localhost:11434/v1"
|
|
||||||
},
|
|
||||||
"models": models
|
"models": models
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -2556,7 +2568,7 @@ fn opencode_config_json(
|
|||||||
);
|
);
|
||||||
root.insert(
|
root.insert(
|
||||||
"disabled_providers".to_owned(),
|
"disabled_providers".to_owned(),
|
||||||
serde_json::json!(["anthropic", "openai", "gemini"]),
|
serde_json::json!(["anthropic", "openai", "gemini", "ollama"]),
|
||||||
);
|
);
|
||||||
serde_json::Value::Object(root)
|
serde_json::Value::Object(root)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1587,7 +1587,16 @@ fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
|
|||||||
fn opencode_profile(id: ProfileId) -> AgentProfile {
|
fn opencode_profile(id: ProfileId) -> AgentProfile {
|
||||||
profile(id, ContextInjection::convention_file("AGENTS.md").unwrap())
|
profile(id, ContextInjection::convention_file("AGENTS.md").unwrap())
|
||||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
.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(
|
.with_mcp(McpCapability::new(
|
||||||
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
||||||
McpTransport::Stdio,
|
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.len(), 1, "one OpenCode config is written");
|
||||||
assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json"));
|
assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json"));
|
||||||
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
|
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!(
|
assert_eq!(
|
||||||
body["provider"]["ollama"]["options"]["baseURL"],
|
body["provider"]["llamacpp"]["options"]["baseURL"],
|
||||||
"http://localhost:11434/v1"
|
"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"]["type"], "local");
|
||||||
assert_eq!(body["mcp"]["idea"]["command"][0], "idea");
|
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]
|
#[tokio::test]
|
||||||
async fn launch_with_store_without_handoff_omits_resume_section() {
|
async fn launch_with_store_without_handoff_omits_resume_section() {
|
||||||
// Provider wired, valid conversation_id, but the store holds NO handoff for it
|
// 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
|
/// OpenCode reste une CLI locale pilotée par process, distincte de
|
||||||
/// [`StructuredAdapter::OpenAiCompatible`] qui est un client HTTP in-process.
|
/// [`StructuredAdapter::OpenAiCompatible`] qui est un client HTTP in-process.
|
||||||
/// Le modèle est optionnel : quand il est présent, IdeA l'écrit dans
|
/// IdeA matérialise cette configuration dans un `opencode.json` minimal pour un
|
||||||
/// `opencode.json`; quand il est absent, OpenCode applique son propre défaut ou
|
/// provider llama.cpp compatible OpenAI.
|
||||||
/// un override explicite porté par la commande utilisateur.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OpenCodeConfig {
|
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")]
|
#[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 {
|
impl OpenCodeConfig {
|
||||||
/// Construit une configuration OpenCode validée.
|
/// Construit une configuration OpenCode validée.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Renvoie [`DomainError::EmptyField`] si `model` est présent mais vide.
|
/// Renvoie une erreur domaine si `base_url` n'est pas HTTP(S) ou si
|
||||||
pub fn new(model: Option<String>) -> Result<Self, DomainError> {
|
/// `base_url`/`model` est vide.
|
||||||
if let Some(model) = &model {
|
pub fn new(
|
||||||
crate::validation::non_empty(model, "opencode.model")?;
|
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()
|
let profile = profile_without_mcp()
|
||||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
.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(
|
.with_mcp(McpCapability::new(
|
||||||
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
||||||
McpTransport::Stdio,
|
McpTransport::Stdio,
|
||||||
@ -1153,6 +1203,28 @@ mod mcp_tests {
|
|||||||
assert!(profile.materializes_idea_bridge());
|
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]
|
#[test]
|
||||||
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
|
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
|
||||||
let profile = profile_without_mcp()
|
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`
|
//! 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
|
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
|
||||||
|
|||||||
@ -1160,20 +1160,18 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
|||||||
cwdTemplate: "{projectRoot}",
|
cwdTemplate: "{projectRoot}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mock-ollama",
|
id: "mock-opencode",
|
||||||
name: "Ollama / OpenAI-compatible local model",
|
name: "OpenCode + llama.cpp",
|
||||||
command: "openai-compatible",
|
command: "opencode",
|
||||||
args: [],
|
args: [],
|
||||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||||
detect: null,
|
detect: "opencode --version",
|
||||||
cwdTemplate: "{projectRoot}",
|
cwdTemplate: "{projectRoot}",
|
||||||
structuredAdapter: "openAiCompatible",
|
structuredAdapter: "openCode",
|
||||||
chatHttp: {
|
opencode: {
|
||||||
endpoint: "http://localhost:11434/v1",
|
baseURL: "http://localhost:8080/v1",
|
||||||
model: "qwen2.5-coder",
|
apiKey: "sk-no-key",
|
||||||
requestTimeoutMs: 120_000,
|
model: "qwen3-coder-30b",
|
||||||
connectTimeoutMs: 5_000,
|
|
||||||
maxToolIterations: 16,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -23,15 +23,15 @@ function customProfile(id: string, command: string): AgentProfile {
|
|||||||
describe("MockProfileGateway", () => {
|
describe("MockProfileGateway", () => {
|
||||||
it("firstRunState is first-run with only the selectable reference profiles", async () => {
|
it("firstRunState is first-run with only the selectable reference profiles", async () => {
|
||||||
// §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs
|
// §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs
|
||||||
// + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are
|
// + the OpenCode + llama.cpp local adapter); Gemini/Aider are filtered out of
|
||||||
// filtered out of the selection path server-side.
|
// the selection path server-side.
|
||||||
const gw = new MockProfileGateway();
|
const gw = new MockProfileGateway();
|
||||||
const state = await gw.firstRunState();
|
const state = await gw.firstRunState();
|
||||||
expect(state.isFirstRun).toBe(true);
|
expect(state.isFirstRun).toBe(true);
|
||||||
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
|
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
|
||||||
"claude",
|
"claude",
|
||||||
"codex",
|
"codex",
|
||||||
"openai-compatible",
|
"opencode",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ describe("MockProfileGateway", () => {
|
|||||||
expect(byCommand).toEqual({
|
expect(byCommand).toEqual({
|
||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
"openai-compatible": false,
|
opencode: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -662,10 +662,31 @@ export interface HttpChatConfig {
|
|||||||
maxToolIterations?: number;
|
maxToolIterations?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configuration for an OpenCode process-backed profile. */
|
/**
|
||||||
|
* Configuration for an OpenCode process-backed profile pointed at its own
|
||||||
|
* llama.cpp (OpenAI-compatible) endpoint. Mirror of the backend `OpenCodeConfig`
|
||||||
|
* (camelCase wire format: `baseURL`, `apiKey?`, `model`, `reasoning?`,
|
||||||
|
* `attachment?`). OpenCode is a locally-piloted CLI, distinct from the in-process
|
||||||
|
* {@link HttpChatConfig} adapter.
|
||||||
|
*/
|
||||||
export interface OpenCodeConfig {
|
export interface OpenCodeConfig {
|
||||||
/** Full OpenCode model id, for example `ollama/qwen3-coder:30b`. */
|
/**
|
||||||
model?: string;
|
* OpenAI-compatible base URL served by `llama-server` (http/https), for
|
||||||
|
* example `http://localhost:8080/v1`.
|
||||||
|
*/
|
||||||
|
baseURL: string;
|
||||||
|
/**
|
||||||
|
* Optional API key forwarded to the provider — the key *itself*, not an env
|
||||||
|
* var name (a local llama.cpp typically needs none / a placeholder). Omitted
|
||||||
|
* when blank (mirrors the backend `skip_serializing_if`).
|
||||||
|
*/
|
||||||
|
apiKey?: string;
|
||||||
|
/** Model name served by `llama-server`, for example `qwen3-coder-30b`. */
|
||||||
|
model: string;
|
||||||
|
/** Enables model reasoning. Effective default: `true`. */
|
||||||
|
reasoning?: boolean;
|
||||||
|
/** Enables attachments. Effective default: `false`. */
|
||||||
|
attachment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -176,130 +176,103 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => {
|
describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||||
const OLLAMA = "Ollama / OpenAI-compatible local model";
|
const OPENCODE = "OpenCode + llama.cpp";
|
||||||
|
|
||||||
it("offers the local model as a selectable, pre-filled HTTP config row", async () => {
|
it("offers the local model as a selectable, pre-filled OpenCode config row", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
expect(screen.getByText(OLLAMA)).toBeTruthy();
|
expect(screen.getByText(OPENCODE)).toBeTruthy();
|
||||||
// The structured HTTP fields are rendered, pre-filled from the reference.
|
// The OpenCode fields are rendered, pre-filled from the reference.
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value,
|
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
|
||||||
).toBe("http://localhost:11434/v1");
|
).toBe("http://localhost:8080/v1");
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value,
|
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
|
||||||
).toBe("qwen2.5-coder");
|
).toBe("qwen3-coder-30b");
|
||||||
// Claude/Codex rows must NOT grow HTTP fields (additive, no regression).
|
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
|
||||||
expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull();
|
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels the API key field as an env var NAME and flags a raw key", async () => {
|
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement;
|
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
|
||||||
// The label/placeholder make the env-var-name intent explicit.
|
expect(apiKey.value).toBe("sk-no-key");
|
||||||
expect(apiKey.placeholder.toLowerCase()).toContain("variable name");
|
|
||||||
|
|
||||||
// A raw secret is not a valid env var identifier ⇒ inline error.
|
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
|
||||||
|
// env-var-name error appears.
|
||||||
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
||||||
|
expect(apiKey.value).toBe("sk-secret-123");
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/valid env var name \(not the key itself\)/i),
|
screen.queryByText(/valid env var name/i),
|
||||||
).toBeTruthy();
|
|
||||||
|
|
||||||
// A proper env var name clears the error.
|
|
||||||
fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } });
|
|
||||||
expect(
|
|
||||||
screen.queryByText(/valid env var name \(not the key itself\)/i),
|
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags an invalid endpoint scheme inline", async () => {
|
it("flags an invalid base URL scheme inline", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`);
|
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
|
||||||
fireEvent.change(endpoint, { target: { value: "ftp://oops" } });
|
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
|
||||||
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => {
|
it("flags an empty model inline", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
expect(
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||||
(screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value,
|
target: { value: "" },
|
||||||
).toBe("120000");
|
});
|
||||||
expect(
|
expect(screen.getByText(/model is required/i)).toBeTruthy();
|
||||||
(screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value,
|
|
||||||
).toBe("5000");
|
|
||||||
expect(
|
|
||||||
(screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement)
|
|
||||||
.value,
|
|
||||||
).toBe("16");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => {
|
it("persists the edited OpenCode config round-trip when selected and saved", async () => {
|
||||||
const { profile } = renderWizard();
|
const { profile } = renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
|
// Select the local model and edit its base URL, model and API key.
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||||
target: { value: "90000" },
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
|
||||||
|
target: { value: "http://localhost:9090/v1" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), {
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||||
target: { value: "3000" },
|
target: { value: "qwen3-coder-14b" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), {
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
target: { value: "8" },
|
target: { value: "sk-local" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const saved = await profile.listProfiles();
|
const saved = await profile.listProfiles();
|
||||||
const ollama = saved.find((p) => p.command === "openai-compatible");
|
const opencode = saved.find((p) => p.command === "opencode");
|
||||||
expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000);
|
expect(opencode?.structuredAdapter).toBe("openCode");
|
||||||
expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000);
|
expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1");
|
||||||
expect(ollama?.chatHttp?.maxToolIterations).toBe(8);
|
expect(opencode?.opencode?.model).toBe("qwen3-coder-14b");
|
||||||
|
expect(opencode?.opencode?.apiKey).toBe("sk-local");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags a zero timeout inline (mirror of the backend guard)", async () => {
|
it("clearing the API key drops it (optional, omitted when blank)", async () => {
|
||||||
renderWizard();
|
|
||||||
await waitForLoaded();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
|
|
||||||
target: { value: "0" },
|
|
||||||
});
|
|
||||||
expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists the edited HTTP config round-trip when selected and saved", async () => {
|
|
||||||
const { profile } = renderWizard();
|
const { profile } = renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
// Select the local model and edit its model name.
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||||
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), {
|
target: { value: "" },
|
||||||
target: { value: "qwen3" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), {
|
|
||||||
target: { value: "LAN_KEY" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const saved = await profile.listProfiles();
|
const saved = await profile.listProfiles();
|
||||||
const ollama = saved.find((p) => p.command === "openai-compatible");
|
const opencode = saved.find((p) => p.command === "opencode");
|
||||||
expect(ollama?.structuredAdapter).toBe("openAiCompatible");
|
expect(opencode?.opencode?.apiKey).toBeUndefined();
|
||||||
expect(ollama?.chatHttp?.model).toBe("qwen3");
|
|
||||||
expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY");
|
|
||||||
// The endpoint round-trips untouched.
|
|
||||||
expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,11 +15,12 @@
|
|||||||
* `./profile`.
|
* `./profile`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentProfile, HttpChatConfig } from "@/domain";
|
import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain";
|
||||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||||
import {
|
import {
|
||||||
defaultHttpChatConfig,
|
defaultHttpChatConfig,
|
||||||
|
defaultOpenCodeConfig,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
type ProfileErrors,
|
type ProfileErrors,
|
||||||
@ -190,10 +191,81 @@ function ProfileRow({
|
|||||||
{profile.structuredAdapter === "openAiCompatible" && (
|
{profile.structuredAdapter === "openAiCompatible" && (
|
||||||
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{profile.structuredAdapter === "openCode" && (
|
||||||
|
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OpenCode + llama.cpp config section: the base URL of the local
|
||||||
|
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key
|
||||||
|
* itself — a local llama.cpp usually needs none). Rendered only for a `openCode`
|
||||||
|
* profile; edits flow back through `onChange` as a patched `opencode`.
|
||||||
|
*/
|
||||||
|
function OpenCodeFields({
|
||||||
|
profile,
|
||||||
|
errors,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
errors: ProfileErrors;
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
||||||
|
const patch = (next: Partial<OpenCodeConfig>) =>
|
||||||
|
onChange({ ...profile, opencode: { ...opencode, ...next } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Local model (OpenCode + llama.cpp)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Base URL</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} base url`}
|
||||||
|
value={opencode.baseURL}
|
||||||
|
placeholder="http://localhost:8080/v1"
|
||||||
|
invalid={Boolean(errors.baseURL)}
|
||||||
|
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.baseURL && (
|
||||||
|
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Model</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={opencode.model}
|
||||||
|
placeholder="qwen3-coder-30b"
|
||||||
|
invalid={Boolean(errors.model)}
|
||||||
|
onChange={(e) => patch({ model: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} api key`}
|
||||||
|
value={opencode.apiKey ?? ""}
|
||||||
|
placeholder="e.g. sk-no-key"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
patch({ apiKey: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
||||||
function parseOptInt(raw: string): number | undefined {
|
function parseOptInt(raw: string): number | undefined {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { AgentProfile } from "@/domain";
|
|||||||
import {
|
import {
|
||||||
defaultHttpChatConfig,
|
defaultHttpChatConfig,
|
||||||
defaultInjection,
|
defaultInjection,
|
||||||
|
defaultOpenCodeConfig,
|
||||||
emptyCustomProfile,
|
emptyCustomProfile,
|
||||||
isProfileValid,
|
isProfileValid,
|
||||||
isRelativeSafe,
|
isRelativeSafe,
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
validateHttpChatConfig,
|
validateHttpChatConfig,
|
||||||
|
validateOpenCodeConfig,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
|
|
||||||
@ -189,6 +191,58 @@ describe("validateProfile for openAiCompatible profiles", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("validateOpenCodeConfig", () => {
|
||||||
|
it("a well-formed llama.cpp config is valid", () => {
|
||||||
|
expect(validateOpenCodeConfig(defaultOpenCodeConfig())).toEqual({});
|
||||||
|
});
|
||||||
|
it("reports a bad base URL scheme and an empty model", () => {
|
||||||
|
const errors = validateOpenCodeConfig({ baseURL: "ftp://x", model: " " });
|
||||||
|
expect(errors.baseURL).toBeDefined();
|
||||||
|
expect(errors.model).toBeDefined();
|
||||||
|
});
|
||||||
|
it("an optional API key is free-form (no env-var-name constraint)", () => {
|
||||||
|
// Unlike the openAiCompatible adapter, OpenCode carries the key itself.
|
||||||
|
expect(
|
||||||
|
validateOpenCodeConfig({
|
||||||
|
baseURL: "http://localhost:8080/v1",
|
||||||
|
apiKey: "sk-secret-123",
|
||||||
|
model: "qwen3-coder-30b",
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateProfile for openCode profiles", () => {
|
||||||
|
function opencode(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
||||||
|
return base({
|
||||||
|
name: "OpenCode + llama.cpp",
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencode: defaultOpenCodeConfig(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("the reference llama.cpp profile is valid", () => {
|
||||||
|
expect(validateProfile(opencode())).toEqual({});
|
||||||
|
expect(isProfileValid(opencode())).toBe(true);
|
||||||
|
});
|
||||||
|
it("surfaces opencode field errors through the profile validator", () => {
|
||||||
|
const errors = validateProfile(
|
||||||
|
opencode({ opencode: { baseURL: "nope", model: "" } }),
|
||||||
|
);
|
||||||
|
expect(errors.baseURL).toBeDefined();
|
||||||
|
expect(errors.model).toBeDefined();
|
||||||
|
expect(
|
||||||
|
isProfileValid(opencode({ opencode: { baseURL: "nope", model: "m" } })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it("a missing opencode config on an openCode profile is invalid", () => {
|
||||||
|
const errors = validateProfile(opencode({ opencode: undefined }));
|
||||||
|
expect(errors.baseURL).toBeDefined();
|
||||||
|
expect(errors.model).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("defaultInjection", () => {
|
describe("defaultInjection", () => {
|
||||||
it("produces a sensible default per strategy", () => {
|
it("produces a sensible default per strategy", () => {
|
||||||
expect(defaultInjection("conventionFile")).toEqual({
|
expect(defaultInjection("conventionFile")).toEqual({
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type {
|
|||||||
ContextInjection,
|
ContextInjection,
|
||||||
HttpChatConfig,
|
HttpChatConfig,
|
||||||
InjectionStrategy,
|
InjectionStrategy,
|
||||||
|
OpenCodeConfig,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
|
||||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||||
@ -20,6 +21,7 @@ export type ProfileErrors = Partial<
|
|||||||
| "flag"
|
| "flag"
|
||||||
| "var"
|
| "var"
|
||||||
| "endpoint"
|
| "endpoint"
|
||||||
|
| "baseURL"
|
||||||
| "model"
|
| "model"
|
||||||
| "apiKeyEnv"
|
| "apiKeyEnv"
|
||||||
| "requestTimeoutMs"
|
| "requestTimeoutMs"
|
||||||
@ -95,6 +97,23 @@ export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an {@link OpenCodeConfig} draft the way the backend
|
||||||
|
* `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty;
|
||||||
|
* the API key is a free-form, optional secret). Returns an empty object when the
|
||||||
|
* draft is valid.
|
||||||
|
*/
|
||||||
|
export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors {
|
||||||
|
const errors: ProfileErrors = {};
|
||||||
|
if (!isValidHttpUrl(c.baseURL)) {
|
||||||
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||||
|
}
|
||||||
|
if (c.model.trim().length === 0) {
|
||||||
|
errors.model = "Model is required.";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a profile draft the way the backend would, so the wizard can surface
|
* Validates a profile draft the way the backend would, so the wizard can surface
|
||||||
* errors before any `invoke`. Returns an empty object when the draft is valid.
|
* errors before any `invoke`. Returns an empty object when the draft is valid.
|
||||||
@ -125,8 +144,15 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
|
|||||||
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (p.structuredAdapter === "openCode" && p.opencode?.model !== undefined) {
|
// An OpenCode profile carries its own llama.cpp endpoint config; the backend
|
||||||
if (p.opencode.model.trim().length === 0) errors.model = "Model is required.";
|
// refuses to persist it unless base URL + model are well-formed, so mirror that.
|
||||||
|
if (p.structuredAdapter === "openCode") {
|
||||||
|
if (!p.opencode) {
|
||||||
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||||
|
errors.model = "Model is required.";
|
||||||
|
} else {
|
||||||
|
Object.assign(errors, validateOpenCodeConfig(p.opencode));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
@ -168,6 +194,19 @@ export function defaultHttpChatConfig(): HttpChatConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft —
|
||||||
|
* pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the
|
||||||
|
* backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`).
|
||||||
|
*/
|
||||||
|
export function defaultOpenCodeConfig(): OpenCodeConfig {
|
||||||
|
return {
|
||||||
|
baseURL: "http://localhost:8080/v1",
|
||||||
|
apiKey: "sk-no-key",
|
||||||
|
model: "qwen3-coder-30b",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** A fresh, empty custom-profile draft (id minted client-side). */
|
/** A fresh, empty custom-profile draft (id minted client-side). */
|
||||||
export function emptyCustomProfile(): AgentProfile {
|
export function emptyCustomProfile(): AgentProfile {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user