Localize the web console and persist custom model and capability settings. Use OpenAI Responses exclusively and move runtime limits into environment configuration. Bootstrap uv and the virtual environment on Windows, support optional China mirrors, and package source distributions without environments, build output, or credentials. Update regression tests and repository quality checks for the new behavior.
347 lines
12 KiB
Python
347 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.
|
|
|
|
"""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
|
|
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["none", "low", "medium", "high"] | None = None
|
|
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
|