diff --git a/server/alembic/versions/2026_03_16_1200-add_model_types_to_provider.py b/server/alembic/versions/2026_03_16_1200-add_model_types_to_provider.py new file mode 100644 index 000000000..c4d6ab914 --- /dev/null +++ b/server/alembic/versions/2026_03_16_1200-add_model_types_to_provider.py @@ -0,0 +1,50 @@ +# ========= 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. ========= + +"""add model_types to provider + +Revision ID: add_model_types_to_provider +Revises: 9464b9d89de7 +Create Date: 2026-03-16 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "add_model_types_to_provider" +down_revision: Union[str, None] = "9464b9d89de7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("provider", sa.Column("model_types", sa.JSON(), nullable=True)) + + # Backfill: copy existing model_type into model_types array + op.execute( + """ + UPDATE provider + SET model_types = json_build_array(model_type) + WHERE model_type IS NOT NULL AND model_type != '' + AND (model_types IS NULL) + """ + ) + + +def downgrade() -> None: + op.drop_column("provider", "model_types") diff --git a/server/app/domains/model_provider/api/provider_controller.py b/server/app/domains/model_provider/api/provider_controller.py index c28664db7..6acdf5435 100644 --- a/server/app/domains/model_provider/api/provider_controller.py +++ b/server/app/domains/model_provider/api/provider_controller.py @@ -68,6 +68,15 @@ async def put(id: int, data: ProviderIn, auth: V1UserAuth = Depends(auth_must)): return result["provider"] +@router.get("/provider/{id}/models", name="get provider models") +async def get_models(id: int, auth: V1UserAuth = Depends(auth_must)): + """Get the list of sub-models for a provider.""" + model = ProviderService.get(id, auth.id) + if not model: + raise HTTPException(status_code=404, detail=_("Provider not found")) + return {"models": model.model_types or []} + + @router.delete("/provider/{id}", name="delete provider") async def delete(id: int, auth: V1UserAuth = Depends(auth_must)): if not ProviderService.delete(id, auth.id): diff --git a/server/app/domains/model_provider/service/provider_service.py b/server/app/domains/model_provider/service/provider_service.py index 599d3f728..3f868e4d4 100644 --- a/server/app/domains/model_provider/service/provider_service.py +++ b/server/app/domains/model_provider/service/provider_service.py @@ -66,7 +66,10 @@ def update(provider_id: int, user_id: int, data: dict) -> dict: if not model: return {"success": False, "error_code": "PROVIDER_NOT_FOUND"} # H10: only allow updating safe fields - _UPDATABLE_FIELDS = {"provider_name", "api_key", "api_base", "extra_config", "prefer", "is_vaild"} + _UPDATABLE_FIELDS = { + "provider_name", "api_key", "endpoint_url", "encrypted_config", + "model_type", "model_types", "prefer", "is_vaild", + } for key, value in data.items(): if key in _UPDATABLE_FIELDS: setattr(model, key, value) diff --git a/server/app/model/provider/provider.py b/server/app/model/provider/provider.py index c8545cdeb..b76fc2bce 100644 --- a/server/app/model/provider/provider.py +++ b/server/app/model/provider/provider.py @@ -1,63 +1,66 @@ -# ========= 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. ========= - -from enum import IntEnum - -from pydantic import BaseModel -from sqlalchemy import Boolean, Column, SmallInteger, text -from sqlalchemy_utils import ChoiceType -from sqlmodel import JSON, Field - -from app.model.abstract.model import AbstractModel, DefaultTimes - - -class VaildStatus(IntEnum): - not_valid = 1 - is_valid = 2 - - -class Provider(AbstractModel, DefaultTimes, table=True): - id: int = Field(default=None, primary_key=True) - user_id: int = Field(index=True) - provider_name: str - model_type: str - api_key: str - endpoint_url: str = "" - encrypted_config: dict | None = Field(default=None, sa_column=Column(JSON)) - prefer: bool = Field(default=False, sa_column=Column(Boolean, server_default=text("false"))) - is_vaild: VaildStatus = Field( - default=VaildStatus.not_valid, - sa_column=Column(ChoiceType(VaildStatus, SmallInteger()), server_default=text("1")), - ) - - -class ProviderIn(BaseModel): - provider_name: str - model_type: str - api_key: str - endpoint_url: str - encrypted_config: dict | None = None - is_vaild: VaildStatus = VaildStatus.not_valid - prefer: bool = False - - -class ProviderPreferIn(BaseModel): - provider_id: int - - -class ProviderOut(ProviderIn): - id: int - user_id: int - prefer: bool - model_type: str | None = None +# 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. ========= + +from enum import IntEnum + +from pydantic import BaseModel +from sqlalchemy import Boolean, Column, SmallInteger, text +from sqlalchemy_utils import ChoiceType +from sqlmodel import JSON, Field + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class VaildStatus(IntEnum): + not_valid = 1 + is_valid = 2 + + +class Provider(AbstractModel, DefaultTimes, table=True): + id: int = Field(default=None, primary_key=True) + user_id: int = Field(index=True) + provider_name: str + model_type: str + api_key: str + endpoint_url: str = "" + encrypted_config: dict | None = Field(default=None, sa_column=Column(JSON)) + prefer: bool = Field(default=False, sa_column=Column(Boolean, server_default=text("false"))) + is_vaild: VaildStatus = Field( + default=VaildStatus.not_valid, + sa_column=Column(ChoiceType(VaildStatus, SmallInteger()), server_default=text("1")), + ) + model_types: list[str] | None = Field(default=None, sa_column=Column(JSON)) + + +class ProviderIn(BaseModel): + provider_name: str + model_type: str + api_key: str + endpoint_url: str + encrypted_config: dict | None = None + is_vaild: VaildStatus = VaildStatus.not_valid + prefer: bool = False + model_types: list[str] | None = None + + +class ProviderPreferIn(BaseModel): + provider_id: int + + +class ProviderOut(ProviderIn): + id: int + user_id: int + prefer: bool + model_type: str | None = None + model_types: list[str] | None = None diff --git a/src/components/AddWorker/index.tsx b/src/components/AddWorker/index.tsx index 64fc05f49..b1ac349d5 100644 --- a/src/components/AddWorker/index.tsx +++ b/src/components/AddWorker/index.tsx @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { fetchPost } from '@/api/http'; +import { fetchPost, proxyFetchGet } from '@/api/http'; import githubIcon from '@/assets/github.svg'; import { Button } from '@/components/ui/button'; import { @@ -36,7 +36,7 @@ import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { INIT_PROVODERS } from '@/lib/llm'; import { useAuthStore, useWorkerList } from '@/store/authStore'; import { Bot, ChevronDown, ChevronUp, Edit, Eye, EyeOff } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ToolSelect from './ToolSelect'; @@ -109,6 +109,27 @@ export function AddWorker({ const [useCustomModel, setUseCustomModel] = useState(false); const [customModelPlatform, setCustomModelPlatform] = useState(''); const [customModelType, setCustomModelType] = useState(''); + const [savedProviders, setSavedProviders] = useState< + { provider_name: string; model_type: string; model_types: string[] }[] + >([]); + + useEffect(() => { + (async () => { + try { + const res = await proxyFetchGet('/api/providers'); + const list = Array.isArray(res) ? res : res.items || []; + setSavedProviders( + list.map((p: any) => ({ + provider_name: p.provider_name, + model_type: p.model_type || '', + model_types: p.model_types || (p.model_type ? [p.model_type] : []), + })) + ); + } catch { + // ignore + } + })(); + }, [dialogOpen]); if (!chatStore) { return null; @@ -666,16 +687,47 @@ export function AddWorker({ - - setCustomModelType(e.target.value) + {(() => { + const saved = savedProviders.find( + (p) => p.provider_name === customModelPlatform + ); + const models = saved?.model_types || []; + if (models.length > 0) { + return ( + + ); } - /> + return ( + + setCustomModelType(e.target.value) + } + /> + ); + })()} )} diff --git a/src/lib/llm.ts b/src/lib/llm.ts index e06105bb0..6ae2d573b 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -23,6 +23,16 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Google Gemini model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-flash-lite-preview', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], }, { id: 'openai', @@ -32,6 +42,22 @@ export const INIT_PROVODERS: Provider[] = [ description: 'OpenAI model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.4-nano', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'o3', + 'o3-mini', + 'o4-mini', + 'gpt-4o', + 'gpt-4o-mini', + ], }, { id: 'anthropic', @@ -41,6 +67,16 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Anthropic Claude API configuration', is_valid: false, model_type: '', + suggestedModels: [ + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-opus-4-5-20251101', + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-1-20250805', + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-haiku-4-5-20251001', + ], }, { id: 'openrouter', @@ -50,6 +86,27 @@ export const INIT_PROVODERS: Provider[] = [ description: 'OpenRouter model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'anthropic/claude-sonnet-4-6', + 'anthropic/claude-sonnet-4', + 'openai/gpt-5.4', + 'openai/gpt-5.4-mini', + 'openai/gpt-5.4-nano', + 'openai/gpt-4.1', + 'openai/o3', + 'openai/o4-mini', + 'google/gemini-3.1-pro-preview', + 'google/gemini-3-flash-preview', + 'google/gemini-2.5-pro', + 'google/gemini-2.5-flash', + 'deepseek/deepseek-v3.2', + 'deepseek/deepseek-r1', + 'minimax/minimax-m2.5', + 'meta-llama/llama-4-maverick', + 'mistralai/mistral-large', + 'qwen/qwen3-235b-a22b', + 'x-ai/grok-4', + ], }, { id: 'tongyi-qianwen', @@ -59,6 +116,19 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Qwen model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'qwen-max', + 'qwen-plus', + 'qwen-turbo', + 'qwen3-235b-a22b', + 'qwen3-32b', + 'qwen3-14b', + 'qwen3-8b', + 'qwen3-4b', + 'qwen3-1.7b', + 'qwen3-0.6b', + 'qwq-plus', + ], }, { id: 'deepseek', @@ -68,6 +138,7 @@ export const INIT_PROVODERS: Provider[] = [ description: 'DeepSeek model configuration.', is_valid: false, model_type: '', + suggestedModels: ['deepseek-chat', 'deepseek-reasoner'], }, { id: 'minimax', @@ -77,6 +148,15 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Minimax model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'MiniMax-M2.7', + 'MiniMax-M2.7-highspeed', + 'MiniMax-M2.5', + 'MiniMax-M2.5-highspeed', + 'MiniMax-M2.1', + 'MiniMax-M2.1-highspeed', + 'MiniMax-M2', + ], }, { id: 'z.ai', @@ -86,6 +166,13 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Z.ai model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'glm-4.7', + 'glm-4.6', + 'glm-4-plus', + 'glm-4-flash', + 'glm-4', + ], }, { id: 'moonshot', @@ -95,6 +182,13 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Kimi model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'kimi-k2.5', + 'kimi-k2', + 'moonshot-v1-128k', + 'moonshot-v1-32k', + 'moonshot-v1-8k', + ], }, { id: 'ModelArk', @@ -104,6 +198,12 @@ export const INIT_PROVODERS: Provider[] = [ description: 'ModelArk model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'doubao-seed-2.0-pro', + 'doubao-seed-2.0-lite', + 'doubao-seed-2.0-mini', + 'doubao-seed-2.0-code', + ], }, { id: 'samba-nova', @@ -113,6 +213,18 @@ export const INIT_PROVODERS: Provider[] = [ description: 'SambaNova model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'DeepSeek-V3.1', + 'DeepSeek-V3-0324', + 'DeepSeek-R1-0528', + 'DeepSeek-R1-Distill-Llama-70B', + 'Meta-Llama-3.3-70B-Instruct', + 'Meta-Llama-3.1-8B-Instruct', + 'Llama-4-Maverick-17B-128E-Instruct', + 'Llama-4-Scout-17B-16E-Instruct', + 'Qwen3-235B-A22B-Instruct-2507', + 'Qwen3-32B', + ], }, { id: 'grok', @@ -122,6 +234,14 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Grok model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'grok-4', + 'grok-3', + 'grok-3-mini', + 'grok-3-fast', + 'grok-code-fast-1', + 'grok-2', + ], }, { id: 'mistral', @@ -131,6 +251,14 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Mistral model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'codestral-latest', + 'devstral-latest', + 'ministral-8b-latest', + ], }, { id: 'aws-bedrock', @@ -175,6 +303,18 @@ export const INIT_PROVODERS: Provider[] = [ ], is_valid: false, model_type: '', + suggestedModels: [ + 'anthropic.claude-opus-4-6-v1', + 'anthropic.claude-sonnet-4-6', + 'anthropic.claude-sonnet-4-5-20250929-v1:0', + 'anthropic.claude-sonnet-4-20250514-v1:0', + 'anthropic.claude-haiku-4-5-20251001-v1:0', + 'us.anthropic.claude-opus-4-6-v1', + 'us.anthropic.claude-sonnet-4-6', + 'us.meta.llama4-maverick-17b-instruct-v1:0', + 'us.meta.llama4-scout-17b-instruct-v1:0', + 'mistral.mistral-large-2407-v1:0', + ], }, { id: 'azure', @@ -197,6 +337,20 @@ export const INIT_PROVODERS: Provider[] = [ ], is_valid: false, model_type: '', + suggestedModels: [ + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.4-nano', + 'gpt-5', + 'gpt-5-mini', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + 'o3', + 'o4-mini', + ], }, { id: 'ernie', @@ -206,6 +360,15 @@ export const INIT_PROVODERS: Provider[] = [ description: 'Baidu Ernie model configuration.', is_valid: false, model_type: '', + suggestedModels: [ + 'ernie-5.0', + 'ernie-5.0-thinking-preview', + 'ernie-4.5-8k', + 'ernie-4.5-turbo-128k', + 'ernie-4.0-turbo-8k', + 'ernie-x1', + 'ernie-3.5-8k', + ], }, { id: 'openai-compatible-model', @@ -216,5 +379,6 @@ export const INIT_PROVODERS: Provider[] = [ hostPlaceHolder: 'e.g. https://api.x.ai/v1', is_valid: false, model_type: '', + suggestedModels: [], }, ]; diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 8e2c12859..5ca2734e5 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -17,7 +17,6 @@ import { proxyFetchDelete, proxyFetchGet, proxyFetchPost, - proxyFetchPut, } from '@/api/http'; import { Button } from '@/components/ui/button'; import { @@ -49,11 +48,14 @@ import { EyeOff, Key, Loader2, + Plus, RotateCcw, Server, Settings, + Star, + X, } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -97,6 +99,106 @@ import { VLLM_PROVIDER_ID, } from './localModels'; +function ModelTypeInput({ + suggestedModels, + existingModels, + placeholder, + onAdd, +}: { + suggestedModels: string[]; + existingModels: string[]; + placeholder: string; + onAdd: (model: string) => void; +}) { + const [value, setValue] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + const availableSuggestions = suggestedModels.filter((m) => + m.toLowerCase().includes(value.toLowerCase()) + ); + + const handleAdd = () => { + const trimmed = value.trim(); + if (trimmed) { + onAdd(trimmed); + setValue(''); + setShowSuggestions(false); + } + }; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setShowSuggestions(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+
+
+ { + setValue(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAdd(); + } + }} + /> +
+ +
+ {showSuggestions && availableSuggestions.length > 0 && ( +
+
+ {availableSuggestions.map((model) => ( + + ))} +
+
+ )} +
+ ); +} + // Sidebar tab types type SidebarTab = | 'cloud' @@ -137,6 +239,7 @@ export default function SettingModels() { apiHost: p.apiHost, is_valid: p.is_valid ?? false, model_type: p.model_type ?? '', + model_types: [] as string[], externalConfig: p.externalConfig ? p.externalConfig.map((ec) => ({ ...ec })) : undefined, @@ -285,11 +388,13 @@ export default function SettingModels() { ...fi, provider_id: found.id, apiKey: found.api_key || '', - // Fall back to provider's default API host if endpoint_url is empty apiHost: found.endpoint_url || item.apiHost, - is_valid: !!found?.is_valid, + is_valid: found?.is_vaild === 2, prefer: found.prefer ?? false, model_type: found.model_type ?? '', + model_types: + found.model_types ?? + (found.model_type ? [found.model_type] : []), externalConfig: fi.externalConfig ? fi.externalConfig.map((ec) => { if ( @@ -352,19 +457,6 @@ export default function SettingModels() { setLocalTypes(types); setLocalProviderIds(providerIds); } - if (modelType === 'cloud') { - setCloudPrefer(true); - setForm((f) => f.map((fi) => ({ ...fi, prefer: false }))); - setLocalPrefer(false); - } else if (modelType === 'local') { - setLocalEnabled(true); - setForm((f) => f.map((fi) => ({ ...fi, prefer: false }))); - setLocalPrefer(true); - setCloudPrefer(false); - } else { - setLocalPrefer(false); - setCloudPrefer(false); - } } catch (e) { console.error('Error fetching providers:', e); // ignore error @@ -375,7 +467,21 @@ export default function SettingModels() { fetchSubscription(); updateCredits(); } - }, [items, modelType, fetchModelsForPlatform]); + }, [items, fetchModelsForPlatform]); + + // Sync prefer UI flags when modelType changes + useEffect(() => { + if (modelType === 'cloud') { + setCloudPrefer(true); + setForm((f) => f.map((fi) => ({ ...fi, prefer: false }))); + setLocalPrefer(false); + } else if (modelType === 'local') { + setLocalEnabled(true); + setForm((f) => f.map((fi) => ({ ...fi, prefer: false }))); + setLocalPrefer(true); + setCloudPrefer(false); + } + }, [modelType]); // Get current default model display text const getDefaultModelDisplayText = (): string => { @@ -391,12 +497,11 @@ export default function SettingModels() { return `${t('setting.eigent-cloud')} / ${modelName}`; } - // Check for custom model preference const preferredIdx = form.findIndex((f) => f.prefer); if (preferredIdx !== -1) { const item = items[preferredIdx]; - const modelType = form[preferredIdx].model_type || ''; - return `${t('setting.custom-model')} / ${item.name}${modelType ? ` (${modelType})` : ''}`; + const activeModel = form[preferredIdx].model_type || ''; + return `${t('setting.custom-model')} / ${item.name}${activeModel ? ` (${activeModel})` : ''}`; } // Check for local model preference @@ -525,7 +630,10 @@ export default function SettingModels() { } else { newErrors[idx].apiHost = ''; } - if (!model_type || model_type.trim() === '') { + if ( + (!model_type || model_type.trim() === '') && + (!form[idx].model_types || form[idx].model_types.length === 0) + ) { newErrors[idx].model_type = t('setting.model-type-can-not-be-empty'); hasError = true; } else { @@ -590,8 +698,9 @@ export default function SettingModels() { provider_name: item.id, api_key: form[idx].apiKey, endpoint_url: form[idx].apiHost, - is_valid: form[idx].is_valid, + is_vaild: 2, model_type: form[idx].model_type, + model_types: form[idx].model_types, }; if (externalConfig) { data.encrypted_config = {}; @@ -599,30 +708,49 @@ export default function SettingModels() { data.encrypted_config[ec.key] = ec.value; }); } + const savedModelType = form[idx].model_type; + const savedModelTypes = form[idx].model_types; try { if (provider_id) { - await proxyFetchPut(`/api/v1/provider/${provider_id}`, data); - } else { - await proxyFetchPost('/api/v1/provider', data); + // DELETE + POST to work around remote server's _UPDATABLE_FIELDS + // not including model_type / model_types in PUT + await proxyFetchDelete(`/api/v1/provider/${provider_id}`); } - // add: refresh provider list after saving, update form and switch editable status + const created = await proxyFetchPost('/api/v1/provider', data); + const newProviderId = created?.id; + + // Always set this provider as preferred using the correct new ID + if (newProviderId) { + await proxyFetchPost('/api/v1/provider/prefer', { + provider_id: newProviderId, + }); + } + + // Refresh provider list after saving const res = await proxyFetchGet('/api/v1/providers'); const providerList = Array.isArray(res) ? res : res.items || []; + setForm((f) => f.map((fi, i) => { - const item = items[i]; + const curItem = items[i]; const found = providerList.find( - (p: any) => p.provider_name === item.id + (p: any) => p.provider_name === curItem.id ); if (found) { + const isCurrentProvider = i === idx; return { ...fi, provider_id: found.id, apiKey: found.api_key || '', - // Fall back to provider's default API host if endpoint_url is empty - apiHost: found.endpoint_url || item.apiHost, - is_valid: !!found.is_valid, - prefer: found.prefer ?? false, + apiHost: found.endpoint_url || curItem.apiHost, + is_valid: found?.is_vaild === 2, + prefer: isCurrentProvider ? true : false, + model_type: isCurrentProvider + ? savedModelType + : (found.model_type ?? fi.model_type), + model_types: isCurrentProvider + ? savedModelTypes + : (found.model_types ?? fi.model_types), externalConfig: fi.externalConfig ? fi.externalConfig.map((ec) => { if ( @@ -640,16 +768,19 @@ export default function SettingModels() { }) ); - // Check if this was a pending default model selection + // Update UI state directly (avoid handleSwitch which reads stale form state) + setModelType('custom'); + setActiveModelIdx(idx); + setLocalEnabled(false); + setCloudPrefer(false); + setLocalPrefer(false); + if ( pendingDefaultModel && pendingDefaultModel.category === 'custom' && pendingDefaultModel.modelId === item.id ) { - await handleSwitch(idx, true); setPendingDefaultModel(null); - } else { - handleSwitch(idx, true); } } finally { setLoading(null); @@ -794,9 +925,10 @@ export default function SettingModels() { }, }; - // Update or create provider + // DELETE + POST to work around remote server ignoring model_type in PUT if (currentProviderId) { - await proxyFetchPut(`/api/v1/provider/${currentProviderId}`, data); + await proxyFetchDelete(`/api/v1/provider/${currentProviderId}`); + await proxyFetchPost('/api/v1/provider', data); } else { await proxyFetchPost('/api/v1/provider', data); } @@ -974,10 +1106,10 @@ export default function SettingModels() { const item = items[i]; return { apiKey: '', - // Restore provider's default API host instead of clearing it apiHost: item.apiHost, is_valid: false, model_type: '', + model_types: [], externalConfig: item.externalConfig ? item.externalConfig.map((ec) => ({ ...ec, value: '' })) : undefined, @@ -1410,29 +1542,98 @@ 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 - ) - ); - }} - /> + {/* Models Management */} +
+
+ {t('setting.model-type-setting')} +
+ {errors[idx]?.model_type && ( + + {errors[idx].model_type} + + )} + {/* Active model indicator + model chips */} + {form[idx].model_types.length > 0 && ( +
+ {form[idx].model_types.map((m) => ( + + {m === form[idx].model_type && ( + + )} + + + + ))} +
+ )} + {/* Add model input with suggestions dropdown */} + { + setForm((f) => + f.map((fi, i) => { + if (i !== idx) return fi; + return { + ...fi, + model_types: [model], + model_type: model, + }; + }) + ); + setErrors((errs) => + errs.map((er, i) => + i === idx ? { ...er, model_type: '' } : er + ) + ); + }} + /> +
{/* externalConfig render */} {item.externalConfig && form[idx].externalConfig && diff --git a/src/types/index.ts b/src/types/index.ts index 82eeea590..41bca650c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,8 @@ export type Provider = { externalConfig?: externalConfig[]; is_valid?: boolean; model_type?: string; + model_types?: string[]; + suggestedModels?: string[]; prefer?: boolean; azure_deployment?: string; };