diff --git a/artemis/config/llm.py b/artemis/config/llm.py index 641d2e198..330196347 100644 --- a/artemis/config/llm.py +++ b/artemis/config/llm.py @@ -14,6 +14,7 @@ """LLM provider, model hierarchy, fallback chaining, and configuration loaders.""" +import os from pathlib import Path from typing import Any @@ -135,17 +136,30 @@ def _expand_default_into_nodes(config_dict: dict) -> dict: if "planner" in config_dict and "utils" in config_dict: return config_dict - default_model_cfg = config_dict.get( - "default", - { - "provider": "google", - "model": "gemini-3.8-flash", - "fallback": { + default_model_cfg = dict( + config_dict.get( + "default", + { "provider": "google", - "model": "gemini-3.7-flash", + "model": "gemini-3.8-flash", + "fallback": { + "provider": "google", + "model": "gemini-3.7-flash", + }, }, - }, + ) ) + env_default_model = os.environ.get("ARTEMIS_DEFAULT_MODEL", "").strip() + if env_default_model: + default_model_cfg["model"] = env_default_model + env_fallback_model = os.environ.get("ARTEMIS_FALLBACK_MODEL", "").strip() + if env_fallback_model: + fallback_cfg = dict( + default_model_cfg.get("fallback") + or {"provider": default_model_cfg.get("provider", "google")} + ) + fallback_cfg["model"] = env_fallback_model + default_model_cfg["fallback"] = fallback_cfg nodes_override = config_dict.get("nodes", {}) diff --git a/artemis/config/settings.py b/artemis/config/settings.py index 182d7ec77..3aea82aa7 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -17,7 +17,7 @@ import os from pathlib import Path -from dotenv import load_dotenv +from dotenv import dotenv_values, load_dotenv from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings @@ -46,13 +46,6 @@ ) from third_party.mobile_use.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__) @@ -89,6 +82,25 @@ def is_placeholder_key(val: str | SecretStr | None) -> bool: return False +def _apply_dotenv_file(env_path: Path, *, override_non_empty: bool = False) -> None: + """Load a .env file, letting configured non-empty values override stale shell env.""" + if not env_path.exists(): + return + load_dotenv(dotenv_path=env_path, verbose=True) + if override_non_empty: + for k, v in dotenv_values(env_path).items(): + if v is not None and not is_placeholder_key(v): + os.environ[k] = v.strip() + + +# Installed wheels load .env from the user directory, outside site-packages. +_canonical_env = get_env_file() +_apply_dotenv_file(_canonical_env, override_non_empty=True) +_global_env = GLOBAL_APP_DIR / ".env" +if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve(): + _apply_dotenv_file(_global_env, override_non_empty=False) + + class Settings(BaseSettings): """Centralized ARTEMIS runtime settings loaded from environment and .env files.""" diff --git a/artemis/core/diagnostics/engine.py b/artemis/core/diagnostics/engine.py index 97f9d20ca..cc69f2c45 100644 --- a/artemis/core/diagnostics/engine.py +++ b/artemis/core/diagnostics/engine.py @@ -66,7 +66,10 @@ def __init__(self): self._toolchain_probe = ToolchainProbe() self._credentials_probe = LLMCredentialsProbe() self._ocr_probe = VisionOCRProbe() - self._adb_probe = AdbDeviceProbe() + self._adb_probe = AdbDeviceProbe( + target_serial=os.environ.get("ARTEMIS_DEVICE_ID") + or os.environ.get("ADB_DEVICE_SERIAL") + ) self._report_cache: SystemReadinessReport | None = None self._report_cache_time = 0.0 self._report_cache_generation = -1 diff --git a/artemis/llm/reliability.py b/artemis/llm/reliability.py index c35ba6b86..3ebb9230b 100644 --- a/artemis/llm/reliability.py +++ b/artemis/llm/reliability.py @@ -75,6 +75,12 @@ def extract_status_code(error: BaseException) -> int | None: _RATE_LIMIT_MARKERS = ("rate limit", "quota", "resource_exhausted") +_HARD_QUOTA_MARKERS = ( + "per day", + "perday", + "generaterequestsperday", + "check your plan and billing details", +) _UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable") _AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential") # "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that @@ -91,6 +97,8 @@ def classify_failure(error: BaseException) -> Failure: message = str(error).lower() if code == 429 or any(marker in message for marker in _RATE_LIMIT_MARKERS): + if any(marker in message for marker in _HARD_QUOTA_MARKERS): + return Failure(FailureCategory.RATE_LIMIT, False, True) return Failure(FailureCategory.RATE_LIMIT, True, True) if code in {500, 502, 503, 504} or any(marker in message for marker in _UNAVAILABLE_MARKERS): return Failure(FailureCategory.PROVIDER_UNAVAILABLE, True, True) diff --git a/artemis/llm/router.py b/artemis/llm/router.py index f2e6b42b6..1e958dd2b 100644 --- a/artemis/llm/router.py +++ b/artemis/llm/router.py @@ -246,6 +246,7 @@ def create_model(cls, endpoint: ModelEndpoint) -> BaseChatModel: "max_output_tokens": endpoint.max_tokens, "api_key": api_key, "timeout": endpoint.timeout_seconds, + "max_retries": 1, "thinking_budget": endpoint.thinking_budget, "thinking_level": thinking_level, "include_thoughts": endpoint.include_thoughts, diff --git a/artemis/sdk/agent.py b/artemis/sdk/agent.py index c0c0da08b..bb53279b6 100644 --- a/artemis/sdk/agent.py +++ b/artemis/sdk/agent.py @@ -155,12 +155,14 @@ async def _prewarm_llm_connections(self, api_key: str | None = None): # 1. Pre-warm Native SDK client client = genai.Client(api_key=key) - # 2. Pre-warm LangChain client - chat = ChatGoogleGenerativeAI(model="gemini-3.8-flash", google_api_key=key) + # 2. Pre-warm LangChain client (use lightweight model & max_retries=1 to avoid burning flagship daily quota) + chat = ChatGoogleGenerativeAI( + model="gemini-3.5-flash-lite", google_api_key=key, max_retries=1, timeout=10 + ) # Fire both calls concurrently in the background await asyncio.gather( - client.aio.models.count_tokens(model="gemini-3.8-flash", contents="ping"), + client.aio.models.count_tokens(model="gemini-3.5-flash-lite", contents="ping"), chat.ainvoke("ping"), return_exceptions=True, ) diff --git a/artemis/services/llm.py b/artemis/services/llm.py index ec91e6fa0..2a17fd855 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -529,15 +529,19 @@ def __init__( base_model: BaseChatModel, ctx: ArtemisContext = None, endpoint: ModelEndpoint | None = None, + fallback_factory: Callable[[], Any] | None = None, ): self.base_model = base_model self.ctx = ctx self.endpoint = endpoint + self._fallback_factory = fallback_factory def __getattr__(self, item: str): return getattr(self.base_model, item) def bind_tools(self, *args, **kwargs): + orig_args = args + orig_kwargs = dict(kwargs) # Extract tools list from positional args or keyword args tools_list = None other_args = list(args) @@ -584,32 +588,67 @@ def bind_tools(self, *args, **kwargs): ] bound = self.base_model.bind_tools(processed_tools, *other_args, **kwargs) - return RobustChatModelWrapper(bound, self.ctx, endpoint=self.endpoint) + fb_factory = ( + (lambda: self._fallback_factory().bind_tools(*orig_args, **orig_kwargs)) + if self._fallback_factory is not None + else None + ) + return RobustChatModelWrapper( + bound, self.ctx, endpoint=self.endpoint, fallback_factory=fb_factory + ) def with_config(self, *args, **kwargs): + fb_factory = ( + (lambda: self._fallback_factory().with_config(*args, **kwargs)) + if self._fallback_factory is not None + else None + ) return RobustChatModelWrapper( self.base_model.with_config(*args, **kwargs), self.ctx, endpoint=self.endpoint, + fallback_factory=fb_factory, ) def with_structured_output(self, *args, **kwargs): if hasattr(self.base_model, "with_structured_output"): + fb_factory = ( + (lambda: self._fallback_factory().with_structured_output(*args, **kwargs)) + if self._fallback_factory is not None + else None + ) return RobustChatModelWrapper( self.base_model.with_structured_output(*args, **kwargs), self.ctx, endpoint=self.endpoint, + fallback_factory=fb_factory, ) raise AttributeError(f"{type(self.base_model)} does not support with_structured_output") def __or__(self, other): + fb_factory = ( + (lambda: self._fallback_factory().__or__(other)) + if self._fallback_factory is not None + else None + ) return RobustChatModelWrapper( - self.base_model.__or__(other), self.ctx, endpoint=self.endpoint + self.base_model.__or__(other), + self.ctx, + endpoint=self.endpoint, + fallback_factory=fb_factory, ) def __ror__(self, other): + fb_factory = ( + (lambda: self._fallback_factory().__ror__(other)) + if self._fallback_factory is not None + else None + ) return RobustChatModelWrapper( - self.base_model.__ror__(other), self.ctx, endpoint=self.endpoint + self.base_model.__ror__(other), + self.ctx, + endpoint=self.endpoint, + fallback_factory=fb_factory, ) def _provider_value(self) -> str | None: @@ -638,7 +677,7 @@ def _meter_usage(self, response) -> None: engine = _get_current_data_engine() record_llm_usage(engine, response, source=self._endpoint_key()) - async def ainvoke(self, *args, **kwargs): + async def _ainvoke_direct(self, *args, **kwargs): args, kwargs, _ = self._traced_call(args, kwargs) response = await _run_with_recovery( functools.partial(self.base_model.ainvoke, *args, **kwargs), @@ -648,16 +687,15 @@ async def ainvoke(self, *args, **kwargs): self._meter_usage(response) return response - async def complete(self, *args, **kwargs): - """Single completion entry point: returns the full final message. + async def ainvoke(self, *args, **kwargs): + if self._fallback_factory is not None and not _FALLBACK_AVAILABLE.get(): + return await with_fallback( + main_call=lambda: self._ainvoke_direct(*args, **kwargs), + fallback_call=lambda: self._fallback_factory().ainvoke(*args, **kwargs), + ) + return await self._ainvoke_direct(*args, **kwargs) - Streaming is a transport detail handled internally — provider chunks - are forwarded to the live UI as deltas and accumulated here, so - downstream consumers only ever see one complete message (or a typed - LLMCallError). A mid-stream failure discards the partial output, - signals the UI to drop it, and retries the whole call per policy; - partial or duplicated chunks can never reach message history. - """ + async def _complete_direct(self, *args, **kwargs): args, kwargs, _ = self._traced_call(args, kwargs) emit_deltas = bool(self.ctx and self.ctx.data_engine) response = await _run_with_recovery( @@ -668,6 +706,23 @@ async def complete(self, *args, **kwargs): self._meter_usage(response) return response + async def complete(self, *args, **kwargs): + """Single completion entry point: returns the full final message. + + Streaming is a transport detail handled internally — provider chunks + are forwarded to the live UI as deltas and accumulated here, so + downstream consumers only ever see one complete message (or a typed + LLMCallError). A mid-stream failure discards the partial output, + signals the UI to drop it, and retries the whole call per policy; + partial or duplicated chunks can never reach message history. + """ + if self._fallback_factory is not None and not _FALLBACK_AVAILABLE.get(): + return await with_fallback( + main_call=lambda: self._complete_direct(*args, **kwargs), + fallback_call=lambda: self._fallback_factory().complete(*args, **kwargs), + ) + return await self._complete_direct(*args, **kwargs) + def _emit_stream_delta(self, stream_exec_id, chunk) -> None: content = getattr(chunk, "content", None) if not content: @@ -955,7 +1010,27 @@ def _get_llm( handler = DataEngineCallbackHandler(ctx) bound_model = raw_model.with_config(callbacks=[handler]) - return RobustChatModelWrapper(bound_model, ctx, endpoint=endpoint) # type: ignore + fallback_factory = None + if not use_fallback: + try: + fb_endpoint = _resolve_endpoint(ctx, name, is_utils=is_utils, use_fallback=True) + if (fb_endpoint.provider, fb_endpoint.model_name) != ( + endpoint.provider, + endpoint.model_name, + ): + fallback_factory = lambda: _get_llm( # noqa: E731 + ctx, + name, + is_utils=is_utils, + use_fallback=True, + temperature=temperature, + ) + except Exception: + fallback_factory = None + + return RobustChatModelWrapper( + bound_model, ctx, endpoint=endpoint, fallback_factory=fallback_factory + ) # type: ignore get_llm: GetLLM = _get_llm