diff --git a/apps/desktop/src-tauri/src/anthropic_proxy.rs b/apps/desktop/src-tauri/src/anthropic_proxy.rs index 3eaeeac..3a86027 100644 --- a/apps/desktop/src-tauri/src/anthropic_proxy.rs +++ b/apps/desktop/src-tauri/src/anthropic_proxy.rs @@ -202,11 +202,11 @@ async fn handle_messages( .timeout(std::time::Duration::from_secs(300)) .build() .map_err(|err| format!("Failed to create provider client: {}", err))?; - let response = client + let request = client .post(openai_chat_completions_url(&credential.base_url)) - .bearer_auth(&credential.api_key) .header("Content-Type", "application/json") - .body(openai_request.to_string()) + .body(openai_request.to_string()); + let response = with_optional_bearer_auth(request, &credential.api_key) .send() .await .map_err(|err| format!("Provider request failed: {}", err))?; @@ -873,6 +873,17 @@ fn openai_chat_completions_url(base_url: &str) -> String { } } +fn with_optional_bearer_auth( + request: reqwest::RequestBuilder, + api_key: &str, +) -> reqwest::RequestBuilder { + if api_key.trim().is_empty() { + request + } else { + request.bearer_auth(api_key) + } +} + fn openai_compatible_base_url_has_chat_root(base_url: &str) -> bool { let lower = base_url.to_ascii_lowercase(); if lower == "https://api.deepseek.com" || lower == "http://api.deepseek.com" { diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index e5c75e3..790954c 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -198,6 +198,15 @@ fn normalize_api_key(value: &str) -> Result { Ok(clean) } +fn normalize_optional_api_key(value: &str) -> Result { + let clean = strip_nul(value).trim().to_string(); + if clean.chars().any(char::is_whitespace) { + return Err("API key cannot contain spaces or line breaks".to_string()); + } + + Ok(clean) +} + fn normalize_base_url(value: Option<&str>) -> Result, String> { let Some(value) = value else { return Ok(None); @@ -318,7 +327,7 @@ fn normalized_openai_compatible_credentials( ) -> Vec { let mut credentials = Vec::new(); for credential in &config.openai_credentials { - let Ok(api_key) = normalize_api_key(&credential.api_key) else { + let Ok(api_key) = normalize_optional_api_key(&credential.api_key) else { continue; }; let Some(base_url) = normalize_base_url(Some(credential.base_url.as_str())) @@ -356,7 +365,7 @@ fn normalized_openai_compatible_credentials( config .openai_api_key .as_deref() - .and_then(|value| normalize_api_key(value).ok()), + .and_then(|value| normalize_optional_api_key(value).ok()), normalize_base_url(config.openai_base_url.as_deref()) .ok() .flatten(), @@ -481,9 +490,13 @@ pub async fn save_anthropic_api_key( credential_label: Option, credential_id: Option, ) -> Result<(), String> { - let api_key = normalize_api_key(&api_key)?; - let base_url = normalize_base_url(base_url.as_deref())?; let provider = normalize_provider(provider.as_deref())?; + let api_key = if provider == PROVIDER_OPENAI_COMPATIBLE { + normalize_optional_api_key(&api_key)? + } else { + normalize_api_key(&api_key)? + }; + let base_url = normalize_base_url(base_url.as_deref())?; let model = normalize_model(model.as_deref())?; if let Some(message) = known_proxy_mismatch_error(&provider, base_url.as_deref()) { return Err(message); @@ -564,7 +577,7 @@ pub async fn verify_openai_compatible_api_key( base_url: String, model: String, ) -> Result<(), String> { - let api_key = normalize_api_key(&api_key)?; + let api_key = normalize_optional_api_key(&api_key)?; let base_url = normalize_base_url(Some(base_url.as_str()))? .ok_or("OpenAI-compatible provider requires a Base URL")?; if let Some(message) = @@ -590,7 +603,7 @@ pub async fn list_openai_compatible_models( api_key: String, base_url: String, ) -> Result, String> { - let api_key = normalize_api_key(&api_key)?; + let api_key = normalize_optional_api_key(&api_key)?; let base_url = normalize_base_url(Some(base_url.as_str()))? .ok_or("OpenAI-compatible provider requires a Base URL")?; if let Some(message) = @@ -619,9 +632,8 @@ async fn fetch_openai_compatible_models( api_key: &str, base_url: &str, ) -> Result, String> { - let response = reqwest::Client::new() - .get(openai_models_url(base_url)) - .bearer_auth(api_key) + let request = reqwest::Client::new().get(openai_models_url(base_url)); + let response = with_optional_bearer_auth(request, api_key) .send() .await .map_err(|err| format!("Failed to fetch provider models: {}", err))?; @@ -2351,6 +2363,17 @@ fn openai_chat_completions_url(base_url: &str) -> String { } } +fn with_optional_bearer_auth( + request: reqwest::RequestBuilder, + api_key: &str, +) -> reqwest::RequestBuilder { + if api_key.trim().is_empty() { + request + } else { + request.bearer_auth(api_key) + } +} + fn openai_models_url(base_url: &str) -> String { let clean = base_url.trim_end_matches('/'); if let Some(root) = clean.strip_suffix("/chat/completions") { @@ -2439,11 +2462,11 @@ async fn verify_openai_compatible_credential( .map_err(|err| format!("Failed to create provider client: {}", err))?; let request_body = openai_compatible_verification_body(&credential.model); - let response = client + let request = client .post(openai_chat_completions_url(&credential.base_url)) - .bearer_auth(&credential.api_key) .header("Content-Type", "application/json") - .body(request_body.to_string()) + .body(request_body.to_string()); + let response = with_optional_bearer_auth(request, &credential.api_key) .send() .await .map_err(|err| format!("Provider verification request failed: {}", err))?; @@ -2487,11 +2510,11 @@ async fn send_openai_compatible_no_tools_text_request( "stream": false, }); - let response = client + let request = client .post(openai_chat_completions_url(&credential.base_url)) - .bearer_auth(&credential.api_key) .header("Content-Type", "application/json") - .body(request_body.to_string()) + .body(request_body.to_string()); + let response = with_optional_bearer_auth(request, &credential.api_key) .send() .await .map_err(|err| format!("Provider request failed: {}", err))?; @@ -3930,6 +3953,18 @@ mod tests { ); } + #[test] + fn test_openai_chat_completions_url_supports_ollama_roots() { + assert_eq!( + openai_chat_completions_url("http://localhost:11434"), + "http://localhost:11434/v1/chat/completions" + ); + assert_eq!( + openai_chat_completions_url("http://localhost:11434/v1"), + "http://localhost:11434/v1/chat/completions" + ); + } + #[test] fn test_openai_models_url_matches_provider_roots() { assert_eq!( @@ -3948,6 +3983,22 @@ mod tests { openai_models_url("https://api.openai.com"), "https://api.openai.com/v1/models" ); + assert_eq!( + openai_models_url("http://localhost:11434"), + "http://localhost:11434/v1/models" + ); + assert_eq!( + openai_models_url("http://localhost:11434/v1"), + "http://localhost:11434/v1/models" + ); + } + + #[test] + fn test_openai_compatible_api_key_can_be_empty_for_local_providers() { + assert!(normalize_api_key("").is_err()); + assert_eq!(normalize_optional_api_key("").unwrap(), ""); + assert_eq!(normalize_optional_api_key(" ollama ").unwrap(), "ollama"); + assert!(normalize_optional_api_key("bad key").is_err()); } #[test] diff --git a/apps/desktop/src/__tests__/lib/provider-icons.test.ts b/apps/desktop/src/__tests__/lib/provider-icons.test.ts index 01c83fe..b83d39b 100644 --- a/apps/desktop/src/__tests__/lib/provider-icons.test.ts +++ b/apps/desktop/src/__tests__/lib/provider-icons.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getProviderDisplayName } from "@/lib/provider-icons"; +import { + getProviderDisplayName, + getProviderIconSrc, +} from "@/lib/provider-icons"; describe("getProviderDisplayName", () => { it("derives provider names from old custom labels", () => { @@ -29,4 +32,15 @@ describe("getProviderDisplayName", () => { }), ).toBe("Acme AI"); }); + + it("recognizes local Ollama endpoints", () => { + const provider = { + label: "Custom OpenAI API", + baseUrl: "http://localhost:11434/v1", + model: "llama3.2", + }; + + expect(getProviderDisplayName(provider)).toBe("Ollama"); + expect(getProviderIconSrc(provider)).toContain("ollama"); + }); }); diff --git a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts index 8aef756..67c47e0 100644 --- a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts @@ -178,6 +178,63 @@ describe("useClaudeSetupStore.saveApiKey", () => { }); }); + it("allows local OpenAI-compatible providers without an API key", async () => { + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "check_claude_status") { + return { + installed: true, + authenticated: true, + binary_path: null, + version: "OpenAI-compatible provider", + provider_kind: "openai-compatible", + account_email: null, + provider_model: "llama3.2", + provider_base_url: "http://localhost:11434/v1", + missing_git: false, + }; + } + if (command === "list_openai_compatible_credentials") { + return [ + { + id: "ollama-cred", + label: "Ollama", + model: "llama3.2", + base_url: "http://localhost:11434/v1", + }, + ]; + } + return null; + }); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "", + "http://localhost:11434/v1", + "openai-compatible", + "llama3.2", + "Ollama", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "", + baseUrl: "http://localhost:11434/v1", + model: "llama3.2", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "", + baseUrl: "http://localhost:11434/v1", + provider: "openai-compatible", + model: "llama3.2", + credentialLabel: "Ollama", + }); + }); + it("does not save OpenAI-compatible credentials when verification fails", async () => { vi.mocked(invoke).mockRejectedValueOnce( new Error("Invalid provider API key"), diff --git a/apps/desktop/src/assets/providers/ollama.svg b/apps/desktop/src/assets/providers/ollama.svg new file mode 100644 index 0000000..4f534cf --- /dev/null +++ b/apps/desktop/src/assets/providers/ollama.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx index 17906c0..8a0ac37 100644 --- a/apps/desktop/src/components/claude-setup.tsx +++ b/apps/desktop/src/components/claude-setup.tsx @@ -43,6 +43,7 @@ type OpenAICompatiblePreset = { baseUrl: string; model: string; note: string; + apiKeyOptional?: boolean; }; type ClaudeCompatiblePreset = { @@ -60,6 +61,7 @@ type ModelProviderCard = { model: string; badge: string; note: string; + apiKeyOptional?: boolean; }; const CLAUDE_COMPATIBLE_PRESETS: ClaudeCompatiblePreset[] = [ @@ -107,6 +109,14 @@ const OPENAI_COMPATIBLE_PRESETS: OpenAICompatiblePreset[] = [ model: "", note: "Zhipu BigModel chat completions endpoint.", }, + { + id: "ollama", + label: "Ollama", + baseUrl: "http://localhost:11434/v1", + model: "", + note: "Local Ollama OpenAI-compatible endpoint.", + apiKeyOptional: true, + }, { id: "gemini", label: "Gemini OpenAI", @@ -559,6 +569,9 @@ export function ClaudeSetup({ ? providerPreset : fallbackCardId; const activeCard = providerCards.find((card) => card.id === activeCardId); + const apiKeyOptional = + selectedProvider === "openai-compatible" && !!activeCard?.apiKeyOptional; + const apiKeyRequired = !apiKeyOptional; return ( <> @@ -637,7 +650,9 @@ export function ClaudeSetup({ type="password" placeholder={ selectedProvider === "openai-compatible" - ? "sk-..." + ? apiKeyOptional + ? "Optional for local Ollama" + : "sk-..." : "sk-ant-... or provider key" } value={apiKey} @@ -651,7 +666,9 @@ export function ClaudeSetup({ />

{selectedProvider === "openai-compatible" - ? "Use the API key from your model provider." + ? apiKeyOptional + ? "Ollama runs locally and normally does not require an API key." + : "Use the API key from your model provider." : "Anthropic keys start with sk-ant-. Claude-compatible proxies can use their own key format."}

@@ -714,7 +731,7 @@ export function ClaudeSetup({ disabled={ isSavingApiKey || isFetchingModels || - !apiKey.trim() || + (apiKeyRequired && !apiKey.trim()) || !baseUrl.trim() } > @@ -779,7 +796,7 @@ export function ClaudeSetup({ size="sm" className="w-full gap-2" disabled={ - !apiKey.trim() || + (apiKeyRequired && !apiKey.trim()) || isSavingApiKey || (selectedProvider === "openai-compatible" && (!baseUrl.trim() || !model.trim())) @@ -795,7 +812,9 @@ export function ClaudeSetup({ ? "Verifying..." : "Saving..." : selectedProvider === "openai-compatible" - ? "Verify & Use API Key" + ? apiKeyOptional + ? "Verify & Use Local Provider" + : "Verify & Use API Key" : "Use API Key"} diff --git a/apps/desktop/src/lib/provider-icons.ts b/apps/desktop/src/lib/provider-icons.ts index 9715b91..8200f96 100644 --- a/apps/desktop/src/lib/provider-icons.ts +++ b/apps/desktop/src/lib/provider-icons.ts @@ -2,6 +2,7 @@ import anthropicIcon from "@/assets/providers/anthropic.svg"; import deepseekIcon from "@/assets/providers/deepseek.svg"; import geminiIcon from "@/assets/providers/gemini-color.svg"; import moonshotIcon from "@/assets/providers/moonshot.svg"; +import ollamaIcon from "@/assets/providers/ollama.svg"; import openaiIcon from "@/assets/providers/openai.svg"; import qwenIcon from "@/assets/providers/qwen.svg"; import zhipuIcon from "@/assets/providers/zhipu-color.svg"; @@ -37,6 +38,14 @@ function isGenericOpenAiLabel(label?: string | null) { export function getProviderDisplayName(input: ProviderIconInput): string { const haystack = providerHaystack(input); + if ( + haystack.includes("ollama") || + haystack.includes("localhost:11434") || + haystack.includes("127.0.0.1:11434") + ) { + return "Ollama"; + } + if ( haystack.includes("qwen") || haystack.includes("dashscope") || @@ -96,6 +105,14 @@ export function getProviderDisplayName(input: ProviderIconInput): string { export function getProviderIconSrc(input: ProviderIconInput): string | null { const haystack = providerHaystack(input); + if ( + haystack.includes("ollama") || + haystack.includes("localhost:11434") || + haystack.includes("127.0.0.1:11434") + ) { + return ollamaIcon; + } + if ( haystack.includes("qwen") || haystack.includes("dashscope") || diff --git a/apps/desktop/src/stores/claude-setup-store.ts b/apps/desktop/src/stores/claude-setup-store.ts index 0d251f5..e18200b 100644 --- a/apps/desktop/src/stores/claude-setup-store.ts +++ b/apps/desktop/src/stores/claude-setup-store.ts @@ -294,12 +294,12 @@ export const useClaudeSetupStore = create((set, get) => ({ const key = apiKey.trim(); const url = baseUrl?.trim() ?? ""; const modelName = model?.trim() ?? ""; - if (!key) { + if (provider !== "openai-compatible" && !key) { set({ error: "API key is empty" }); return false; } - if (/\s/.test(key)) { + if (key && /\s/.test(key)) { set({ error: "API key cannot contain spaces or line breaks" }); return false; }