@@ -1404,28 +1492,63 @@ export default function SettingModels() {
}}
/>
{/* Model Type Setting */}
-
{
- 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 ? (
+
{
+ 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')}`}
+ />
+ ) : (
+ {
+ 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 &&
diff --git a/src/pages/Agents/components/ProviderModelCombobox.tsx b/src/pages/Agents/components/ProviderModelCombobox.tsx
new file mode 100644
index 000000000..c714b2aa0
--- /dev/null
+++ b/src/pages/Agents/components/ProviderModelCombobox.tsx
@@ -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(
+ 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 (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+
+
+
+
+
+ {value || triggerPlaceholder || 'Select model'}
+
+
+
+
+
+
+
+
+ {!hasAnyModels && !orphanValue ? (
+
+ {loading
+ ? 'Loading...'
+ : disabled
+ ? disabledReason ?? 'Enter API Key first.'
+ : 'Click the refresh button to load models.'}
+
+ ) : (
+
+ {/* Left column: provider list */}
+
+ {orphanValue ? (
+ 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
+
+ ) : null}
+ {groups.map((g) => (
+ 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'
+ )}
+ >
+ {g.provider}
+
+ {g.models.length}
+
+
+ ))}
+
+
+ {/* Right column: models for active provider */}
+
+ {activeProvider === '__orphan__' && orphanValue ? (
+ {
+ onChange(orphanValue);
+ setOpen(false);
+ }}
+ >
+ {orphanValue}
+
+ ) : activeModels.length > 0 ? (
+ activeModels.map((m) => {
+ const [, modelName] = splitPrefix(m.id);
+ return (
+ {
+ onChange(m.id);
+ setOpen(false);
+ }}
+ className={cn(
+ value === m.id && 'bg-button-transparent-fill-hover'
+ )}
+ >
+ {modelName}
+
+ );
+ })
+ ) : (
+
+ {query.trim() ? 'No matches.' : 'No models.'}
+
+ )}
+
+
+ )}
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 82eeea590..1c20bc041 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -37,6 +37,19 @@ 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;
+ /**
+ * Optional marketing / docs website. When set, the card renders a
+ * clickable link below the description (opened in the user's default
+ * external browser via Electron's `setWindowOpenHandler`).
+ */
+ websiteUrl?: string;
};
export type Model = {