diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index b8eaf7e87..95c76823a 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -15,6 +15,7 @@ import logging import uuid from collections.abc import Callable +from copy import deepcopy from typing import Any from camel.messages import BaseMessage @@ -35,6 +36,106 @@ from app.service.task import ActionCreateAgentData, Agents, get_task_lock from app.utils.event_loop_utils import _schedule_async_task + +def _strip_strict_from_tools_for_anthropic( + tools: list[FunctionTool | Callable] | None, + model_platform: str, +) -> list[FunctionTool | Callable] | None: + """Strip 'strict' from tool schemas for Anthropic models. + + Anthropic's API does not support OpenAI's 'strict' mode for tool calling. + CAMEL's FunctionTool adds 'strict': True by default, which causes + 400 errors when passed to Anthropic models ("does not support strict tools"). + + Also ensures `additionalProperties: false` on all objects for Groq compatibility. + + Args: + tools: List of FunctionTool or callable tools + model_platform: The model platform string (e.g., "anthropic", "groq") + + Returns: + Modified tools list with 'strict' removed from schemas, or None if input was None + """ + if not tools: + return tools + + platform = model_platform.lower() + needs_strict_removal = platform == "anthropic" + needs_additional_props_false = platform in ("groq", "anthropic") + + if not needs_strict_removal and not needs_additional_props_false: + return tools + + def _ensure_additional_props_false(obj: Any) -> Any: + """Recursively ensure additionalProperties: false on all object schemas.""" + if isinstance(obj, dict): + new_obj = {} + for k, v in obj.items(): + if ( + k == "type" + and v == "object" + and "additionalProperties" not in obj + ): + new_obj[k] = v + new_obj["additionalProperties"] = False + elif k in ("properties", "$defs") and isinstance(v, dict): + new_obj[k] = { + pk: _ensure_additional_props_false(pv) + for pk, pv in v.items() + } + elif k in ("items", "allOf", "oneOf", "anyOf") and isinstance( + v, (dict, list) + ): + if isinstance(v, dict): + new_obj[k] = _ensure_additional_props_false(v) + else: + new_obj[k] = [ + _ensure_additional_props_false(item) for item in v + ] + else: + new_obj[k] = _ensure_additional_props_false(v) + return new_obj + elif isinstance(obj, list): + return [_ensure_additional_props_false(item) for item in obj] + return obj + + stripped_tools = [] + for tool in tools: + if isinstance(tool, FunctionTool): + schema = tool.get_openai_tool_schema() + if isinstance(schema, dict) and "function" in schema: + func_schema = schema["function"] + new_func_schema = deepcopy(func_schema) + modified = False + + if needs_strict_removal and "strict" in new_func_schema: + del new_func_schema["strict"] + modified = True + + if needs_additional_props_false: + if "parameters" in new_func_schema: + new_func_schema["parameters"] = ( + _ensure_additional_props_false( + new_func_schema["parameters"] + ) + ) + modified = True + + if modified: + new_schema = { + "type": "function", + "function": new_func_schema, + } + new_tool = FunctionTool( + tool.func, openai_tool_schema=new_schema + ) + stripped_tools.append(new_tool) + continue + stripped_tools.append(tool) + + return stripped_tools + + # OpenAI chat-completions streaming only returns token usage when # `stream_options.include_usage` is requested. Without it the request-level # usage callback (on_request_usage) fires with 0 tokens, and because the @@ -295,12 +396,19 @@ def build_model(force_refresh: bool = False): model = build_model() + # Strip 'strict' from tool schemas for Anthropic models + # (CAMEL adds strict=True by default, which causes 400 errors) + effective_platform = effective_config.get("model_platform", "") + tools_for_agent = _strip_strict_from_tools_for_anthropic( + tools, effective_platform + ) + return ListenChatAgent( options.project_id, agent_name, system_message, model=model, - tools=tools, + tools=tools_for_agent, agent_id=agent_id, prune_tool_calls_from_memory=prune_tool_calls_from_memory, toolkits_to_register_agent=toolkits_to_register_agent, diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index 2e16856ae..4cad5d5b3 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -17,7 +17,8 @@ from pydantic import BeforeValidator PLATFORM_ALIAS_MAPPING: Final[dict[str, str]] = { - "z.ai": "zhipuai", + "z.ai": "openai-compatible-model", + "deepseek": "openai-compatible-model", "ModelArk": "openai-compatible-model", "grok": "openai-compatible-model", "ernie": "qianfan", diff --git a/backend/tests/app/model/test_model_platform.py b/backend/tests/app/model/test_model_platform.py index 7211a2403..151344c81 100644 --- a/backend/tests/app/model/test_model_platform.py +++ b/backend/tests/app/model/test_model_platform.py @@ -24,7 +24,8 @@ def test_normalize_model_platform_maps_known_aliases(): assert normalize_model_platform("grok") == "openai-compatible-model" - assert normalize_model_platform("z.ai") == "zhipuai" + assert normalize_model_platform("z.ai") == "openai-compatible-model" + assert normalize_model_platform("deepseek") == "openai-compatible-model" assert normalize_model_platform("ModelArk") == "openai-compatible-model" assert normalize_model_platform("ernie") == "qianfan" assert normalize_model_platform("llama.cpp") == "openai-compatible-model" diff --git a/server/.env.example b/server/.env.example index bc3184c10..c800bcd50 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,6 @@ debug=false url_prefix=/api -secret_key=postgres +secret_key=CHANGE_ME # Optional but recommended: set a distinct issuer per deployed environment # (for example,https://dev.eigent.ai). # If omitted, the server derives a non-secret environment fingerprint from @@ -8,8 +8,11 @@ secret_key=postgres # TOKEN_ISSUER=https://dev.eigent.ai TOKEN_AUDIENCE=eigent-api # Chat Share Secret Key -CHAT_SHARE_SECRET_KEY=put-your-secret-key-here -CHAT_SHARE_SALT=put-your-encode-salt-here +CHAT_SHARE_SECRET_KEY=CHANGE_ME +CHAT_SHARE_SALT=CHANGE_ME + +# Database password for docker-compose +POSTGRES_PASSWORD=CHANGE_ME # Remote control public web origin. Set this to the HTTPS site users open # from a phone or another computer, not the local desktop server. diff --git a/server/.gitignore b/server/.gitignore index 565fdf76a..52451be8f 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -10,6 +10,13 @@ wheels/ # Virtual environments .venv +# Environment files +.env +.env.* + +# Alembic config (contains database credentials) +alembic.ini + runtime app/public/upload/ diff --git a/server/app/model/chat/chat_share.py b/server/app/model/chat/chat_share.py index 7637f0282..385392d7a 100644 --- a/server/app/model/chat/chat_share.py +++ b/server/app/model/chat/chat_share.py @@ -22,28 +22,31 @@ logger = logging.getLogger(__name__) -def _get_secret_key() -> str: - """Return the share-token signing key. - - Falls back to a random ephemeral key when the environment variable - is not set. A hardcoded default must never be used because the - source code is public and anyone could forge valid share tokens. - """ - key = os.getenv("CHAT_SHARE_SECRET_KEY") - if key: - return key - logger.warning( - "CHAT_SHARE_SECRET_KEY not set — using a random ephemeral key. " - "Share links will not survive server restarts. " - "Set the CHAT_SHARE_SECRET_KEY environment variable for persistence." +def _get_secret_key() -> str: + """Return the share-token signing key. + + Raises an error if the environment variable is not set. + A hardcoded default or random fallback must never be used because the + source code is public and anyone could forge valid share tokens. + """ + key = os.getenv("CHAT_SHARE_SECRET_KEY") + if key: + return key + raise RuntimeError( + "CHAT_SHARE_SECRET_KEY environment variable is required but not set. " + "Generate a secure key with: openssl rand -base64 32" ) - return secrets.token_urlsafe(32) -def _get_salt() -> str: - salt = os.getenv("CHAT_SHARE_SALT") - if salt: - return salt +def _get_salt() -> str: + salt = os.getenv("CHAT_SHARE_SALT") + if salt: + return salt + logger.warning( + "CHAT_SHARE_SALT not set — using a random ephemeral salt. " + "Share links will not survive server restarts. " + "Set the CHAT_SHARE_SALT environment variable for persistence." + ) return secrets.token_urlsafe(8) diff --git a/server/docker-compose.dev.yml b/server/docker-compose.dev.yml index 15685f034..6275864be 100644 --- a/server/docker-compose.dev.yml +++ b/server/docker-compose.dev.yml @@ -7,7 +7,7 @@ services: environment: POSTGRES_DB: eigent POSTGRES_USER: postgres - POSTGRES_PASSWORD: 123456 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456} POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C' ports: - '5432:5432' diff --git a/server/docker-compose.yml b/server/docker-compose.yml index a777a7906..229c59976 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,5 +1,4 @@ -services: - # PostgreSQL Database +# PostgreSQL Database postgres: image: postgres:15 container_name: eigent_postgres @@ -7,7 +6,7 @@ services: environment: POSTGRES_DB: eigent POSTGRES_USER: postgres - POSTGRES_PASSWORD: 123456 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456} POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C' ports: - '5432:5432' @@ -44,7 +43,7 @@ services: context: .. dockerfile: server/Dockerfile args: - database_url: postgresql://postgres:123456@postgres:5432/eigent + database_url: ${DATABASE_URL:-postgresql://postgres:123456@postgres:5432/eigent} container_name: eigent_api restart: unless-stopped ports: @@ -52,7 +51,7 @@ services: env_file: - .env environment: - - database_url=postgresql://postgres:123456@postgres:5432/eigent + - database_url=${DATABASE_URL:-postgresql://postgres:123456@postgres:5432/eigent} - redis_url=redis://redis:6379/0 - celery_broker_url=redis://redis:6379/0 - celery_result_url=redis://redis:6379/0