Files
Artemis-Eapil/artemis/config/settings.py
T
chenmingxuan 83bcfd54d5 feat: release 2.1.0 with optional Flash final AI review
Default Flash final review to off and persist the setting across the UI,
MCP, SDK and queued tasks. Keep execution separate from test verdicts.

Bundle UI fonts and licenses to remove Google Fonts from startup builds.
Align backend/frontend versions and document installation and upgrades.
2026-10-23 12:36:56 +08:00

305 lines
12 KiB
Python

# Copyright 2026 Google LLC
#
# 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.
"""Application settings and environment variable configuration management."""
import os
from pathlib import Path
from dotenv import load_dotenv
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings
from artemis.config.constants import (
DEFAULT_ADB_HOST,
DEFAULT_ADB_PORT,
DEFAULT_MODEL,
DEFAULT_PROFILE,
ENV_DATA_ENGINE_DB_PATH,
ENV_OCR_API_KEY,
ENV_OPENAI_API_KEY,
ENV_VISION_API_KEY,
)
from artemis.config.paths import (
GLOBAL_APP_DIR,
get_data_engine_db_path,
get_default_traces_path,
get_env_file,
get_temp_dir,
)
from artemis.utils.logger import get_logger
# Installed wheels load .env from the user directory, outside site-packages.
_canonical_env = get_env_file()
load_dotenv(dotenv_path=_canonical_env, verbose=True)
_global_env = GLOBAL_APP_DIR / ".env"
if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve():
load_dotenv(dotenv_path=_global_env, verbose=True)
logger = get_logger(__name__)
def is_placeholder_key(val: str | SecretStr | None) -> bool:
"""Check if an API credential is empty or an unconfigured placeholder value."""
if val is None:
return True
raw_str = val.get_secret_value() if isinstance(val, SecretStr) else str(val)
raw_str = raw_str.strip().lower()
if not raw_str:
return True
if raw_str in (
"api_key",
"your_api_key",
"your_api_key_here",
"your_gemini_api_key_here",
"your_google_cloud_vision_api_key_here",
"your_openai_api_key_here",
"your_anthropic_api_key_here",
"your_openrouter_api_key_here",
"your_xai_api_key_here",
"none",
"empty",
"null",
"undefined",
):
return True
if (
(raw_str.startswith("your_") and raw_str.endswith("_here"))
or (raw_str.startswith("<") and raw_str.endswith(">"))
or (raw_str.startswith("[") and raw_str.endswith("]"))
):
return True
return False
class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json"
)
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS: float = Field(default=15, gt=0, le=60)
ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES: int = Field(default=134217728, gt=0)
ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS: float = Field(default=300, gt=0, le=1800)
ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES: int = Field(default=536870912, gt=0)
ARTEMIS_PLUGIN_RESULT_MAX_BYTES: int = Field(default=16777216, gt=0)
# LLM Provider Authentication
OPENAI_API_KEY: SecretStr | None = None
# Google Cloud Vision OCR Authentication
OCR_API_KEY: SecretStr | None = None
VISION_API_KEY: SecretStr | None = None
API_KEY: SecretStr | None = None
# Custom Provider Endpoints
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
OPENAI_TOOL_FALLBACK_MODEL: str = Field(default="gpt-6-luna", min_length=1)
OPENAI_TOOL_FALLBACK_REASONING_EFFORT: str = "high"
OPENAI_TOOL_FALLBACK_MAX_RETRIES: int = Field(default=3, ge=0, le=10)
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=10)
OPENAI_REASONING_EFFORT: str = "high"
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS: float = Field(default=2.0, gt=0)
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
ARTEMIS_VIDEO_ENABLED: bool = False
ARTEMIS_RECORDING_ENABLED: bool = False
ARTEMIS_RECORDING_CONFIG: dict = Field(default_factory=dict)
# Worker-only override, applied after the user's JSON recording defaults.
ARTEMIS_TASK_RECORDING_CONFIG: dict = Field(default_factory=dict)
ARTEMIS_OUTPUTTER_ENABLED: bool = True
ARTEMIS_FLASH_FINAL_CHECK_ENABLED: bool = False
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task.
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
ARTEMIS_BATCH_MAX_EXECUTIONS: int = Field(default=10000, ge=1)
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
ARTEMIS_ACTION_FAILURE_ATTEMPTS: int = Field(default=3, ge=1, le=10)
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
# Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
ADB_DEVICE_SERIAL: str | None = None
ARTEMIS_ADB_AUTO_CONNECT: bool = True
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS: float = Field(default=10, ge=1, le=300)
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=5, gt=0, le=30)
# UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2
# fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only.
ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto")
# Whether a task may install / upgrade the Accessibility Helper APK on a device
# it holds. False = only attach to a helper installed by `artemis helper install`.
ARTEMIS_HELPER_AUTO_INSTALL: bool = Field(default=True)
# Execution Defaults
PROJECT_NAME: str | None = None
ARTEMIS_DEFAULT_PROFILE: str = Field(default=DEFAULT_PROFILE)
ARTEMIS_DEFAULT_MODEL: str = Field(default=DEFAULT_MODEL)
# LLM Reliability
LLM_PAUSE_TIMEOUT_SECONDS: float = Field(
default=900.0,
description=(
"How long a task waits in the paused state after exhausting LLM"
" retries before failing with LLMExhaustedError. <= 0 waits"
" forever (legacy interactive behavior)."
),
)
# Paths & Storage
TRACES_PATH: Path = Field(default_factory=get_default_traces_path)
DATA_ENGINE_DB_PATH: Path = Field(default_factory=get_data_engine_db_path)
TEMP_PATH: Path = Field(default_factory=get_temp_dir)
model_config = {"env_file": ".env", "extra": "ignore"}
@model_validator(mode="after")
def fallback_api_keys(self) -> "Settings":
"""Normalize OpenAI and OCR API keys and filter placeholders."""
# Sanitize any placeholder values loaded from environment or .env
for attr in (
"OPENAI_API_KEY",
"OCR_API_KEY",
"VISION_API_KEY",
"API_KEY",
):
val = getattr(self, attr, None)
if val and is_placeholder_key(val):
setattr(self, attr, None)
# Fallback for OCR & Vision API keys
if not self.OCR_API_KEY and self.VISION_API_KEY:
self.OCR_API_KEY = self.VISION_API_KEY
return self
def get_api_key(self, provider: str) -> SecretStr | None:
"""Get API key for a specified provider.
Args:
provider: Provider name ('openai', 'ocr', or 'vision').
Returns:
SecretStr containing the API key or None if not configured.
"""
provider_lower = provider.lower()
key: SecretStr | None = None
if provider_lower in ("ocr", "vision", "google_vision"):
key = self.OCR_API_KEY or self.VISION_API_KEY
elif provider_lower == "openai":
key = self.OPENAI_API_KEY
if key and not is_placeholder_key(key):
return key
return None
def set_api_key(self, provider: str, key: str, persist_to_env: bool = False) -> None:
"""Dynamically set an API key at runtime, optionally persisting to .env in the app dir.
Args:
provider: Target provider name.
key: Secret API key string.
persist_to_env: Whether to save the key to the app directory's .env file.
"""
secret = SecretStr(key)
provider_lower = provider.lower()
env_key_name = None
if provider_lower == "openai":
self.OPENAI_API_KEY = secret
env_key_name = ENV_OPENAI_API_KEY
os.environ[ENV_OPENAI_API_KEY] = key
elif provider_lower in ("ocr", "vision", "google_vision"):
self.OCR_API_KEY = secret
self.VISION_API_KEY = secret
env_key_name = ENV_OCR_API_KEY
os.environ[ENV_OCR_API_KEY] = key
os.environ[ENV_VISION_API_KEY] = key
else:
raise ValueError(f"Unsupported credential provider: {provider}")
if persist_to_env and env_key_name:
target_env_files = [get_env_file()]
seen_paths = set()
for env_file in target_env_files:
try:
env_file.parent.mkdir(parents=True, exist_ok=True)
resolved = env_file.resolve()
if resolved in seen_paths:
continue
seen_paths.add(resolved)
lines = []
if env_file.exists():
lines = env_file.read_text(encoding="utf-8").splitlines()
# Determine keys to update
keys_to_update = [env_key_name]
if provider_lower in ("ocr", "vision", "google_vision"):
keys_to_update = [ENV_OCR_API_KEY, ENV_VISION_API_KEY]
new_lines = []
updated_set = set()
for line in lines:
replaced = False
for k in keys_to_update:
if (
line.startswith(f"{k}=")
or line.startswith(f"#{k}=")
or line.startswith(f"# {k}=")
):
new_lines.append(f"{k}={key}")
updated_set.add(k)
replaced = True
break
if not replaced:
new_lines.append(line)
# Ensure the primary key is present if not replaced
primary_key = keys_to_update[0]
if primary_key not in updated_set:
new_lines.append(f"{primary_key}={key}")
env_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
except Exception as e:
logger.warning(f"Could not persist {env_key_name} to {env_file}: {e}")
# Singleton instance
settings = Settings()
# Synchronize DATA_ENGINE_DB_PATH in environment for external sub-processes/tools
if settings.DATA_ENGINE_DB_PATH:
os.environ[ENV_DATA_ENGINE_DB_PATH] = str(settings.DATA_ENGINE_DB_PATH)