diff --git a/studio/backend/core/inference/api_monitor.py b/studio/backend/core/inference/api_monitor.py index 92104df688..10d177fc23 100644 --- a/studio/backend/core/inference/api_monitor.py +++ b/studio/backend/core/inference/api_monitor.py @@ -173,6 +173,7 @@ class ApiMonitor: model: str, reason: Optional[str] = None, running: bool = False, + via_api_key: bool = False, ) -> str: """Record a model load/unload alongside the request traffic that caused it. @@ -199,6 +200,11 @@ class ApiMonitor: event = event, reason = reason, shared = True, + # The overlay opens on API-key traffic only. A switch or download that + # is refused never reaches api_monitor.start, so this row is the whole + # trace of it, and without the attribution the monitor stayed shut on + # exactly the failures it exists to surface. + via_api_key = via_api_key, ) with self._lock: self._entries.appendleft(entry) diff --git a/studio/backend/core/inference/openai_auto_download.py b/studio/backend/core/inference/openai_auto_download.py index cad5e40d14..e313aa081f 100644 --- a/studio/backend/core/inference/openai_auto_download.py +++ b/studio/backend/core/inference/openai_auto_download.py @@ -777,7 +777,8 @@ async def _dispatch( return busy monitor_id = api_monitor.record_lifecycle( - event = "download", model = label, reason = "api", running = True + # Only an API request reaches auto-download, hence reason "api". + event = "download", model = label, reason = "api", running = True, via_api_key = True ) with _lock: if _active is active: diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 61b3520d19..69362486dd 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5321,6 +5321,9 @@ async def _load_model_impl( event = "load", model = _lifecycle_model_label(request.model_path, request.gguf_variant), running = True, + # Auto-switch loads run before the endpoint opens its request row, so a + # load that fails there leaves this as the only trace of API traffic. + via_api_key = _request_used_api_key(fastapi_request), ) native_grant_backed = False diff --git a/studio/backend/tests/test_api_monitor.py b/studio/backend/tests/test_api_monitor.py index 2b611928e9..13e6b8f39e 100644 --- a/studio/backend/tests/test_api_monitor.py +++ b/studio/backend/tests/test_api_monitor.py @@ -517,3 +517,28 @@ def test_hidden_shared_ids_do_not_outlive_their_entries(): for i in range(5): monitor.record_lifecycle(event = "unload", model = f"org/M{i}") assert not monitor._hidden_shared.get("alice") + + +def test_an_api_triggered_lifecycle_row_carries_the_attribution(): + """The overlay opens on API-key traffic only. An auto-switch or auto-download + that is refused never reaches api_monitor.start, so the lifecycle row is the + whole trace of that request; without the attribution the monitor stayed shut + on exactly the failures it exists to surface.""" + monitor = ApiMonitor(max_entries = 5) + + api_load = monitor.record_lifecycle( + event = "load", model = "org/Repo-GGUF", running = True, via_api_key = True + ) + monitor.record_lifecycle(event = "unload", model = "org/Repo-GGUF", reason = "idle") + + rows = {e["id"]: e for e in monitor.snapshot()} + assert rows[api_load]["via_api_key"] is True + # A background unload is not API traffic and must not pop the overlay. + idle = [e for e in rows.values() if e["event"] == "unload"] + assert idle and all(e["via_api_key"] is False for e in idle) + + # The failure path keeps it: failing the row must not drop the attribution. + monitor.fail(api_load, error = "auto-switch refused") + after = {e["id"]: e for e in monitor.snapshot()} + assert after[api_load]["via_api_key"] is True + assert after[api_load]["status"] == "error"