feat: add native LLaMA.cpp local provider support (#1346)

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: bytecii <994513625@qq.com>
This commit is contained in:
it-education-md 2026-03-02 19:45:02 -05:00 committed by GitHub
parent 478926d33f
commit d606fae458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 499 additions and 262 deletions

View file

@ -20,6 +20,7 @@ PLATFORM_ALIAS_MAPPING: Final[dict[str, str]] = {
"z.ai": "openai-compatible-model",
"ModelArk": "openai-compatible-model",
"grok": "openai-compatible-model",
"llama.cpp": "openai-compatible-model",
}

View file

@ -26,6 +26,7 @@ def test_normalize_model_platform_maps_known_aliases():
assert normalize_model_platform("grok") == "openai-compatible-model"
assert normalize_model_platform("z.ai") == "openai-compatible-model"
assert normalize_model_platform("ModelArk") == "openai-compatible-model"
assert normalize_model_platform("llama.cpp") == "openai-compatible-model"
def test_normalize_model_platform_keeps_non_alias_unchanged():
@ -43,7 +44,7 @@ def test_normalized_model_platform_type_applies_in_pydantic_model():
optional_model_platform: NormalizedOptionalModelPlatform = None
item = _Model(
model_platform="grok",
model_platform="llama.cpp",
optional_model_platform="ModelArk",
)

View file

@ -38,6 +38,11 @@ print(f"Server started on http://localhost:{port}")
ollama pull qwen2.5:7b
```
```bash
# LLaMA.cpp server https://github.com/ggml-org/llama.cpp/tree/master/tools/server
./llama-server -m /path/to/model.gguf --host 0.0.0.0 --port 8080
```
2. Setting your model
![set_local_model](/docs/images/models_local_model.png)

View file

@ -173,7 +173,7 @@ Eigent can run in two modes. Your choice here affects how you are billed and wha
- **Cloud Version:** We provide pre-configured, state-of-the-art models, including GPT-4.1, GPT-4.1 mini and Gemini 2.5 Pro. Using these models is the easiest way to get started and will be billed to your account based on usage (credits).
- **Self-hosted Version:** You can connect your own models.
- **Cloud Models:** Connect your personal accounts from providers like OpenAI, Anthropic, Qwen, Deepseek and Azure by entering your own API key.
- **Local Models:** For advanced users, you can run models locally using Ollama, vLLM, or SGLang.
- **Local Models:** For advanced users, you can run models locally using Ollama, vLLM, SGLang, LM Studio, or LLaMA.cpp server.
### **MCP Servers**

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="Layer_1"
version="1.1"
viewBox="0 0 250 250"
sodipodi:docname="llama1-icon-transparent.svg"
width="250"
height="250"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:zoom="2.48"
inkscape:cx="49.596774"
inkscape:cy="189.91935"
inkscape:window-width="3440"
inkscape:window-height="1440"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
<defs
id="defs1">
<style
id="style1">
.st0 {
fill: #ff8236;
}
.st1 {
fill: #fff;
}
.st2 {
fill: #1b1f20;
}
</style>
</defs>
<g
id="g7">
<g
id="g6"
transform="translate(-995.51066,-129.70875)">
<path
class="st0"
d="m 1163.3,226.8 -13.5,24 c -17.8,-13.7 -44.2,-15.7 -62,-1 -28.7,23.7 -26.7,78.5 18,78.8 12.5,0 23.1,-5.9 34.5,-9.8 l 6,23.9 c -10.1,4.7 -20.4,9.5 -31.5,11 -101.2,13.8 -95.4,-132.3 -3.9,-139.9 19.2,-1.6 36.1,3.4 52.5,13 z"
id="path4" />
<path
class="st0"
d="m 1093.4,203.8 c -15.4,4.6 -29.7,13.1 -40.5,25 -2,-24.2 3.4,-73.1 30.3,-82.7 4,-1.4 17.7,-4.9 17.3,2.2 -0.4,7.1 -9.9,19.3 -12.2,25.9 -4,11.6 -0.3,19.6 5.2,29.7 z"
id="path5" />
<polygon
class="st0"
points="1131.4,307.8 1116.4,307.8 1116.4,290.8 1099.4,290.8 1099.4,276.8 1114.9,276.8 1116.4,275.3 1116.4,258.8 1131.4,258.8 1131.4,276.8 1147.4,276.8 1147.4,290.8 1131.4,290.8 "
id="polygon5" />
<polygon
class="st0"
points="1186.4,290.8 1186.4,307.8 1171.4,307.8 1171.4,290.8 1155.4,290.8 1155.4,276.8 1171.4,276.8 1171.4,258.8 1186.4,258.8 1186.4,275.3 1187.9,276.8 1203.4,276.8 1203.4,290.8 "
id="polygon6" />
<path
class="st0"
d="m 1142.3,156.9 c 2,3 -9.3,15.9 -11.1,19.2 -5.2,9.8 -1.7,15.4 2.2,24.7 -11.3,-1.7 -21.8,-0.3 -33,1 2.5,-21.5 14.6,-52.8 41.9,-44.9 z"
id="path6" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -53,7 +53,7 @@ import {
Server,
Settings,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@ -65,6 +65,7 @@ import bedrockImage from '@/assets/model/bedrock.svg';
import deepseekImage from '@/assets/model/deepseek.svg';
import eigentImage from '@/assets/model/eigent.svg';
import geminiImage from '@/assets/model/gemini.svg';
import llamaCppImage from '@/assets/model/llamacpp.svg';
import lmstudioImage from '@/assets/model/lmstudio.svg';
import minimaxImage from '@/assets/model/minimax.svg';
import modelarkImage from '@/assets/model/modelark.svg';
@ -77,11 +78,23 @@ import sglangImage from '@/assets/model/sglang.svg';
import vllmImage from '@/assets/model/vllm.svg';
import zaiImage from '@/assets/model/zai.svg';
const LOCAL_PROVIDER_NAMES = ['ollama', 'vllm', 'sglang', 'lmstudio'];
const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434/v1';
const OLLAMA_ENDPOINT_AUTO_FIX_TITLE = 'Ollama endpoint updated';
const OLLAMA_ENDPOINT_AUTO_FIX_DESC =
'Added /v1 once. You can remove it if not needed.';
import {
appendV1ToEndpoint,
canAutoFixOllamaEndpoint,
DARK_FILL_MODELS,
getDefaultLocalEndpoint,
getLocalPlatformName,
LLAMA_CPP_PROVIDER_ID,
LMSTUDIO_PROVIDER_ID,
LOCAL_MODEL_OPTIONS,
OLLAMA_ENDPOINT_AUTO_FIX_DESC,
OLLAMA_ENDPOINT_AUTO_FIX_TITLE,
OLLAMA_PROVIDER_ID,
PROVIDER_AVATAR_URLS,
SGLANG_PROVIDER_ID,
toEndpointBaseUrl,
VLLM_PROVIDER_ID,
} from './localModels';
// Sidebar tab types
type SidebarTab =
@ -92,25 +105,8 @@ type SidebarTab =
| 'local-ollama'
| 'local-vllm'
| 'local-sglang'
| 'local-lmstudio';
// Provider logos that use dark fills (black or currentColor) and need inversion in dark mode
const DARK_FILL_MODELS = new Set([
'openai',
'anthropic',
'moonshot',
'ollama',
'openrouter',
'lmstudio',
'z.ai',
'openai-compatible-model',
]);
const PROVIDER_AVATAR_URLS: Record<string, string> = {
'samba-nova': 'https://github.com/sambanova.png',
mistral: 'https://github.com/mistralai.png',
grok: 'https://github.com/xai-org.png',
};
| 'local-lmstudio'
| 'local-llama.cpp';
export default function SettingModels() {
const {
@ -177,7 +173,8 @@ export default function SettingModels() {
// Local Model independent state - per platform
const [localEnabled, setLocalEnabled] = useState(true);
const [localPlatform, setLocalPlatform] = useState('ollama');
const [localPlatform, setLocalPlatform] =
useState<string>(OLLAMA_PROVIDER_ID);
const [localEndpoints, setLocalEndpoints] = useState<Record<string, string>>(
{}
);
@ -190,36 +187,72 @@ export default function SettingModels() {
const [localInputError, setLocalInputError] = useState(false);
const [localPrefer, setLocalPrefer] = useState(false); // Local model prefer state (for current platform)
// Ollama-specific state for model fetching
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false);
const [ollamaModelsError, setOllamaModelsError] = useState<string | null>(
null
);
// Per-platform model list state: { models, loading, error } keyed by platform ID.
const [platformModelState, setPlatformModelState] = useState<
Record<string, { models: string[]; loading: boolean; error: string | null }>
>({});
const [ollamaEndpointAutoFixedOnce, setOllamaEndpointAutoFixedOnce] =
useState(false);
// Fetch available models from Ollama API
const fetchOllamaModels = async (endpoint?: string) => {
const url = endpoint || DEFAULT_OLLAMA_ENDPOINT;
setOllamaModelsLoading(true);
setOllamaModelsError(null);
try {
const baseUrl = url.replace(/\/v1\/?$/, '').replace(/\/$/, '');
const response = await fetch(`${baseUrl}/api/tags`);
if (!response.ok) throw new Error(`Failed: ${response.status}`);
// Generic model fetcher driven by LOCAL_MODEL_OPTIONS config.
// Only fetches for providers that define fetchPath and parseModels.
const fetchModelsForPlatform = useCallback(
async (platform: string, endpoint?: string) => {
const option = LOCAL_MODEL_OPTIONS.find((m) => m.id === platform);
if (!option?.fetchPath || !option?.parseModels) return;
const data = await response.json();
const modelNames = data.models?.map((m: any) => m.name) || [];
setOllamaModels(modelNames);
} catch (error: any) {
console.error('Failed to fetch Ollama models:', error);
setOllamaModels([]);
setOllamaModelsError('Failed to fetch Ollama models. Is Ollama running?');
} finally {
setOllamaModelsLoading(false);
const url = endpoint || option.defaultEndpoint;
setPlatformModelState((prev) => ({
...prev,
[platform]: {
models: prev[platform]?.models || [],
loading: true,
error: null,
},
}));
try {
const baseUrl = toEndpointBaseUrl(url);
const response = await fetch(`${baseUrl}${option.fetchPath}`);
if (!response.ok) throw new Error(`Failed: ${response.status}`);
const data = await response.json();
const modelNames = option.parseModels(data);
setPlatformModelState((prev) => ({
...prev,
[platform]: { models: modelNames, loading: false, error: null },
}));
} catch (error: any) {
console.error(`Failed to fetch ${option.name} models:`, error);
setPlatformModelState((prev) => ({
...prev,
[platform]: {
models: [],
loading: false,
error: `Failed to fetch ${option.name} models. Is ${option.name} running?`,
},
}));
}
},
[]
);
const clearPlatformModelsError = useCallback((platform: string) => {
setPlatformModelState((prev) => {
const current = prev[platform];
if (!current || !current.error) return prev;
return { ...prev, [platform]: { ...current, error: null } };
});
}, []);
const checkLlamaCppHealth = useCallback(async (endpoint: string) => {
const baseUrl = toEndpointBaseUrl(endpoint);
const response = await fetch(`${baseUrl}/v1/health`);
if (!response.ok) {
throw new Error(
'LLaMA.cpp health check failed. Please confirm llama-server is running and reachable.'
);
}
};
}, []);
// Default model dropdown state (removed - using DropdownMenu's built-in state)
@ -270,7 +303,7 @@ export default function SettingModels() {
);
// Handle local models - load all local providers per platform
const localProviders = providerList.filter((p: any) =>
LOCAL_PROVIDER_NAMES.includes(p.provider_name)
LOCAL_MODEL_OPTIONS.some((model) => model.id === p.provider_name)
);
const endpoints: Record<string, string> = {};
@ -280,10 +313,9 @@ export default function SettingModels() {
localProviders.forEach((local: any) => {
const platform =
local.encrypted_config?.model_platform || local.provider_name;
// Auto-populate default Ollama endpoint if not set
// Auto-populate platform default endpoint if not set
endpoints[platform] =
local.endpoint_url ||
(platform === 'ollama' ? DEFAULT_OLLAMA_ENDPOINT : '');
local.endpoint_url || getDefaultLocalEndpoint(platform);
types[platform] = local.encrypted_config?.model_type || '';
providerIds[platform] = local.id;
@ -298,17 +330,18 @@ export default function SettingModels() {
setLocalTypes(types);
setLocalProviderIds(providerIds);
// Fetch Ollama models if ollama endpoint is set
const ollamaEndpoint = endpoints['ollama'] || DEFAULT_OLLAMA_ENDPOINT;
fetchOllamaModels(ollamaEndpoint);
// Fetch model lists for all providers that support it
LOCAL_MODEL_OPTIONS.filter((m) => m.fetchPath).forEach((m) => {
const ep = endpoints[m.id] || m.defaultEndpoint;
fetchModelsForPlatform(m.id, ep);
});
// If no local providers found, initialize empty state with Ollama default
if (localProviders.length === 0) {
LOCAL_PROVIDER_NAMES.forEach((platform) => {
endpoints[platform] =
platform === 'ollama' ? DEFAULT_OLLAMA_ENDPOINT : '';
types[platform] = '';
providerIds[platform] = undefined;
LOCAL_MODEL_OPTIONS.forEach((model) => {
endpoints[model.id] = getDefaultLocalEndpoint(model.id);
types[model.id] = '';
providerIds[model.id] = undefined;
});
setLocalEndpoints(endpoints);
setLocalTypes(types);
@ -337,7 +370,7 @@ export default function SettingModels() {
fetchSubscription();
updateCredits();
}
}, [items, modelType]);
}, [items, modelType, fetchModelsForPlatform]);
// Get current default model display text
const getDefaultModelDisplayText = (): string => {
@ -363,16 +396,7 @@ export default function SettingModels() {
// Check for local model preference
if (localPrefer && localPlatform) {
const localModel = localModelOptions.find((m) => m.id === localPlatform);
const platformName = localModel
? localModel.name
: localPlatform === 'ollama'
? 'Ollama'
: localPlatform === 'vllm'
? 'vLLM'
: localPlatform === 'sglang'
? 'SGLang'
: 'LM Studio';
const platformName = getLocalPlatformName(localPlatform);
const modelType = localTypes[localPlatform] || '';
return `${t('setting.local-model')} / ${platformName}${modelType ? ` (${modelType})` : ''}`;
}
@ -473,14 +497,6 @@ export default function SettingModels() {
{ id: 'minimax_m2_5', name: 'Minimax M2.5' },
];
// Local model options
const localModelOptions = [
{ id: 'ollama', name: 'Ollama' },
{ id: 'vllm', name: 'vLLM' },
{ id: 'sglang', name: 'SGLang' },
{ id: 'lmstudio', name: 'LM Studio' },
];
const handleVerify = async (idx: number) => {
const { apiKey, apiHost, externalConfig, model_type, provider_id } =
form[idx];
@ -631,43 +647,6 @@ export default function SettingModels() {
}
};
// Local Model verification
const isOllamaEndpointMissingV1 = (endpoint: string): boolean => {
const trimmed = endpoint.trim();
if (!trimmed) return false;
try {
const normalizedPath = new URL(trimmed).pathname.replace(/\/+$/, '');
return !normalizedPath.endsWith('/v1');
} catch {
return !trimmed.replace(/\/+$/, '').endsWith('/v1');
}
};
const canAutoFixOllamaEndpoint = (endpoint: string): boolean => {
const trimmed = endpoint.trim();
if (!trimmed || !isOllamaEndpointMissingV1(trimmed)) return false;
try {
// Auto-fix only when endpoint has no extra path, e.g. http://localhost:11434
const normalizedPath = new URL(trimmed).pathname.replace(/\/+$/, '');
return normalizedPath === '';
} catch {
const withoutQueryOrHash = trimmed.split(/[?#]/)[0] || '';
const normalized = withoutQueryOrHash.replace(/\/+$/, '');
return !normalized.includes('/');
}
};
const appendV1ToEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!trimmed) return trimmed;
try {
const parsed = new URL(trimmed);
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
parsed.pathname = `${normalizedPath}/v1`.replace(/\/{2,}/g, '/');
return parsed.toString();
} catch {
return `${trimmed.replace(/\/+$/, '')}/v1`;
}
};
const handleLocalVerify = async () => {
setLocalVerifying(true);
setLocalError(null);
@ -678,7 +657,7 @@ export default function SettingModels() {
// Fallback guard for fast save interactions: ensure one-time auto-fix
// still applies even if blur state hasn't committed yet.
if (
localPlatform === 'ollama' &&
localPlatform === OLLAMA_PROVIDER_ID &&
!ollamaEndpointAutoFixedOnce &&
canAutoFixOllamaEndpoint(currentEndpoint)
) {
@ -704,6 +683,10 @@ export default function SettingModels() {
return;
}
try {
if (localPlatform === LLAMA_CPP_PROVIDER_ID) {
await checkLlamaCppHealth(currentEndpoint);
}
// // 1. Check if endpoint returns response
// let baseUrl = localEndpoint;
// let testUrl = baseUrl;
@ -740,25 +723,43 @@ export default function SettingModels() {
// throw new Error("Endpoint is not responding");
// }
try {
const res = await fetchPost('/model/validate', {
model_platform: localPlatform,
model_type: currentType,
api_key: 'not-required',
url: currentEndpoint,
});
if (res.is_tool_calls && res.is_valid) {
console.log('success');
toast(t('setting.validate-success'), {
description: t(
'setting.the-model-has-been-verified-to-support-function-calling-which-is-required-to-use-eigent'
),
closeButton: true,
// Temporary: skip /model/validate for llama.cpp.
// Current validation flow is not fully compatible.
if (localPlatform !== LLAMA_CPP_PROVIDER_ID) {
try {
const res = await fetchPost('/model/validate', {
model_platform: localPlatform,
model_type: currentType,
api_key: 'not-required',
url: currentEndpoint,
});
} else {
console.log('failed', res.message);
if (res.is_tool_calls && res.is_valid) {
console.log('success');
toast(t('setting.validate-success'), {
description: t(
'setting.the-model-has-been-verified-to-support-function-calling-which-is-required-to-use-eigent'
),
closeButton: true,
});
} else {
console.log('failed', res.message);
const toastId = toast(t('setting.validate-failed'), {
description: getValidateMessage(res),
action: {
label: t('setting.close'),
onClick: () => {
toast.dismiss(toastId);
},
},
});
return;
}
console.log(res);
} catch (e) {
console.log(e);
const toastId = toast(t('setting.validate-failed'), {
description: getValidateMessage(res),
description: getValidateMessage(e),
action: {
label: t('setting.close'),
onClick: () => {
@ -766,24 +767,8 @@ export default function SettingModels() {
},
},
});
return;
}
console.log(res);
} catch (e) {
console.log(e);
const toastId = toast(t('setting.validate-failed'), {
description: getValidateMessage(e),
action: {
label: t('setting.close'),
onClick: () => {
toast.dismiss(toastId);
},
},
});
return;
} finally {
setLoading(null);
}
// 2. Save to /api/provider/ (save only base URL)
@ -831,6 +816,8 @@ export default function SettingModels() {
await handleLocalSwitch(true, local.id);
}
}
await fetchModelsForPlatform(localPlatform, currentEndpoint);
} catch (e: any) {
setLocalError(
e.message || t('setting.verification-failed-please-check-endpoint-url')
@ -939,9 +926,8 @@ export default function SettingModels() {
if (currentProviderId !== undefined) {
await proxyFetchDelete(`/api/provider/${currentProviderId}`);
}
// Set endpoint to default for Ollama, empty for others
const defaultEndpoint =
localPlatform === 'ollama' ? DEFAULT_OLLAMA_ENDPOINT : '';
// Set endpoint to platform default
const defaultEndpoint = getDefaultLocalEndpoint(localPlatform);
setLocalEndpoints((prev) => ({
...prev,
[localPlatform]: defaultEndpoint,
@ -954,12 +940,12 @@ export default function SettingModels() {
}
setLocalEnabled(true);
setActiveModelIdx(null);
// Re-fetch Ollama models after reset
if (localPlatform === 'ollama') {
// Re-fetch model list after reset
if (localPlatform === OLLAMA_PROVIDER_ID) {
setOllamaEndpointAutoFixedOnce(false);
setOllamaModelsError(null);
fetchOllamaModels(DEFAULT_OLLAMA_ENDPOINT);
}
clearPlatformModelsError(localPlatform);
await fetchModelsForPlatform(localPlatform);
toast.success(t('setting.reset-success'));
} catch (e) {
console.error('Error resetting local model:', e);
@ -1083,11 +1069,13 @@ export default function SettingModels() {
vllm: vllmImage,
sglang: sglangImage,
lmstudio: lmstudioImage,
[LLAMA_CPP_PROVIDER_ID]: llamaCppImage,
// Local model tab IDs
'local-ollama': ollamaImage,
'local-vllm': vllmImage,
'local-sglang': sglangImage,
'local-lmstudio': lmstudioImage,
'local-llama.cpp': llamaCppImage,
};
return modelImageMap[modelId] || null;
};
@ -1115,13 +1103,13 @@ export default function SettingModels() {
<button
key={tabId}
onClick={() => setSelectedTab(tabId)}
className={`rounded-xl px-3 py-2 flex w-full items-center justify-between transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
isActive
? 'bg-fill-fill-transparent-active'
: 'bg-fill-fill-transparent hover:bg-fill-fill-transparent-hover'
} `}
>
<div className="gap-3 flex items-center justify-center">
<div className="flex items-center justify-center gap-3">
{modelImage ? (
<img
src={modelImage}
@ -1141,7 +1129,7 @@ export default function SettingModels() {
</span>
</div>
{isConfigured && (
<div className="m-1 h-2 w-2 bg-text-success rounded-full" />
<div className="m-1 h-2 w-2 rounded-full bg-text-success" />
)}
</button>
);
@ -1153,16 +1141,16 @@ export default function SettingModels() {
if (selectedTab === 'cloud') {
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
return (
<div className="h-64 text-text-label flex items-center justify-center">
<div className="flex h-64 items-center justify-center text-text-label">
{t('setting.cloud-not-available-in-local-proxy')}
</div>
);
}
return (
<div className="rounded-2xl bg-surface-tertiary flex w-full flex-col">
<div className="mx-6 mb-4 border-border-secondary pb-4 pt-2 flex flex-col justify-start self-stretch border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-start self-stretch">
<div className="text-body-base my-2 font-bold text-text-heading flex-1 justify-center">
<div className="flex w-full flex-col rounded-2xl bg-surface-tertiary">
<div className="mx-6 mb-4 flex flex-col justify-start self-stretch border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary pb-4 pt-2">
<div className="inline-flex items-center justify-start gap-2 self-stretch">
<div className="text-body-base my-2 flex-1 justify-center font-bold text-text-heading">
{t('setting.eigent-cloud')}
</div>
{cloudPrefer ? (
@ -1181,7 +1169,7 @@ export default function SettingModels() {
<Button
variant="ghost"
size="xs"
className="!text-text-label rounded-full"
className="rounded-full !text-text-label"
onClick={() => {
setLocalPrefer(false);
setActiveModelIdx(null);
@ -1205,7 +1193,7 @@ export default function SettingModels() {
onClick={() => {
window.location.href = `https://www.eigent.ai/pricing`;
}}
className="text-body-sm text-text-label cursor-pointer underline"
className="cursor-pointer text-body-sm text-text-label underline"
>
{t('setting.pricing-options')}
</span>
@ -1215,7 +1203,7 @@ export default function SettingModels() {
</div>
</div>
{/*Content Area*/}
<div className="gap-4 px-6 pb-4 flex w-full flex-row items-center justify-between">
<div className="flex w-full flex-row items-center justify-between gap-4 px-6 pb-4">
<div className="text-body-sm text-text-body">
{t('setting.credits')}:{' '}
{loadingCredits ? (
@ -1240,9 +1228,9 @@ export default function SettingModels() {
<Settings />
</Button>
</div>
<div className="px-6 pb-4 flex w-full flex-1 items-center justify-between">
<div className="min-w-0 flex flex-1 items-center">
<span className="text-body-sm overflow-hidden text-ellipsis whitespace-nowrap">
<div className="flex w-full flex-1 items-center justify-between px-6 pb-4">
<div className="flex min-w-0 flex-1 items-center">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-body-sm">
{t('setting.select-model-type')}
</span>
</div>
@ -1306,15 +1294,15 @@ export default function SettingModels() {
const canSwitch = !!form[idx].provider_id;
return (
<div className="rounded-2xl bg-surface-tertiary flex w-full flex-col">
<div className="mx-6 mb-4 border-border-secondary pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-between self-stretch">
<div className="flex w-full flex-col rounded-2xl bg-surface-tertiary">
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="text-body-base my-2 font-bold text-text-heading">
{item.name}
</div>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{form[idx].prefer ? (
<span className="px-2 py-1 text-label-xs font-bold text-text-success inline-flex items-center rounded-full">
<span className="inline-flex items-center rounded-full px-2 py-1 text-label-xs font-bold text-text-success">
{t('setting.default')}
</span>
) : (
@ -1325,8 +1313,8 @@ export default function SettingModels() {
onClick={() => handleSwitch(idx, true)}
className={
canSwitch
? 'bg-button-transparent-fill-hover !text-text-label hover:bg-button-transparent-fill-active inline-flex items-center rounded-full shadow-none'
: 'gap-1.5 inline-flex items-center'
? 'inline-flex items-center rounded-full bg-button-transparent-fill-hover !text-text-label shadow-none hover:bg-button-transparent-fill-active'
: 'inline-flex items-center gap-1.5'
}
>
{!canSwitch
@ -1335,9 +1323,9 @@ export default function SettingModels() {
</Button>
)}
{form[idx].provider_id ? (
<div className="h-2 w-2 bg-text-success shrink-0 rounded-full" />
<div className="h-2 w-2 shrink-0 rounded-full bg-text-success" />
) : (
<div className="h-2 w-2 bg-text-label shrink-0 rounded-full opacity-10" />
<div className="h-2 w-2 shrink-0 rounded-full bg-text-label opacity-10" />
)}
</div>
</div>
@ -1345,7 +1333,7 @@ export default function SettingModels() {
{item.description}
</div>
</div>
<div className="gap-4 px-6 flex w-full flex-col items-center">
<div className="flex w-full flex-col items-center gap-4 px-6">
{/* API Key Setting */}
<Input
id={`apiKey-${item.id}`}
@ -1426,7 +1414,7 @@ export default function SettingModels() {
{item.externalConfig &&
form[idx].externalConfig &&
form[idx].externalConfig.map((ec, ecIdx) => (
<div key={ec.key} className="gap-4 flex h-full w-full flex-col">
<div key={ec.key} className="flex h-full w-full flex-col gap-4">
{ec.options && ec.options.length > 0 ? (
<Select
value={ec.value}
@ -1493,7 +1481,7 @@ export default function SettingModels() {
))}
</div>
{/* Action Button */}
<div className="gap-2 px-6 py-4 flex justify-end">
<div className="flex justify-end gap-2 px-6 py-4">
<Button
variant="ghost"
size="sm"
@ -1524,20 +1512,21 @@ export default function SettingModels() {
const currentType = localTypes[platform] || '';
const isConfigured = !!localProviderIds[platform];
const isPreferred = localPrefer && localPlatform === platform;
const platformState = platformModelState[platform];
const isModelListPlatform = !!LOCAL_MODEL_OPTIONS.find(
(m) => m.id === platform && m.fetchPath
);
const platformModels = platformState?.models || [];
const platformModelsLoading = platformState?.loading || false;
const platformModelsError = platformState?.error || null;
return (
<div className="rounded-2xl bg-surface-tertiary flex w-full flex-col">
<div className="mx-6 mb-4 border-border-secondary pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-between self-stretch">
<div className="gap-2 flex items-center">
<div className="flex w-full flex-col rounded-2xl bg-surface-tertiary">
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="flex items-center gap-2">
<div className="text-body-base my-2 font-bold text-text-heading">
{platform === 'ollama'
? 'Ollama'
: platform === 'vllm'
? 'vLLM'
: platform === 'sglang'
? 'SGLang'
: 'LM Studio'}
{getLocalPlatformName(platform)}
</div>
{isPreferred ? (
<Button
@ -1557,7 +1546,7 @@ export default function SettingModels() {
onClick={() => handleLocalSwitch(true)}
className={
isConfigured
? 'bg-button-transparent-fill-hover !text-text-label rounded-full shadow-none'
? 'rounded-full bg-button-transparent-fill-hover !text-text-label shadow-none'
: ''
}
>
@ -1568,14 +1557,14 @@ export default function SettingModels() {
)}
</div>
{isConfigured ? (
<div className="h-2 w-2 bg-text-success rounded-full" />
<div className="h-2 w-2 rounded-full bg-text-success" />
) : (
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
)}
</div>
</div>
{/* Model Endpoint URL Setting */}
<div className="gap-4 px-6 flex w-full flex-col items-center">
<div className="flex w-full flex-col items-center gap-4 px-6">
<Input
size="default"
title={t('setting.model-endpoint-url')}
@ -1588,14 +1577,12 @@ export default function SettingModels() {
}));
setLocalInputError(false);
setLocalError(null);
// Clear Ollama models error when endpoint changes
if (platform === 'ollama') {
setOllamaModelsError(null);
}
// Clear model list error when endpoint changes
clearPlatformModelsError(platform);
}}
onBlur={(e) => {
if (
platform !== 'ollama' ||
platform !== OLLAMA_PROVIDER_ID ||
ollamaEndpointAutoFixedOnce ||
!canAutoFixOllamaEndpoint(e.target.value)
) {
@ -1614,17 +1601,13 @@ export default function SettingModels() {
}}
disabled={!localEnabled}
placeholder={
platform === 'ollama'
? 'http://localhost:11434/v1'
: platform === 'lmstudio'
? 'http://localhost:1234/v1'
: 'http://localhost:8000/v1'
getDefaultLocalEndpoint(platform) || 'http://localhost:8000/v1'
}
note={localError ?? undefined}
/>
{platform === 'ollama' ? (
<div className="gap-1 flex w-full flex-col">
<div className="gap-2 flex w-full items-end">
{isModelListPlatform ? (
<div className="flex w-full flex-col gap-1">
<div className="flex w-full items-end gap-2">
<div className="flex-1">
<Select
value={currentType}
@ -1634,20 +1617,20 @@ export default function SettingModels() {
[platform]: v,
}))
}
disabled={!localEnabled || ollamaModelsLoading}
disabled={!localEnabled || platformModelsLoading}
>
<SelectTrigger
size="default"
title={t('setting.model-type')}
state={
localInputError || ollamaModelsError
localInputError || platformModelsError
? 'error'
: undefined
}
>
<SelectValue
placeholder={
ollamaModelsLoading
platformModelsLoading
? 'Loading models...'
: 'Select model'
}
@ -1656,10 +1639,10 @@ export default function SettingModels() {
<SelectContent>
{(() => {
const modelList =
currentType && !ollamaModels.includes(currentType)
? [currentType, ...ollamaModels]
currentType && !platformModels.includes(currentType)
? [currentType, ...platformModels]
: [
...new Set([currentType, ...ollamaModels]),
...new Set([currentType, ...platformModels]),
].filter(Boolean);
return modelList.length > 0 ? (
modelList.map((model) => (
@ -1680,23 +1663,24 @@ export default function SettingModels() {
variant="ghost"
size="icon"
onClick={() =>
fetchOllamaModels(
currentEndpoint || DEFAULT_OLLAMA_ENDPOINT
void fetchModelsForPlatform(
platform,
currentEndpoint || getDefaultLocalEndpoint(platform)
)
}
disabled={!localEnabled || ollamaModelsLoading}
disabled={!localEnabled || platformModelsLoading}
className="mb-1 flex-shrink-0"
>
{ollamaModelsLoading ? (
{platformModelsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
</Button>
</div>
{ollamaModelsError && (
{platformModelsError && (
<span className="text-label-sm text-text-error">
{ollamaModelsError}
{platformModelsError}
</span>
)}
</div>
@ -1718,7 +1702,7 @@ export default function SettingModels() {
)}
</div>
{/* Action Button */}
<div className="gap-2 px-6 py-4 flex justify-end">
<div className="flex justify-end gap-2 px-6 py-4">
<Button
variant="ghost"
size="sm"
@ -1748,8 +1732,8 @@ export default function SettingModels() {
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="top-0 bg-surface-primary px-6 pb-6 pt-8 sticky z-10 flex w-full items-center justify-between">
<div className="gap-4 flex w-full flex-col items-start justify-between">
<div className="sticky top-0 z-10 flex w-full items-center justify-between bg-surface-primary px-6 pb-6 pt-8">
<div className="flex w-full flex-col items-start justify-between gap-4">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-text-heading">
{t('setting.models')}
@ -1758,10 +1742,10 @@ export default function SettingModels() {
</div>
</div>
{/* Content Section */}
<div className="mb-8 gap-6 flex flex-col">
<div className="mb-8 flex flex-col gap-6">
{/* Default Model Cascading Dropdown */}
<div className="gap-4 rounded-2xl bg-surface-secondary px-6 py-4 flex w-full flex-col items-end justify-between">
<div className="gap-1 flex w-full flex-col items-start justify-center">
<div className="flex w-full flex-col items-end justify-between gap-4 rounded-2xl bg-surface-secondary px-6 py-4">
<div className="flex w-full flex-col items-start justify-center gap-1">
<div className="text-body-base font-bold text-text-heading">
{t('setting.models-default-setting-title')}
</div>
@ -1771,11 +1755,11 @@ export default function SettingModels() {
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="gap-2 rounded-lg border-border-success bg-surface-success px-3 py-1 font-semibold text-text-success flex w-fit items-center justify-between border-[0.5px] border-solid transition-colors hover:opacity-70 active:opacity-90">
<span className="text-body-sm whitespace-nowrap">
<button className="flex w-fit items-center justify-between gap-2 rounded-lg border-[0.5px] border-solid border-border-success bg-surface-success px-3 py-1 font-semibold text-text-success transition-colors hover:opacity-70 active:opacity-90">
<span className="whitespace-nowrap text-body-sm">
{getDefaultModelDisplayText()}
</span>
<ChevronDown className="h-4 w-4 text-text-success flex-shrink-0" />
<ChevronDown className="h-4 w-4 flex-shrink-0 text-text-success" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
@ -1829,7 +1813,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -1850,15 +1834,15 @@ export default function SettingModels() {
{item.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-text-success" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-text-success rounded-full" />
<div className="h-2 w-2 rounded-full bg-text-success" />
)}
</div>
</DropdownMenuItem>
@ -1876,7 +1860,7 @@ export default function SettingModels() {
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-[200px]">
{localModelOptions.map((model) => {
{LOCAL_MODEL_OPTIONS.map((model) => {
const isConfigured = !!localProviderIds[model.id];
const isPreferred =
localPrefer && localPlatform === model.id;
@ -1890,7 +1874,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -1911,15 +1895,15 @@ export default function SettingModels() {
{model.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-text-success" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-text-success rounded-full" />
<div className="h-2 w-2 rounded-full bg-text-success" />
)}
</div>
</DropdownMenuItem>
@ -1932,17 +1916,17 @@ export default function SettingModels() {
</div>
{/* Content Section with Sidebar */}
<div className="gap-2 rounded-2xl bg-surface-secondary px-6 py-4 flex w-full flex-col items-start justify-between">
<div className="text-body-base mb-2 border-border-secondary bg-surface-secondary pb-4 font-bold text-text-heading sticky top-[86px] z-10 w-full border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="flex w-full flex-col items-start justify-between gap-2 rounded-2xl bg-surface-secondary px-6 py-4">
<div className="text-body-base sticky top-[86px] z-10 mb-2 w-full border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary bg-surface-secondary pb-4 font-bold text-text-heading">
{t('setting.models-configuration')}
</div>
<div className="flex w-full flex-row items-start justify-between">
{/* Sidebar */}
<div className="-ml-2 mr-4 rounded-2xl bg-surface-secondary h-full w-[240px]">
<div className="gap-4 flex flex-col">
<div className="-ml-2 mr-4 h-full w-[240px] rounded-2xl bg-surface-secondary">
<div className="flex flex-col gap-4">
{/* Eigent Cloud Section */}
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<div className="px-3 py-2 text-body-sm font-bold text-text-heading">
{t('setting.eigent-cloud')}
</div>
@ -1957,10 +1941,10 @@ export default function SettingModels() {
)}
</div>
{/* Bring Your Own Key Section */}
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<button
onClick={() => setByokCollapsed(!byokCollapsed)}
className="rounded-lg px-3 py-2 hover:bg-surface-secondary flex items-center justify-between bg-transparent transition-colors"
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-surface-secondary"
>
<div className="text-body-sm font-bold text-text-heading">
{t('setting.custom-model')}
@ -1972,7 +1956,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`ease-in-out overflow-hidden transition-all duration-300 ${
className={`overflow-hidden transition-all duration-300 ease-in-out ${
byokCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -1992,10 +1976,10 @@ export default function SettingModels() {
</div>
{/* Local Model Section */}
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<button
onClick={() => setLocalCollapsed(!localCollapsed)}
className="rounded-lg px-3 py-2 hover:bg-surface-secondary flex items-center justify-between bg-transparent transition-colors"
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-surface-secondary"
>
<div className="text-body-sm font-bold text-text-heading">
{t('setting.local-model')}
@ -2007,7 +1991,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`ease-in-out overflow-hidden transition-all duration-300 ${
className={`overflow-hidden transition-all duration-300 ease-in-out ${
localCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2019,7 +2003,7 @@ export default function SettingModels() {
'local-ollama',
selectedTab === 'local-ollama',
true,
!!localProviderIds['ollama']
!!localProviderIds[OLLAMA_PROVIDER_ID]
)}
{renderSidebarItem(
'local-vllm',
@ -2027,7 +2011,7 @@ export default function SettingModels() {
'local-vllm',
selectedTab === 'local-vllm',
true,
!!localProviderIds['vllm']
!!localProviderIds[VLLM_PROVIDER_ID]
)}
{renderSidebarItem(
'local-sglang',
@ -2035,7 +2019,7 @@ export default function SettingModels() {
'local-sglang',
selectedTab === 'local-sglang',
true,
!!localProviderIds['sglang']
!!localProviderIds[SGLANG_PROVIDER_ID]
)}
{renderSidebarItem(
'local-lmstudio',
@ -2043,14 +2027,22 @@ export default function SettingModels() {
'local-lmstudio',
selectedTab === 'local-lmstudio',
true,
!!localProviderIds['lmstudio']
!!localProviderIds[LMSTUDIO_PROVIDER_ID]
)}
{renderSidebarItem(
'local-llama.cpp',
'LLaMA.cpp',
'local-llama.cpp',
selectedTab === 'local-llama.cpp',
true,
!!localProviderIds[LLAMA_CPP_PROVIDER_ID]
)}
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="min-w-0 sticky top-[136px] z-10 flex-1">
<div className="sticky top-[136px] z-10 min-w-0 flex-1">
{renderContent()}
</div>
</div>

View file

@ -0,0 +1,163 @@
// ========= 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. =========
// --- Provider IDs ---
export const OLLAMA_PROVIDER_ID = 'ollama' as const;
export const VLLM_PROVIDER_ID = 'vllm' as const;
export const SGLANG_PROVIDER_ID = 'sglang' as const;
export const LMSTUDIO_PROVIDER_ID = 'lmstudio' as const;
export const LLAMA_CPP_PROVIDER_ID = 'llama.cpp' as const;
// --- Ollama endpoint auto-fix ---
// Toast strings shown when the Ollama endpoint input auto-appends "/v1".
// Triggered on blur when a user enters a bare Ollama URL (e.g. http://localhost:11434)
// that is missing the required /v1 suffix for the OpenAI-compatible API.
export const OLLAMA_ENDPOINT_AUTO_FIX_TITLE = 'Ollama endpoint updated';
export const OLLAMA_ENDPOINT_AUTO_FIX_DESC =
'Added /v1 once. You can remove it if not needed.';
// --- Local model config ---
// Model fetch config per local provider.
// - fetchPath: the API path (relative to the base URL) to list available models.
// - parseModels: extracts model name strings from the JSON response.
// Ollama uses a proprietary /api/tags endpoint; LLaMA.cpp uses the OpenAI-compatible
// /v1/models endpoint. vLLM, SGLang, and LM Studio also expose /v1/models and can
// be added here to enable model listing for those providers.
export type LocalModelOption = {
id: string;
name: string;
defaultEndpoint: string;
fetchPath?: string;
parseModels?: (data: any) => string[];
};
const parseOllamaModels = (data: any): string[] =>
data.models?.map((m: any) => m.name) || [];
const parseOpenAICompatibleModels = (data: any): string[] =>
data?.data
?.map((m: any) => m?.id)
.filter((name: string | undefined) => !!name) || [];
export const LOCAL_MODEL_OPTIONS: LocalModelOption[] = [
{
id: OLLAMA_PROVIDER_ID,
name: 'Ollama',
defaultEndpoint: 'http://localhost:11434/v1',
fetchPath: '/api/tags',
parseModels: parseOllamaModels,
},
{
id: VLLM_PROVIDER_ID,
name: 'vLLM',
defaultEndpoint: 'http://localhost:8000/v1',
},
{
id: SGLANG_PROVIDER_ID,
name: 'SGLang',
defaultEndpoint: 'http://localhost:30000/v1',
},
{
id: LMSTUDIO_PROVIDER_ID,
name: 'LM Studio',
defaultEndpoint: 'http://localhost:1234/v1',
},
{
id: LLAMA_CPP_PROVIDER_ID,
name: 'LLaMA.cpp',
defaultEndpoint: 'http://localhost:8080/v1',
// Uses the OpenAI-compatible /v1/models endpoint.
// vLLM, SGLang, and LM Studio also support this same endpoint — to enable
// model listing for them, add fetchPath and parseModels to their entries above.
fetchPath: '/v1/models',
parseModels: parseOpenAICompatibleModels,
},
];
// Provider logos that use dark fills (black or currentColor) and need inversion in dark mode
export const DARK_FILL_MODELS = new Set([
'openai',
'anthropic',
'moonshot',
OLLAMA_PROVIDER_ID,
'openrouter',
LMSTUDIO_PROVIDER_ID,
'z.ai',
'openai-compatible-model',
]);
export const PROVIDER_AVATAR_URLS: Record<string, string> = {
'samba-nova': 'https://github.com/sambanova.png',
mistral: 'https://github.com/mistralai.png',
grok: 'https://github.com/xai-org.png',
};
// --- Helper functions ---
// Strip trailing /v1 (and optional slash) to get the base server URL.
// e.g. "http://localhost:8080/v1" -> "http://localhost:8080"
export const toEndpointBaseUrl = (endpoint: string): string =>
endpoint.replace(/\/v1\/?$/, '').replace(/\/$/, '');
// Look up the default endpoint URL for a local provider by its platform ID.
export const getDefaultLocalEndpoint = (platform: string): string =>
LOCAL_MODEL_OPTIONS.find((model) => model.id === platform)?.defaultEndpoint ||
'';
// Look up the display name for a local provider by its platform ID.
export const getLocalPlatformName = (platform: string): string =>
LOCAL_MODEL_OPTIONS.find((m) => m.id === platform)?.name || platform;
// --- Ollama endpoint helpers ---
export const isOllamaEndpointMissingV1 = (endpoint: string): boolean => {
const trimmed = endpoint.trim();
if (!trimmed) return false;
try {
const normalizedPath = new URL(trimmed).pathname.replace(/\/+$/, '');
return !normalizedPath.endsWith('/v1');
} catch {
return !trimmed.replace(/\/+$/, '').endsWith('/v1');
}
};
export const canAutoFixOllamaEndpoint = (endpoint: string): boolean => {
const trimmed = endpoint.trim();
if (!trimmed || !isOllamaEndpointMissingV1(trimmed)) return false;
try {
// Auto-fix only when endpoint has no extra path, e.g. http://localhost:11434
const normalizedPath = new URL(trimmed).pathname.replace(/\/+$/, '');
return normalizedPath === '';
} catch {
const withoutQueryOrHash = trimmed.split(/[?#]/)[0] || '';
const normalized = withoutQueryOrHash.replace(/\/+$/, '');
return !normalized.includes('/');
}
};
export const appendV1ToEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!trimmed) return trimmed;
try {
const parsed = new URL(trimmed);
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
parsed.pathname = `${normalizedPath}/v1`.replace(/\/{2,}/g, '/');
return parsed.toString();
} catch {
return `${trimmed.replace(/\/+$/, '')}/v1`;
}
};