From 384a55de431216eb273b01e0a63c42cf2e78cf54 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 28 Jul 2026 22:04:57 +0000 Subject: [PATCH] Attribute API-key traffic on the lifecycle rows it creates The overlay opens on API-key traffic only, and record_lifecycle never set that flag. Auto-switch and auto-download run before the endpoint opens its request row, so a switch or a download that is refused never reaches api_monitor.start and its lifecycle row is the whole trace of the request. The monitor therefore stayed shut on exactly the failures automatic observability is for. The row now carries the attribution: a load takes it from the request that drove it, and auto-download passes it directly, since only an API request reaches that path at all. A manual unload and an idle unload are not API traffic and stay unattributed, so neither pops the overlay. --- studio/backend/core/inference/api_monitor.py | 6 +++++ .../core/inference/openai_auto_download.py | 3 ++- studio/backend/routes/inference.py | 3 +++ studio/backend/tests/test_api_monitor.py | 25 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) 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"