# 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. """LLM provider, model hierarchy, fallback chaining, and configuration loaders.""" import os from artemis.config.core.interpolator import interpolate_config_value from pathlib import Path from typing import Any, Literal from pydantic import Field, BaseModel, ValidationError from artemis.config.constants import ( LLM_CONFIG_FILENAME, AgentNode, LLMProvider, LLMUtilsNode, ) from artemis.config.paths import ROOT_DIR, get_config_path from artemis.config.settings import settings from artemis.utils.file import load_jsonc from artemis.utils.logger import get_logger logger = get_logger(__name__) class _CyFunctionDetectorMeta(type): def __instancecheck__(self, instance): name = type(instance).__name__ return ( name in ( "cyfunction", "cython_function_or_method", "builtin_function_or_method", ) or "cyfunction" in name.lower() ) class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta): """Utility type detector for Cython and C-extension functions in Pydantic.""" pass class LLM(BaseModel): """Base model representing an LLM model provider and runtime parameters.""" model_config = {"ignored_types": (CyFunctionDetector,)} provider: LLMProvider = "openai" model: str = Field(default_factory=lambda: settings.OPENAI_MODEL) api_base: str | None = None transport: Literal["http", "websocket"] = Field( default_factory=lambda: settings.OPENAI_TRANSPORT ) timeout: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0) max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0) max_tokens: int | None = Field(default=None, gt=0) temperature: float | None = None reasoning_effort: ( Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None ) = Field(default_factory=lambda: settings.OPENAI_REASONING_EFFORT) enable_grounding: bool | None = None def validate_provider(self, name: str) -> None: """Ensure the required API key or credentials exist in settings for this provider.""" if self.provider == "openai": if not settings.OPENAI_API_KEY: raise Exception(f"{name} requires OPENAI_API_KEY in .env") def __str__(self) -> str: return f"{self.provider}/{self.model}" class LLMWithFallback(LLM): """LLM configuration with automatic secondary fallback and timeout specs.""" fallback: LLM fix_model: str | None = None def __str__(self) -> str: return f"{self.provider}/{self.model} (fallback: {self.fallback})" def lightweight_judge_default() -> "LLMWithFallback": """Factory default for the lightweight judge nodes (pixel safety net and planner validation): the configured Responses model at temperature 0.""" return LLMWithFallback( provider="openai", model=settings.OPENAI_MODEL, temperature=0.0, fallback=LLM( provider="openai", model=settings.OPENAI_MODEL, temperature=0.0, ), ) class LLMConfigUtils(BaseModel): """Configuration container for auxiliary utility agents/nodes.""" model_config = {"ignored_types": (CyFunctionDetector,)} outputter: LLMWithFallback hopper: LLMWithFallback video_analyzer: LLMWithFallback | None = None object_detector: LLMWithFallback | None = None class LLMConfig(BaseModel): """Comprehensive LLM configuration mapping every node to primary/fallback models.""" model_config = {"ignored_types": (CyFunctionDetector,)} planner: LLMWithFallback utils: LLMConfigUtils summarizer: LLMWithFallback operator: LLMWithFallback operator_summarizer: LLMWithFallback log_reader_sub_agent: LLMWithFallback log_analyzer: LLMWithFallback diagnoser: LLMWithFallback checker: LLMWithFallback planner_avatar: LLMWithFallback history_analyzer_expert: LLMWithFallback diagnoser_expert: LLMWithFallback explorer: LLMWithFallback history_analyzer: LLMWithFallback | None = None validator_pixel_safety_net: LLMWithFallback | None = None planner_validation: LLMWithFallback | None = None output_analyzer: LLMWithFallback | None = None def validate_providers(self) -> None: """Validate credentials across all configured agent nodes.""" self.planner.validate_provider("Planner") self.utils.outputter.validate_provider("Outputter") self.utils.hopper.validate_provider("Hopper") if self.utils.video_analyzer: self.utils.video_analyzer.validate_provider("VideoAnalyzer") if self.utils.object_detector: self.utils.object_detector.validate_provider("ObjectDetector") self.summarizer.validate_provider("Summarizer") self.operator.validate_provider("Operator") self.operator_summarizer.validate_provider("OperatorSummarizer") self.log_reader_sub_agent.validate_provider("LogReaderSubAgent") self.log_analyzer.validate_provider("LogAnalyzer") self.diagnoser.validate_provider("Diagnoser") self.checker.validate_provider("Checker") self.planner_avatar.validate_provider("PlannerAvatar") self.history_analyzer_expert.validate_provider("HistoryAnalyzerExpert") self.diagnoser_expert.validate_provider("DiagnoserExpert") self.explorer.validate_provider("Explorer") if self.history_analyzer: self.history_analyzer.validate_provider("HistoryAnalyzer") if self.validator_pixel_safety_net: self.validator_pixel_safety_net.validate_provider("ValidatorPixelSafetyNet") if self.planner_validation: self.planner_validation.validate_provider("PlannerValidation") if self.output_analyzer: self.output_analyzer.validate_provider("OutputAnalyzer") def __str__(self) -> str: return f""" 📃 Planner: {self.planner} 🧩 Utils: 🔽 Hopper: {self.utils.hopper} 📝 Outputter: {self.utils.outputter} 🎬 Video Analyzer: {self.utils.video_analyzer or "Not configured"} 👁️ Object Detector: {self.utils.object_detector or "Not configured"} """ def get_agent(self, item: AgentNode) -> LLMWithFallback: """Retrieve model configuration for a specific agent node with sensible defaults.""" val = getattr(self, item) if val is None: if item == "history_analyzer": return self.operator elif item in ("validator_pixel_safety_net", "planner_validation"): # Both are cheap, high-frequency judges: the pixel safety net # runs before actions, the planner validator after every # milestone edit. They share one lightweight default. return lightweight_judge_default() elif item == "output_analyzer": return self.log_analyzer return val def get_utils(self, item: LLMUtilsNode) -> LLMWithFallback: """Retrieve model configuration for a specific utility node.""" value = getattr(self.utils, item) if value is None: raise ValueError( f"Utils '{item}' is not configured. Please add it to your LLM " "config or enable it via AgentConfigBuilder." ) return value def _expand_default_into_nodes(config_dict: dict) -> dict: """Expand unified config format with 'default' and 'nodes' into full LLMConfig schema.""" if "planner" in config_dict and "utils" in config_dict: return config_dict default_model_cfg = config_dict.get( "default", { "provider": "openai", "model": settings.OPENAI_MODEL, "fallback": { "provider": "openai", "model": settings.OPENAI_MODEL, }, }, ) nodes_override = config_dict.get("nodes", {}) all_agent_nodes = [ "planner", "summarizer", "operator", "operator_summarizer", "log_reader_sub_agent", "log_analyzer", "diagnoser", "checker", "planner_avatar", "history_analyzer_expert", "diagnoser_expert", "explorer", ] all_utils_nodes = [ "outputter", "hopper", "video_analyzer", "object_detector", ] result: dict[str, Any] = {} for node in all_agent_nodes: node_cfg = dict(default_model_cfg) if node in nodes_override: for k, v in nodes_override[node].items(): if isinstance(v, dict) and isinstance(node_cfg.get(k), dict): node_cfg[k] = {**node_cfg[k], **v} else: node_cfg[k] = v result[node] = node_cfg utils_dict: dict[str, Any] = {} for util in all_utils_nodes: util_cfg = dict(default_model_cfg) if util in nodes_override: for k, v in nodes_override[util].items(): if isinstance(v, dict) and isinstance(util_cfg.get(k), dict): util_cfg[k] = {**util_cfg[k], **v} else: util_cfg[k] = v utils_dict[util] = util_cfg result["utils"] = utils_dict return result def parse_llm_config() -> LLMConfig: """Parse and instantiate LLMConfig from artemis.jsonc or llm-config.json.""" config_path = None for candidate in ("artemis.jsonc", "artemis.json", LLM_CONFIG_FILENAME): try: config_path = get_config_path(candidate) break except FileNotFoundError: continue if not config_path: config_path = get_config_path(LLM_CONFIG_FILENAME, ROOT_DIR / LLM_CONFIG_FILENAME) try: with open(config_path, encoding="utf-8") as f: config_dict = interpolate_config_value(load_jsonc(f)) expanded_dict = _expand_default_into_nodes(config_dict) return LLMConfig.model_validate(expanded_dict) except Exception as e: logger.error(f"Failed to load or parse llm config: {config_path}. Error: {e}") raise def initialize_llm_config() -> LLMConfig: """Parse and validate credentials for LLMConfig.""" llm_config = parse_llm_config() llm_config.validate_providers() logger.success("LLM config initialized") return llm_config def get_default_llm_config() -> LLMConfig: """Returns default LLMConfig parsed from standard configuration file.""" return parse_llm_config() def deep_merge_llm_config(base: LLMConfig, overrides: dict) -> LLMConfig: """Recursively merge dictionary overrides into an existing LLMConfig object.""" base_dict = base.model_dump() def merge(d1: dict, d2: dict) -> None: for k, v in d2.items(): if k in d1 and isinstance(d1[k], dict) and isinstance(v, dict): merge(d1[k], v) else: d1[k] = v merge(base_dict, overrides) return LLMConfig.model_validate(base_dict) def load_llm_config_override(path: Path | str) -> LLMConfig: """Load custom LLM configuration JSON/JSONC overrides on top of default configuration.""" default_config = get_default_llm_config() resolved_path = Path(path) if not resolved_path.exists(): try: resolved_path = get_config_path(str(path)) except OSError: pass override_config_dict = {} if resolved_path.exists(): logger.info(f"Loading custom LLM config from {resolved_path.resolve()}...") with open(resolved_path, encoding="utf-8") as f: override_config_dict = interpolate_config_value(load_jsonc(f)) else: logger.warning(f"Custom LLM config not found at {path} - using default config") try: return deep_merge_llm_config(default_config, override_config_dict) except ValidationError as e: logger.error(f"Invalid LLM config: {e}") logger.info("Falling back to default config") return default_config