Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions artemis/config/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

"""LLM provider, model hierarchy, fallback chaining, and configuration loaders."""

import os
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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", {})

Expand Down
28 changes: 20 additions & 8 deletions artemis/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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__)


Expand Down Expand Up @@ -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."""

Expand Down
5 changes: 4 additions & 1 deletion artemis/core/diagnostics/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions artemis/llm/reliability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions artemis/llm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions artemis/sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
103 changes: 89 additions & 14 deletions artemis/services/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading