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
7 changes: 5 additions & 2 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ export interface ProviderStatus {
export interface ProvidersResponse {
providers: ProviderStatus;
has_any: boolean;
base_urls?: Record<string, string>;
}

export interface ProviderActionResponse {
Expand Down Expand Up @@ -1010,11 +1011,13 @@ export const api = {

// Provider management
providers: () => fetchJson<ProvidersResponse>("/providers"),
updateProvider: async (provider: string, apiKey: string) => {
updateProvider: async (provider: string, apiKey: string, baseUrl?: string) => {
const body: Record<string, unknown> = { 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}`);
Expand Down
95 changes: 91 additions & 4 deletions interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ const PROVIDERS = [
},
] as const;

// Endpoint presets for providers that support alternate base URLs.
const ENDPOINT_PRESETS: Record<string, { label: string; url: string }[]> = {
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();
Expand All @@ -165,6 +173,7 @@ export function Settings() {
};
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [keyInput, setKeyInput] = useState("");
const [baseUrlInput, setBaseUrlInput] = useState("");
const [message, setMessage] = useState<{
text: string;
type: "success" | "error";
Expand All @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -362,6 +378,13 @@ export function Settings() {
if (e.key === "Enter") handleSave();
}}
/>
{editingProvider && (
<EndpointSelector
presets={ENDPOINT_PRESETS[editingProvider] ?? []}
value={baseUrlInput}
onChange={setBaseUrlInput}
/>
)}
{message && (
<div
className={`rounded-md border px-3 py-2 text-sm ${
Expand Down Expand Up @@ -2066,6 +2089,70 @@ function ConfigFileSection() {
);
}

interface EndpointSelectorProps {
presets: { label: string; url: string }[];
value: string;
onChange: (url: string) => 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 (
<div>
<label className="mb-1.5 block text-sm font-medium text-ink">Endpoint</label>
<Select
value={selectValue}
onValueChange={(v) => {
if (v === "__custom__") {
onChange("https://");
} else if (v === "__default__") {
onChange("");
} else {
onChange(v);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{hasPresets ? (
presets.map((preset) => (
<SelectItem key={preset.label} value={preset.url || "__default__"}>
{preset.label}
</SelectItem>
))
) : (
<SelectItem value="__default__">Default</SelectItem>
)}
<SelectItem value="__custom__">Custom URL</SelectItem>
</SelectContent>
</Select>
{isCustom && (
<>
<Input
type="url"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://api.example.com/v1/chat/completions"
className="mt-2"
/>
<p className="mt-1 text-tiny text-ink-faint">
Full endpoint path required (e.g. ending in /v1/chat/completions)
</p>
</>
)}
</div>
);
}

interface ProviderCardProps {
provider: string;
name: string;
Expand Down
106 changes: 84 additions & 22 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
}

#[derive(Deserialize)]
struct ProviderUpdateRequest {
provider: String,
api_key: String,
#[serde(default)]
base_url: Option<String>,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -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())
};

(
Expand All @@ -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"),
)
};

Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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/*.
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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
}

Expand Down
Loading