diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db0aea637f..1f59993516 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -527,3 +527,5 @@ their sign-off. When in doubt, check with your legal team. *Thank you for contributing to Buzz. Every bug report, documentation fix, and code contribution makes the project better for everyone. 🐝* + +Ollama Cloud provider setup: [docs/ollama-cloud.md](docs/ollama-cloud.md). diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 5d942777d5..74490060ea 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -50,6 +50,13 @@ OPENAI_COMPAT_MODEL=gpt-5 \ OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \ ./target/release/buzz-agent +# Or Ollama Cloud (OpenAI-compatible hosted models) +BUZZ_AGENT_PROVIDER=ollama-cloud \ +OPENAI_COMPAT_API_KEY=... \ +OPENAI_COMPAT_MODEL=kimi-k2.5 \ + ./target/release/buzz-agent + + # Or OpenRouter BUZZ_AGENT_PROVIDER=openrouter \ OPENROUTER_API_KEY=sk-or-v1-... \ diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index afbda5379d..55dc21d451 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -660,6 +660,11 @@ pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; const DEFAULT_SYSTEM_PROMPT: &str = "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; +/// Default OpenAI-compatible endpoint for Ollama Cloud when +/// `BUZZ_AGENT_PROVIDER=ollama-cloud` and `OPENAI_COMPAT_BASE_URL` is unset. +/// See https://docs.ollama.com/cloud +pub const OLLAMA_CLOUD_DEFAULT_BASE_URL: &str = "https://ollama.com/v1"; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Provider { Anthropic, @@ -801,7 +806,10 @@ impl Config { env("OPENAI_COMPAT_MODEL").as_deref(), ) .ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?, - env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), + resolve_openai_base_url_for_provider( + env("BUZZ_AGENT_PROVIDER").as_deref(), + env("OPENAI_COMPAT_BASE_URL"), + ), parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, ), Provider::Databricks | Provider::DatabricksV2 => ( @@ -1024,6 +1032,28 @@ fn present_nonempty(v: Option<&str>) -> bool { v.map(str::trim).is_some_and(|s| !s.is_empty()) } +fn is_ollama_cloud_provider(requested: Option<&str>) -> bool { + requested + .map(str::trim) + .is_some_and(|s| { + let n = s.to_ascii_lowercase(); + n == "ollama-cloud" || n == "ollama_cloud" + }) +} + +fn resolve_openai_base_url_for_provider( + requested_provider: Option<&str>, + base_url: Option, +) -> String { + base_url.unwrap_or_else(|| { + if is_ollama_cloud_provider(requested_provider) { + OLLAMA_CLOUD_DEFAULT_BASE_URL.to_owned() + } else { + "https://api.openai.com/v1".to_owned() + } + }) +} + fn resolve_provider( requested: Option<&str>, anthropic_key: Option<&str>, @@ -1042,6 +1072,13 @@ fn resolve_provider( "openai" | "openai-compat" => Err( "config: OPENAI_COMPAT_API_KEY required".into(), ), + // Ollama Cloud is OpenAI-compatible; requires a real API key. + "ollama-cloud" | "ollama_cloud" if present_nonempty(openai_key) => { + Ok(Provider::OpenAi) + } + "ollama-cloud" | "ollama_cloud" => Err( + "config: OPENAI_COMPAT_API_KEY required for ollama-cloud".into(), + ), "databricks" => Ok(Provider::Databricks), "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter), @@ -1052,7 +1089,7 @@ fn resolve_provider( } } None => Err( - "config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, databricks)".into(), + "config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, ollama-cloud, databricks)".into(), ), } } @@ -1311,6 +1348,46 @@ mod tests { assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish")); } + #[test] + fn resolve_provider_ollama_cloud_requires_api_key() { + let err = resolve_provider(Some("ollama-cloud"), None, None, None).unwrap_err(); + assert!( + err.contains("OPENAI_COMPAT_API_KEY"), + "{err}" + ); + } + + #[test] + fn resolve_provider_ollama_cloud_maps_to_openai() { + assert_eq!( + resolve_provider(Some("ollama-cloud"), None, Some("sk-test"), None).unwrap(), + Provider::OpenAi + ); + assert_eq!( + resolve_provider(Some("ollama_cloud"), None, Some("sk-test"), None).unwrap(), + Provider::OpenAi + ); + } + + #[test] + fn ollama_cloud_default_base_url() { + assert_eq!( + resolve_openai_base_url_for_provider(Some("ollama-cloud"), None), + OLLAMA_CLOUD_DEFAULT_BASE_URL + ); + assert_eq!( + resolve_openai_base_url_for_provider(Some("openai"), None), + "https://api.openai.com/v1" + ); + assert_eq!( + resolve_openai_base_url_for_provider( + Some("ollama-cloud"), + Some("https://custom.example/v1".into()) + ), + "https://custom.example/v1" + ); + } + #[test] fn is_openai_host_matrix() { // Lookalike-safe: `api.openai.com.evil.example` and malformed URLs diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..ef80ce0793 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -480,7 +480,9 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { Some("DATABRICKS_MODEL") } Some("anthropic") => Some("ANTHROPIC_MODEL"), - Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), + Some("openai") | Some("openai-compat") | Some("ollama-cloud") | Some("ollama_cloud") => { + Some("OPENAI_COMPAT_MODEL") + } Some("openrouter") => Some("OPENROUTER_MODEL"), _ => None, }; @@ -510,7 +512,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "ANTHROPIC_API_KEY".to_string(), }); } - Some("openai") + Some("openai") | Some("openai-compat") | Some("ollama-cloud") | Some("ollama_cloud") if env_key_missing("OPENAI_COMPAT_API_KEY") => { missing.push(Requirement::EnvKey { key: "OPENAI_COMPAT_API_KEY".to_string(), @@ -622,7 +624,7 @@ fn goose_requirements( key: "ANTHROPIC_API_KEY".to_string(), }); } - Some("openai") + Some("openai") | Some("openai-compat") | Some("ollama-cloud") | Some("ollama_cloud") if env_key_missing("OPENAI_COMPAT_API_KEY") && !file_key_present("OPENAI_COMPAT_API_KEY") => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index d51c970f29..04a78e0191 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -43,6 +43,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "openai", "openai-compat", "openrouter", + "ollama-cloud", ] as const; type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number]; @@ -114,6 +115,10 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< requiredEnvKeys: ["OPENROUTER_API_KEY"], secretEnvVar: "OPENROUTER_API_KEY", }, + "ollama-cloud": { + requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"], + secretEnvVar: "OPENAI_COMPAT_API_KEY", + }, }; const DEFAULT_MODEL_OPTION: PersonaModelOption = { @@ -126,6 +131,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, { id: "openrouter", label: "OpenRouter" }, + { id: "ollama-cloud", label: "Ollama Cloud" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, @@ -286,7 +292,8 @@ export function providerRequiresExplicitModel( trimmedProvider === "anthropic" || trimmedProvider === "openai" || trimmedProvider === "openai-compat" || - trimmedProvider === "openrouter" + trimmedProvider === "openrouter" || + trimmedProvider === "ollama-cloud" ); } diff --git a/desktop/src/features/agents/ui/bakedEnvHelpers.ts b/desktop/src/features/agents/ui/bakedEnvHelpers.ts index 40b3328256..90b6e6631e 100644 --- a/desktop/src/features/agents/ui/bakedEnvHelpers.ts +++ b/desktop/src/features/agents/ui/bakedEnvHelpers.ts @@ -68,6 +68,7 @@ function providerModelEnvKey(provider: string): string | null { return "ANTHROPIC_MODEL"; case "openai": case "openai-compat": + case "ollama-cloud": return "OPENAI_COMPAT_MODEL"; default: return null; diff --git a/docs/ollama-cloud.md b/docs/ollama-cloud.md new file mode 100644 index 0000000000..d1db31aa42 --- /dev/null +++ b/docs/ollama-cloud.md @@ -0,0 +1,30 @@ +# Ollama Cloud in Buzz + +[Ollama Cloud](https://docs.ollama.com/cloud) exposes an OpenAI-compatible API. +Buzz treats it as a **named provider** so you do not have to pick +“OpenAI-compatible” and type the base URL by hand ([#3874](https://github.com/block/buzz/issues/3874)). + +## Desktop + +1. Agent defaults / persona → provider **Ollama Cloud** +2. Paste your Ollama API key into the credential field (`OPENAI_COMPAT_API_KEY`) +3. Set a model id from your Ollama Cloud account (e.g. a hosted open model) + +Buzz maps this to `BUZZ_AGENT_PROVIDER=ollama-cloud` and defaults +`OPENAI_COMPAT_BASE_URL` to `https://ollama.com/v1`. + +## CLI / buzz-agent + +```bash +BUZZ_AGENT_PROVIDER=ollama-cloud \ +OPENAI_COMPAT_API_KEY=... \ +OPENAI_COMPAT_MODEL=... \ + buzz-agent +``` + +Override the endpoint with `OPENAI_COMPAT_BASE_URL` if needed. + +## Related + +- Local Ollama (localhost): see open PR / docs for the `ollama` alias (#3145 / #3152) +- Generic OpenAI-compatible: provider `openai-compat` with any base URL