Skip to content
Open
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
9 changes: 6 additions & 3 deletions src/core/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub const SavedChannelUpdate = struct {
fn providerLabel(provider: []const u8) []const u8 {
const map = .{
.{ "openrouter", "OpenRouter" },
.{ "minimax", "MiniMax" },
.{ "anthropic", "Anthropic" },
.{ "openai", "OpenAI" },
.{ "google", "Google" },
Expand Down Expand Up @@ -1272,13 +1273,15 @@ test "auto-generated name increments per provider type" {

try s.addSavedProvider(.{ .provider = "openrouter", .api_key = "key1" });
try s.addSavedProvider(.{ .provider = "openrouter", .api_key = "key2" });
try s.addSavedProvider(.{ .provider = "anthropic", .api_key = "key3" });
try s.addSavedProvider(.{ .provider = "minimax", .api_key = "key3" });
try s.addSavedProvider(.{ .provider = "anthropic", .api_key = "key4" });

const providers = s.savedProviders();
try std.testing.expectEqual(@as(usize, 3), providers.len);
try std.testing.expectEqual(@as(usize, 4), providers.len);
try std.testing.expectEqualStrings("OpenRouter", providers[0].name);
try std.testing.expectEqualStrings("OpenRouter #2", providers[1].name);
try std.testing.expectEqualStrings("Anthropic", providers[2].name);
try std.testing.expectEqualStrings("MiniMax", providers[2].name);
try std.testing.expectEqualStrings("Anthropic", providers[3].name);
}

test "update saved provider name only" {
Expand Down
34 changes: 26 additions & 8 deletions ui/src/lib/components/ProviderList.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "$lib/api/client";
import { OPENAI_COMPATIBLE_VALUE, LOCAL_PROVIDERS, mergeWithManifestOptions } from "$lib/providers";
import {
OPENAI_COMPATIBLE_VALUE,
LOCAL_PROVIDERS,
PROVIDER_DEFAULT_BASE_URLS,
PROVIDER_DEFAULT_MODELS,
mergeWithManifestOptions,
providerUsesOpenAiCompatibleEndpoint,
} from "$lib/providers";
import type { ProviderOption } from "$lib/providers";

let {
Expand Down Expand Up @@ -176,7 +183,13 @@
if (provider === OPENAI_COMPATIBLE_VALUE) {
return { ...e, provider, base_url: e.base_url || "", provider_name: e.provider_name || "" };
}
return { ...e, provider, base_url: "", provider_name: "" };
return {
...e,
provider,
base_url: PROVIDER_DEFAULT_BASE_URLS[provider] || "",
model: e.model || PROVIDER_DEFAULT_MODELS[provider] || "",
provider_name: "",
};
});
emitChange();
}
Expand Down Expand Up @@ -233,9 +246,9 @@

async function ensureModelOptions(entry: ProviderEntry) {
if (!entry.provider) return;
// openai-compatible requires a base_url to probe; skip until one is entered.
if (entry.provider === OPENAI_COMPATIBLE_VALUE && !entry.base_url) return;
if (entry.provider !== OPENAI_COMPATIBLE_VALUE && !component) return;
// OpenAI-compatible endpoints require a base_url to probe; skip until one is entered.
if (providerUsesOpenAiCompatibleEndpoint(entry.provider) && !entry.base_url) return;
if (!providerUsesOpenAiCompatibleEndpoint(entry.provider) && !component) return;

const key = modelKey(entry);
if (modelLoadingByKey[key] || modelLoadedByKey[key]) return;
Expand All @@ -245,7 +258,7 @@

try {
let models: string[];
if (entry.provider === OPENAI_COMPATIBLE_VALUE && entry.base_url) {
if (providerUsesOpenAiCompatibleEndpoint(entry.provider) && entry.base_url) {
const data = await api.probeProviderModels(entry.base_url, entry.api_key || "");
models = data.live_ok && Array.isArray(data.models) ? data.models : [];
} else {
Expand Down Expand Up @@ -338,6 +351,9 @@
if (entry.provider === OPENAI_COMPATIBLE_VALUE) {
return "e.g. gpt-4o-mini";
}
if (entry.provider in PROVIDER_DEFAULT_MODELS) {
return PROVIDER_DEFAULT_MODELS[entry.provider];
}
if (entry.provider === "codex-cli" || entry.provider === "openai-codex") {
return "e.g. gpt-5.4";
}
Expand All @@ -351,7 +367,7 @@
if (entry.provider === "openai-codex") {
return "Uses ChatGPT/Codex auth from ~/.codex/auth.json. No API key required here.";
}
if (entry.provider === OPENAI_COMPATIBLE_VALUE) {
if (providerUsesOpenAiCompatibleEndpoint(entry.provider)) {
return "Click to load models from the endpoint, then filter as you type.";
}
return "Click to load models, then filter as you type.";
Expand Down Expand Up @@ -432,14 +448,16 @@
placeholder="e.g. infini-ai, xiaomi-mimo"
/>
</div>
{/if}
{#if providerUsesOpenAiCompatibleEndpoint(entry.provider)}
<div class="provider-field">
<label for={`provider-base-url-${i}`}>Base URL</label>
<input
id={`provider-base-url-${i}`}
type="text"
value={entry.base_url}
oninput={(e) => updateEntry(i, "base_url", e.currentTarget.value)}
placeholder="https://api.example.com/v1"
placeholder={PROVIDER_DEFAULT_BASE_URLS[entry.provider] || "https://api.example.com/v1"}
/>
</div>
{/if}
Expand Down
13 changes: 13 additions & 0 deletions ui/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type ProviderOption = {
*/
export const PROVIDER_OPTIONS: ProviderOption[] = [
{ value: "openrouter", label: "OpenRouter (multi-provider, recommended)", recommended: true },
{ value: "minimax", label: "MiniMax" },
{ value: "anthropic", label: "Anthropic" },
{ value: "openai", label: "OpenAI" },
{ value: "google", label: "Google AI" },
Expand All @@ -34,6 +35,18 @@ export const OPENAI_COMPATIBLE_VALUE = "openai-compatible";

export const LOCAL_PROVIDERS = ["ollama", "lm-studio", "claude-cli", "codex-cli", "openai-codex"];

export const PROVIDER_DEFAULT_BASE_URLS: Record<string, string> = {
minimax: "https://api.minimax.io/v1",
};

export const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
minimax: "MiniMax-M3",
};

export function providerUsesOpenAiCompatibleEndpoint(provider: string) {
return provider === OPENAI_COMPATIBLE_VALUE || provider in PROVIDER_DEFAULT_BASE_URLS;
}

/**
* Set of all provider values that are NOT the openai-compatible catch-all.
* Used to determine whether a saved provider entry is a named standard provider
Expand Down
54 changes: 43 additions & 11 deletions ui/src/routes/providers/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "$lib/api/client";
import { PROVIDER_OPTIONS, OPENAI_COMPATIBLE_VALUE, LOCAL_PROVIDERS, KNOWN_PROVIDER_VALUES } from "$lib/providers";
import {
PROVIDER_OPTIONS,
OPENAI_COMPATIBLE_VALUE,
LOCAL_PROVIDERS,
KNOWN_PROVIDER_VALUES,
PROVIDER_DEFAULT_BASE_URLS,
PROVIDER_DEFAULT_MODELS,
providerUsesOpenAiCompatibleEndpoint,
} from "$lib/providers";


let providers = $state<any[]>([]);
Expand Down Expand Up @@ -107,26 +115,28 @@
addValidating = true;
addError = "";
try {
const usesCompatibleEndpoint = providerUsesOpenAiCompatibleEndpoint(addForm.provider);
const isCustom = addForm.provider === OPENAI_COMPATIBLE_VALUE;
const providerValue = addForm.provider === OPENAI_COMPATIBLE_VALUE
? addForm.provider_name.trim()
: addForm.provider;
const baseUrl = addForm.base_url.trim();
const baseUrl = addForm.base_url.trim() || PROVIDER_DEFAULT_BASE_URLS[addForm.provider] || "";
const model = addForm.model || PROVIDER_DEFAULT_MODELS[addForm.provider] || undefined;
if (isCustom && !providerValue) {
addError = "Provider name is required for OpenAI Compatible providers.";
addValidating = false;
return;
}
if (isCustom && !baseUrl) {
addError = "Base URL is required for OpenAI Compatible providers.";
if (usesCompatibleEndpoint && !baseUrl) {
addError = "Base URL is required for this provider.";
addValidating = false;
return;
}
await api.createSavedProvider({
provider: providerValue,
api_key: addForm.api_key,
model: addForm.model || undefined,
base_url: isCustom ? baseUrl : undefined,
model,
base_url: usesCompatibleEndpoint ? baseUrl : undefined,
});
showAddForm = false;
addForm = { provider: "openrouter", provider_name: "", api_key: "", model: "", base_url: "" };
Expand Down Expand Up @@ -209,7 +219,7 @@
// A provider is "custom" if its type is not one of the built-in nullclaw-known providers.
// This determines whether the base_url / Fetch Models fields appear in edit form.
function isCustomProvider(p: any) {
return !KNOWN_PROVIDER_VALUES.has(p.provider);
return !KNOWN_PROVIDER_VALUES.has(p.provider) || p.provider in PROVIDER_DEFAULT_BASE_URLS;
}

function getProviderLabel(value: string) {
Expand Down Expand Up @@ -237,7 +247,17 @@
}

$effect(() => {
// Clear probed models when the add form's base_url or api_key changes
if (addForm.provider in PROVIDER_DEFAULT_BASE_URLS && !addForm.base_url) {
addForm.base_url = PROVIDER_DEFAULT_BASE_URLS[addForm.provider];
}
if (addForm.provider in PROVIDER_DEFAULT_MODELS && !addForm.model) {
addForm.model = PROVIDER_DEFAULT_MODELS[addForm.provider];
}
});

$effect(() => {
// Clear probed models when the add form's endpoint, key, or provider changes
addForm.provider;
addForm.base_url;
addForm.api_key;
addProbedModels = [];
Expand Down Expand Up @@ -277,9 +297,16 @@
<label for="add-provider-name">Provider Name</label>
<input id="add-provider-name" type="text" bind:value={addForm.provider_name} placeholder="e.g. infini-ai, xiaomi-mimo" />
</div>
{/if}
{#if providerUsesOpenAiCompatibleEndpoint(addForm.provider)}
<div class="field">
<label for="add-base-url">Base URL</label>
<input id="add-base-url" type="text" bind:value={addForm.base_url} placeholder="https://api.example.com/v1" />
<input
id="add-base-url"
type="text"
bind:value={addForm.base_url}
placeholder={PROVIDER_DEFAULT_BASE_URLS[addForm.provider] || "https://api.example.com/v1"}
/>
</div>
{/if}
{#if !isLocal(addForm.provider)}
Expand All @@ -288,11 +315,16 @@
<input id="add-api-key" type="password" bind:value={addForm.api_key} placeholder="Enter API key..." />
</div>
{/if}
{#if addForm.provider === OPENAI_COMPATIBLE_VALUE}
{#if providerUsesOpenAiCompatibleEndpoint(addForm.provider)}
<div class="field">
<label for="add-model">Model</label>
<div class="model-input-row">
<input id="add-model" type="text" bind:value={addForm.model} placeholder="e.g. gpt-4" />
<input
id="add-model"
type="text"
bind:value={addForm.model}
placeholder={PROVIDER_DEFAULT_MODELS[addForm.provider] || "e.g. gpt-4"}
/>
<button
class="btn fetch-models-btn"
onclick={fetchAddModels}
Expand Down