From dba06270a4322f1a024d970f004a82d75e859ef7 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Mon, 16 Mar 2026 04:55:00 +0000 Subject: [PATCH] fix(1ru.7): Make LM Studio and Ollama first-class local endpoint integrations --- Cargo.lock | 1 + crates/agent-openai/Cargo.toml | 1 + crates/agent-openai/src/lib.rs | 440 +++++++++++++++++++++++++++++++-- crates/workflow/src/render.rs | 22 +- crates/workflow/src/tests.rs | 146 ++++++++++- templates/config.toml | 15 ++ 6 files changed, 600 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf4888f..690363e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2626,6 +2626,7 @@ dependencies = [ "polyphony-agent-common", "polyphony-core", "reqwest", + "serde", "serde_json", "tempfile", "thiserror 2.0.18", diff --git a/crates/agent-openai/Cargo.toml b/crates/agent-openai/Cargo.toml index 886208e..8edc066 100644 --- a/crates/agent-openai/Cargo.toml +++ b/crates/agent-openai/Cargo.toml @@ -9,6 +9,7 @@ async-trait.workspace = true chrono.workspace = true futures-util.workspace = true reqwest.workspace = true +serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/agent-openai/src/lib.rs b/crates/agent-openai/src/lib.rs index 2e79c79..e89ffef 100644 --- a/crates/agent-openai/src/lib.rs +++ b/crates/agent-openai/src/lib.rs @@ -1,4 +1,9 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error as StdError, + io::ErrorKind, + sync::Arc, +}; use { async_trait::async_trait, @@ -36,6 +41,71 @@ fn resolve_base_url(agent: &AgentDefinition) -> String { .unwrap_or_else(|| "https://api.openai.com/v1".into()) } +#[derive(Debug, serde::Deserialize)] +struct OllamaTagsModel { + name: String, +} + +#[derive(Debug, serde::Deserialize)] +struct OllamaTagsResponse { + #[serde(default)] + models: Vec, +} + +fn configured_api_key(agent: &AgentDefinition) -> Option<&str> { + agent + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn provider_requires_api_key(agent: &AgentDefinition) -> bool { + !matches!(agent.kind.as_str(), "ollama" | "lmstudio") +} + +fn local_provider_discovery_error(agent: &AgentDefinition, error: &reqwest::Error) -> bool { + is_local_provider(agent) && (error.is_timeout() || is_connection_refused(error)) +} + +fn is_local_provider(agent: &AgentDefinition) -> bool { + matches!(agent.kind.as_str(), "ollama" | "lmstudio") +} + +fn is_connection_refused(error: &reqwest::Error) -> bool { + if !error.is_connect() { + return false; + } + + let mut source = error.source(); + while let Some(cause) = source { + if let Some(io_error) = cause.downcast_ref::() + && io_error.kind() == ErrorKind::ConnectionRefused + { + return true; + } + source = cause.source(); + } + + false +} + +fn maybe_bearer_auth( + request: reqwest::RequestBuilder, + api_key: Option<&str>, +) -> reqwest::RequestBuilder { + match api_key { + Some(api_key) => request.bearer_auth(api_key), + None => request, + } +} + +fn ollama_root_url(agent: &AgentDefinition) -> String { + let base_url = resolve_base_url(agent); + let trimmed = base_url.trim_end_matches('/'); + trimmed.strip_suffix("/v1").unwrap_or(trimmed).to_string() +} + #[derive(Clone)] pub struct OpenAiRuntime { http: reqwest::Client, @@ -123,7 +193,10 @@ async fn discover_models_for_agent( polyphony_agent_common::parse_model_list( &run_shell_capture(command, None, &agent.env).await?, )? - } else if agent.fetch_models && agent.api_key.is_none() { + } else if agent.fetch_models + && provider_requires_api_key(agent) + && configured_api_key(agent).is_none() + { debug!( agent_name = %agent.name, provider_kind = %agent.kind, @@ -152,12 +225,53 @@ async fn discover_openai_models( client: &reqwest::Client, agent: &AgentDefinition, ) -> Result, CoreError> { - let api_key = agent.api_key.clone().ok_or_else(|| { - CoreError::Adapter(format!( + if provider_requires_api_key(agent) && configured_api_key(agent).is_none() { + return Err(CoreError::Adapter(format!( "agent `{}` api_key is required for model discovery", agent.name - )) - })?; + ))); + } + + if agent.kind == "ollama" { + let openai_models = discover_models_from_openai_endpoint(client, agent).await; + let ollama_models = discover_models_from_ollama_tags(client, agent).await; + return match (openai_models, ollama_models) { + (Ok(openai_models), Ok(ollama_models)) => { + Ok(merge_models(openai_models, ollama_models)) + }, + (Ok(openai_models), Err(error)) => { + warn!( + agent_name = %agent.name, + provider_kind = %agent.kind, + error = %error, + "Ollama /api/tags discovery failed, using /v1/models only" + ); + Ok(openai_models) + }, + (Err(error), Ok(ollama_models)) => { + warn!( + agent_name = %agent.name, + provider_kind = %agent.kind, + error = %error, + "Ollama /v1/models discovery failed, using /api/tags only" + ); + Ok(ollama_models) + }, + (Err(openai_error), Err(ollama_error)) => Err(CoreError::Adapter(format!( + "model discovery failed for {}: /v1/models: {openai_error}; /api/tags: {ollama_error}", + agent.name + ))), + }; + } + + discover_models_from_openai_endpoint(client, agent).await +} + +async fn discover_models_from_openai_endpoint( + client: &reqwest::Client, + agent: &AgentDefinition, +) -> Result, CoreError> { + let api_key = configured_api_key(agent); let base_url = resolve_base_url(agent); info!( agent_name = %agent.name, @@ -165,13 +279,26 @@ async fn discover_openai_models( base_url, "discovering OpenAI-compatible models" ); - let response = client - .get(format!("{}/models", base_url.trim_end_matches('/'))) - .bearer_auth(api_key) - .header("User-Agent", "polyphony") - .send() - .await - .map_err(|error| CoreError::Adapter(error.to_string()))?; + let request = maybe_bearer_auth( + client + .get(format!("{}/models", base_url.trim_end_matches('/'))) + .header("User-Agent", "polyphony"), + api_key, + ); + let response = match request.send().await { + Ok(response) => response, + Err(error) if local_provider_discovery_error(agent, &error) => { + warn!( + agent_name = %agent.name, + provider_kind = %agent.kind, + base_url, + error = %error, + "local OpenAI-compatible model discovery skipped because the provider is unreachable" + ); + return Ok(Vec::new()); + }, + Err(error) => return Err(CoreError::Adapter(error.to_string())), + }; let status = response.status(); if status.as_u16() == 429 { warn!( @@ -214,17 +341,81 @@ async fn discover_openai_models( Ok(models) } +async fn discover_models_from_ollama_tags( + client: &reqwest::Client, + agent: &AgentDefinition, +) -> Result, CoreError> { + let api_key = configured_api_key(agent); + let base_url = ollama_root_url(agent); + info!( + agent_name = %agent.name, + provider_kind = %agent.kind, + base_url, + "discovering Ollama models via /api/tags" + ); + let request = maybe_bearer_auth( + client + .get(format!("{}/api/tags", base_url.trim_end_matches('/'))) + .header("User-Agent", "polyphony"), + api_key, + ); + let response = match request.send().await { + Ok(response) => response, + Err(error) if local_provider_discovery_error(agent, &error) => { + warn!( + agent_name = %agent.name, + provider_kind = %agent.kind, + base_url, + error = %error, + "local Ollama /api/tags discovery skipped because the provider is unreachable" + ); + return Ok(Vec::new()); + }, + Err(error) => return Err(CoreError::Adapter(error.to_string())), + }; + let status = response.status(); + let payload = response + .json::() + .await + .map_err(|error| CoreError::Adapter(error.to_string()))?; + if !status.is_success() { + return Err(CoreError::Adapter(format!( + "model discovery failed for {}: {status}", + agent.name + ))); + } + + let models = payload + .models + .into_iter() + .map(|model| model.name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect::>() + .into_iter() + .map(|id| AgentModel { + display_name: Some(id.clone()), + id, + created_at: None, + }) + .collect::>(); + debug!( + agent_name = %agent.name, + discovered_models = models.len(), + "discovered Ollama models via /api/tags" + ); + Ok(models) +} + async fn run_openai_chat( client: &reqwest::Client, spec: AgentRunSpec, event_tx: mpsc::UnboundedSender, tool_executor: Option<&Arc>, ) -> Result { - let api_key = spec - .agent - .api_key - .clone() - .ok_or_else(|| CoreError::Adapter("openai_chat api_key is required".into()))?; + let api_key = configured_api_key(&spec.agent); + if provider_requires_api_key(&spec.agent) && api_key.is_none() { + return Err(CoreError::Adapter("openai_chat api_key is required".into())); + } let model_catalog = discover_models_for_agent(client, &spec.agent).await?; let model = spec .agent @@ -287,14 +478,14 @@ async fn run_openai_chat( ); let response = client .post(&url) - .bearer_auth(&api_key) .header("User-Agent", "polyphony") .json(&json!({ "model": model, "messages": messages, "stream": true, "stream_options": {"include_usage": true}, - })) + })); + let response = maybe_bearer_auth(response, api_key) .send() .await .map_err(|error| CoreError::Adapter(error.to_string()))?; @@ -695,7 +886,7 @@ fn parse_openai_usage(payload: &Value) -> Option { #[cfg(test)] mod tests { use { - super::{OpenAiRuntime, parse_openai_usage}, + super::{OllamaTagsResponse, OpenAiRuntime, parse_openai_usage}, async_trait::async_trait, polyphony_core::{ AgentDefinition, AgentEventKind, AgentProviderRuntime, AgentRunSpec, AgentTransport, @@ -745,6 +936,24 @@ mod tests { assert_eq!(usage.total_tokens, 20); } + #[test] + fn parses_ollama_tags_payload() { + let payload = serde_json::from_value::(serde_json::json!({ + "models": [ + {"name": "llama3.2"}, + {"name": "qwen2.5:latest"} + ] + })) + .unwrap(); + + let names = payload + .models + .into_iter() + .map(|model| model.name) + .collect::>(); + assert_eq!(names, vec!["llama3.2", "qwen2.5:latest"]); + } + #[tokio::test] async fn discovers_models_from_command() { let runtime = OpenAiRuntime::default(); @@ -786,6 +995,123 @@ mod tests { assert!(catalog.models.is_empty()); } + #[tokio::test] + async fn local_model_discovery_returns_empty_when_provider_is_unreachable() { + let ollama_addr = TcpListener::bind("127.0.0.1:0") + .await + .unwrap() + .local_addr() + .unwrap(); + let lmstudio_addr = TcpListener::bind("127.0.0.1:0") + .await + .unwrap() + .local_addr() + .unwrap(); + + let runtime = OpenAiRuntime::default(); + let ollama_catalog = runtime + .discover_models(&AgentDefinition { + name: "ollama".into(), + kind: "ollama".into(), + transport: AgentTransport::OpenAiChat, + base_url: Some(format!("http://{ollama_addr}/v1")), + fetch_models: true, + ..AgentDefinition::default() + }) + .await + .unwrap(); + let lmstudio_catalog = runtime + .discover_models(&AgentDefinition { + name: "lmstudio".into(), + kind: "lmstudio".into(), + transport: AgentTransport::OpenAiChat, + base_url: Some(format!("http://{lmstudio_addr}/v1")), + fetch_models: true, + ..AgentDefinition::default() + }) + .await + .unwrap(); + + assert!(ollama_catalog.is_none()); + assert!(lmstudio_catalog.is_none()); + } + + #[tokio::test] + async fn ollama_model_discovery_queries_tags_without_auth_header() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut paths = Vec::new(); + let mut authorization_headers = Vec::new(); + for _ in 0..2 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 8192]; + let read = socket.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_string(); + authorization_headers.push( + request + .lines() + .any(|line| line.starts_with("Authorization: Bearer ")), + ); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap() + .to_string(); + paths.push(path.clone()); + let payload = if path == "/v1/models" { + serde_json::json!({ + "data": [{"id": "llama3.2"}] + }) + } else { + serde_json::json!({ + "models": [ + {"name": " llama3.2 "}, + {"name": "qwen2.5"}, + {"name": "qwen2.5"} + ] + }) + } + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ); + socket.write_all(response.as_bytes()).await.unwrap(); + } + (paths, authorization_headers) + }); + + let runtime = OpenAiRuntime::default(); + let catalog = runtime + .discover_models(&AgentDefinition { + name: "ollama".into(), + kind: "ollama".into(), + transport: AgentTransport::OpenAiChat, + base_url: Some(format!("http://{addr}/v1")), + fetch_models: true, + ..AgentDefinition::default() + }) + .await + .unwrap() + .unwrap(); + let (paths, authorization_headers) = server.await.unwrap(); + + assert_eq!(paths, vec!["/v1/models", "/api/tags"]); + assert_eq!(authorization_headers, vec![false, false]); + assert_eq!(catalog.selected_model.as_deref(), Some("llama3.2")); + assert_eq!( + catalog + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["llama3.2", "qwen2.5"] + ); + } + #[tokio::test] async fn openai_runner_handles_tool_loop() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -880,6 +1206,78 @@ mod tests { assert!(saw_tool_warning); } + #[tokio::test] + async fn local_openai_runner_skips_authorization_header_when_api_key_is_empty() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 8192]; + let read = socket.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_string(); + let payload = serde_json::json!({ + "choices": [{ + "message": { + "content": "done" + } + }] + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ); + socket.write_all(response.as_bytes()).await.unwrap(); + request + }); + + let runtime = OpenAiRuntime::default(); + let (tx, _rx) = mpsc::unbounded_channel(); + let result = runtime + .run( + AgentRunSpec { + issue: Issue { + id: "1".into(), + identifier: "TEST-1".into(), + title: "Test".into(), + state: "Todo".into(), + ..Issue::default() + }, + attempt: None, + workspace_path: std::env::temp_dir(), + prompt: "hello".into(), + max_turns: 1, + prior_context: None, + agent: AgentDefinition { + name: "lmstudio".into(), + kind: "lmstudio".into(), + transport: AgentTransport::OpenAiChat, + base_url: Some(format!("http://{addr}/v1")), + api_key: Some(" ".into()), + model: Some("local-model".into()), + fetch_models: false, + turn_timeout_ms: 5_000, + read_timeout_ms: 1_000, + stall_timeout_ms: 60_000, + idle_timeout_ms: 1_000, + ..AgentDefinition::default() + }, + }, + tx, + ) + .await + .unwrap(); + let request = server.await.unwrap(); + + assert!(matches!( + result.status, + polyphony_core::AttemptStatus::Succeeded + )); + assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1")); + assert!(!request.contains("\r\nAuthorization: Bearer ")); + } + #[tokio::test] async fn openai_runner_executes_supported_tools() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/workflow/src/render.rs b/crates/workflow/src/render.rs index 05a3a79..9e3d7ae 100644 --- a/crates/workflow/src/render.rs +++ b/crates/workflow/src/render.rs @@ -343,6 +343,8 @@ pub(crate) fn resolve_agent_api_key(kind: &str, api_key: Option) -> Opti "kimi" | "kimi-2.5" | "kimi-k2" | "moonshot" | "moonshotai" => env::var("KIMI_API_KEY") .ok() .or_else(|| env::var("MOONSHOT_API_KEY").ok()), + "ollama" => env::var("OLLAMA_API_KEY").ok(), + "lmstudio" => env::var("LMSTUDIO_API_KEY").ok(), _ => None, }; resolve_env_token(api_key.or(fallback)) @@ -418,7 +420,9 @@ pub(crate) fn infer_agent_transport(profile: &AgentProfileConfig) -> AgentTransp "acpx" => AgentTransport::Acpx, "openai" | "openai-compatible" | "openrouter" | "kimi" | "kimi-2.5" | "kimi-k2" | "moonshot" | "moonshotai" | "mistral" | "deepseek" | "cerebras" | "gemini" - | "zai" | "minimax" | "venice" | "groq" => AgentTransport::OpenAiChat, + | "zai" | "minimax" | "venice" | "groq" | "ollama" | "lmstudio" => { + AgentTransport::OpenAiChat + }, _ => AgentTransport::LocalCli, }, } @@ -466,6 +470,12 @@ pub fn agent_definition(name: &str, profile: &AgentProfileConfig) -> AgentDefini } pub(crate) fn default_agent_base_url(kind: &str) -> Option { + if let Some(env_var) = local_agent_base_url_env_var(kind) + && let Some(base_url) = normalize_optional_string(env::var(env_var).ok()) + { + return Some(base_url); + } + let url = match kind { "kimi" | "kimi-2.5" | "kimi-k2" | "moonshot" | "moonshotai" => "https://api.moonshot.ai/v1", "openrouter" => "https://openrouter.ai/api/v1", @@ -477,11 +487,21 @@ pub(crate) fn default_agent_base_url(kind: &str) -> Option { "minimax" => "https://api.minimax.io/v1", "venice" => "https://api.venice.ai/api/v1", "groq" => "https://api.groq.com/openai/v1", + "ollama" => "http://localhost:11434/v1", + "lmstudio" => "http://127.0.0.1:1234/v1", _ => return None, }; Some(url.into()) } +fn local_agent_base_url_env_var(kind: &str) -> Option<&'static str> { + match kind { + "ollama" => Some("OLLAMA_BASE_URL"), + "lmstudio" => Some("LMSTUDIO_BASE_URL"), + _ => None, + } +} + pub(crate) fn parse_interaction_mode(value: Option<&str>) -> AgentInteractionMode { match value { Some("interactive") => AgentInteractionMode::Interactive, diff --git a/crates/workflow/src/tests.rs b/crates/workflow/src/tests.rs index 82fddf2..c3d90de 100644 --- a/crates/workflow/src/tests.rs +++ b/crates/workflow/src/tests.rs @@ -1,13 +1,14 @@ use std::{ fs, + sync::{LazyLock, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; use { crate::{ - AgentProfileOverride, AgentPromptConfig, LoadedWorkflow, ServiceConfig, WorkflowDefinition, - files::*, load_workflow_with_user_config, render::*, render_issue_template, - render_turn_template, repo_config_path, + AgentProfileConfig, AgentProfileOverride, AgentPromptConfig, LoadedWorkflow, ServiceConfig, + WorkflowDefinition, files::*, load_workflow_with_user_config, render::*, + render_issue_template, render_turn_template, repo_config_path, }, polyphony_core::{ AgentInteractionMode, AgentPromptMode, AgentTransport, CheckoutKind, Issue, TrackerKind, @@ -15,6 +16,8 @@ use { serde_yaml::Value as YamlValue, }; +static ENV_MUTEX: LazyLock> = LazyLock::new(|| Mutex::new(())); + fn sample_issue() -> Issue { Issue { id: "1".into(), @@ -36,6 +39,42 @@ fn unique_temp_path(name: &str, extension: &str) -> std::path::PathBuf { )) } +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var(key).ok(); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } + + fn clear(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + unsafe { + std::env::remove_var(key); + } + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } +} + #[test] fn workspace_defaults_include_reuse_and_transient_paths() { let workflow = WorkflowDefinition { @@ -412,6 +451,57 @@ agents: assert_eq!(candidates[0].fallback_agents, vec!["kimi", "claude"]); } +#[test] +fn ollama_and_lmstudio_profiles_use_openai_chat_transport_and_local_base_urls() { + for (kind, expected_base_url) in [ + ("ollama", "http://localhost:11434/v1"), + ("lmstudio", "http://127.0.0.1:1234/v1"), + ] { + let profile = AgentProfileConfig { + kind: kind.into(), + ..AgentProfileConfig::default() + }; + let definition = agent_definition(kind, &profile); + + assert_eq!(infer_agent_transport(&profile), AgentTransport::OpenAiChat); + assert_eq!(definition.transport, AgentTransport::OpenAiChat); + assert_eq!(definition.base_url.as_deref(), Some(expected_base_url)); + } +} + +#[test] +fn local_profiles_use_env_base_url_overrides_unless_base_url_is_configured() { + let _env_lock = ENV_MUTEX.lock().unwrap(); + let _clear_ollama = EnvVarGuard::clear("OLLAMA_BASE_URL"); + let _clear_lmstudio = EnvVarGuard::clear("LMSTUDIO_BASE_URL"); + let _ollama_override = EnvVarGuard::set("OLLAMA_BASE_URL", "http://127.0.0.1:4317/v1"); + let _lmstudio_override = EnvVarGuard::set("LMSTUDIO_BASE_URL", "http://127.0.0.1:8123/v1"); + + let ollama = agent_definition("ollama", &AgentProfileConfig { + kind: "ollama".into(), + ..AgentProfileConfig::default() + }); + let lmstudio = agent_definition("lmstudio", &AgentProfileConfig { + kind: "lmstudio".into(), + ..AgentProfileConfig::default() + }); + let explicit = agent_definition("explicit_ollama", &AgentProfileConfig { + kind: "ollama".into(), + base_url: Some("http://192.168.1.10:11434/v1".into()), + ..AgentProfileConfig::default() + }); + + assert_eq!(ollama.base_url.as_deref(), Some("http://127.0.0.1:4317/v1")); + assert_eq!( + lmstudio.base_url.as_deref(), + Some("http://127.0.0.1:8123/v1") + ); + assert_eq!( + explicit.base_url.as_deref(), + Some("http://192.168.1.10:11434/v1") + ); +} + #[test] fn invalid_fallback_reference_is_rejected() { let config = serde_yaml::from_str::( @@ -806,6 +896,10 @@ review_triggers: #[test] fn openai_chat_fallbacks_without_api_keys_do_not_block_workflow_load() { + let _env_lock = ENV_MUTEX.lock().unwrap(); + let _clear_openai = EnvVarGuard::clear("OPENAI_API_KEY"); + let _clear_kimi = EnvVarGuard::clear("KIMI_API_KEY"); + let _clear_moonshot = EnvVarGuard::clear("MOONSHOT_API_KEY"); let config = serde_yaml::from_str::( r#" agents: @@ -847,6 +941,49 @@ agents: assert_eq!(candidates[2].api_key, None); } +#[test] +fn local_openai_compatible_profiles_do_not_require_api_keys() { + let _env_lock = ENV_MUTEX.lock().unwrap(); + let _clear_ollama = EnvVarGuard::clear("OLLAMA_API_KEY"); + let _clear_lmstudio = EnvVarGuard::clear("LMSTUDIO_API_KEY"); + let config = serde_yaml::from_str::( + r#" +agents: + default: ollama + profiles: + ollama: + kind: ollama + fetch_models: true + lmstudio: + kind: lmstudio + model: local-model + fetch_models: false +"#, + ) + .unwrap(); + let workflow = WorkflowDefinition { + config, + prompt_template: String::new(), + }; + let config = ServiceConfig::from_workflow(&workflow).unwrap(); + + let candidates = config.candidate_agents_for_issue(&sample_issue()).unwrap(); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].name, "ollama"); + assert_eq!(candidates[0].kind, "ollama"); + assert_eq!(candidates[0].transport, AgentTransport::OpenAiChat); + assert_eq!(candidates[0].api_key, None); + assert_eq!( + config + .agents + .profiles + .get("lmstudio") + .and_then(|profile| profile.api_key.as_deref()), + None + ); +} + #[test] fn tracker_only_workflow_without_agents_is_valid() { let config = serde_yaml::from_str::( @@ -875,6 +1012,9 @@ tracker: #[test] fn github_tracker_without_api_key_is_valid() { + let _env_lock = ENV_MUTEX.lock().unwrap(); + let _clear_github = EnvVarGuard::clear("GITHUB_TOKEN"); + let _clear_gh = EnvVarGuard::clear("GH_TOKEN"); let config = serde_yaml::from_str::( r#" tracker: diff --git a/templates/config.toml b/templates/config.toml index 5984da3..d6a9a08 100644 --- a/templates/config.toml +++ b/templates/config.toml @@ -167,6 +167,21 @@ max_turns = 20 # api_key = "$OPENAI_API_KEY" # fetch_models = true +# Ollama local server example. +# [agents.profiles.ollama] +# kind = "ollama" +# `transport = "openai_chat"`, `base_url = "http://localhost:11434/v1"`, +# and `fetch_models = true` are inferred automatically. +# Set `base_url` or `OLLAMA_BASE_URL` only if you need a non-default endpoint. + +# LM Studio local server example. +# [agents.profiles.lmstudio] +# kind = "lmstudio" +# `transport = "openai_chat"`, `base_url = "http://127.0.0.1:1234/v1"`, +# and `fetch_models = true` are inferred automatically. +# Set `base_url` or `LMSTUDIO_BASE_URL` only if you need a non-default endpoint. +# model = "local-model" + # ACP agent example. # [agents.profiles.acp_agent] # kind = "custom"