feat: searchable model picker dropdown for OrcaRouter

Adds a generic `Provider.modelsEndpoint` field and a two-column
searchable dropdown for picking a model when the field is set. Only
OrcaRouter opts in; other providers are untouched (text input as-is).

Also moves the OrcaRouter card up the list (after Anthropic, above
OpenRouter) so gateway-style providers sit together near the top.

- src/types/index.ts: add optional `modelsEndpoint` to Provider
- src/lib/llm.ts: reorder OrcaRouter + set `modelsEndpoint: '/models'`
- src/lib/providerModels.ts (new): fetch /v1/models with Bearer auth,
  filter chat-capable, group by id prefix, localStorage cache
- src/pages/Agents/components/ProviderModelCombobox.tsx (new): Popover
  + Command combobox with provider list on the left, model list on the
  right, search filters the active provider's models, refresh button
- src/pages/Agents/Models.tsx: state for fetched models, conditional
  render combobox vs input based on `modelsEndpoint`
This commit is contained in:
zhenjun.chen 2026-05-12 17:41:47 +08:00
parent 6990691809
commit b83362785c
5 changed files with 577 additions and 31 deletions

View file

@ -42,6 +42,16 @@ export const INIT_PROVODERS: Provider[] = [
is_valid: false,
model_type: '',
},
{
id: 'orcarouter',
name: 'OrcaRouter',
apiKey: '',
apiHost: 'https://api.orcarouter.ai/v1',
description: 'OrcaRouter model configuration.',
is_valid: false,
model_type: '',
modelsEndpoint: '/models',
},
{
id: 'openrouter',
name: 'OpenRouter',
@ -217,13 +227,4 @@ export const INIT_PROVODERS: Provider[] = [
is_valid: false,
model_type: '',
},
{
id: 'orcarouter',
name: 'OrcaRouter',
apiKey: '',
apiHost: 'https://api.orcarouter.ai/v1',
description: 'OrcaRouter model configuration.',
is_valid: false,
model_type: '',
},
];

155
src/lib/providerModels.ts Normal file
View file

@ -0,0 +1,155 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* Fetch + parse helper for cloud providers that expose an OpenAI-compatible
* `/v1/models` listing endpoint (e.g. OrcaRouter). Returns chat-capable
* models grouped by their `<provider>/<model>` prefix so the UI can render
* provider tabs.
*/
/** Single model entry as returned by an OpenAI-compatible /v1/models call. */
type RawModel = {
id: string;
architecture?: {
input_modalities?: string[] | null;
output_modalities?: string[] | null;
};
context_length?: number;
max_completion_tokens?: number;
};
export type ProviderModelInfo = {
id: string;
contextLength?: number;
maxCompletionTokens?: number;
};
export type ProviderModelGroup = {
provider: string;
models: ProviderModelInfo[];
};
/**
* Decide whether a model is chat-capable enough to surface in the dropdown.
* Keeps models that explicitly emit text, plus models that omit the
* architecture field entirely (some upstream listings e.g. deepseek-reasoner
* leave it null even though they are usable for chat).
*
* Filters out: TTS / image-only / video-only outputs.
*/
function isChatCapable(model: RawModel): boolean {
const arch = model.architecture;
if (!arch) return true;
const out = arch.output_modalities;
if (out == null) return true;
return out.includes('text');
}
/** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */
function splitProviderPrefix(id: string): [string, string] {
const idx = id.indexOf('/');
if (idx <= 0) return ['', id];
return [id.slice(0, idx), id.slice(idx + 1)];
}
/**
* Hit `${apiHost}${modelsEndpoint}` with a Bearer token and return chat-capable
* models grouped by provider prefix, sorted alphabetically by provider, with
* models within each group sorted alphabetically by id.
*
* Throws on network failure or non-2xx response with a user-readable message.
*/
export async function fetchProviderModels(
apiHost: string,
modelsEndpoint: string,
apiKey: string
): Promise<ProviderModelGroup[]> {
if (!apiKey) {
throw new Error('API key is required to fetch model list.');
}
const trimmedHost = apiHost.replace(/\/+$/, '');
const url = `${trimmedHost}${modelsEndpoint}`;
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch models: ${response.status} ${response.statusText}`
);
}
const payload = await response.json();
const data: RawModel[] = Array.isArray(payload?.data) ? payload.data : [];
const grouped = new Map<string, ProviderModelInfo[]>();
for (const model of data) {
if (!model?.id || !isChatCapable(model)) continue;
const [provider] = splitProviderPrefix(model.id);
const bucket = provider || 'other';
const info: ProviderModelInfo = {
id: model.id,
contextLength: model.context_length,
maxCompletionTokens: model.max_completion_tokens,
};
const arr = grouped.get(bucket);
if (arr) arr.push(info);
else grouped.set(bucket, [info]);
}
const groups: ProviderModelGroup[] = Array.from(grouped.entries())
.map(([provider, models]) => ({
provider,
models: models.sort((a, b) => a.id.localeCompare(b.id)),
}))
.sort((a, b) => a.provider.localeCompare(b.provider));
return groups;
}
/** localStorage cache helpers — keyed per provider id to keep entries small. */
const CACHE_KEY_PREFIX = 'eigent-provider-models-v1:';
export function loadCachedModels(
providerId: string
): ProviderModelGroup[] | null {
try {
const raw = localStorage.getItem(CACHE_KEY_PREFIX + providerId);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed as ProviderModelGroup[];
} catch {
return null;
}
}
export function saveCachedModels(
providerId: string,
groups: ProviderModelGroup[]
): void {
try {
localStorage.setItem(
CACHE_KEY_PREFIX + providerId,
JSON.stringify(groups)
);
} catch {
// localStorage may be unavailable (quota / private mode); silently ignore.
}
}

View file

@ -98,6 +98,13 @@ import {
toEndpointBaseUrl,
VLLM_PROVIDER_ID,
} from './localModels';
import { ProviderModelCombobox } from './components/ProviderModelCombobox';
import {
fetchProviderModels,
loadCachedModels,
saveCachedModels,
type ProviderModelGroup,
} from '@/lib/providerModels';
// Sidebar tab types
type SidebarTab =
@ -201,6 +208,72 @@ export default function SettingModels() {
const [ollamaEndpointAutoFixedOnce, setOllamaEndpointAutoFixedOnce] =
useState(false);
// Per-cloud-provider model list state: { groups, loading, error } keyed by
// provider id. Populated for providers whose `INIT_PROVODERS` entry declares
// a `modelsEndpoint` (today: only OrcaRouter).
const [cloudModelsState, setCloudModelsState] = useState<
Record<
string,
{ groups: ProviderModelGroup[]; loading: boolean; error: string | null }
>
>(() => {
const initial: Record<
string,
{ groups: ProviderModelGroup[]; loading: boolean; error: string | null }
> = {};
for (const p of INIT_PROVODERS) {
if (!p.modelsEndpoint) continue;
const cached = loadCachedModels(p.id);
if (cached) {
initial[p.id] = { groups: cached, loading: false, error: null };
}
}
return initial;
});
const fetchCloudProviderModels = useCallback(
async (idx: number) => {
const item = items[idx];
if (!item?.modelsEndpoint) return;
const apiKey = form[idx]?.apiKey;
const apiHost = form[idx]?.apiHost || item.apiHost;
if (!apiKey) return;
setCloudModelsState((prev) => ({
...prev,
[item.id]: {
groups: prev[item.id]?.groups || [],
loading: true,
error: null,
},
}));
try {
const groups = await fetchProviderModels(
apiHost,
item.modelsEndpoint,
apiKey
);
setCloudModelsState((prev) => ({
...prev,
[item.id]: { groups, loading: false, error: null },
}));
saveCachedModels(item.id, groups);
} catch (err: any) {
setCloudModelsState((prev) => ({
...prev,
[item.id]: {
groups: prev[item.id]?.groups || [],
loading: false,
error:
typeof err?.message === 'string'
? err.message
: 'Failed to fetch models.',
},
}));
}
},
[items, form]
);
// Generic model fetcher driven by LOCAL_MODEL_OPTIONS config.
// Only fetches for providers that define fetchPath and parseModels.
const fetchModelsForPlatform = useCallback(
@ -1406,28 +1479,63 @@ export default function SettingModels() {
}}
/>
{/* Model Type Setting */}
<Input
id={`modelType-${item.id}`}
size="default"
title={t('setting.model-type-setting')}
state={errors[idx]?.model_type ? 'error' : 'default'}
note={errors[idx]?.model_type ?? undefined}
placeholder={`${t('setting.enter-your-model-type')} ${
item.name
} ${t('setting.model-type')}`}
value={form[idx].model_type}
onChange={(e) => {
const v = e.target.value;
setForm((f) =>
f.map((fi, i) => (i === idx ? { ...fi, model_type: v } : fi))
);
setErrors((errs) =>
errs.map((er, i) =>
i === idx ? { ...er, model_type: '' } : er
)
);
}}
/>
{item.modelsEndpoint ? (
<ProviderModelCombobox
providerName={item.name}
title={t('setting.model-type-setting')}
value={form[idx].model_type || ''}
onChange={(v) => {
setForm((f) =>
f.map((fi, i) =>
i === idx ? { ...fi, model_type: v } : fi
)
);
setErrors((errs) =>
errs.map((er, i) =>
i === idx ? { ...er, model_type: '' } : er
)
);
}}
groups={cloudModelsState[item.id]?.groups || []}
loading={cloudModelsState[item.id]?.loading || false}
error={
cloudModelsState[item.id]?.error ??
errors[idx]?.model_type ??
null
}
disabled={!form[idx].apiKey}
disabledReason="Enter API Key first."
onRefresh={() => void fetchCloudProviderModels(idx)}
triggerPlaceholder={`${t('setting.enter-your-model-type')} ${
item.name
} ${t('setting.model-type')}`}
/>
) : (
<Input
id={`modelType-${item.id}`}
size="default"
title={t('setting.model-type-setting')}
state={errors[idx]?.model_type ? 'error' : 'default'}
note={errors[idx]?.model_type ?? undefined}
placeholder={`${t('setting.enter-your-model-type')} ${
item.name
} ${t('setting.model-type')}`}
value={form[idx].model_type}
onChange={(e) => {
const v = e.target.value;
setForm((f) =>
f.map((fi, i) =>
i === idx ? { ...fi, model_type: v } : fi
)
);
setErrors((errs) =>
errs.map((er, i) =>
i === idx ? { ...er, model_type: '' } : er
)
);
}}
/>
)}
{/* externalConfig render */}
{item.externalConfig &&
form[idx].externalConfig &&

View file

@ -0,0 +1,275 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, Loader2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import type { ProviderModelGroup } from '@/lib/providerModels';
type Props = {
/** Stable id used for "selected" comparison and aria-label scoping. */
providerName: string;
/** Localized field title shown above the trigger (e.g. "Model Type Setting"). */
title: string;
/** Currently saved model id. May be empty or a value not in `groups`. */
value: string;
onChange: (value: string) => void;
groups: ProviderModelGroup[];
loading: boolean;
error: string | null;
/** Disable everything when the user hasn't filled in an API key yet. */
disabled: boolean;
/** Reason to show inside the popover when disabled (e.g. "Enter API Key first"). */
disabledReason?: string;
onRefresh: () => void;
triggerPlaceholder?: string;
};
/** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */
function splitPrefix(id: string): [string, string] {
const idx = id.indexOf('/');
if (idx <= 0) return ['', id];
return [id.slice(0, idx), id.slice(idx + 1)];
}
export function ProviderModelCombobox({
providerName,
title,
value,
onChange,
groups,
loading,
error,
disabled,
disabledReason,
onRefresh,
triggerPlaceholder,
}: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
// Default the active left-column entry to the provider of the saved value,
// falling back to the first provider with at least one model.
const initialActiveProvider = useMemo(() => {
if (value) {
const [prefix] = splitPrefix(value);
if (prefix && groups.some((g) => g.provider === prefix)) return prefix;
}
const first = groups.find((g) => g.models.length > 0);
return first?.provider ?? '';
}, [value, groups]);
const [activeProvider, setActiveProvider] = useState<string>(
initialActiveProvider
);
// Keep activeProvider sane if `groups` changes (e.g. after a refresh).
useEffect(() => {
if (!activeProvider && initialActiveProvider) {
setActiveProvider(initialActiveProvider);
} else if (
activeProvider &&
groups.length > 0 &&
!groups.some((g) => g.provider === activeProvider)
) {
setActiveProvider(initialActiveProvider);
}
}, [groups, activeProvider, initialActiveProvider]);
// Saved value not present in any group — surface a one-row "Current" section.
const orphanValue = useMemo(() => {
if (!value) return null;
const known = groups.some((g) => g.models.some((m) => m.id === value));
return known ? null : value;
}, [value, groups]);
// Models for the right column: active provider's models filtered by query.
const activeModels = useMemo(() => {
const group = groups.find((g) => g.provider === activeProvider);
if (!group) return [];
const q = query.trim().toLowerCase();
if (!q) return group.models;
return group.models.filter((m) => m.id.toLowerCase().includes(q));
}, [groups, activeProvider, query]);
const hasAnyModels = groups.some((g) => g.models.length > 0);
return (
<div className="flex w-full flex-col">
{title ? (
<div className="mb-1.5 flex items-center gap-1 text-body-sm font-bold text-text-heading">
{title}
</div>
) : null}
<div className="flex w-full items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-label={`${providerName} model type`}
disabled={disabled}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border px-3 text-body-sm transition-colors',
'border-input-border-default bg-input-bg-default text-text-heading',
'hover:border-input-border-hover focus:border-input-border-focus focus:outline-none',
disabled && 'cursor-not-allowed opacity-50',
error && 'border-input-border-cuation'
)}
>
<span
className={cn(
'truncate text-left',
!value && 'text-input-label-default'
)}
>
{value || triggerPlaceholder || 'Select model'}
</span>
<ChevronDown className="ml-2 h-4 w-4 flex-shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search model..."
value={query}
onValueChange={setQuery}
/>
{!hasAnyModels && !orphanValue ? (
<div className="px-3 py-6 text-center text-xs text-text-label">
{loading
? 'Loading...'
: disabled
? disabledReason ?? 'Enter API Key first.'
: 'Click the refresh button to load models.'}
</div>
) : (
<div className="flex max-h-80">
{/* Left column: provider list */}
<div className="w-[120px] flex-shrink-0 overflow-y-auto border-r border-border-secondary py-1">
{orphanValue ? (
<button
type="button"
onClick={() => setActiveProvider('__orphan__')}
className={cn(
'flex w-full items-center px-3 py-1.5 text-left text-xs text-text-label transition-colors',
activeProvider === '__orphan__'
? 'bg-button-transparent-fill-hover text-text-heading'
: 'hover:bg-button-transparent-fill-hover'
)}
>
Current
</button>
) : null}
{groups.map((g) => (
<button
key={g.provider}
type="button"
onClick={() => setActiveProvider(g.provider)}
className={cn(
'flex w-full items-center justify-between px-3 py-1.5 text-left text-xs transition-colors',
activeProvider === g.provider
? 'bg-button-transparent-fill-hover text-text-heading'
: 'text-text-label hover:bg-button-transparent-fill-hover'
)}
>
<span className="truncate">{g.provider}</span>
<span className="ml-2 flex-shrink-0 text-text-label opacity-60">
{g.models.length}
</span>
</button>
))}
</div>
{/* Right column: models for active provider */}
<CommandList className="max-h-80 flex-1">
{activeProvider === '__orphan__' && orphanValue ? (
<CommandItem
value={orphanValue}
onSelect={() => {
onChange(orphanValue);
setOpen(false);
}}
>
<span className="truncate">{orphanValue}</span>
</CommandItem>
) : activeModels.length > 0 ? (
activeModels.map((m) => {
const [, modelName] = splitPrefix(m.id);
return (
<CommandItem
key={m.id}
value={m.id}
onSelect={() => {
onChange(m.id);
setOpen(false);
}}
className={cn(
value === m.id && 'bg-button-transparent-fill-hover'
)}
>
<span className="truncate">{modelName}</span>
</CommandItem>
);
})
) : (
<CommandEmpty>
{query.trim() ? 'No matches.' : 'No models.'}
</CommandEmpty>
)}
</CommandList>
</div>
)}
</Command>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={disabled || loading}
aria-label={`Refresh ${providerName} models`}
className="flex-shrink-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
</Button>
</div>
{error ? (
<div className="mt-1.5 text-xs text-text-cuation">{error}</div>
) : null}
</div>
);
}

View file

@ -37,6 +37,13 @@ export type Provider = {
model_type?: string;
prefer?: boolean;
azure_deployment?: string;
/**
* If set, the provider exposes an OpenAI-compatible `/v1/models` listing
* endpoint. Value is the path relative to `apiHost` (e.g. `/v1/models`).
* Cards with this field render a searchable model dropdown grouped by
* provider prefix instead of a free-form text input.
*/
modelsEndpoint?: string;
};
export type Model = {