From 699069180913babe3884291bd60cd74e90eedce4 Mon Sep 17 00:00:00 2001 From: "zhenjun.chen" Date: Tue, 12 May 2026 15:38:51 +0800 Subject: [PATCH 1/3] feat: add OrcaRouter as a BYOK provider Adds OrcaRouter (https://orcarouter.ai) as a dedicated BYOK provider card in Agents > Models. OrcaRouter is an OpenAI-compatible LLM gateway that routes each request to the cheapest or fastest provider across many upstream model providers. Follows the existing alias pattern for OpenAI-compatible gateways (ModelArk, grok, llama.cpp): provider id `orcarouter` is aliased to `openai-compatible-model` on the backend, so no CAMEL changes are required. - backend/app/model/model_platform.py: PLATFORM_ALIAS_MAPPING entry - src/lib/llm.ts: INIT_PROVODERS entry with default base URL, positioned at the end of the list to preserve existing ordering - src/assets/model/orcarouter.svg: official OrcaRouter brand logo - src/pages/Agents/Models.tsx: import + register icon - docs/core/models/byok.md: add row to Supported Providers table --- backend/app/model/model_platform.py | 1 + docs/core/models/byok.md | 1 + src/assets/model/orcarouter.svg | 1 + src/lib/llm.ts | 9 +++++++++ src/pages/Agents/Models.tsx | 2 ++ 5 files changed, 14 insertions(+) create mode 100644 src/assets/model/orcarouter.svg diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index 94ca0d918..53658a739 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -22,6 +22,7 @@ "grok": "openai-compatible-model", "ernie": "qianfan", "llama.cpp": "openai-compatible-model", + "orcarouter": "openai-compatible-model", } # Bedrock Converse requires a region during model initialization. diff --git a/docs/core/models/byok.md b/docs/core/models/byok.md index f83a9600f..5adf6731f 100644 --- a/docs/core/models/byok.md +++ b/docs/core/models/byok.md @@ -74,6 +74,7 @@ Eigent supports the following BYOK providers: | **Anthropic** | `https://api.anthropic.com/` | [Anthropic API Docs](https://docs.anthropic.com/en/api/getting-started) | | **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai/` | [Gemini API Docs](https://ai.google.dev/gemini-api/docs) | | **OpenRouter** | `https://openrouter.ai/api/v1` | [OpenRouter Docs](https://openrouter.ai/docs) | +| **OrcaRouter** | `https://api.orcarouter.ai/v1` | [OrcaRouter Docs](https://docs.orcarouter.ai/) | | **Qwen (Alibaba)** | `https://dashscope.aliyuncs.com/compatible-mode/v1` | [Qwen API Docs](https://help.aliyun.com/zh/dashscope/developer-reference/api-details) | | **DeepSeek** | `https://api.deepseek.com` | [DeepSeek API Docs](https://platform.deepseek.com/api-docs) | | **Minimax** | `https://api.minimax.io/v1` | [Minimax API Docs](https://platform.minimaxi.com/document/Announcement) | diff --git a/src/assets/model/orcarouter.svg b/src/assets/model/orcarouter.svg new file mode 100644 index 000000000..b5651c942 --- /dev/null +++ b/src/assets/model/orcarouter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/llm.ts b/src/lib/llm.ts index e06105bb0..80f1b2d01 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -217,4 +217,13 @@ 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: '', + }, ]; diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 230717eae..14f7ad20f 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -75,6 +75,7 @@ import moonshotImage from '@/assets/model/moonshot.svg'; import ollamaImage from '@/assets/model/ollama.svg'; import openaiImage from '@/assets/model/openai.svg'; import openrouterImage from '@/assets/model/openrouter.svg'; +import orcarouterImage from '@/assets/model/orcarouter.svg'; import qwenImage from '@/assets/model/qwen.svg'; import sglangImage from '@/assets/model/sglang.svg'; import vllmImage from '@/assets/model/vllm.svg'; @@ -1060,6 +1061,7 @@ export default function SettingModels() { anthropic: anthropicImage, gemini: geminiImage, openrouter: openrouterImage, + orcarouter: orcarouterImage, 'tongyi-qianwen': qwenImage, deepseek: deepseekImage, ernie: ernieImage, From b83362785c82bc13b8301ea7fcdfbeea7298e8f2 Mon Sep 17 00:00:00 2001 From: "zhenjun.chen" Date: Tue, 12 May 2026 17:41:47 +0800 Subject: [PATCH 2/3] 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` --- src/lib/llm.ts | 19 +- src/lib/providerModels.ts | 155 ++++++++++ src/pages/Agents/Models.tsx | 152 ++++++++-- .../components/ProviderModelCombobox.tsx | 275 ++++++++++++++++++ src/types/index.ts | 7 + 5 files changed, 577 insertions(+), 31 deletions(-) create mode 100644 src/lib/providerModels.ts create mode 100644 src/pages/Agents/components/ProviderModelCombobox.tsx diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 80f1b2d01..727bd6233 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -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: '', - }, ]; diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts new file mode 100644 index 000000000..46ee7a19f --- /dev/null +++ b/src/lib/providerModels.ts @@ -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 `/` 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 { + 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(); + 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. + } +} diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 14f7ad20f..d2e2f647f 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -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 */} - { - 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} + +
+ + + + + + + + + {!hasAnyModels && !orphanValue ? ( +
+ {loading + ? 'Loading...' + : disabled + ? disabledReason ?? 'Enter API Key first.' + : 'Click the refresh button to load models.'} +
+ ) : ( +
+ {/* Left column: provider list */} +
+ {orphanValue ? ( + + ) : null} + {groups.map((g) => ( + + ))} +
+ + {/* 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.'} + + )} + +
+ )} +
+
+
+ + +
+ + {error ? ( +
{error}
+ ) : null} +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 82eeea590..a7d894834 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 = { From 0701a9c9549a2da3de68afd3128545d3a3fd51ff Mon Sep 17 00:00:00 2001 From: "zhenjun.chen" Date: Tue, 12 May 2026 18:09:24 +0800 Subject: [PATCH 3/3] feat: inline website link on provider cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `Provider.websiteUrl` field. When set, the BYOK card renders a "Visit " link inline after the description. Only OrcaRouter opts in (https://www.orcarouter.ai); other existing providers are unchanged. Link text uses `item.name` so the field stays a generic primitive — future providers can opt in by adding the field with no copy changes. --- src/lib/llm.ts | 1 + src/pages/Agents/Models.tsx | 13 +++++++++++++ src/types/index.ts | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 727bd6233..4d785f4b9 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -51,6 +51,7 @@ export const INIT_PROVODERS: Provider[] = [ is_valid: false, model_type: '', modelsEndpoint: '/models', + websiteUrl: 'https://www.orcarouter.ai', }, { id: 'openrouter', diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index d2e2f647f..22ae10e94 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -1422,6 +1422,19 @@ export default function SettingModels() {
{item.description} + {item.websiteUrl ? ( + <> + {' '} + + Visit {item.name} + + + ) : null}
diff --git a/src/types/index.ts b/src/types/index.ts index a7d894834..1c20bc041 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,6 +44,12 @@ export type Provider = { * 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 = {