Skip to content
Open
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
33 changes: 28 additions & 5 deletions apps/admin_console/routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,14 @@ def _list_sessions_sync():
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
llm_model, llm_provider = model_service.resolve_session_llm_override(row_dict)
# A stored override is reported even when the profile stays unresolved
# (the architecture stays Flash until the trace-name pass below settles
# it), because the pinned model is a fact about the run. Legacy rows
# without the keys keep the global default.
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
model_service.get_active_model_info(sess_profile, llm_model, llm_provider)
if sess_profile or llm_model or llm_provider
else default_model_info
)
if not sess_profile:
Expand All @@ -143,7 +148,10 @@ def _list_sessions_sync():
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
llm_model, llm_provider = model_service.resolve_session_llm_override(row_dict)
row_dict["model_info"] = model_service.get_active_model_info(
sess_profile, llm_model, llm_provider
)

if orphaned_ids:
try:
Expand All @@ -169,11 +177,26 @@ def _list_sessions_sync():

@router.get("/api/sessions/{session_id}")
async def get_session_details(session_id: str):
"""Retrieve details for a single automation session."""
"""Retrieve details for a single automation session.

Also echoes the per-task LLM override the run recorded in device_info (the
SDK writes it there), unpacking the same JSON ``get_session_usage`` reads, so
clients see which model the session actually used. Rows written before the
override existed report ``null`` and keep the configured model.
"""
row = session_repo.get_session_by_id(session_id)
if not row:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return dict(row)
payload = dict(row)
llm_model, llm_provider = model_service.resolve_session_llm_override(payload)
payload["llm_model"] = llm_model
payload["llm_provider"] = llm_provider
# model_info for parity with the list endpoint; the profile comes from the
# stored device_info, so no extra trace lookups are needed here.
payload["model_info"] = model_service.get_active_model_info(
model_service.resolve_session_profile(payload), llm_model, llm_provider
)
return payload


@router.get("/api/sessions/{session_id}/usage")
Expand Down
62 changes: 50 additions & 12 deletions apps/admin_console/routers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ async def run_task(request: RunRequest):
task_payload.setdefault("session_id", requested_sid)
task_payload.setdefault("goal", incoming_goals[0])
task_payload.setdefault("profile", request.profile or "flash")
# setdefault, not assignment: an idempotent retry replays the first
# override, so a task already queued/active keeps the LLM it started with.
task_payload.setdefault("llm_model", request.llm_model)
task_payload.setdefault("llm_provider", request.llm_provider)
task_payload.setdefault("device_serial", request.device_serial)
task_payload.setdefault("status", "running" if is_active else "queued")
return {
Expand Down Expand Up @@ -172,6 +176,8 @@ async def run_task(request: RunRequest):
enable_outputter=request.enable_outputter,
verification_level=request.verification_level,
explorer_mode=request.explorer_mode,
llm_model=request.llm_model,
llm_provider=request.llm_provider,
locked_app_package=request.locked_app_package,
app_path=request.app_path,
device_serial=target_serial,
Expand All @@ -197,6 +203,16 @@ async def get_run_defaults():
}


@router.get("/api/llm-options")
async def get_llm_options():
"""Provider allowlist, ``artemis.jsonc`` presets and the configured default.

The model picker builds its dropdowns from this instead of hard-coding a
provider list, so a new provider or preset shows up without a UI change.
"""
return model_service.get_llm_options()


@router.get("/api/devices")
async def list_devices():
"""List all connected Android devices with their busy / idle status."""
Expand Down Expand Up @@ -305,17 +321,27 @@ async def get_status():
or state.current_profile
or (running_task.get("profile") if running_task and not global_owner else None)
)
if not active_profile and (running_sid or latest_session_id):
check_sid = running_sid or latest_session_id
sess_row = session_repo.get_session_by_id(check_sid)
if sess_row:
llm_traces = session_repo.get_llm_traces_for_profile(check_sid)
agent_names = session_repo.get_agent_trace_names(check_sid)
active_profile = model_service.resolve_session_profile(
sess_row, llm_traces, agent_names=agent_names
)

model_info = model_service.get_active_model_info(active_profile)
# One row fetch, reused for both consumers below: the model echo needs it
# whenever a session exists, and profile resolution falls back to it when the
# worker left no profile behind. Hence the name - it feeds model resolution,
# not just the profile. No second query is issued for the override.
sess_row_for_model: dict[str, Any] | None = None
check_sid = running_sid or latest_session_id
if check_sid:
sess_row_for_model = session_repo.get_session_by_id(check_sid)

if not active_profile and sess_row_for_model:
llm_traces = session_repo.get_llm_traces_for_profile(check_sid)
agent_names = session_repo.get_agent_trace_names(check_sid)
active_profile = model_service.resolve_session_profile(
sess_row_for_model, llm_traces, agent_names=agent_names
)

# The stored override applies even when active_profile came from the owner
# connection, the worker state or the queue item: a pinned model is a fact
# about the run, independent of how the profile was resolved.
llm_model, llm_provider = model_service.resolve_session_llm_override(sess_row_for_model or {})
model_info = model_service.get_active_model_info(active_profile, llm_model, llm_provider)

# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
global_queued = DeviceExecutionLock.get_queued_tasks()
Expand Down Expand Up @@ -358,7 +384,19 @@ async def get_status():
conn_info = state.active_connections[str(latest_session_id)]
is_paused = state.is_paused
conn_profile = conn_info.get("profile") or active_profile
conn_model_info = model_service.get_active_model_info(conn_profile)
# This branch reports the connection's own session, so reuse the row read
# above only when it describes that same session; otherwise read it here.
# Just the one indexed lookup - the trace-name profile lookups stay on the
# main path, so this branch stays cheap.
conn_row = sess_row_for_model
if not conn_row or str(conn_row.get("session_id") or "") != str(latest_session_id):
conn_row = session_repo.get_session_by_id(latest_session_id)
conn_llm_model, conn_llm_provider = model_service.resolve_session_llm_override(
conn_row or {}
)
conn_model_info = model_service.get_active_model_info(
conn_profile, conn_llm_model, conn_llm_provider
)
return {
"status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None,
Expand Down
22 changes: 21 additions & 1 deletion apps/admin_console/schemas/task_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from pydantic import BaseModel
from pydantic import BaseModel, model_validator

from artemis.config.llm_override import normalize_llm_override


class RunRequest(BaseModel):
Expand All @@ -26,13 +28,31 @@ class RunRequest(BaseModel):
# version used by the Operator ('flash' | 'pro' | 'ultra').
verification_level: str | None = None
explorer_mode: str | None = None
# Per-task LLM override: pins every model of this one task to `llm_model`
# (optionally on `llm_provider`) without touching artemis.jsonc or any
# global state. Omitted/blank means "use the configured nodes".
llm_model: str | None = None
llm_provider: str | None = None
locked_app_package: str | None = None
app_path: str | None = None
device_serial: str | None = None
ingress: str | None = "frontend"
session_id: str | None = None
conversation_id: str | None = None

@model_validator(mode="after")
def _normalize_llm_override(self) -> "RunRequest":
"""Normalise the override and reject an unusable one up front.

Blank means "not requested", the provider is lower-cased against the
supported set, and a provider without a model is refused so FastAPI
answers 422 instead of the task dying mid-run on a broken endpoint.
"""
self.llm_model, self.llm_provider = normalize_llm_override(
self.llm_model, self.llm_provider
)
return self


class ReplayRequest(BaseModel):
device_id: str
Expand Down
91 changes: 88 additions & 3 deletions apps/admin_console/services/model_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import json
import logging
import time
from typing import Any
from typing import Any, get_args

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -55,11 +55,31 @@ def _get_llm_provider_and_model(cls) -> tuple[str, str]:
return provider, model_id

@classmethod
def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
def get_active_model_info(
cls,
profile: str | None = None,
llm_model: str | None = None,
llm_provider: str | None = None,
) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration.

Args:
profile: Resolved architecture ('flash' / 'pro') or None for Flash.
llm_model: Per-task model override recorded for the session. It wins
the ``id`` so the reported model is the one that actually ran.
llm_provider: Provider of that override. Only replaces the
configured provider when the task pinned it; without it each
node keeps its own provider.
"""
# 1. Determine underlying LLM model and provider from config (cached)
provider, model_id = cls._get_llm_provider_and_model()

# 1b. A recorded per-task override outranks the configured defaults.
if llm_model:
model_id = str(llm_model)
if llm_provider:
provider = str(llm_provider)

# 2. Determine agent architecture name (Flash vs Pro)
arch_name = "Flash"
if profile:
Expand All @@ -78,6 +98,71 @@ def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]:
"architecture": f"ARTEMIS {arch_name}",
}

@classmethod
def get_llm_options(cls) -> dict[str, Any]:
"""Providers, the raw ``artemis.jsonc`` presets, and the configured default.

Feeds the Console model picker: the provider allowlist is the same
``LLMProvider`` literal the override validates against, and the presets
are read straight from the config so the choices stay in sync with
``artemis.jsonc`` instead of being duplicated in the UI. An unreadable
config yields no presets rather than an error, matching how the rest of
the display paths degrade.
"""
from artemis.config.constants import ARTEMIS_CONFIG_FILENAME, LLMProvider
from artemis.config.paths import get_config_path
from artemis.utils.file import load_jsonc

provider, model = cls._get_llm_provider_and_model()
presets: list[dict[str, str]] = []
try:
with open(get_config_path(ARTEMIS_CONFIG_FILENAME), encoding="utf-8") as f:
raw = load_jsonc(f)
except Exception as exc:
logger.warning("Could not read LLM presets for display: %s", exc)
raw = None
block = raw.get("presets") if isinstance(raw, dict) else None
if isinstance(block, dict):
for name, preset in block.items():
if isinstance(preset, dict):
presets.append(
{
"name": str(name),
"provider": str(preset.get("provider") or ""),
"model": str(preset.get("model") or ""),
}
)
return {
"providers": list(get_args(LLMProvider)),
"presets": presets,
"default": {"provider": provider, "model": model},
}

@staticmethod
def resolve_session_llm_override(row_dict: dict[str, Any]) -> tuple[str | None, str | None]:
"""Read the per-task LLM override a session recorded in its device_info.

Reads the same schemaless JSON ``resolve_session_profile`` parses. Rows
written before the override existed carry neither key, which is reported
as "no override" so the caller falls back to the configured model.
"""
d_info_raw = row_dict.get("device_info")
if not d_info_raw:
return (None, None)
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
except (ValueError, TypeError):
# Malformed device_info JSON: treat the run as un-overridden.
return (None, None)
if not isinstance(d_info, dict):
return (None, None)
model = d_info.get("llm_model")
provider = d_info.get("llm_provider")
return (
str(model).strip() or None if isinstance(model, str) else None,
str(provider).strip().lower() or None if isinstance(provider, str) else None,
)

@staticmethod
def resolve_session_profile(
row_dict: dict[str, Any],
Expand Down
24 changes: 24 additions & 0 deletions apps/admin_console/services/task_queue_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from artemis.config.llm_override import normalize_llm_override
from artemis.runtime import (
AdbEndpoint,
AdbTarget,
Expand Down Expand Up @@ -505,6 +506,8 @@ def _build_worker_invocation(
enable_outputter = task_item.get("enable_outputter")
verification_level = task_item.get("verification_level")
explorer_mode = task_item.get("explorer_mode")
llm_model = task_item.get("llm_model")
llm_provider = task_item.get("llm_provider")
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
app_path = task_item.get("app_path")

Expand Down Expand Up @@ -551,6 +554,10 @@ def _build_worker_invocation(
cmd.extend(["--verification-level", str(verification_level)])
if explorer_mode:
cmd.extend(["--explorer-pro-mode", str(explorer_mode)])
if llm_model:
cmd.extend(["--model", str(llm_model)])
if llm_provider:
cmd.extend(["--provider", str(llm_provider)])
if locked_app:
cmd.extend(["--locked-app", str(locked_app)])
if app_path:
Expand Down Expand Up @@ -1004,6 +1011,8 @@ def _create_queue_item(
conversation_id: str | None,
verification_level: str | None = None,
explorer_mode: str | None = None,
llm_model: str | None = None,
llm_provider: str | None = None,
) -> dict[str, Any]:
"""Reserve a device slot and build one pending queue item for a goal."""
sess_id = single_session_id if single_session_id else str(uuid.uuid4())
Expand All @@ -1025,6 +1034,8 @@ def _create_queue_item(
"enable_outputter": enable_outputter,
"verification_level": verification_level,
"explorer_mode": explorer_mode,
"llm_model": llm_model,
"llm_provider": llm_provider,
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
Expand Down Expand Up @@ -1052,17 +1063,28 @@ async def enqueue_tasks(
conversation_id: str | None = None,
verification_level: str | None = None,
explorer_mode: str | None = None,
llm_model: str | None = None,
llm_provider: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker.

``verification_level`` and ``explorer_mode`` are Pro-profile tuning knobs
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
they are normalised here so the queue item and the CLI see one spelling.

``llm_model`` / ``llm_provider`` are the per-task LLM override: they are
persisted on the queue item and forwarded to the worker as ``--model`` /
``--provider``, which pin the models of that one task without editing
``artemis.jsonc`` or restarting anything. A blank value means "unset".
"""
verification_level = (
str(verification_level).strip().lower() or None if verification_level else None
)
explorer_mode = str(explorer_mode).strip().lower() or None if explorer_mode else None
# Model identifiers are case-sensitive, so only whitespace is trimmed;
# provider names are normalised to lower case and validated, so an
# unusable override is rejected before the worker is woken.
llm_model, llm_provider = normalize_llm_override(llm_model, llm_provider)
cls.ensure_worker_running()

enqueued_tasks = []
Expand Down Expand Up @@ -1105,6 +1127,8 @@ async def enqueue_tasks(
conversation_id,
verification_level=verification_level,
explorer_mode=explorer_mode,
llm_model=llm_model,
llm_provider=llm_provider,
)
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
Expand Down
Loading
Loading