diff --git a/server/app/domains/model_provider/service/provider_service.py b/server/app/domains/model_provider/service/provider_service.py index 599d3f72..0eefddd1 100644 --- a/server/app/domains/model_provider/service/provider_service.py +++ b/server/app/domains/model_provider/service/provider_service.py @@ -14,9 +14,9 @@ """ProviderService: model provider CRUD with prefer/invalidate. Follows CreditsService pattern.""" -from sqlalchemy import update -from sqlmodel import select, col from loguru import logger +from sqlalchemy import update +from sqlmodel import col, select from app.core.database import session_make from app.model.provider.provider import Provider, VaildStatus @@ -66,7 +66,15 @@ class ProviderService: 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", + "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 c8545cde..40dafd8d 100644 --- a/server/app/model/provider/provider.py +++ b/server/app/model/provider/provider.py @@ -1,20 +1,21 @@ -# ========= 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. ========= +# 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 typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from sqlalchemy import Boolean, Column, SmallInteger, text from sqlalchemy_utils import ChoiceType from sqlmodel import JSON, Field @@ -51,6 +52,25 @@ class ProviderIn(BaseModel): is_vaild: VaildStatus = VaildStatus.not_valid prefer: bool = False + @model_validator(mode="before") + @classmethod + def normalize_valid_field(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + if "is_vaild" in data or "is_valid" not in data: + return data + data = dict(data) + is_valid = data.pop("is_valid") + if isinstance(is_valid, bool): + data["is_vaild"] = ( + VaildStatus.is_valid + if is_valid + else VaildStatus.not_valid + ) + else: + data["is_vaild"] = is_valid + return data + class ProviderPreferIn(BaseModel): provider_id: int diff --git a/server/tests/test_provider_model.py b/server/tests/test_provider_model.py new file mode 100644 index 00000000..53d90c73 --- /dev/null +++ b/server/tests/test_provider_model.py @@ -0,0 +1,39 @@ +# ========= 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 app.model.provider.provider import ProviderIn, VaildStatus + + +def test_provider_in_accepts_is_valid_alias(): + provider = ProviderIn( + provider_name="aws-bedrock-converse", + model_type="anthropic.claude-3-5-sonnet", + api_key="", + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com", + is_valid=True, + ) + + assert provider.is_vaild == VaildStatus.is_valid + + +def test_provider_in_preserves_is_vaild_field(): + provider = ProviderIn( + provider_name="aws-bedrock-converse", + model_type="anthropic.claude-3-5-sonnet", + api_key="", + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com", + is_vaild=VaildStatus.not_valid, + ) + + assert provider.is_vaild == VaildStatus.not_valid diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 2e6bac79..c2bbb08c 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -130,6 +130,13 @@ const PLAN_CREDITS_BY_KEY: Record = { pro: 10000, }; +const VALID_PROVIDER_STATUS = 2; + +const isProviderValid = (provider: any): boolean => + provider?.is_valid === true || + provider?.is_vaild === VALID_PROVIDER_STATUS || + provider?.is_vaild === 'is_valid'; + export default function SettingModels() { const { modelType, @@ -375,7 +382,7 @@ export default function SettingModels() { 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: isProviderValid(found), prefer: found.prefer ?? false, model_type: found.model_type ?? '', externalConfig: fi.externalConfig @@ -677,7 +684,7 @@ 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: VALID_PROVIDER_STATUS, model_type: form[idx].model_type, }; if (externalConfig) { @@ -695,6 +702,9 @@ export default function SettingModels() { // add: refresh provider list after saving, update form and switch editable status const res = await proxyFetchGet('/api/v1/providers'); const providerList = Array.isArray(res) ? res : res.items || []; + const savedProvider = providerList.find( + (p: any) => p.provider_name === item.id + ); setForm((f) => f.map((fi, i) => { const item = items[i]; @@ -708,7 +718,7 @@ export default function SettingModels() { 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: isProviderValid(found), prefer: found.prefer ?? false, externalConfig: fi.externalConfig ? fi.externalConfig.map((ec) => { @@ -733,10 +743,10 @@ export default function SettingModels() { pendingDefaultModel.category === 'custom' && pendingDefaultModel.modelId === item.id ) { - await handleSwitch(idx, true); + await handleSwitch(idx, true, savedProvider?.id); setPendingDefaultModel(null); } else { - handleSwitch(idx, true); + await handleSwitch(idx, true, savedProvider?.id); } } finally { setLoading(null); @@ -873,7 +883,7 @@ export default function SettingModels() { provider_name: localPlatform, api_key: 'not-required', endpoint_url: currentEndpoint, // Save base URL without specific endpoints - is_valid: true, + is_vaild: VALID_PROVIDER_STATUS, model_type: currentType, encrypted_config: { model_platform: localPlatform, @@ -950,12 +960,19 @@ export default function SettingModels() { } }, [selectedTab, localPlatform]); - const handleSwitch = async (idx: number, checked: boolean) => { + const handleSwitch = async ( + idx: number, + checked: boolean, + providerId?: number + ) => { if (!checked) { setActiveModelIdx(null); setLocalEnabled(true); return; } + const targetProviderId = + providerId !== undefined ? providerId : form[idx].provider_id; + if (targetProviderId === undefined) return; const hasSearchKey = await checkHasSearchKey(); if (!hasSearchKey) { // Show warning toast instead of blocking @@ -968,7 +985,7 @@ export default function SettingModels() { } try { await proxyFetchPost('/api/v1/provider/prefer', { - provider_id: form[idx].provider_id, + provider_id: targetProviderId, }); setModelType('custom'); setActiveModelIdx(idx);