Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions NotchBuddy/Sources/App/ClaudeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,35 @@ 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

/// 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),
Expand Down
59 changes: 55 additions & 4 deletions windows/src-tauri/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,6 +99,7 @@ pub struct ChatReply {
pub async fn send(
chat: &Chat,
model: &str,
base_url: Option<&str>,
query: String,
context: Option<ChatContext>,
) -> Result<ChatReply, String> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -157,14 +185,14 @@ pub async fn send(
Ok(ChatReply { text })
}

async fn call(key: &str, body: &Value) -> Result<Value, String> {
async fn call(endpoint: &str, key: &str, body: &Value) -> Result<Value, String> {
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)
Expand Down Expand Up @@ -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"
);
}
}
7 changes: 5 additions & 2 deletions windows/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,11 @@ async fn chat_send(
query: String,
context: Option<ChatContext>,
) -> Result<ChatReply, String> {
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]
Expand Down
5 changes: 5 additions & 0 deletions windows/src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn default_model() -> String {
Expand All @@ -43,6 +47,7 @@ impl Default for Settings {
autostart: false,
hooks_installed: false,
model: default_model(),
anthropic_base_url: None,
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions windows/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -106,6 +108,7 @@ export const DEFAULT_SETTINGS: Settings = {
autostart: false,
hooksInstalled: false,
model: "claude-opus-5",
anthropicBaseUrl: null,
};

type Listener = () => void;
Expand Down
11 changes: 11 additions & 0 deletions windows/src/settings/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down