diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 306a2e076..31edea043 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -658,6 +658,7 @@ export interface ProviderStatus { export interface ProvidersResponse { providers: ProviderStatus; has_any: boolean; + base_urls?: Record; } export interface ProviderActionResponse { @@ -1010,11 +1011,13 @@ export const api = { // Provider management providers: () => fetchJson("/providers"), - updateProvider: async (provider: string, apiKey: string) => { + updateProvider: async (provider: string, apiKey: string, baseUrl?: string) => { + const body: Record = { provider, api_key: apiKey }; + if (baseUrl !== undefined) body.base_url = baseUrl; const response = await fetch(`${API_BASE}/providers`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, api_key: apiKey }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`API error: ${response.status}`); diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 716e1cd4d..fad8f38ff 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -146,6 +146,14 @@ const PROVIDERS = [ }, ] as const; +// Endpoint presets for providers that support alternate base URLs. +const ENDPOINT_PRESETS: Record = { + zhipu: [ + { label: "Z.ai (General)", url: "" }, + { label: "Z.ai Coding Plan", url: "https://api.z.ai/api/coding/paas/v4/chat/completions" }, + ], +}; + export function Settings() { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -165,6 +173,7 @@ export function Settings() { }; const [editingProvider, setEditingProvider] = useState(null); const [keyInput, setKeyInput] = useState(""); + const [baseUrlInput, setBaseUrlInput] = useState(""); const [message, setMessage] = useState<{ text: string; type: "success" | "error"; @@ -187,12 +196,13 @@ export function Settings() { }); const updateMutation = useMutation({ - mutationFn: ({provider, apiKey}: {provider: string; apiKey: string}) => - api.updateProvider(provider, apiKey), + mutationFn: ({provider, apiKey, baseUrl}: {provider: string; apiKey: string; baseUrl?: string}) => + api.updateProvider(provider, apiKey, baseUrl), onSuccess: (result) => { if (result.success) { setEditingProvider(null); setKeyInput(""); + setBaseUrlInput(""); setMessage({text: result.message, type: "success"}); queryClient.invalidateQueries({queryKey: ["providers"]}); // Agents will auto-start on the backend, refetch agent list after a short delay @@ -228,17 +238,22 @@ export function Settings() { const handleSave = () => { if (!keyInput.trim() || !editingProvider) return; - updateMutation.mutate({provider: editingProvider, apiKey: keyInput.trim()}); + const baseUrl = baseUrlInput.trim() || undefined; + updateMutation.mutate({provider: editingProvider, apiKey: keyInput.trim(), baseUrl}); }; const handleClose = () => { setEditingProvider(null); setKeyInput(""); + setBaseUrlInput(""); }; const isConfigured = (providerId: string): boolean => { if (!data) return false; - return data.providers[providerId as keyof typeof data.providers] ?? false; + // Provider IDs use hyphens (e.g. "opencode-zen") but the API response + // uses underscores as struct field names (e.g. "opencode_zen"). + const key = providerId.replace(/-/g, "_") as keyof typeof data.providers; + return data.providers[key] ?? false; }; return ( @@ -301,6 +316,7 @@ export function Settings() { onEdit={() => { setEditingProvider(provider.id); setKeyInput(""); + setBaseUrlInput(data?.base_urls?.[provider.id] ?? ""); setMessage(null); }} onRemove={() => removeMutation.mutate(provider.id)} @@ -362,6 +378,13 @@ export function Settings() { if (e.key === "Enter") handleSave(); }} /> + {editingProvider && ( + + )} {message && (
void; +} + +// Endpoint URL selector for providers with alternate API surfaces. +// Convention: empty string = provider default URL, non-empty = override. +// Presets offer known endpoints (e.g. Z.ai General vs Coding Plan); +// "Custom URL" lets users enter any endpoint for proxies or self-hosted. +function EndpointSelector({ presets, value, onChange }: EndpointSelectorProps) { + const isCustom = value !== "" && !presets.some((p) => p.url === value); + const selectValue = isCustom ? "__custom__" : (value || "__default__"); + const hasPresets = presets.length > 0; + + return ( +
+ + + {isCustom && ( + <> + onChange(e.target.value)} + placeholder="https://api.example.com/v1/chat/completions" + className="mt-2" + /> +

+ Full endpoint path required (e.g. ending in /v1/chat/completions) +

+ + )} +
+ ); +} + interface ProviderCardProps { provider: string; name: string; diff --git a/src/api/server.rs b/src/api/server.rs index 75753556f..994a26180 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -2042,12 +2042,17 @@ struct ProviderStatus { struct ProvidersResponse { providers: ProviderStatus, has_any: bool, + /// Custom base URLs currently configured per provider (only present entries). + #[serde(skip_serializing_if = "HashMap::is_empty")] + base_urls: HashMap, } #[derive(Deserialize)] struct ProviderUpdateRequest { provider: String, api_key: String, + #[serde(default)] + base_url: Option, } #[derive(Serialize)] @@ -2075,16 +2080,16 @@ async fn get_providers( if let Some(llm) = doc.get("llm") { if let Some(val) = llm.get(key) { if let Some(s) = val.as_str() { - // If it's an env reference, check if the env var is set + // If it's an env reference, check if the env var is set and non-empty if let Some(var_name) = s.strip_prefix("env:") { - return std::env::var(var_name).is_ok(); + return std::env::var(var_name).is_ok_and(|v| !v.is_empty()); } return !s.is_empty(); } } } // Fall back to checking env vars directly - std::env::var(env_var).is_ok() + std::env::var(env_var).is_ok_and(|v| !v.is_empty()) }; ( @@ -2102,18 +2107,19 @@ async fn get_providers( ) } else { // No config file — check env vars only + let env_set = |var: &str| std::env::var(var).is_ok_and(|v| !v.is_empty()); ( - std::env::var("ANTHROPIC_API_KEY").is_ok(), - std::env::var("OPENAI_API_KEY").is_ok(), - std::env::var("OPENROUTER_API_KEY").is_ok(), - std::env::var("ZHIPU_API_KEY").is_ok(), - std::env::var("GROQ_API_KEY").is_ok(), - std::env::var("TOGETHER_API_KEY").is_ok(), - std::env::var("FIREWORKS_API_KEY").is_ok(), - std::env::var("DEEPSEEK_API_KEY").is_ok(), - std::env::var("XAI_API_KEY").is_ok(), - std::env::var("MISTRAL_API_KEY").is_ok(), - std::env::var("OPENCODE_ZEN_API_KEY").is_ok(), + env_set("ANTHROPIC_API_KEY"), + env_set("OPENAI_API_KEY"), + env_set("OPENROUTER_API_KEY"), + env_set("ZHIPU_API_KEY"), + env_set("GROQ_API_KEY"), + env_set("TOGETHER_API_KEY"), + env_set("FIREWORKS_API_KEY"), + env_set("DEEPSEEK_API_KEY"), + env_set("XAI_API_KEY"), + env_set("MISTRAL_API_KEY"), + env_set("OPENCODE_ZEN_API_KEY"), ) }; @@ -2130,9 +2136,9 @@ async fn get_providers( mistral, opencode_zen, }; - let has_any = providers.anthropic - || providers.openai - || providers.openrouter + let has_any = providers.anthropic + || providers.openai + || providers.openrouter || providers.zhipu || providers.groq || providers.together @@ -2142,7 +2148,43 @@ async fn get_providers( || providers.mistral || providers.opencode_zen; - Ok(Json(ProvidersResponse { providers, has_any })) + // Read any custom base_url overrides from the config + let base_urls = if config_path.exists() { + let content = tokio::fs::read_to_string(&config_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let doc: toml_edit::DocumentMut = content + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut urls = HashMap::new(); + if let Some(llm) = doc.get("llm") { + for (provider, key) in [ + ("anthropic", "anthropic_base_url"), + ("openai", "openai_base_url"), + ("openrouter", "openrouter_base_url"), + ("zhipu", "zhipu_base_url"), + ("groq", "groq_base_url"), + ("together", "together_base_url"), + ("fireworks", "fireworks_base_url"), + ("deepseek", "deepseek_base_url"), + ("xai", "xai_base_url"), + ("mistral", "mistral_base_url"), + ("opencode_zen", "opencode_zen_base_url"), + ] { + if let Some(val) = llm.get(key).and_then(|v| v.as_str()) { + if !val.is_empty() { + urls.insert(provider.to_string(), val.to_string()); + } + } + } + } + urls + } else { + HashMap::new() + }; + + Ok(Json(ProvidersResponse { providers, has_any, base_urls })) } async fn update_provider( @@ -2199,6 +2241,25 @@ async fn update_provider( // Set the key doc["llm"][key_name] = toml_edit::value(request.api_key); + // Set or remove base_url if provided. + // Derive the TOML key from the API key name: "anthropic_key" → "anthropic_base_url". + let base_url_key = format!("{}_base_url", key_name.trim_end_matches("_key")); + if let Some(url) = &request.base_url { + if url.is_empty() { + // Empty string means "remove override, use default" + if let Some(llm) = doc.get_mut("llm").and_then(|l| l.as_table_mut()) { + llm.remove(&base_url_key); + } + } else if !url.starts_with("http://") && !url.starts_with("https://") { + return Ok(Json(ProviderUpdateResponse { + success: false, + message: "Base URL must start with http:// or https://".into(), + })); + } else { + doc["llm"][&base_url_key] = toml_edit::value(url.as_str()); + } + } + // Auto-set routing defaults if the current routing points to a provider // the user doesn't have a key for. This prevents the common case where // someone sets up OpenRouter but routing still defaults to anthropic/*. @@ -2338,10 +2399,12 @@ async fn delete_provider( .parse() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Remove the key from [llm] + // Remove the key and any associated base_url from [llm] if let Some(llm) = doc.get_mut("llm") { if let Some(table) = llm.as_table_mut() { table.remove(key_name); + let base_url_key = format!("{}_base_url", key_name.trim_end_matches("_key")); + table.remove(&base_url_key); } } @@ -2874,13 +2937,13 @@ async fn configured_providers(config_path: &std::path::Path) -> Vec<&'static str if let Some(val) = llm.get(key) { if let Some(s) = val.as_str() { if let Some(var_name) = s.strip_prefix("env:") { - return std::env::var(var_name).is_ok(); + return std::env::var(var_name).is_ok_and(|v| !v.is_empty()); } return !s.is_empty(); } } } - std::env::var(env_var).is_ok() + std::env::var(env_var).is_ok_and(|v| !v.is_empty()) }; if has_key("anthropic_key", "ANTHROPIC_API_KEY") { @@ -2916,7 +2979,6 @@ async fn configured_providers(config_path: &std::path::Path) -> Vec<&'static str if has_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY") { providers.push("opencode-zen"); } - providers } diff --git a/src/config.rs b/src/config.rs index 94c289a86..9259d3e59 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,24 +53,35 @@ impl Default for ApiConfig { #[derive(Debug, Clone)] pub struct LlmConfig { pub anthropic_key: Option, + pub anthropic_base_url: Option, pub openai_key: Option, + pub openai_base_url: Option, pub openrouter_key: Option, + pub openrouter_base_url: Option, pub zhipu_key: Option, + pub zhipu_base_url: Option, pub groq_key: Option, + pub groq_base_url: Option, pub together_key: Option, + pub together_base_url: Option, pub fireworks_key: Option, + pub fireworks_base_url: Option, pub deepseek_key: Option, + pub deepseek_base_url: Option, pub xai_key: Option, + pub xai_base_url: Option, pub mistral_key: Option, + pub mistral_base_url: Option, pub opencode_zen_key: Option, + pub opencode_zen_base_url: Option, } impl LlmConfig { /// Check if any provider key is configured. pub fn has_any_key(&self) -> bool { - self.anthropic_key.is_some() - || self.openai_key.is_some() - || self.openrouter_key.is_some() + self.anthropic_key.is_some() + || self.openai_key.is_some() + || self.openrouter_key.is_some() || self.zhipu_key.is_some() || self.groq_key.is_some() || self.together_key.is_some() @@ -867,16 +878,27 @@ fn default_api_bind() -> String { #[derive(Deserialize, Default)] struct TomlLlmConfig { anthropic_key: Option, + anthropic_base_url: Option, openai_key: Option, + openai_base_url: Option, openrouter_key: Option, + openrouter_base_url: Option, zhipu_key: Option, + zhipu_base_url: Option, groq_key: Option, + groq_base_url: Option, together_key: Option, + together_base_url: Option, fireworks_key: Option, + fireworks_base_url: Option, deepseek_key: Option, + deepseek_base_url: Option, xai_key: Option, + xai_base_url: Option, mistral_key: Option, + mistral_base_url: Option, opencode_zen_key: Option, + opencode_zen_base_url: Option, } #[derive(Deserialize, Default)] @@ -1146,6 +1168,13 @@ impl Config { std::env::var("ANTHROPIC_API_KEY").is_err() && std::env::var("OPENAI_API_KEY").is_err() && std::env::var("OPENROUTER_API_KEY").is_err() + && std::env::var("ZHIPU_API_KEY").is_err() + && std::env::var("GROQ_API_KEY").is_err() + && std::env::var("TOGETHER_API_KEY").is_err() + && std::env::var("FIREWORKS_API_KEY").is_err() + && std::env::var("DEEPSEEK_API_KEY").is_err() + && std::env::var("XAI_API_KEY").is_err() + && std::env::var("MISTRAL_API_KEY").is_err() && std::env::var("OPENCODE_ZEN_API_KEY").is_err() } @@ -1181,16 +1210,27 @@ impl Config { pub fn load_from_env(instance_dir: &Path) -> Result { let llm = LlmConfig { anthropic_key: std::env::var("ANTHROPIC_API_KEY").ok(), + anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL").ok(), openai_key: std::env::var("OPENAI_API_KEY").ok(), + openai_base_url: std::env::var("OPENAI_BASE_URL").ok(), openrouter_key: std::env::var("OPENROUTER_API_KEY").ok(), + openrouter_base_url: std::env::var("OPENROUTER_BASE_URL").ok(), zhipu_key: std::env::var("ZHIPU_API_KEY").ok(), + zhipu_base_url: std::env::var("ZHIPU_BASE_URL").ok(), groq_key: std::env::var("GROQ_API_KEY").ok(), + groq_base_url: std::env::var("GROQ_BASE_URL").ok(), together_key: std::env::var("TOGETHER_API_KEY").ok(), + together_base_url: std::env::var("TOGETHER_BASE_URL").ok(), fireworks_key: std::env::var("FIREWORKS_API_KEY").ok(), + fireworks_base_url: std::env::var("FIREWORKS_BASE_URL").ok(), deepseek_key: std::env::var("DEEPSEEK_API_KEY").ok(), + deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL").ok(), xai_key: std::env::var("XAI_API_KEY").ok(), + xai_base_url: std::env::var("XAI_BASE_URL").ok(), mistral_key: std::env::var("MISTRAL_API_KEY").ok(), + mistral_base_url: std::env::var("MISTRAL_BASE_URL").ok(), opencode_zen_key: std::env::var("OPENCODE_ZEN_API_KEY").ok(), + opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL").ok(), }; // Note: We allow boot without provider keys now. System starts in setup mode. @@ -1255,66 +1295,110 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()), + anthropic_base_url: toml.llm.anthropic_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("ANTHROPIC_BASE_URL").ok()), openai_key: toml .llm .openai_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENAI_API_KEY").ok()), + openai_base_url: toml.llm.openai_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("OPENAI_BASE_URL").ok()), openrouter_key: toml .llm .openrouter_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENROUTER_API_KEY").ok()), + openrouter_base_url: toml.llm.openrouter_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("OPENROUTER_BASE_URL").ok()), zhipu_key: toml .llm .zhipu_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("ZHIPU_API_KEY").ok()), + zhipu_base_url: toml.llm.zhipu_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("ZHIPU_BASE_URL").ok()), groq_key: toml .llm .groq_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("GROQ_API_KEY").ok()), + groq_base_url: toml.llm.groq_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("GROQ_BASE_URL").ok()), together_key: toml .llm .together_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("TOGETHER_API_KEY").ok()), + together_base_url: toml.llm.together_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("TOGETHER_BASE_URL").ok()), fireworks_key: toml .llm .fireworks_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("FIREWORKS_API_KEY").ok()), + fireworks_base_url: toml.llm.fireworks_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("FIREWORKS_BASE_URL").ok()), deepseek_key: toml .llm .deepseek_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("DEEPSEEK_API_KEY").ok()), + deepseek_base_url: toml.llm.deepseek_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("DEEPSEEK_BASE_URL").ok()), xai_key: toml .llm .xai_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("XAI_API_KEY").ok()), + xai_base_url: toml.llm.xai_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("XAI_BASE_URL").ok()), mistral_key: toml .llm .mistral_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("MISTRAL_API_KEY").ok()), + mistral_base_url: toml.llm.mistral_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("MISTRAL_BASE_URL").ok()), opencode_zen_key: toml .llm .opencode_zen_key .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENCODE_ZEN_API_KEY").ok()), + opencode_zen_base_url: toml.llm.opencode_zen_base_url + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("OPENCODE_ZEN_BASE_URL").ok()), }; // Note: We allow boot without provider keys now. System starts in setup mode. diff --git a/src/llm/manager.rs b/src/llm/manager.rs index 80c476e2e..b5053ca84 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -12,6 +12,19 @@ use std::sync::Arc; use std::time::Instant; use tokio::sync::RwLock; +// Default API endpoints per provider (used when no base_url is configured). +const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1/messages"; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1/chat/completions"; +const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; +const DEFAULT_ZHIPU_BASE_URL: &str = "https://api.z.ai/api/paas/v4/chat/completions"; +const DEFAULT_GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1/chat/completions"; +const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1/chat/completions"; +const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1/chat/completions"; +const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1/chat/completions"; +const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1/chat/completions"; +const DEFAULT_MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1/chat/completions"; +const DEFAULT_OPENCODE_ZEN_BASE_URL: &str = "https://opencode.ai/zen/v1/chat/completions"; + /// Manages LLM provider clients and tracks rate limit state. pub struct LlmManager { config: LlmConfig, @@ -64,6 +77,38 @@ impl LlmManager { } } + /// Get the base URL for a provider, falling back to the default. + /// + /// Panics if `provider` is not a known provider name — callers must + /// validate the provider string before reaching this point. + pub fn get_base_url(&self, provider: &str) -> &str { + match provider { + "anthropic" => self.config.anthropic_base_url.as_deref() + .unwrap_or(DEFAULT_ANTHROPIC_BASE_URL), + "openai" => self.config.openai_base_url.as_deref() + .unwrap_or(DEFAULT_OPENAI_BASE_URL), + "openrouter" => self.config.openrouter_base_url.as_deref() + .unwrap_or(DEFAULT_OPENROUTER_BASE_URL), + "zhipu" => self.config.zhipu_base_url.as_deref() + .unwrap_or(DEFAULT_ZHIPU_BASE_URL), + "groq" => self.config.groq_base_url.as_deref() + .unwrap_or(DEFAULT_GROQ_BASE_URL), + "together" => self.config.together_base_url.as_deref() + .unwrap_or(DEFAULT_TOGETHER_BASE_URL), + "fireworks" => self.config.fireworks_base_url.as_deref() + .unwrap_or(DEFAULT_FIREWORKS_BASE_URL), + "deepseek" => self.config.deepseek_base_url.as_deref() + .unwrap_or(DEFAULT_DEEPSEEK_BASE_URL), + "xai" => self.config.xai_base_url.as_deref() + .unwrap_or(DEFAULT_XAI_BASE_URL), + "mistral" => self.config.mistral_base_url.as_deref() + .unwrap_or(DEFAULT_MISTRAL_BASE_URL), + "opencode-zen" => self.config.opencode_zen_base_url.as_deref() + .unwrap_or(DEFAULT_OPENCODE_ZEN_BASE_URL), + _ => unreachable!("unknown provider: {provider}"), + } + } + /// Get the HTTP client. pub fn http_client(&self) -> &reqwest::Client { &self.http_client diff --git a/src/llm/model.rs b/src/llm/model.rs index 6394a4354..ead80c582 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -66,17 +66,50 @@ impl SpacebotModel { request: CompletionRequest, ) -> Result, CompletionError> { match self.provider.as_str() { - "anthropic" => self.call_anthropic(request).await, - "openai" => self.call_openai(request).await, - "openrouter" => self.call_openrouter(request).await, - "zhipu" => self.call_zhipu(request).await, - "groq" => self.call_groq(request).await, - "together" => self.call_together(request).await, - "fireworks" => self.call_fireworks(request).await, - "deepseek" => self.call_deepseek(request).await, - "xai" => self.call_xai(request).await, - "mistral" => self.call_mistral(request).await, - "opencode-zen" => self.call_opencode_zen(request).await, + "anthropic" => { + let url = self.llm_manager.get_base_url("anthropic"); + self.call_anthropic_compatible(request, "anthropic", "Anthropic", url).await + } + "openai" => { + let url = self.llm_manager.get_base_url("openai"); + self.call_openai_compatible(request, "openai", "OpenAI", url).await + } + "openrouter" => { + let url = self.llm_manager.get_base_url("openrouter"); + self.call_openai_compatible(request, "openrouter", "OpenRouter", url).await + } + "zhipu" => { + let url = self.llm_manager.get_base_url("zhipu"); + self.call_openai_compatible(request, "zhipu", "Z.ai", url).await + } + "groq" => { + let url = self.llm_manager.get_base_url("groq"); + self.call_openai_compatible(request, "groq", "Groq", url).await + } + "together" => { + let url = self.llm_manager.get_base_url("together"); + self.call_openai_compatible(request, "together", "Together AI", url).await + } + "fireworks" => { + let url = self.llm_manager.get_base_url("fireworks"); + self.call_openai_compatible(request, "fireworks", "Fireworks AI", url).await + } + "deepseek" => { + let url = self.llm_manager.get_base_url("deepseek"); + self.call_openai_compatible(request, "deepseek", "DeepSeek", url).await + } + "xai" => { + let url = self.llm_manager.get_base_url("xai"); + self.call_openai_compatible(request, "xai", "xAI", url).await + } + "mistral" => { + let url = self.llm_manager.get_base_url("mistral"); + self.call_openai_compatible(request, "mistral", "Mistral AI", url).await + } + "opencode-zen" => { + let url = self.llm_manager.get_base_url("opencode-zen"); + self.call_openai_compatible(request, "opencode-zen", "OpenCode Zen", url).await + } other => Err(CompletionError::ProviderError(format!( "unknown provider: {other}" ))), @@ -271,13 +304,18 @@ impl CompletionModel for SpacebotModel { } impl SpacebotModel { - async fn call_anthropic( + /// Generic Anthropic-compatible API call. + /// Used by providers that implement the Anthropic messages format. + async fn call_anthropic_compatible( &self, request: CompletionRequest, + provider_id: &str, + provider_display_name: &str, + endpoint: &str, ) -> Result, CompletionError> { let api_key = self .llm_manager - .get_api_key("anthropic") + .get_api_key(provider_id) .map_err(|e| CompletionError::ProviderError(e.to_string()))?; let messages = convert_messages_to_anthropic(&request.chat_history); @@ -314,7 +352,7 @@ impl SpacebotModel { let response = self .llm_manager .http_client() - .post("https://api.anthropic.com/v1/messages") + .post(endpoint) .header("x-api-key", &api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") @@ -331,7 +369,7 @@ impl SpacebotModel { let response_body: serde_json::Value = serde_json::from_str(&response_text) .map_err(|e| CompletionError::ProviderError(format!( - "Anthropic response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) + "{provider_display_name} response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) )))?; if !status.is_success() { @@ -339,271 +377,13 @@ impl SpacebotModel { .as_str() .unwrap_or("unknown error"); return Err(CompletionError::ProviderError(format!( - "Anthropic API error ({status}): {message}" + "{provider_display_name} API error ({status}): {message}" ))); } parse_anthropic_response(response_body) } - async fn call_openai( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - let api_key = self - .llm_manager - .get_api_key("openai") - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - - let mut messages = Vec::new(); - - if let Some(preamble) = &request.preamble { - messages.push(serde_json::json!({ - "role": "system", - "content": preamble, - })); - } - - messages.extend(convert_messages_to_openai(&request.chat_history)); - - let mut body = serde_json::json!({ - "model": self.model_name, - "messages": messages, - }); - - if let Some(max_tokens) = request.max_tokens { - body["max_tokens"] = serde_json::json!(max_tokens); - } - - if let Some(temperature) = request.temperature { - body["temperature"] = serde_json::json!(temperature); - } - - if !request.tools.is_empty() { - let tools: Vec = request - .tools - .iter() - .map(|t| { - serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.parameters, - } - }) - }) - .collect(); - body["tools"] = serde_json::json!(tools); - } - - let response = self - .llm_manager - .http_client() - .post("https://api.openai.com/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .json(&body) - .send() - .await - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - - let status = response.status(); - let response_text = response - .text() - .await - .map_err(|e| CompletionError::ProviderError(format!("failed to read response body: {e}")))?; - - let response_body: serde_json::Value = serde_json::from_str(&response_text) - .map_err(|e| CompletionError::ProviderError(format!( - "OpenAI response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) - )))?; - - if !status.is_success() { - let message = response_body["error"]["message"] - .as_str() - .unwrap_or("unknown error"); - return Err(CompletionError::ProviderError(format!( - "OpenAI API error ({status}): {message}" - ))); - } - - parse_openai_response(response_body, "OpenAI") - } - - async fn call_openrouter( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - let api_key = self - .llm_manager - .get_api_key("openrouter") - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - - // OpenRouter uses the OpenAI chat completions format. - // model_name is the full OpenRouter model ID (e.g. "anthropic/claude-sonnet-4-20250514"). - let mut messages = Vec::new(); - - if let Some(preamble) = &request.preamble { - messages.push(serde_json::json!({ - "role": "system", - "content": preamble, - })); - } - - messages.extend(convert_messages_to_openai(&request.chat_history)); - - let mut body = serde_json::json!({ - "model": self.model_name, - "messages": messages, - }); - - if let Some(max_tokens) = request.max_tokens { - body["max_tokens"] = serde_json::json!(max_tokens); - } - - if let Some(temperature) = request.temperature { - body["temperature"] = serde_json::json!(temperature); - } - - if !request.tools.is_empty() { - let tools: Vec = request - .tools - .iter() - .map(|t| { - serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.parameters, - } - }) - }) - .collect(); - body["tools"] = serde_json::json!(tools); - } - - let response = self - .llm_manager - .http_client() - .post("https://openrouter.ai/api/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .json(&body) - .send() - .await - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - - let status = response.status(); - let response_text = response - .text() - .await - .map_err(|e| CompletionError::ProviderError(format!("failed to read response body: {e}")))?; - - let response_body: serde_json::Value = serde_json::from_str(&response_text) - .map_err(|e| CompletionError::ProviderError(format!( - "OpenRouter response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) - )))?; - - if !status.is_success() { - let message = response_body["error"]["message"] - .as_str() - .unwrap_or("unknown error"); - return Err(CompletionError::ProviderError(format!( - "OpenRouter API error ({status}): {message}" - ))); - } - - // OpenRouter returns OpenAI-format responses - parse_openai_response(response_body, "OpenRouter") - } - - async fn call_zhipu( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - let api_key = self - .llm_manager - .get_api_key("zhipu") - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - - let mut messages = Vec::new(); - - if let Some(preamble) = &request.preamble { - messages.push(serde_json::json!({ - "role": "system", - "content": preamble, - })); - } - - messages.extend(convert_messages_to_openai(&request.chat_history)); - - let mut body = serde_json::json!({ - "model": self.model_name, - "messages": messages, - }); - - if let Some(max_tokens) = request.max_tokens { - body["max_tokens"] = serde_json::json!(max_tokens); - } - - if let Some(temperature) = request.temperature { - body["temperature"] = serde_json::json!(temperature); - } - - if !request.tools.is_empty() { - let tools: Vec = request - .tools - .iter() - .map(|t| { - serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.parameters, - } - }) - }) - .collect(); - body["tools"] = serde_json::json!(tools); - } - - let response = self - .llm_manager - .http_client() - .post("https://api.z.ai/api/paas/v4/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .json(&body) - .send() - .await - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - - let status = response.status(); - let response_text = response - .text() - .await - .map_err(|e| CompletionError::ProviderError(format!("failed to read response body: {e}")))?; - - let response_body: serde_json::Value = serde_json::from_str(&response_text) - .map_err(|e| CompletionError::ProviderError(format!( - "Z.ai response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) - )))?; - - if !status.is_success() { - let message = response_body["error"]["message"] - .as_str() - .unwrap_or("unknown error"); - return Err(CompletionError::ProviderError(format!( - "Z.ai API error ({status}): {message}" - ))); - } - - parse_openai_response(response_body, "Z.ai") - } - /// Generic OpenAI-compatible API call. /// Used by providers that implement the OpenAI chat completions format. async fn call_openai_compatible( @@ -693,90 +473,6 @@ impl SpacebotModel { parse_openai_response(response_body, provider_display_name) } - - async fn call_groq( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "groq", - "Groq", - "https://api.groq.com/openai/v1/chat/completions", - ).await - } - - async fn call_together( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "together", - "Together AI", - "https://api.together.xyz/v1/chat/completions", - ).await - } - - async fn call_fireworks( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "fireworks", - "Fireworks AI", - "https://api.fireworks.ai/inference/v1/chat/completions", - ).await - } - - async fn call_deepseek( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "deepseek", - "DeepSeek", - "https://api.deepseek.com/v1/chat/completions", - ).await - } - - async fn call_xai( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "xai", - "xAI", - "https://api.x.ai/v1/chat/completions", - ).await - } - - async fn call_mistral( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "mistral", - "Mistral AI", - "https://api.mistral.ai/v1/chat/completions", - ).await - } - - async fn call_opencode_zen( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - self.call_openai_compatible( - request, - "opencode-zen", - "OpenCode Zen", - "https://opencode.ai/zen/v1/chat/completions", - ).await - } } // --- Helpers --- diff --git a/src/llm/providers.rs b/src/llm/providers.rs index e3ce47ccc..bcca091a7 100644 --- a/src/llm/providers.rs +++ b/src/llm/providers.rs @@ -20,6 +20,6 @@ pub async fn init_providers(config: &LlmConfig) -> Result<()> { if config.opencode_zen_key.is_some() { tracing::info!("OpenCode Zen provider configured"); } - + Ok(()) }