"""Configuration for the FastAPI run server. Outbound HTTP client settings describe the FastAPI lifespan-owned ``httpx.AsyncClient`` instances shared by local run tasks for plugin-daemon and Dify API inner calls. Layers and Agenton providers do not own those clients, so these settings are process resource limits rather than per-run lifecycle knobs. Endpoint URLs and API keys stay service-specific. The Agent Stub also uses this settings model directly: the public Agent Stub API base URL, server secret, and optional Dify inner API bridge settings live here under the ``DIFY_AGENT_...`` environment-variable namespace. """ import httpx from typing import ClassVar, Literal, cast from pydantic import AliasChoices, AnyHttpUrl, Field, TypeAdapter, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS from dify_agent.runtime_backend import RuntimeBackendProfile from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS from dify_agent.runtime_backend.profile import ( DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT, DEFAULT_LOCAL_MATERIALIZED_HOME_ROOT, DEFAULT_LOCAL_WORKSPACE_ROOT, RuntimeBackendSettings, create_runtime_backend_profile, ) DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60 class ServerSettings(BaseSettings): """Environment settings for scheduling, outbound HTTP, and runtime resources.""" redis_url: str = "redis://localhost:6379/0" redis_prefix: str = "dify-agent" shutdown_grace_seconds: float = 30 run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1) run_timeout_seconds: float = Field(default=DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, gt=0) plugin_daemon_url: str = "http://localhost:5002" plugin_daemon_api_key: str = "" inner_api_url: str = "http://localhost:5001" inner_api_key: str | None = None runtime_backend: Literal["local", "enterprise", "e2b"] = "local" local_sandbox_endpoint: str | None = Field( default=None, validation_alias=AliasChoices("DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT", "DIFY_AGENT_SHELLCTL_ENTRYPOINT"), ) local_sandbox_auth_token: str | None = Field( default=None, validation_alias=AliasChoices("DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN", "DIFY_AGENT_SHELLCTL_AUTH_TOKEN"), ) local_sandbox_materialized_home_root: str = DEFAULT_LOCAL_MATERIALIZED_HOME_ROOT local_sandbox_workspace_root: str = DEFAULT_LOCAL_WORKSPACE_ROOT local_sandbox_home_snapshot_root: str = DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT enterprise_sandbox_gateway_endpoint: str | None = None enterprise_sandbox_gateway_auth_token: str | None = None enterprise_sandbox_gateway_timeout: float = Field(default=30.0, gt=0) enterprise_sandbox_proxy_timeout: float = Field(default=60.0, gt=0) e2b_api_key: str | None = None e2b_template: str = "difys-default-team/dify-agent-local-sandbox" e2b_active_timeout_seconds: int = Field( default=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ge=1, le=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535) agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL") sandbox_files_base_url: str | None = Field( default=None, validation_alias="DIFY_AGENT_SANDBOX_FILES_BASE_URL", ) stub_upload_file_size_limit: int = Field( default=50, ge=0, description="Maximum Agent Stub upload size in MiB", validation_alias="DIFY_AGENT_STUB_UPLOAD_FILE_SIZE_LIMIT", ) binding_file_download_command_timeout_seconds: float = Field(default=210.0, gt=0) server_secret_key: str | None = None api_token: str | None = None shell_redact_patterns: str = "" outbound_http_connect_timeout: float = Field(default=10.0, ge=0) outbound_http_read_timeout: float = Field(default=600.0, ge=0) outbound_http_write_timeout: float = Field(default=30.0, ge=0) outbound_http_pool_timeout: float = Field(default=10.0, ge=0) outbound_http_max_connections: int = Field(default=100, ge=1) outbound_http_max_keepalive_connections: int = Field(default=20, ge=0) outbound_http_keepalive_expiry: float = Field(default=30.0, ge=0) model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( env_prefix="DIFY_AGENT_", env_file=(".env", "dify-agent/.env"), extra="ignore", populate_by_name=True, ) @field_validator("agent_stub_api_base_url") @classmethod def normalize_agent_stub_api_base_url_value(cls, value: str | None) -> str | None: """Normalize the public Agent Stub URL while still validating its scheme.""" if value is None: return None stripped = value.strip() if not stripped: return None if stripped.startswith(("http://", "https://")): validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped)) return normalize_agent_stub_api_base_url(validated) return normalize_agent_stub_api_base_url(stripped) @field_validator("sandbox_files_base_url") @classmethod def normalize_sandbox_files_base_url_value(cls, value: str | None) -> str | None: """Normalize the Dify API base URL reachable from the Sandbox.""" if value is None: return None stripped = value.strip() if not stripped: return None validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped)) parsed = validated.rstrip("/") if "?" in parsed or "#" in parsed: raise ValueError("DIFY_AGENT_SANDBOX_FILES_BASE_URL must not include a query string or fragment") return parsed @field_validator("server_secret_key") @classmethod def validate_server_secret_key(cls, value: str | None) -> str | None: """Validate the configured base64url-encoded server root secret.""" if value is None: return None stripped = value.strip() if not stripped: return None _ = decode_server_secret_key(stripped) return stripped @field_validator("inner_api_url") @classmethod def normalize_inner_api_url(cls, value: str) -> str: """Normalize the trusted Dify API base URL used for inner API calls.""" stripped = value.strip() if not stripped: raise ValueError("DIFY_AGENT_INNER_API_URL must not be empty") validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped)) parsed = validated.rstrip("/") if "?" in parsed or "#" in parsed: raise ValueError("DIFY_AGENT_INNER_API_URL must not include a query string or fragment") return parsed @field_validator("inner_api_key", "api_token") @classmethod def normalize_optional_api_token(cls, value: str | None) -> str | None: """Normalize optional API authentication tokens.""" if value is None: return None return value.strip() or None def get_shell_redact_patterns(self) -> list[str]: """Parse the JSON array from shell_redact_patterns; empty/blank → empty list.""" stripped = self.shell_redact_patterns.strip() if not stripped: return [] import json as _json parsed = cast(object, _json.loads(stripped)) if not isinstance(parsed, list): raise ValueError("DIFY_AGENT_SHELL_REDACT_PATTERNS must be a JSON array of strings") return TypeAdapter(list[str]).validate_python(parsed) @model_validator(mode="after") def validate_agent_stub_requirements(self) -> "ServerSettings": """Require Agent Stub settings while allowing deployments without inner API calls.""" if self.agent_stub_api_base_url is not None and self.server_secret_key is None: raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_API_BASE_URL is set.") if ( self.agent_stub_api_base_url is not None and self.inner_api_key is not None and self.sandbox_files_base_url is None ): raise ValueError( "DIFY_AGENT_SANDBOX_FILES_BASE_URL is required for Agent Stub file transfers and Config downloads." ) return self def build_runtime_backend_profile(self) -> RuntimeBackendProfile | None: """Build the deployment-selected resource backend without adding service state.""" if self.runtime_backend == "local" and not self.local_sandbox_endpoint: return None return create_runtime_backend_profile( RuntimeBackendSettings( runtime_backend=self.runtime_backend, local_sandbox_endpoint=self.local_sandbox_endpoint, local_sandbox_auth_token=self.local_sandbox_auth_token, local_sandbox_materialized_home_root=self.local_sandbox_materialized_home_root, local_sandbox_workspace_root=self.local_sandbox_workspace_root, local_sandbox_home_snapshot_root=self.local_sandbox_home_snapshot_root, enterprise_sandbox_gateway_endpoint=self.enterprise_sandbox_gateway_endpoint, enterprise_sandbox_gateway_auth_token=self.enterprise_sandbox_gateway_auth_token, enterprise_sandbox_gateway_timeout=self.enterprise_sandbox_gateway_timeout, enterprise_sandbox_proxy_timeout=self.enterprise_sandbox_proxy_timeout, e2b_api_key=self.e2b_api_key, e2b_template=self.e2b_template, e2b_active_timeout_seconds=self.e2b_active_timeout_seconds, e2b_shellctl_port=self.e2b_shellctl_port, ) ) def create_agent_stub_token_codec(self) -> AgentStubTokenCodec | None: """Return the Agent Stub token codec when the server secret is configured.""" if self.server_secret_key is None: return None return AgentStubTokenCodec.from_server_secret(self.server_secret_key) def create_agent_stub_file_request_handler(self) -> DifyApiAgentStubFileRequestHandler | None: """Return the file bridge when inner API and Sandbox data-plane settings are configured.""" if self.inner_api_key is None or self.sandbox_files_base_url is None: return None return DifyApiAgentStubFileRequestHandler( inner_api_url=self.inner_api_url, inner_api_key=self.inner_api_key, sandbox_files_base_url=self.sandbox_files_base_url, max_upload_size_bytes=self.stub_upload_file_size_limit * 1024 * 1024, timeout=self.create_outbound_http_timeout(), ) def create_agent_stub_config_request_handler(self) -> DifyApiAgentStubConfigRequestHandler | None: """Return the Config bridge when inner API and Sandbox data-plane settings are configured.""" if self.inner_api_key is None or self.sandbox_files_base_url is None: return None return DifyApiAgentStubConfigRequestHandler( inner_api_url=self.inner_api_url, inner_api_key=self.inner_api_key, sandbox_files_base_url=self.sandbox_files_base_url, timeout=self.create_outbound_http_timeout(), ) def create_outbound_http_timeout(self) -> httpx.Timeout: """Build one shared outbound HTTP timeout object from server settings.""" return httpx.Timeout( connect=self.outbound_http_connect_timeout, read=self.outbound_http_read_timeout, write=self.outbound_http_write_timeout, pool=self.outbound_http_pool_timeout, ) __all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"]