From 820a68921a2ce9bb8b4f6b032ff312684ee63b75 Mon Sep 17 00:00:00 2001 From: Tao Sun <168447269+fengju0213@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:38:41 +0800 Subject: [PATCH 01/17] chore: bump camel-ai to 0.2.91a5 (#1760) --- backend/pyproject.toml | 2 +- backend/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7ba96d28..20241b1b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11,<3.12" dependencies = [ "pip>=23.0", - "camel-ai[eigent]==0.2.91a3", + "camel-ai[eigent]==0.2.91a5", "fastapi>=0.115.12", "fastapi-babel>=1.0.0", "uvicorn[standard]>=0.34.2", diff --git a/backend/uv.lock b/backend/uv.lock index e9b316c5..365db845 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -244,7 +244,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, - { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.91a3" }, + { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.91a5" }, { name = "debugpy", specifier = ">=1.8.17" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi-babel", specifier = ">=1.0.0" }, @@ -315,7 +315,7 @@ wheels = [ [[package]] name = "camel-ai" -version = "0.2.91a3" +version = "0.2.91a5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astor" }, @@ -333,9 +333,9 @@ dependencies = [ { name = "tiktoken" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/ce/44a09fb47372d8ebea3a0c7aafefd5640caa1c1ab0cdfccfd0260de5b495/camel_ai-0.2.91a3.tar.gz", hash = "sha256:eb94ba4bd084967f55fe73568bbeda4a49d3bf5ac2eb79e018870df3661d1d85", size = 1230710, upload-time = "2026-04-25T14:00:37.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/c9/7015c4f4733b535e2f56eef9a4c1da7b9e49db8d209dc850e996f2b6b5da/camel_ai-0.2.91a5.tar.gz", hash = "sha256:9606d50d7ec4062cc44e0f4c085e043c7d5b7b2779ea66c4ac9142c98a3380a7", size = 1239665, upload-time = "2026-07-13T11:24:06.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/8c/ba682c71766bc61a1a1115af50bddb6040432f85784a0ec5df2ed91897a9/camel_ai-0.2.91a3-py3-none-any.whl", hash = "sha256:ead956ab8d2f22685cded8c03e7ecad6bdee3fee85041eb1488bdaad7e115372", size = 1718236, upload-time = "2026-04-25T14:00:34.606Z" }, + { url = "https://files.pythonhosted.org/packages/81/c7/80317df8f49ff9d16e8e29eb838b4f9e3f537a02c697c55b18bc6a24d9f4/camel_ai-0.2.91a5-py3-none-any.whl", hash = "sha256:dd1f3ce9ce324bbd29952d4f2020c5279d31ebc70c428661c258bdb67c9943d1", size = 1729980, upload-time = "2026-07-13T11:24:03.785Z" }, ] [package.optional-dependencies] From 7f509176a3fdf7dd0f7397e7b6524aa44ce6801d Mon Sep 17 00:00:00 2001 From: "Naveen R. Iyer" <12383569+iyernaveenr@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:50:21 -0500 Subject: [PATCH 02/17] Resume the last visited session at launch (#1745) Signed-off-by: Naveen R. Iyer Co-authored-by: Tong Chen --- src/components/ProjectPageSidebar/index.tsx | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/components/ProjectPageSidebar/index.tsx b/src/components/ProjectPageSidebar/index.tsx index f8506721..6d6471f1 100644 --- a/src/components/ProjectPageSidebar/index.tsx +++ b/src/components/ProjectPageSidebar/index.tsx @@ -67,6 +67,8 @@ export interface ProjectPageSidebarProps { className?: string; } +let didAttemptBootSessionResume = false; + export default function ProjectPageSidebar({ chatStore: _chatStore, className, @@ -314,6 +316,35 @@ export default function ProjectPageSidebar({ ] ); + // Boot-time session resume: reopen the last visited Project (the way an + // editor reopens its last workspace) instead of landing on the empty home + // tab. One-shot per renderer boot (launch or reload; the flag is module + // scoped so sidebar remounts within a session never re-trigger it), and + // only from the pristine boot state (default tab, no active Project), so + // deliberately navigating home later is never hijacked. Uses the same + // path as clicking the Project in the sidebar. + useEffect(() => { + if (didAttemptBootSessionResume) return; + didAttemptBootSessionResume = true; + + if (activeWorkspaceTab !== 'workforce') return; + if (projectStore.activeProjectId) return; + if (!activeSpaceId) return; + const lastVisitedId = + useSpaceStore.getState().lastVisitedProjectBySpace[activeSpaceId]; + if (!lastVisitedId) return; + const lastVisitedMeta = projectMetasForActiveSpace.find( + (project) => project.id === lastVisitedId + ); + if (!lastVisitedMeta || !shouldShowProjectInNavList(lastVisitedMeta)) { + return; + } + void selectProject(lastVisitedId); + // One-shot boot effect: later changes to these values must not + // re-trigger a resume. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const navProjects = useMemo( () => projectMetasForActiveSpace From d2d08ff850f6fa181b88fc0bd60fef5e52560693 Mon Sep 17 00:00:00 2001 From: "Naveen R. Iyer" <12383569+iyernaveenr@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:57:15 -0500 Subject: [PATCH 03/17] Make the agent step timeout configurable and record spend of failed runs (#1746) Signed-off-by: Naveen R. Iyer Co-authored-by: Tong Chen --- backend/app/agent/listen_chat_agent.py | 20 ++++++++++++++++++- .../app/agent/toolkit/screenshot_toolkit.py | 5 ++++- src/store/chatStore.ts | 13 ++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 309e788e..a2d505ee 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -36,6 +36,7 @@ from camel.types import ModelPlatformType, ModelType from camel.types.agents import ToolCallingRecord from pydantic import BaseModel +from app.component.environment import env from app.service.task import ( Action, ActionActivateAgentData, @@ -52,6 +53,21 @@ from app.utils.event_loop_utils import _schedule_async_task logger = logging.getLogger("agent") +# Default 30 minutes; long agent turns (e.g. writing many chapters in one +# run) can legitimately exceed it, so allow tuning without a rebuild. +# A non-positive value disables the per-step timeout entirely. +def default_step_timeout() -> float | None: + raw = env("AGENT_STEP_TIMEOUT_SECONDS", "1800") + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning( + "Invalid AGENT_STEP_TIMEOUT_SECONDS value %r; using 1800", raw + ) + return 1800.0 + return value if value > 0 else None + + class ListenChatAgent(ChatAgent): _cdp_clone_lock = ( threading.Lock() @@ -95,12 +111,14 @@ class ListenChatAgent(ChatAgent): pause_event: asyncio.Event | None = None, prune_tool_calls_from_memory: bool = False, enable_snapshot_clean: bool = False, - step_timeout: float | None = 1800, # 30 minutes + step_timeout: float | None = None, model_reload_callback: ( Callable[[], BaseModelBackend | ModelManager] | None ) = None, **kwargs: Any, ) -> None: + if step_timeout is None: + step_timeout = default_step_timeout() super().__init__( system_message=system_message, model=model, diff --git a/backend/app/agent/toolkit/screenshot_toolkit.py b/backend/app/agent/toolkit/screenshot_toolkit.py index 77f6aa29..43f4459e 100644 --- a/backend/app/agent/toolkit/screenshot_toolkit.py +++ b/backend/app/agent/toolkit/screenshot_toolkit.py @@ -20,6 +20,7 @@ from camel.messages import BaseMessage from camel.toolkits import ScreenshotToolkit as BaseScreenshotToolkit from PIL import Image +from app.agent.listen_chat_agent import default_step_timeout from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.component.environment import env from app.utils.listen.toolkit_listen import auto_listen_toolkit @@ -86,7 +87,9 @@ class ScreenshotToolkit(BaseScreenshotToolkit, AbstractToolkit): tools=[], toolkits_to_register_agent=None, external_tools=None, - step_timeout=getattr(self.agent, "step_timeout", 1800), + step_timeout=getattr( + self.agent, "step_timeout", default_step_timeout() + ), ) response = vision_agent.step(message) if getattr(response, "msg", None) is not None: diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 6b8a112c..86b98755 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -3350,6 +3350,19 @@ const chatStore = (initial?: Partial) => role: 'agent', content: `❌ **Error**: ${errorMessage}`, }); + // Record the tokens consumed before the failure so the run's + // spend is not lost from the history row (a failed run + // otherwise stays at zero tokens forever). + if (!type && historyId && !isProjectBusyError) { + const tokensSoFar = getTokens(currentTaskId); + if (tokensSoFar > 0) { + proxyFetchPut(`/api/v1/chat/history/${historyId}`, { + tokens: tokensSoFar, + }).catch((err) => { + console.warn('History token update failed on error:', err); + }); + } + } uploadLog(currentTaskId, type); // Update trigger execution status to Failed on error updateTriggerExecutionStatus( From c6a97e25902b76d5e323fc4cc8d2a9907fd2dd04 Mon Sep 17 00:00:00 2001 From: "Naveen R. Iyer" <12383569+iyernaveenr@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:10:58 -0500 Subject: [PATCH 04/17] Fix invisible chat code block text when OS color scheme and app theme disagree (#1735) Signed-off-by: Naveen R. Iyer Co-authored-by: Tong Chen --- src/style/markdown-styles.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/style/markdown-styles.css b/src/style/markdown-styles.css index e5d741b4..3a016899 100644 --- a/src/style/markdown-styles.css +++ b/src/style/markdown-styles.css @@ -53,9 +53,15 @@ line-height: var(--lineHeight-14, 20px); } -/* Code blocks styling */ +/* Code blocks styling. + The imported github-markdown-css colors code text via variables keyed to + the OS color scheme (prefers-color-scheme), while this file forces the + code backgrounds per app theme (data-theme). When the two disagree the + text becomes invisible (near-white on the light background and vice + versa), so pin the text color alongside each forced background. */ .markdown-body pre { background-color: #f6f8fa; + color: #1f2328; border-radius: 6px; padding: 16px; overflow-x: auto; @@ -81,6 +87,7 @@ .markdown-body pre code { background-color: transparent; + color: inherit; padding: 0; font-size: 100%; } @@ -172,6 +179,7 @@ [data-theme='dark'] .markdown-body pre { background-color: #161b22; + color: #e6edf3; } [data-theme='dark'] .markdown-body code { From 2305e0208a981a4fc769575a1ce8b571a0cf1bef Mon Sep 17 00:00:00 2001 From: Tao Sun <168447269+fengju0213@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:24:36 +0800 Subject: [PATCH 05/17] feat: support BYOK model parameters (#1765) Co-authored-by: Tong Chen Co-authored-by: Douglas --- backend/app/agent/agent_model.py | 50 ++- backend/app/controller/task_controller.py | 8 +- backend/app/model/chat.py | 6 + backend/app/service/chat_service.py | 10 +- backend/tests/app/agent/test_agent_model.py | 232 +++++++++++++- .../app/controller/test_task_controller.py | 21 +- backend/tests/app/model/test_chat.py | 40 ++- .../tests/app/service/test_chat_service.py | 20 +- src/components/AddWorker/index.tsx | 142 +++++++-- src/i18n/locales/ar/setting.json | 3 + src/i18n/locales/de/setting.json | 3 + src/i18n/locales/en-us/setting.json | 3 + src/i18n/locales/es/setting.json | 3 + src/i18n/locales/fr/setting.json | 3 + src/i18n/locales/it/setting.json | 3 + src/i18n/locales/ja/setting.json | 3 + src/i18n/locales/ko/setting.json | 3 + src/i18n/locales/ru/setting.json | 3 + src/i18n/locales/zh-Hans/setting.json | 3 + src/i18n/locales/zh-Hant/setting.json | 3 + src/lib/modelConfig.ts | 169 +++++++++++ src/pages/Agents/Models.tsx | 287 ++++++++++++------ src/store/chatStore.ts | 64 +++- src/types/chatbox.d.ts | 1 + test/unit/lib/modelConfig.test.ts | 166 ++++++++++ 25 files changed, 1112 insertions(+), 137 deletions(-) create mode 100644 src/lib/modelConfig.ts create mode 100644 test/unit/lib/modelConfig.test.ts diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index c7c40099..acbb4368 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -73,11 +73,21 @@ def agent_model( if custom_model_config and custom_model_config.has_custom_config(): for attr in config_attrs: - effective_config[attr] = getattr( - custom_model_config, attr, None - ) or getattr(options, attr) + custom_value = getattr(custom_model_config, attr, None) + effective_config[attr] = ( + custom_value + if custom_value is not None + else getattr(options, attr) + ) extra_params = ( - custom_model_config.extra_params or options.extra_params or {} + custom_model_config.extra_params + if custom_model_config.extra_params is not None + else options.extra_params or {} + ) + explicit_model_config = ( + custom_model_config.model_config_dict + if custom_model_config.model_config_dict is not None + else options.model_config_dict or {} ) logger.info( f"Agent {agent_name} using custom model config: " @@ -88,6 +98,7 @@ def agent_model( for attr in config_attrs: effective_config[attr] = getattr(options, attr) extra_params = options.extra_params or {} + explicit_model_config = options.model_config_dict or {} has_explicit_custom_api_key = ( custom_model_config is not None @@ -100,10 +111,12 @@ def agent_model( base_effective_config = dict(effective_config) base_extra_params = dict(extra_params or {}) + base_model_config = dict(explicit_model_config or {}) def build_model(force_refresh: bool = False): effective_config = dict(base_effective_config) extra_params = dict(base_extra_params) + explicit_model_config = dict(base_model_config) if use_subscription_runtime: effective_config, extra_params = apply_subscription_runtime( @@ -113,10 +126,16 @@ def agent_model( force_refresh=force_refresh, ) + effective_api_url = effective_config.get("api_url") + is_effective_cloud = isinstance(effective_api_url, str) and any( + marker in effective_api_url + for marker in ("eigent-proxy", "proxy.eigent.ai") + ) + # Cloud mode: inject default Bedrock region and adjust URL for proxy. if ( effective_config.get("model_platform") == "aws-bedrock-converse" - and options.is_cloud() + and is_effective_cloud ): ( effective_config["api_url"], @@ -128,7 +147,7 @@ def agent_model( # construction does not blow up when the frontend omits extra_params. if ( effective_config.get("model_platform") == "azure" - and options.is_cloud() + and is_effective_cloud ): extra_params = patch_azure_cloud_config(extra_params) init_param_keys = { @@ -151,8 +170,10 @@ def agent_model( init_params = {} model_config: dict[str, Any] = {} - if options.is_cloud(): - model_config["user"] = str(options.project_id) + # A nested model_config_dict may arrive inside legacy extra_params + # while stored providers migrate to the explicit top-level field. + # Treat it as less specific than the explicit request field. + nested_model_config = extra_params.pop("model_config_dict", None) excluded_keys = {"model_platform", "model_type", "api_key", "url"} @@ -169,6 +190,13 @@ def agent_model( else: model_config[k] = v + if isinstance(nested_model_config, dict): + model_config.update(nested_model_config) + + # The explicit model config is the canonical API and wins over legacy + # flat values from extra_params. + model_config.update(explicit_model_config) + # Auto-inject prompt caching based on model platform try: model_platform_enum = ModelPlatformType( @@ -190,6 +218,12 @@ def agent_model( exc_info=True, ) + # Runtime-owned values are applied after user configuration. + if is_effective_cloud: + model_config["user"] = str(options.project_id) + if use_subscription_runtime: + model_config["stream"] = True + model_config["store"] = False if agent_name == Agents.task_agent: model_config["stream"] = True if agent_name == Agents.browser_agent: diff --git a/backend/app/controller/task_controller.py b/backend/app/controller/task_controller.py index ae087a9c..cd7159a3 100644 --- a/backend/app/controller/task_controller.py +++ b/backend/app/controller/task_controller.py @@ -136,7 +136,13 @@ def add_agent(id: str, data: NewAgent): ) logger.debug( "New agent data", - extra={"task_id": id, "agent_data": data.model_dump_json()}, + extra={ + "task_id": id, + "agent_name": data.name, + "tools": list(data.tools), + "has_mcp_tools": bool(data.mcp_tools), + "has_custom_model_config": data.custom_model_config is not None, + }, ) # Set user-specific environment path for this thread set_user_env_path(data.env_path) diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 0cc9dc6b..911c0544 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -83,6 +83,10 @@ class Chat(BaseModel): env_path: str | None = None summary_prompt: str = DEFAULT_SUMMARY_PROMPT new_agents: list["NewAgent"] = [] + # Parameters forwarded with each inference request, such as temperature, + # top_p, or max_tokens. Constructor-only provider settings remain in + # extra_params for backward compatibility. + model_config_dict: dict[str, Any] | None = None # For provider-specific parameters like Azure extra_params: dict | None = None # User-specific search engine configurations @@ -230,6 +234,7 @@ class AgentModelConfig(BaseModel): model_type: str | None = None api_key: str | None = None api_url: str | None = None + model_config_dict: dict[str, Any] | None = None extra_params: dict | None = None def has_custom_config(self) -> bool: @@ -240,6 +245,7 @@ class AgentModelConfig(BaseModel): self.model_type is not None, self.api_key is not None, self.api_url is not None, + self.model_config_dict is not None, self.extra_params is not None, ] ) diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 4ec10923..029f83c2 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -2810,7 +2810,15 @@ async def new_agent_model( }, ) logger.debug( - "New agent data", extra={"agent_data": data.model_dump_json()} + "New agent data", + extra={ + "agent_name": data.name, + "tools": list(data.tools), + "has_mcp_tools": bool(data.mcp_tools), + "has_custom_model_config": ( + getattr(data, "custom_model_config", None) is not None + ), + }, ) working_directory = get_working_directory(options) tool_names = [] diff --git a/backend/tests/app/agent/test_agent_model.py b/backend/tests/app/agent/test_agent_model.py index 9c6e7dbd..721ce6d6 100644 --- a/backend/tests/app/agent/test_agent_model.py +++ b/backend/tests/app/agent/test_agent_model.py @@ -18,7 +18,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.agent.agent_model import agent_model -from app.model.chat import Chat +from app.model.chat import AgentModelConfig, Chat +from app.service.task import Agents pytestmark = pytest.mark.unit @@ -67,6 +68,7 @@ class TestAgentFactoryFunctions: "model_platform": "openai", "model_type": "gpt-5.5", "auth_source": "codex_subscription", + "model_config_dict": {"stream": False, "store": True}, } ) monkeypatch.setenv("CODEX_RESOLVER_URL", "http://127.0.0.1:12345") @@ -154,6 +156,232 @@ class TestAgentFactoryFunctions: assert "store" not in model_config assert kwargs["url"] == "https://api.openai.com/v1" + def test_explicit_model_config_wins_over_legacy_extra_params( + self, sample_chat_data + ): + options = Chat( + **{ + **sample_chat_data, + "extra_params": { + "temperature": 0.8, + "top_p": 0.6, + "max_retries": 7, + "model_config_dict": { + "temperature": 0.5, + "presence_penalty": 0.1, + }, + }, + "model_config_dict": { + "temperature": 0.2, + "max_tokens": 2048, + }, + } + ) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + mock_task_lock.put_queue = MagicMock(return_value=None) + task_locks[options.project_id] = mock_task_lock + + _m = sys.modules["app.agent.agent_model"] + with ( + patch.object(_m, "ListenChatAgent"), + patch.object(_m, "ModelFactory") as mock_model_factory, + patch.object(_m, "get_task_lock", return_value=mock_task_lock), + patch.object(_m, "_schedule_async_task"), + ): + mock_model_factory.create.return_value = MagicMock() + + agent_model("TestAgent", "You are helpful", options, []) + + kwargs = mock_model_factory.create.call_args.kwargs + assert kwargs["max_retries"] == 7 + assert kwargs["model_config_dict"]["temperature"] == 0.2 + assert kwargs["model_config_dict"]["top_p"] == 0.6 + assert kwargs["model_config_dict"]["presence_penalty"] == 0.1 + assert kwargs["model_config_dict"]["max_tokens"] == 2048 + assert "max_retries" not in kwargs["model_config_dict"] + assert "model_config_dict" not in kwargs["model_config_dict"] + + def test_runtime_owned_agent_model_values_override_user_config( + self, sample_chat_data + ): + options = Chat( + **{ + **sample_chat_data, + "model_config_dict": { + "stream": False, + "parallel_tool_calls": True, + }, + } + ) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + mock_task_lock.put_queue = MagicMock(return_value=None) + task_locks[options.project_id] = mock_task_lock + + _m = sys.modules["app.agent.agent_model"] + with ( + patch.object(_m, "ListenChatAgent"), + patch.object(_m, "ModelFactory") as mock_model_factory, + patch.object(_m, "get_task_lock", return_value=mock_task_lock), + patch.object(_m, "_schedule_async_task"), + ): + mock_model_factory.create.return_value = MagicMock() + + agent_model(Agents.task_agent, "Task agent", options, []) + agent_model(Agents.browser_agent, "Browser agent", options, []) + + task_config = mock_model_factory.create.call_args_list[0].kwargs[ + "model_config_dict" + ] + browser_config = mock_model_factory.create.call_args_list[1].kwargs[ + "model_config_dict" + ] + assert task_config["stream"] is True + assert browser_config["parallel_tool_calls"] is False + + def test_per_agent_explicit_model_config_overrides_task_config( + self, sample_chat_data + ): + options = Chat( + **{ + **sample_chat_data, + "model_config_dict": {"temperature": 0.7, "top_p": 0.8}, + } + ) + custom_config = AgentModelConfig( + model_config_dict={"temperature": 0.1} + ) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + mock_task_lock.put_queue = MagicMock(return_value=None) + task_locks[options.project_id] = mock_task_lock + + _m = sys.modules["app.agent.agent_model"] + with ( + patch.object(_m, "ListenChatAgent"), + patch.object(_m, "ModelFactory") as mock_model_factory, + patch.object(_m, "get_task_lock", return_value=mock_task_lock), + patch.object(_m, "_schedule_async_task"), + ): + mock_model_factory.create.return_value = MagicMock() + + agent_model( + "CustomAgent", + "Custom agent", + options, + [], + custom_model_config=custom_config, + ) + + model_config = mock_model_factory.create.call_args.kwargs[ + "model_config_dict" + ] + assert model_config["temperature"] == 0.1 + assert "top_p" not in model_config + + def test_per_agent_empty_credentials_do_not_inherit_task_credentials( + self, sample_chat_data + ): + options = Chat(**sample_chat_data) + custom_config = AgentModelConfig( + model_platform="aws-bedrock-converse", + model_type="anthropic.claude-3-5-sonnet-20241022-v2:0", + api_key="", + api_url="", + extra_params={ + "region_name": "us-east-1", + "aws_access_key_id": "worker-access-key", + "aws_secret_access_key": "worker-secret-key", + }, + ) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + mock_task_lock.put_queue = MagicMock(return_value=None) + task_locks[options.project_id] = mock_task_lock + + _m = sys.modules["app.agent.agent_model"] + with ( + patch.object(_m, "ListenChatAgent"), + patch.object(_m, "ModelFactory") as mock_model_factory, + patch.object(_m, "get_task_lock", return_value=mock_task_lock), + patch.object(_m, "_schedule_async_task"), + ): + mock_model_factory.create.return_value = MagicMock() + + agent_model( + "BedrockAgent", + "Bedrock agent", + options, + [], + custom_model_config=custom_config, + ) + + kwargs = mock_model_factory.create.call_args.kwargs + assert kwargs["api_key"] == "" + assert kwargs["url"] == "" + assert kwargs["aws_access_key_id"] == "worker-access-key" + assert kwargs["aws_secret_access_key"] == "worker-secret-key" + + def test_direct_per_agent_provider_is_not_treated_as_task_cloud_model( + self, sample_chat_data + ): + options = Chat( + **{ + **sample_chat_data, + "api_url": "https://eigent-proxy.example.com", + } + ) + custom_config = AgentModelConfig( + model_platform="aws-bedrock-converse", + model_type="anthropic.claude-3-5-sonnet-20241022-v2:0", + api_key="", + api_url="https://bedrock-runtime.us-east-1.amazonaws.com", + extra_params={ + "region_name": "eu-west-1", + "aws_access_key_id": "worker-access-key", + "aws_secret_access_key": "worker-secret-key", + }, + ) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + mock_task_lock.put_queue = MagicMock(return_value=None) + task_locks[options.project_id] = mock_task_lock + + _m = sys.modules["app.agent.agent_model"] + with ( + patch.object(_m, "ListenChatAgent"), + patch.object(_m, "ModelFactory") as mock_model_factory, + patch.object(_m, "get_task_lock", return_value=mock_task_lock), + patch.object(_m, "_schedule_async_task"), + ): + mock_model_factory.create.return_value = MagicMock() + + agent_model( + "BedrockAgent", + "Bedrock agent", + options, + [], + custom_model_config=custom_config, + ) + + kwargs = mock_model_factory.create.call_args.kwargs + assert kwargs["url"] == ( + "https://bedrock-runtime.us-east-1.amazonaws.com" + ) + assert kwargs["region_name"] == "eu-west-1" + assert "user" not in (kwargs["model_config_dict"] or {}) + def test_agent_model_with_missing_options(self): """Test agent_model with missing required options.""" agent_name = "ErrorAgent" @@ -184,7 +412,7 @@ class TestAgentIntegration: # Create task lock mock_task_lock = MagicMock() - mock_task_lock.put_queue = AsyncMock() + mock_task_lock.put_queue = MagicMock(return_value=None) task_locks[api_task_id] = mock_task_lock # Create agent diff --git a/backend/tests/app/controller/test_task_controller.py b/backend/tests/app/controller/test_task_controller.py index d5f5f91f..85d3218d 100644 --- a/backend/tests/app/controller/test_task_controller.py +++ b/backend/tests/app/controller/test_task_controller.py @@ -25,7 +25,7 @@ from app.controller.task_controller import ( start, take_control, ) -from app.model.chat import NewAgent, TaskContent, UpdateData +from app.model.chat import AgentModelConfig, NewAgent, TaskContent, UpdateData from app.service.task import Action @@ -126,6 +126,11 @@ class TestTaskController: tools=["search", "code"], mcp_tools=None, env_path=".env", + custom_model_config=AgentModelConfig( + model_platform="aws-bedrock-converse", + api_key="super-secret-api-key", + extra_params={"aws_secret_access_key": "super-secret-aws-key"}, + ), ) with ( @@ -137,12 +142,26 @@ class TestTaskController: patch( "app.controller.task_controller._queue_action_from_worker" ) as mock_queue_action, + patch("app.controller.task_controller.logger") as mock_logger, ): response = add_agent(task_id, new_agent) assert isinstance(response, Response) assert response.status_code == 204 mock_queue_action.assert_called_once() + mock_logger.debug.assert_called_once_with( + "New agent data", + extra={ + "task_id": task_id, + "agent_name": "Test Agent", + "tools": ["search", "code"], + "has_mcp_tools": False, + "has_custom_model_config": True, + }, + ) + logged_calls = repr(mock_logger.mock_calls) + assert "super-secret-api-key" not in logged_calls + assert "super-secret-aws-key" not in logged_calls def test_start_task_nonexistent_task(self): """Test start task with nonexistent task ID.""" diff --git a/backend/tests/app/model/test_chat.py b/backend/tests/app/model/test_chat.py index 31e314be..45005785 100644 --- a/backend/tests/app/model/test_chat.py +++ b/backend/tests/app/model/test_chat.py @@ -30,6 +30,7 @@ class TestAgentModelConfig: assert config.model_type is None assert config.api_key is None assert config.api_url is None + assert config.model_config_dict is None assert config.extra_params is None def test_agent_model_config_creation_with_values(self): @@ -39,13 +40,18 @@ class TestAgentModelConfig: model_type="gpt-4", api_key="test-key", api_url="https://api.openai.com/v1", - extra_params={"temperature": 0.7}, + model_config_dict={"temperature": 0.7, "stream": False}, + extra_params={"api_version": "2025-01-01"}, ) assert config.model_platform == "openai" assert config.model_type == "gpt-4" assert config.api_key == "test-key" assert config.api_url == "https://api.openai.com/v1" - assert config.extra_params == {"temperature": 0.7} + assert config.model_config_dict == { + "temperature": 0.7, + "stream": False, + } + assert config.extra_params == {"api_version": "2025-01-01"} def test_has_custom_config_false_when_empty(self): """Test has_custom_config returns False for empty config.""" @@ -74,6 +80,36 @@ class TestAgentModelConfig: config = AgentModelConfig(api_key="some-key") assert config.has_custom_config() is True + def test_has_custom_config_true_with_only_model_config_dict(self): + config = AgentModelConfig(model_config_dict={"temperature": 0.2}) + + assert config.has_custom_config() is True + + +class TestChatModelConfig: + def test_chat_accepts_and_serializes_model_config_dict( + self, sample_chat_data + ): + chat = Chat( + **{ + **sample_chat_data, + "model_config_dict": { + "temperature": 0.2, + "top_p": 0.9, + "response_format": {"type": "json_object"}, + }, + } + ) + + assert chat.model_config_dict == { + "temperature": 0.2, + "top_p": 0.9, + "response_format": {"type": "json_object"}, + } + assert chat.model_dump()["model_config_dict"] == ( + chat.model_config_dict + ) + class TestNewAgentWithModelConfig: """Tests for NewAgent with custom_model_config.""" diff --git a/backend/tests/app/service/test_chat_service.py b/backend/tests/app/service/test_chat_service.py index c0732e4f..4b332faf 100644 --- a/backend/tests/app/service/test_chat_service.py +++ b/backend/tests/app/service/test_chat_service.py @@ -20,7 +20,7 @@ import pytest from camel.tasks import Task from camel.tasks.task import TaskState -from app.model.chat import Chat, NewAgent +from app.model.chat import AgentModelConfig, Chat, NewAgent from app.service.chat_service import ( _extract_stream_chunk_content, _render_subtask_report, @@ -1024,6 +1024,11 @@ class TestChatServiceAgentOperations: tools=["search", "code"], mcp_tools=None, env_path=".env", + custom_model_config=AgentModelConfig( + model_platform="aws-bedrock-converse", + api_key="super-secret-api-key", + extra_params={"aws_secret_access_key": "super-secret-aws-key"}, + ), ) mock_agent = MagicMock() @@ -1038,10 +1043,23 @@ class TestChatServiceAgentOperations: "app.agent.toolkit.human_toolkit.get_task_lock", return_value=MagicMock(), ), + patch("app.service.chat_service.logger") as mock_logger, ): result = await new_agent_model(agent_data, options) assert result is mock_agent + mock_logger.debug.assert_any_call( + "New agent data", + extra={ + "agent_name": "TestAgent", + "tools": ["search", "code"], + "has_mcp_tools": False, + "has_custom_model_config": True, + }, + ) + logged_calls = repr(mock_logger.mock_calls) + assert "super-secret-api-key" not in logged_calls + assert "super-secret-aws-key" not in logged_calls @pytest.mark.asyncio async def test_construct_workforce(self, sample_chat_data, mock_task_lock): diff --git a/src/components/AddWorker/index.tsx b/src/components/AddWorker/index.tsx index f5a8e31e..894bde08 100644 --- a/src/components/AddWorker/index.tsx +++ b/src/components/AddWorker/index.tsx @@ -36,6 +36,11 @@ import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { INIT_PROVODERS } from '@/lib/llm'; +import { + type AgentModelConfigSource, + buildAgentModelConfig, + buildAgentModelConfigFromProvider, +} from '@/lib/modelConfig'; import { getLocalPlatformName, LOCAL_MODEL_OPTIONS, @@ -71,11 +76,10 @@ interface McpItem { type WorkerModelMode = 'eigent' | 'custom' | 'local'; -interface WorkerModelOption { +interface WorkerModelOption extends AgentModelConfigSource { value: string; label: string; - model_platform: string; - model_type: string; + provider_id?: number; } export function AddWorker({ @@ -125,6 +129,7 @@ export function AddWorker({ const [workerModelMode, setWorkerModelMode] = useState('eigent'); const [workerModelName, setWorkerModelName] = useState(''); + const [modelSelectionTouched, setModelSelectionTouched] = useState(false); const [customModelOptions, setCustomModelOptions] = useState< WorkerModelOption[] >([]); @@ -300,6 +305,7 @@ export function AddWorker({ setShowModelConfig(false); setWorkerModelMode('eigent'); setWorkerModelName(''); + setModelSelectionTouched(false); setCustomModelOptions([]); setLocalModelOptions([]); }; @@ -322,10 +328,63 @@ export function AddWorker({ setWorkerName(workerInfo.workerInfo?.name || ''); setWorkerDescription(workerInfo.workerInfo?.description || ''); setSelectedTools(workerInfo.workerInfo?.selectedTools || []); + setWorkerModelMode('eigent'); + setWorkerModelName(''); + setModelSelectionTouched(false); + setShowModelConfig( + Number.isInteger(workerInfo.workerInfo?.model_provider_id) + ); }, [dialogOpen, edit, workerInfo]); + useEffect(() => { + if ( + !dialogOpen || + !edit || + !showModelConfig || + !workerInfo || + modelSelectionTouched + ) + return; + const providerId = workerInfo.workerInfo?.model_provider_id; + if (!Number.isInteger(providerId)) return; + + const customOption = customModelOptions.find( + (option) => option.provider_id === providerId + ); + if (customOption) { + setWorkerModelMode('custom'); + setWorkerModelName(customOption.value); + return; + } + + const localOption = localModelOptions.find( + (option) => option.provider_id === providerId + ); + if (localOption) { + setWorkerModelMode('local'); + setWorkerModelName(localOption.value); + } + }, [ + customModelOptions, + dialogOpen, + edit, + localModelOptions, + modelSelectionTouched, + showModelConfig, + workerInfo, + ]); + useEffect(() => { if (!showModelConfig) return; + const editingProviderId = workerInfo?.workerInfo?.model_provider_id; + if ( + dialogOpen && + edit && + Number.isInteger(editingProviderId) && + !modelSelectionTouched + ) { + return; + } const options = activeWorkerModelOptions; if (options.length === 0) { setWorkerModelName(''); @@ -334,7 +393,15 @@ export function AddWorker({ if (!options.some((opt) => opt.value === workerModelName)) { setWorkerModelName(options[0].value); } - }, [activeWorkerModelOptions, showModelConfig, workerModelName]); + }, [ + activeWorkerModelOptions, + dialogOpen, + edit, + modelSelectionTouched, + showModelConfig, + workerInfo, + workerModelName, + ]); useEffect(() => { if (!showModelConfig) return; @@ -358,6 +425,8 @@ export function AddWorker({ .map((provider: any) => { const modelType = String(provider.model_type || ''); const providerName = String(provider.provider_name || ''); + const agentModelConfig = + buildAgentModelConfigFromProvider(provider); return { value: `${providerName}::${modelType}`, label: modelType @@ -365,6 +434,11 @@ export function AddWorker({ : providerName, model_platform: providerName, model_type: modelType, + provider_id: Number(provider.id), + api_key: agentModelConfig.api_key, + api_url: agentModelConfig.api_url, + model_config_dict: agentModelConfig.model_config_dict, + extra_params: agentModelConfig.extra_params, }; }); @@ -373,13 +447,10 @@ export function AddWorker({ localProviderIds.has(provider.provider_name) ) .map((provider: any) => { - const config = provider.encrypted_config || {}; - const modelPlatform = String( - config.model_platform || provider.provider_name || '' - ); - const modelType = String( - config.model_type || provider.model_type || '' - ); + const agentModelConfig = + buildAgentModelConfigFromProvider(provider); + const modelPlatform = agentModelConfig.model_platform; + const modelType = agentModelConfig.model_type || ''; const platformName = getLocalPlatformName(modelPlatform); return { value: `${modelPlatform}::${modelType}`, @@ -388,6 +459,11 @@ export function AddWorker({ : platformName, model_platform: modelPlatform, model_type: modelType, + provider_id: Number(provider.id), + api_key: agentModelConfig.api_key, + api_url: agentModelConfig.api_url, + model_config_dict: agentModelConfig.model_config_dict, + extra_params: agentModelConfig.extra_params, }; }); @@ -451,6 +527,23 @@ export function AddWorker({ } } } + const selectedModelOption = workerModelOptions[workerModelMode].find( + (option) => option.value === workerModelName + ); + const customModelConfig = + showModelConfig && selectedModelOption + ? buildAgentModelConfig(selectedModelOption) + : undefined; + const modelProviderId = + showModelConfig && selectedModelOption?.provider_id + ? selectedModelOption.provider_id + : showModelConfig && + edit && + !modelSelectionTouched && + Number.isInteger(workerInfo?.workerInfo?.model_provider_id) + ? workerInfo?.workerInfo?.model_provider_id + : undefined; + if (edit) { const newWorkerList = workerList.map((worker) => { if (worker.type === workerInfo?.type) { @@ -473,6 +566,7 @@ export function AddWorker({ tools: localTool, mcp_tools: mcpLocal, selectedTools: JSON.parse(JSON.stringify(selectedTools)), + model_provider_id: modelProviderId, }, }; return { @@ -503,22 +597,12 @@ export function AddWorker({ tools: localTool, mcp_tools: mcpLocal, selectedTools: JSON.parse(JSON.stringify(selectedTools)), + model_provider_id: modelProviderId, }, }; setWorkerList([...workerList, worker]); } else { // Add-worker custom model config is applied to this agent only. - const selectedModelOption = workerModelOptions[workerModelMode].find( - (opt) => opt.value === workerModelName - ); - const customModelConfig = - showModelConfig && selectedModelOption - ? { - model_platform: selectedModelOption.model_platform, - model_type: selectedModelOption.model_type || undefined, - } - : undefined; - if (activeProjectId) { fetchPost(`/task/${activeProjectId}/add-agent`, { name: workerName, @@ -542,6 +626,7 @@ export function AddWorker({ tools: localTool, mcp_tools: mcpLocal, selectedTools: JSON.parse(JSON.stringify(selectedTools)), + model_provider_id: modelProviderId, }, }; setWorkerList([...workerList, worker]); @@ -753,6 +838,7 @@ export function AddWorker({ { + setModelSelectionTouched(true); setShowModelConfig(checked); if (!checked) { setWorkerModelName(''); @@ -771,9 +857,10 @@ export function AddWorker({ { + setModelSelectionTouched(true); + setWorkerModelName(value); + }} > ; + +export type AgentModelConfig = { + model_platform: string; + model_type?: string; + api_key?: string; + api_url?: string; + model_config_dict?: ModelConfigDict; + extra_params?: Record; +}; + +export type AgentModelConfigSource = { + model_platform: string; + model_type: string; + api_key?: string; + api_url?: string; + model_config_dict?: ModelConfigDict; + extra_params?: Record; +}; + +export type StoredModelProvider = { + provider_name?: unknown; + model_type?: unknown; + api_key?: unknown; + endpoint_url?: unknown; + api_url?: unknown; + encrypted_config?: unknown; +}; + +export type ModelConfigJsonErrorCode = 'invalid_json' | 'not_object'; + +export class ModelConfigJsonError extends Error { + readonly code: ModelConfigJsonErrorCode; + + constructor(code: ModelConfigJsonErrorCode, message: string) { + super(message); + this.name = 'ModelConfigJsonError'; + this.code = code; + } +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Parse the BYOK model-parameters field. Blank input represents no overrides. */ +export function parseModelConfigJson(input: string): ModelConfigDict { + if (!input.trim()) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + throw new ModelConfigJsonError( + 'invalid_json', + 'Model parameters must be valid JSON.' + ); + } + + if (!isObjectRecord(parsed)) { + throw new ModelConfigJsonError( + 'not_object', + 'Model parameters must be a JSON object.' + ); + } + + return parsed; +} + +/** Format persisted model parameters for the BYOK JSON editor. */ +export function formatModelConfigJson(value: unknown): string { + return JSON.stringify(isObjectRecord(value) ? value : {}, null, 2); +} + +/** + * Separate the reserved CAMEL model configuration from provider constructor + * parameters stored in the provider's existing `encrypted_config` JSON field. + */ +export function splitProviderConfig(config: unknown): { + modelConfigDict: ModelConfigDict; + extraParams: Record; +} { + if (!isObjectRecord(config)) { + return { modelConfigDict: {}, extraParams: {} }; + } + + const { [MODEL_CONFIG_DICT_KEY]: storedModelConfig, ...extraParams } = config; + + return { + modelConfigDict: isObjectRecord(storedModelConfig) + ? { ...storedModelConfig } + : {}, + extraParams, + }; +} + +/** Build the provider JSON while preventing extra parameters from shadowing the reserved key. */ +export function buildProviderConfig( + extraParams: Record, + modelConfigDict: ModelConfigDict +): Record { + const { extraParams: sanitizedExtraParams } = + splitProviderConfig(extraParams); + if (Object.keys(modelConfigDict).length === 0) { + return sanitizedExtraParams; + } + + return { + ...sanitizedExtraParams, + [MODEL_CONFIG_DICT_KEY]: { ...modelConfigDict }, + }; +} + +/** Build the per-agent payload while preserving explicit empty provider maps. */ +export function buildAgentModelConfig( + source: AgentModelConfigSource +): AgentModelConfig { + const config: AgentModelConfig = { + model_platform: source.model_platform, + ...(source.model_type ? { model_type: source.model_type } : {}), + }; + + if (source.api_key !== undefined) config.api_key = source.api_key; + if (source.api_url !== undefined) config.api_url = source.api_url; + if (source.model_config_dict !== undefined) { + config.model_config_dict = { ...source.model_config_dict }; + } + if (source.extra_params !== undefined) { + config.extra_params = { ...source.extra_params }; + } + + return config; +} + +/** Resolve a stored custom/local provider into a transient per-agent config. */ +export function buildAgentModelConfigFromProvider( + provider: StoredModelProvider +): AgentModelConfig { + const { modelConfigDict, extraParams } = splitProviderConfig( + provider.encrypted_config + ); + + return buildAgentModelConfig({ + model_platform: String( + extraParams.model_platform || provider.provider_name || '' + ), + model_type: String(extraParams.model_type || provider.model_type || ''), + api_key: String(provider.api_key ?? ''), + api_url: String(provider.endpoint_url || provider.api_url || ''), + model_config_dict: modelConfigDict, + extra_params: extraParams, + }); +} diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 22495971..5ef33d6b 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -19,6 +19,12 @@ import { proxyFetchPost, proxyFetchPut, } from '@/api/http'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -44,9 +50,16 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; import { createHost } from '@/host/createHost'; import { SITE_URL } from '@/lib'; import { INIT_PROVODERS } from '@/lib/llm'; +import { + buildProviderConfig, + formatModelConfigJson, + parseModelConfigJson, + splitProviderConfig, +} from '@/lib/modelConfig'; import { getProviderValid, toProviderValidStatus } from '@/lib/providerStatus'; import { useAuthStore } from '@/store/authStore'; import { useCloudModelStore } from '@/store/cloudModelStore'; @@ -161,6 +174,7 @@ export default function SettingModels() { apiHost: p.apiHost, is_valid: p.is_valid ?? false, model_type: p.model_type ?? '', + modelConfigJson: '', externalConfig: p.externalConfig ? p.externalConfig.map((ec) => ({ ...ec })) : undefined, @@ -196,6 +210,7 @@ export default function SettingModels() { apiKey?: string; apiHost?: string; model_type?: string; + modelConfigJson?: string; externalConfig?: string; }[] >(() => @@ -401,6 +416,9 @@ export default function SettingModels() { (p: any) => p.provider_name === item.id ); if (found) { + const { modelConfigDict } = splitProviderConfig( + found.encrypted_config + ); return { ...fi, provider_id: found.id, @@ -410,6 +428,10 @@ export default function SettingModels() { is_valid: getProviderValid(found), prefer: found.prefer ?? false, model_type: found.model_type ?? '', + modelConfigJson: + Object.keys(modelConfigDict).length > 0 + ? formatModelConfigJson(modelConfigDict) + : '', externalConfig: fi.externalConfig ? fi.externalConfig.map((ec) => { if ( @@ -633,9 +655,16 @@ export default function SettingModels() { }; const handleVerify = async (idx: number) => { - const { apiKey, apiHost, externalConfig, model_type, provider_id } = - form[idx]; + const { + apiKey, + apiHost, + externalConfig, + model_type, + modelConfigJson, + provider_id, + } = form[idx]; let hasError = false; + let modelConfigDict: Record = {}; const newErrors = [...errors]; if (items[idx].id !== 'local' && items[idx].id !== 'aws-bedrock-converse') { if (!apiKey || apiKey.trim() === '') { @@ -657,6 +686,15 @@ export default function SettingModels() { } else { newErrors[idx].model_type = ''; } + try { + modelConfigDict = parseModelConfigJson(modelConfigJson); + newErrors[idx].modelConfigJson = ''; + } catch { + newErrors[idx].modelConfigJson = t( + 'setting.model-parameters-must-be-valid-json-object' + ); + hasError = true; + } setErrors(newErrors); if (hasError) { showConfigCardRing('error'); @@ -666,20 +704,34 @@ export default function SettingModels() { showConfigCardRing('configuring'); setLoading(idx); const item = items[idx]; - let external: any = {}; - if (form[idx]?.externalConfig) { - form[idx]?.externalConfig.map((item) => { + const external: Record = {}; + if (externalConfig) { + externalConfig.forEach((item) => { external[item.key] = item.value; }); } - console.log(form[idx]); + setForm((currentForm) => + currentForm.map((entry, entryIdx) => + entryIdx === idx + ? { + ...entry, + modelConfigJson: + Object.keys(modelConfigDict).length > 0 + ? formatModelConfigJson(modelConfigDict) + : '', + } + : entry + ) + ); + try { const res = await fetchPost('/model/validate', { model_platform: item.id, model_type: form[idx].model_type, api_key: form[idx].apiKey || null, url: form[idx].apiHost, + model_config_dict: modelConfigDict, extra_params: external, }); if (res.is_tool_calls && res.is_valid) { @@ -724,13 +776,8 @@ export default function SettingModels() { endpoint_url: form[idx].apiHost, is_valid: toProviderValidStatus(true), model_type: form[idx].model_type, + encrypted_config: buildProviderConfig(external, modelConfigDict), }; - if (externalConfig) { - data.encrypted_config = {}; - externalConfig.forEach((ec) => { - data.encrypted_config[ec.key] = ec.value; - }); - } try { if (provider_id) { await proxyFetchPut(`/api/v1/provider/${provider_id}`, data); @@ -747,6 +794,9 @@ export default function SettingModels() { (p: any) => p.provider_name === item.id ); if (found) { + const { modelConfigDict } = splitProviderConfig( + found.encrypted_config + ); return { ...fi, provider_id: found.id, @@ -756,6 +806,10 @@ export default function SettingModels() { is_valid: getProviderValid(found), prefer: found.prefer ?? false, model_type: found.model_type ?? fi.model_type ?? '', + modelConfigJson: + Object.keys(modelConfigDict).length > 0 + ? formatModelConfigJson(modelConfigDict) + : '', externalConfig: fi.externalConfig ? fi.externalConfig.map((ec) => { if ( @@ -1123,6 +1177,7 @@ export default function SettingModels() { apiHost: item.apiHost, is_valid: false, model_type: '', + modelConfigJson: '', externalConfig: item.externalConfig ? item.externalConfig.map((ec) => ({ ...ec, value: '' })) : undefined, @@ -1133,7 +1188,14 @@ export default function SettingModels() { ); setErrors((prev) => prev.map((er, i) => - i === idx ? ({ apiKey: '', apiHost: '', model_type: '' } as any) : er + i === idx + ? ({ + apiKey: '', + apiHost: '', + model_type: '', + modelConfigJson: '', + } as any) + : er ) ); if (activeModelIdx === idx) { @@ -1286,13 +1348,13 @@ export default function SettingModels() { ); @@ -1477,7 +1539,7 @@ export default function SettingModels() { if (selectedTab === 'cloud') { if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') { return ( -
+
{t('setting.cloud-not-available-in-local-proxy')}
); @@ -1489,13 +1551,13 @@ export default function SettingModels() { const trialTotalLimit = Number(subscription?.trial_total_credits_limit) || 1000; return ( -
-
-
-
+
+
+
+
{t('setting.eigent-cloud')}
-
+
{cloudPrefer ? (
{/*Content Area*/} -
-
-
+
+
+
{t('setting.credits')}: {loadingCredits ? ( @@ -1571,7 +1633,7 @@ export default function SettingModels() { )}
{isTrialing && ( -

+

{t('setting.trial-plan-notice-before-upgrade', { defaultValue: "You're on a trial. Your {{planName}} plan includes {{planCredits}} credits; the trial unlocks {{daily}} credits/day (up to {{total}}) before you upgrade.", @@ -1583,7 +1645,7 @@ export default function SettingModels() { {' '} @@ -1653,9 +1715,9 @@ export default function SettingModels() { /> -

-
- +
+
+ {t('setting.select-model-type')}
@@ -1697,12 +1759,12 @@ export default function SettingModels() { return ( -
-
+
+
{item.name}
-
+
{isConnected ? ( isDefault ? (
-
+
{/* Login row: left status text, right action */} -
-
+
+
{isConnected ? codexStatus.account_label || @@ -1766,7 +1828,7 @@ export default function SettingModels() { ) : null}
-
+
{isConnected ? (