Files
Artemis-Eapil/artemis/config/llm.py
T
chenmingxuan 2ddec53954
CI / Python 3.12 / ubuntu-latest (push) Canceled after 0s
CI / Python 3.12 / windows-latest (push) Canceled after 0s
CI / Frontend tests and production build (push) Canceled after 0s
CI / Build and smoke-test Python packages (push) Canceled after 0s
feat: add Chinese UI, Responses models, and mirrored Windows setup
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.
2026-09-22 19:16:17 +08:00

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