Report device actions as dispatched and preserve full failure details. Route Flash through McpActionExecutor and use ToolFailure across helper tools and agent loops instead of inferring failure from response text. Require model-provided descriptions for coordinate targets. Keep observed element metadata separate from those descriptions in safety checks, visual summaries, history, and frontend labels. Add Gemini Interactions API video analysis with configurable processing mode, chunk size, and timeouts. Centralize Google provider capabilities and token usage normalization, and update google-genai to 2.22.0. Preserve system instructions on follow-up interactions, replan pending chunks when falling back to static processing, and reject unanswered native video and audio segments instead of caching placeholder results. Keep helper-tool failure status intact through history tools and committee calls. Validation: 1,762 Python unit tests passed (3 skipped), 40 frontend formatter tests passed, and the production UI build completed. Ruff lint, formatting, the quality ratchet, and git diff checks passed.
310 lines
11 KiB
Python
310 lines
11 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_ANTHROPIC_API_KEY,
|
|
ENV_DATA_ENGINE_DB_PATH,
|
|
ENV_GCP_API_KEY,
|
|
ENV_GEMINI_API_KEY,
|
|
ENV_GOOGLE_API_KEY,
|
|
ENV_OCR_API_KEY,
|
|
ENV_OPEN_ROUTER_API_KEY,
|
|
ENV_OPENAI_API_KEY,
|
|
ENV_VISION_API_KEY,
|
|
ENV_XAI_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."""
|
|
|
|
# LLM Provider Authentication
|
|
OPENAI_API_KEY: SecretStr | None = None
|
|
GOOGLE_API_KEY: SecretStr | None = None
|
|
GEMINI_API_KEY: SecretStr | None = None
|
|
GCP_API_KEY: SecretStr | None = None
|
|
ANTHROPIC_API_KEY: SecretStr | None = None
|
|
XAI_API_KEY: SecretStr | None = None
|
|
OPEN_ROUTER_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 = None
|
|
|
|
# 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
|
|
|
|
# Execution Defaults
|
|
PROJECT_NAME: str | None = None
|
|
ARTEMIS_DEFAULT_PROFILE: str = Field(default=DEFAULT_PROFILE)
|
|
ARTEMIS_DEFAULT_MODEL: str = Field(default=DEFAULT_MODEL)
|
|
|
|
# Explorer Tool Settings (the tier itself is configured in artemis.jsonc or
|
|
# via ARTEMIS_EXPLORER_VERSION; see artemis.config.agent.ExplorerConfig)
|
|
EXPLORER_CACHING: bool | None = Field(
|
|
default=None,
|
|
description=(
|
|
"Environment-level override for Explorer context caching; unset"
|
|
" defers to the agent configuration and the tier default."
|
|
),
|
|
)
|
|
|
|
# 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 Google / Gemini / GCP and OCR API keys and filter placeholders."""
|
|
# Sanitize any placeholder values loaded from environment or .env
|
|
for attr in (
|
|
"OPENAI_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"GEMINI_API_KEY",
|
|
"GCP_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"XAI_API_KEY",
|
|
"OPEN_ROUTER_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)
|
|
|
|
if not self.GOOGLE_API_KEY:
|
|
if self.GEMINI_API_KEY:
|
|
self.GOOGLE_API_KEY = self.GEMINI_API_KEY
|
|
elif self.GCP_API_KEY:
|
|
self.GOOGLE_API_KEY = self.GCP_API_KEY
|
|
|
|
# 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 (e.g., 'google', 'gemini', 'openai', 'ocr', 'vision').
|
|
|
|
Returns:
|
|
SecretStr containing the API key or None if not configured.
|
|
"""
|
|
from artemis.llm.google.provider import is_google_family_provider
|
|
|
|
provider_lower = provider.lower()
|
|
key: SecretStr | None = None
|
|
if is_google_family_provider(provider_lower):
|
|
key = self.GOOGLE_API_KEY or self.GEMINI_API_KEY or self.GCP_API_KEY
|
|
elif 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
|
|
elif provider_lower in ("anthropic", "claude"):
|
|
key = self.ANTHROPIC_API_KEY
|
|
elif provider_lower == "openrouter":
|
|
key = self.OPEN_ROUTER_API_KEY
|
|
elif provider_lower in ("xai", "grok"):
|
|
key = self.XAI_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)
|
|
from artemis.llm.google.provider import is_google_family_provider
|
|
|
|
provider_lower = provider.lower()
|
|
env_key_name = None
|
|
|
|
if is_google_family_provider(provider_lower):
|
|
self.GOOGLE_API_KEY = secret
|
|
self.GEMINI_API_KEY = secret
|
|
self.GCP_API_KEY = secret
|
|
env_key_name = ENV_GEMINI_API_KEY
|
|
os.environ[ENV_GOOGLE_API_KEY] = key
|
|
os.environ[ENV_GEMINI_API_KEY] = key
|
|
os.environ[ENV_GCP_API_KEY] = key
|
|
elif 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 ("anthropic", "claude"):
|
|
self.ANTHROPIC_API_KEY = secret
|
|
env_key_name = ENV_ANTHROPIC_API_KEY
|
|
os.environ[ENV_ANTHROPIC_API_KEY] = key
|
|
elif provider_lower == "openrouter":
|
|
self.OPEN_ROUTER_API_KEY = secret
|
|
env_key_name = ENV_OPEN_ROUTER_API_KEY
|
|
os.environ[ENV_OPEN_ROUTER_API_KEY] = key
|
|
elif provider_lower == "xai":
|
|
self.XAI_API_KEY = secret
|
|
env_key_name = ENV_XAI_API_KEY
|
|
os.environ[ENV_XAI_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
|
|
|
|
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 is_google_family_provider(provider_lower):
|
|
keys_to_update = [ENV_GEMINI_API_KEY, ENV_GOOGLE_API_KEY, ENV_GCP_API_KEY]
|
|
elif 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)
|