diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 5144fc49c2..64cae43f35 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -108,7 +108,19 @@ final class KeychainStore: @unchecked Sendable { final class ClaudeService { static let shared = ClaudeService() - private let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + private var endpoint: URL { + if let custom = ProcessInfo.processInfo.environment["ANTHROPIC_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), !custom.isEmpty { + let trimmed = custom.hasSuffix("/") ? String(custom.dropLast()) : custom + if trimmed.hasSuffix("/v1/messages") { + return URL(string: trimmed) ?? URL(string: "https://api.anthropic.com/v1/messages")! + } else if trimmed.hasSuffix("/v1") { + return URL(string: "\(trimmed)/messages") ?? URL(string: "https://api.anthropic.com/v1/messages")! + } else { + return URL(string: "\(trimmed)/v1/messages") ?? URL(string: "https://api.anthropic.com/v1/messages")! + } + } + return URL(string: "https://api.anthropic.com/v1/messages")! + } private let anthropicVersion = "2023-06-01" // MARK: - Model list @@ -116,8 +128,15 @@ final class ClaudeService { /// Fetches available models from the Anthropic API in the order the API returns them /// (newest first). Returns an empty array on any error — callers fall back to a static list. static func fetchModels(apiKey: String) async -> [(id: String, label: String)] { - guard let url = URL(string: "https://api.anthropic.com/v1/models?limit=100") else { return [] } - var req = URLRequest(url: url, timeoutInterval: 10) + let modelsUrl: URL + if let custom = ProcessInfo.processInfo.environment["ANTHROPIC_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), !custom.isEmpty { + let trimmed = custom.hasSuffix("/") ? String(custom.dropLast()) : custom + let base = trimmed.hasSuffix("/v1") ? String(trimmed.dropLast(3)) : trimmed + modelsUrl = URL(string: "\(base)/v1/models?limit=100") ?? URL(string: "https://api.anthropic.com/v1/models?limit=100")! + } else { + modelsUrl = URL(string: "https://api.anthropic.com/v1/models?limit=100")! + } + var req = URLRequest(url: modelsUrl, timeoutInterval: 10) req.setValue(apiKey, forHTTPHeaderField: "x-api-key") req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") guard let (data, response) = try? await URLSession.shared.data(for: req), diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c641..845ccda6d8 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -11,7 +11,33 @@ use serde_json::{json, Value}; use crate::secrets; -const ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +const DEFAULT_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; + +/// Resolves the messages API endpoint, supporting custom base URLs from +/// settings or the `ANTHROPIC_BASE_URL` environment variable. +pub fn resolve_endpoint(custom: Option<&str>) -> String { + let custom_url = custom + .filter(|s| !s.trim().is_empty()) + .map(|s| s.to_string()) + .or_else(|| std::env::var("ANTHROPIC_BASE_URL").ok()) + .filter(|s| !s.trim().is_empty()); + + match custom_url { + Some(url) => { + let trimmed = url.trim().trim_end_matches('/'); + if trimmed.ends_with("/v1/messages") { + trimmed.to_string() + } else if trimmed.ends_with("/messages") { + trimmed.to_string() + } else if trimmed.ends_with("/v1") { + format!("{trimmed}/messages") + } else { + format!("{trimmed}/v1/messages") + } + } + None => DEFAULT_ENDPOINT.to_string(), + } +} const ANTHROPIC_VERSION: &str = "2023-06-01"; /// Server-side fallback: on a policy decline the API retries the same request on /// a fallback model inside the same call, so the island never shows a dead end. @@ -73,6 +99,7 @@ pub struct ChatReply { pub async fn send( chat: &Chat, model: &str, + base_url: Option<&str>, query: String, context: Option, ) -> Result { @@ -114,7 +141,8 @@ pub async fn send( "messages": chat.snapshot(), }); - let response = match call(&key, &body).await { + let endpoint = resolve_endpoint(base_url); + let response = match call(&endpoint, &key, &body).await { Ok(v) => v, Err(err) => { chat.pop(); // keep the history consistent with what the model saw @@ -157,14 +185,14 @@ pub async fn send( Ok(ChatReply { text }) } -async fn call(key: &str, body: &Value) -> Result { +async fn call(endpoint: &str, key: &str, body: &Value) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(90)) .build() .map_err(|e| e.to_string())?; let response = client - .post(ENDPOINT) + .post(endpoint) .header("x-api-key", key) .header("anthropic-version", ANTHROPIC_VERSION) .header("anthropic-beta", FALLBACK_BETA) @@ -260,4 +288,27 @@ mod tests { assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + #[test] + fn endpoint_resolution_handles_various_url_formats() { + use super::{resolve_endpoint, DEFAULT_ENDPOINT}; + assert_eq!(resolve_endpoint(None), DEFAULT_ENDPOINT); + assert_eq!(resolve_endpoint(Some("")), DEFAULT_ENDPOINT); + assert_eq!( + resolve_endpoint(Some("https://api.anthropic.com")), + "https://api.anthropic.com/v1/messages" + ); + assert_eq!( + resolve_endpoint(Some("https://proxy.example.com/v1/")), + "https://proxy.example.com/v1/messages" + ); + assert_eq!( + resolve_endpoint(Some("https://proxy.example.com/v1/messages")), + "https://proxy.example.com/v1/messages" + ); + assert_eq!( + resolve_endpoint(Some("http://localhost:11434/v1")), + "http://localhost:11434/v1/messages" + ); + } } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59e..09483e6f70 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -241,8 +241,11 @@ async fn chat_send( query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let (model, base_url) = { + let s = shared.settings.lock().unwrap(); + (s.model.clone(), s.anthropic_base_url.clone()) + }; + claude::send(&chat, &model, base_url.as_deref(), query, context).await } #[tauri::command] diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359c..94a843b83b 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,10 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// Custom Anthropic base URL (proxy, LiteLLM, Ollama proxy, etc.). + /// Falls back to $ANTHROPIC_BASE_URL, then the official Anthropic API. + #[serde(default)] + pub anthropic_base_url: Option, } fn default_model() -> String { @@ -43,6 +47,7 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + anthropic_base_url: None, } } } diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a9..7864860fdf 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,6 +92,8 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Custom Anthropic base URL. */ + anthropicBaseUrl?: string | null; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +108,7 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + anthropicBaseUrl: null, }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a7..6173194f20 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -243,12 +243,23 @@ function apiSection(hasKey: boolean): HTMLElement { clearBtn.style.display = hasKey ? "" : "none"; + const baseUrl = h("input", { + type: "text", + placeholder: "https://api.anthropic.com (or $ANTHROPIC_BASE_URL)", + value: settings.anthropicBaseUrl ?? "", + }) as HTMLInputElement; + baseUrl.addEventListener("change", () => { + settings.anthropicBaseUrl = baseUrl.value.trim() || null; + void save(); + }); + return h( "section", {}, h("h2", {}, dot, h("span", { text: "Claude" })), state, h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), + h("div", { class: "row" }, h("label", { text: "Base URL" }), baseUrl), h("div", { class: "row" }, h("label", { text: "Model" }), model), feedback, );