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
58 changes: 45 additions & 13 deletions ui/src/lib/components/ProviderList.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<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_BASE_URL_OPTIONS,
PROVIDER_DEFAULT_BASE_URLS,
PROVIDER_DEFAULT_MODELS,
mergeProviderModelOptions,
mergeWithManifestOptions,
providerUsesOpenAiCompatibleEndpoint,
} from "$lib/providers";
import type { ProviderOption } from "$lib/providers";

let {
Expand Down Expand Up @@ -82,7 +91,8 @@
}

function useSaved(sp: any) {
const isCompat = sp.base_url && sp.base_url.length > 0;
const isNamedEndpointProvider = sp.provider in PROVIDER_DEFAULT_BASE_URLS;
const isCompat = Boolean(sp.base_url) && !isNamedEndpointProvider;
const savedEntry = {
provider: isCompat ? OPENAI_COMPATIBLE_VALUE : sp.provider,
api_key: sp.api_key,
Expand Down Expand Up @@ -176,7 +186,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 @@ -220,7 +236,7 @@
}

function getModelOptions(entry: ProviderEntry) {
return modelOptionsByKey[modelKey(entry)] || [];
return mergeProviderModelOptions(entry.provider, modelOptionsByKey[modelKey(entry)] || []);
}

function getModelError(entry: ProviderEntry) {
Expand All @@ -233,9 +249,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 +261,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 +354,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 +370,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,15 +451,25 @@
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"}
list={`provider-base-url-options-${i}`}
/>
{#if PROVIDER_BASE_URL_OPTIONS[entry.provider]?.length}
<datalist id={`provider-base-url-options-${i}`}>
{#each PROVIDER_BASE_URL_OPTIONS[entry.provider] as option}
<option value={option.value}>{option.label}</option>
{/each}
</datalist>
{/if}
</div>
{/if}

Expand All @@ -464,9 +493,7 @@
{@const filteredModels = getFilteredModels(entry)}
{@const totalMatches = getFilteredModelCount(entry)}
<div class="model-dropdown">
{#if isModelLoading(entry)}
<div class="model-empty">Loading models...</div>
{:else if filteredModels.length > 0}
{#if filteredModels.length > 0}
{#each filteredModels as model}
<button
type="button"
Expand All @@ -485,6 +512,11 @@
Showing {filteredModels.length} of {totalMatches}. Keep typing to narrow.
</div>
{/if}
{#if isModelLoading(entry)}
<div class="model-summary">Loading additional models from the endpoint...</div>
{/if}
{:else if isModelLoading(entry)}
<div class="model-empty">Loading models...</div>
{:else if getModelError(entry)}
<div class="model-empty model-error">
{getModelError(entry)}. You can still type a model manually.
Expand Down
34 changes: 34 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,39 @@ export const OPENAI_COMPATIBLE_VALUE = "openai-compatible";

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

export type ProviderBaseUrlOption = {
value: string;
label: string;
};

export const PROVIDER_BASE_URL_OPTIONS: Record<string, ProviderBaseUrlOption[]> = {
minimax: [
{ value: "https://api.minimax.io/v1", label: "Global" },
{ value: "https://api.minimaxi.com/v1", label: "China" },
],
};

export const PROVIDER_MODEL_OPTIONS: Record<string, string[]> = {
minimax: ["MiniMax-M3", "MiniMax-M2.7"],
};

export const PROVIDER_DEFAULT_BASE_URLS: Record<string, string> = {
minimax: PROVIDER_BASE_URL_OPTIONS.minimax[0].value,
};

export const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
minimax: PROVIDER_MODEL_OPTIONS.minimax[0],
};

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

export function mergeProviderModelOptions(provider: string, models: unknown[] = []): string[] {
const candidates = [...(PROVIDER_MODEL_OPTIONS[provider] || []), ...models];
return [...new Set(candidates.filter((model): model is string => typeof model === "string" && model.length > 0))];
}

/**
* 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
Loading