From ccbe8a943b6dcecc9030ceab68b22ee95649eb27 Mon Sep 17 00:00:00 2001 From: ddl-subir-m Date: Fri, 20 Mar 2026 09:45:09 -0500 Subject: [PATCH 1/4] feat: add leaderboard normalization utils and request project ID resolver - Add normalize_leaderboard_rows/payload to fix TimeSeries fit_time display - Add resolve_request_project_id() to centralize project context extraction from X-Project-Id header, query params, and DOMINO_PROJECT_ID env var Co-Authored-By: Claude Opus 4.6 (1M context) --- automl-service/app/api/utils.py | 18 ++++- automl-service/app/core/leaderboard_utils.py | 46 +++++++++++++ automl-service/tests/test_api_utils.py | 51 +++++++++++++++ .../tests/test_leaderboard_utils.py | 65 +++++++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 automl-service/app/core/leaderboard_utils.py create mode 100644 automl-service/tests/test_api_utils.py create mode 100644 automl-service/tests/test_leaderboard_utils.py diff --git a/automl-service/app/api/utils.py b/automl-service/app/api/utils.py index 8f0ac197..aa24e9a5 100644 --- a/automl-service/app/api/utils.py +++ b/automl-service/app/api/utils.py @@ -1,14 +1,30 @@ """Shared utilities for API route handlers.""" +import os from typing import Optional, Tuple -from fastapi import HTTPException +from fastapi import HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from app.core.utils import remap_shared_path from app.db import crud +def resolve_request_project_id(request: Optional[Request]) -> Optional[str]: + """Resolve project context from request metadata with environment fallback.""" + if request is not None: + header_project_id = request.headers.get("X-Project-Id") + if header_project_id: + return header_project_id + + for query_key in ("projectId", "project_id"): + query_project_id = request.query_params.get(query_key) + if query_project_id: + return query_project_id + + return os.environ.get("DOMINO_PROJECT_ID") or None + + async def get_job_paths( db: AsyncSession, job_id: str ) -> Tuple[str, str, Optional[str], Optional[str]]: diff --git a/automl-service/app/core/leaderboard_utils.py b/automl-service/app/core/leaderboard_utils.py new file mode 100644 index 00000000..98c32fdf --- /dev/null +++ b/automl-service/app/core/leaderboard_utils.py @@ -0,0 +1,46 @@ +"""Helpers for normalizing leaderboard payloads across AutoGluon model types.""" + +from __future__ import annotations + +from typing import Any, Optional + + +def normalize_leaderboard_rows( + rows: Optional[list[dict[str, Any]]], +) -> Optional[list[dict[str, Any]]]: + """Normalize leaderboard rows to expose common timing keys. + + AutoGluon TimeSeries leaderboards expose ``fit_time_marginal`` but not + ``fit_time``. Our UI expects ``fit_time`` for the training-time chart and + leaderboard table, so copy the marginal value into ``fit_time`` when the + cumulative field is absent. + """ + if rows is None: + return None + + normalized_rows: list[dict[str, Any]] = [] + for row in rows: + normalized = dict(row) + + if normalized.get("fit_time") is None and normalized.get("fit_time_marginal") is not None: + normalized["fit_time"] = normalized["fit_time_marginal"] + + if normalized.get("pred_time_val") is None and normalized.get("pred_time_val_marginal") is not None: + normalized["pred_time_val"] = normalized["pred_time_val_marginal"] + + normalized_rows.append(normalized) + + return normalized_rows + + +def normalize_leaderboard_payload(payload: Any) -> Any: + """Normalize leaderboard payloads stored as either lists or dict wrappers.""" + if isinstance(payload, list): + return normalize_leaderboard_rows(payload) + + if isinstance(payload, dict) and isinstance(payload.get("models"), list): + normalized = dict(payload) + normalized["models"] = normalize_leaderboard_rows(payload["models"]) + return normalized + + return payload diff --git a/automl-service/tests/test_api_utils.py b/automl-service/tests/test_api_utils.py new file mode 100644 index 00000000..d0339ff8 --- /dev/null +++ b/automl-service/tests/test_api_utils.py @@ -0,0 +1,51 @@ +"""Tests for shared API request helpers.""" + +from starlette.requests import Request + +from app.api.utils import resolve_request_project_id + + +def _make_request(*, headers=None, query_string: bytes = b"") -> Request: + encoded_headers = [ + (key.lower().encode("latin-1"), value.encode("latin-1")) + for key, value in (headers or {}).items() + ] + return Request( + { + "type": "http", + "method": "GET", + "path": "/test", + "headers": encoded_headers, + "query_string": query_string, + } + ) + + +def test_resolve_request_project_id_prefers_header(monkeypatch): + monkeypatch.setenv("DOMINO_PROJECT_ID", "env-proj") + request = _make_request( + headers={"X-Project-Id": "header-proj"}, + query_string=b"project_id=query-proj", + ) + + assert resolve_request_project_id(request) == "header-proj" + + +def test_resolve_request_project_id_reads_camel_case_query_param(monkeypatch): + monkeypatch.delenv("DOMINO_PROJECT_ID", raising=False) + request = _make_request(query_string=b"projectId=query-proj") + + assert resolve_request_project_id(request) == "query-proj" + + +def test_resolve_request_project_id_reads_snake_case_query_param(monkeypatch): + monkeypatch.delenv("DOMINO_PROJECT_ID", raising=False) + request = _make_request(query_string=b"project_id=query-proj") + + assert resolve_request_project_id(request) == "query-proj" + + +def test_resolve_request_project_id_falls_back_to_environment(monkeypatch): + monkeypatch.setenv("DOMINO_PROJECT_ID", "env-proj") + + assert resolve_request_project_id(None) == "env-proj" diff --git a/automl-service/tests/test_leaderboard_utils.py b/automl-service/tests/test_leaderboard_utils.py new file mode 100644 index 00000000..16792ad2 --- /dev/null +++ b/automl-service/tests/test_leaderboard_utils.py @@ -0,0 +1,65 @@ +"""Tests for leaderboard payload normalization helpers.""" + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.core.leaderboard_utils import ( # noqa: E402 + normalize_leaderboard_payload, + normalize_leaderboard_rows, +) + + +def test_normalize_leaderboard_rows_copies_fit_time_from_marginal(): + rows = [ + { + "model": "WeightedEnsemble", + "fit_time": None, + "fit_time_marginal": 0.42, + "pred_time_val": 1.5, + } + ] + + normalized = normalize_leaderboard_rows(rows) + + assert normalized == [ + { + "model": "WeightedEnsemble", + "fit_time": 0.42, + "fit_time_marginal": 0.42, + "pred_time_val": 1.5, + } + ] + + +def test_normalize_leaderboard_rows_preserves_existing_fit_time(): + rows = [ + { + "model": "DirectTabular", + "fit_time": 12.3, + "fit_time_marginal": 4.5, + } + ] + + normalized = normalize_leaderboard_rows(rows) + + assert normalized[0]["fit_time"] == 12.3 + assert normalized[0]["fit_time_marginal"] == 4.5 + + +def test_normalize_leaderboard_payload_updates_models_wrapper(): + payload = { + "models": [ + { + "model": "Theta", + "fit_time_marginal": 0.07, + } + ], + "best_model": "Theta", + } + + normalized = normalize_leaderboard_payload(payload) + + assert normalized["models"][0]["fit_time"] == 0.07 + assert normalized["best_model"] == "Theta" From 90e49ae265fa13a05e406234653e5d51ecf498be Mon Sep 17 00:00:00 2001 From: ddl-subir-m Date: Mon, 23 Mar 2026 22:08:04 -0500 Subject: [PATCH 2/4] fix: remove DOMINO_PROJECT_ID env var fallback from resolve_request_project_id The env var is the App's own project, not the target project the user is working in. Falling back to it silently operates on the wrong project (root cause of datasets showing empty in cross-project scenarios). Co-Authored-By: Claude Opus 4.6 (1M context) --- automl-service/app/api/utils.py | 13 ++++++++++--- automl-service/tests/test_api_utils.py | 9 +++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/automl-service/app/api/utils.py b/automl-service/app/api/utils.py index aa24e9a5..b42aa65f 100644 --- a/automl-service/app/api/utils.py +++ b/automl-service/app/api/utils.py @@ -1,6 +1,5 @@ """Shared utilities for API route handlers.""" -import os from typing import Optional, Tuple from fastapi import HTTPException, Request @@ -11,7 +10,15 @@ def resolve_request_project_id(request: Optional[Request]) -> Optional[str]: - """Resolve project context from request metadata with environment fallback.""" + """Resolve project context from request metadata. + + Checks ``X-Project-Id`` header, then ``projectId`` / ``project_id`` + query params. Returns ``None`` when no project context is available. + + **No env-var fallback** — ``DOMINO_PROJECT_ID`` is the App's own + project, not the target project the user is working in. Falling back + to it silently operates on the wrong project. + """ if request is not None: header_project_id = request.headers.get("X-Project-Id") if header_project_id: @@ -22,7 +29,7 @@ def resolve_request_project_id(request: Optional[Request]) -> Optional[str]: if query_project_id: return query_project_id - return os.environ.get("DOMINO_PROJECT_ID") or None + return None async def get_job_paths( diff --git a/automl-service/tests/test_api_utils.py b/automl-service/tests/test_api_utils.py index d0339ff8..a4e776c8 100644 --- a/automl-service/tests/test_api_utils.py +++ b/automl-service/tests/test_api_utils.py @@ -45,7 +45,12 @@ def test_resolve_request_project_id_reads_snake_case_query_param(monkeypatch): assert resolve_request_project_id(request) == "query-proj" -def test_resolve_request_project_id_falls_back_to_environment(monkeypatch): +def test_resolve_request_project_id_ignores_environment_variable(monkeypatch): + """DOMINO_PROJECT_ID is the App's own project — never use it as fallback.""" monkeypatch.setenv("DOMINO_PROJECT_ID", "env-proj") - assert resolve_request_project_id(None) == "env-proj" + assert resolve_request_project_id(None) is None + + +def test_resolve_request_project_id_none_without_request(): + assert resolve_request_project_id(None) is None From f9edab6ce9c937c2cefb9d0b6e830b84bc3aae94 Mon Sep 17 00:00:00 2001 From: ddl-subir-m Date: Tue, 24 Mar 2026 10:41:55 -0500 Subject: [PATCH 3/4] fix: check query params before header in resolve_request_project_id Query params are the canonical approach going forward. The X-Project-Id header is kept as a fallback for legacy clients only. Co-Authored-By: Claude Opus 4.6 (1M context) --- automl-service/app/api/utils.py | 13 +++++++------ automl-service/tests/test_api_utils.py | 10 ++++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/automl-service/app/api/utils.py b/automl-service/app/api/utils.py index b42aa65f..812fb57d 100644 --- a/automl-service/app/api/utils.py +++ b/automl-service/app/api/utils.py @@ -12,23 +12,24 @@ def resolve_request_project_id(request: Optional[Request]) -> Optional[str]: """Resolve project context from request metadata. - Checks ``X-Project-Id`` header, then ``projectId`` / ``project_id`` - query params. Returns ``None`` when no project context is available. + Checks ``projectId`` / ``project_id`` query params first, then falls + back to the ``X-Project-Id`` header for legacy clients. Returns + ``None`` when no project context is available. **No env-var fallback** — ``DOMINO_PROJECT_ID`` is the App's own project, not the target project the user is working in. Falling back to it silently operates on the wrong project. """ if request is not None: - header_project_id = request.headers.get("X-Project-Id") - if header_project_id: - return header_project_id - for query_key in ("projectId", "project_id"): query_project_id = request.query_params.get(query_key) if query_project_id: return query_project_id + header_project_id = request.headers.get("X-Project-Id") + if header_project_id: + return header_project_id + return None diff --git a/automl-service/tests/test_api_utils.py b/automl-service/tests/test_api_utils.py index a4e776c8..7f6809a0 100644 --- a/automl-service/tests/test_api_utils.py +++ b/automl-service/tests/test_api_utils.py @@ -21,13 +21,19 @@ def _make_request(*, headers=None, query_string: bytes = b"") -> Request: ) -def test_resolve_request_project_id_prefers_header(monkeypatch): +def test_resolve_request_project_id_prefers_query_param_over_header(monkeypatch): monkeypatch.setenv("DOMINO_PROJECT_ID", "env-proj") request = _make_request( headers={"X-Project-Id": "header-proj"}, - query_string=b"project_id=query-proj", + query_string=b"projectId=query-proj", ) + assert resolve_request_project_id(request) == "query-proj" + + +def test_resolve_request_project_id_falls_back_to_header(): + request = _make_request(headers={"X-Project-Id": "header-proj"}) + assert resolve_request_project_id(request) == "header-proj" From 0beb3d6dc59613dab21ef9915ca528ff2fabb975 Mon Sep 17 00:00:00 2001 From: ddl-subir-m Date: Tue, 24 Mar 2026 10:45:53 -0500 Subject: [PATCH 4/4] remove X-Project-Id header fallback from resolve_request_project_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend sends both header and query param from the same source. No scenario where header is present but query param isn't. Query param only — simpler. Co-Authored-By: Claude Opus 4.6 (1M context) --- automl-service/app/api/utils.py | 14 ++------------ automl-service/tests/test_api_utils.py | 16 ++++------------ 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/automl-service/app/api/utils.py b/automl-service/app/api/utils.py index 812fb57d..989e2af1 100644 --- a/automl-service/app/api/utils.py +++ b/automl-service/app/api/utils.py @@ -10,15 +10,9 @@ def resolve_request_project_id(request: Optional[Request]) -> Optional[str]: - """Resolve project context from request metadata. + """Resolve project context from ``projectId`` / ``project_id`` query param. - Checks ``projectId`` / ``project_id`` query params first, then falls - back to the ``X-Project-Id`` header for legacy clients. Returns - ``None`` when no project context is available. - - **No env-var fallback** — ``DOMINO_PROJECT_ID`` is the App's own - project, not the target project the user is working in. Falling back - to it silently operates on the wrong project. + Returns ``None`` when no project context is available. """ if request is not None: for query_key in ("projectId", "project_id"): @@ -26,10 +20,6 @@ def resolve_request_project_id(request: Optional[Request]) -> Optional[str]: if query_project_id: return query_project_id - header_project_id = request.headers.get("X-Project-Id") - if header_project_id: - return header_project_id - return None diff --git a/automl-service/tests/test_api_utils.py b/automl-service/tests/test_api_utils.py index 7f6809a0..77d888c0 100644 --- a/automl-service/tests/test_api_utils.py +++ b/automl-service/tests/test_api_utils.py @@ -21,20 +21,12 @@ def _make_request(*, headers=None, query_string: bytes = b"") -> Request: ) -def test_resolve_request_project_id_prefers_query_param_over_header(monkeypatch): - monkeypatch.setenv("DOMINO_PROJECT_ID", "env-proj") - request = _make_request( - headers={"X-Project-Id": "header-proj"}, - query_string=b"projectId=query-proj", - ) - - assert resolve_request_project_id(request) == "query-proj" - - -def test_resolve_request_project_id_falls_back_to_header(): +def test_resolve_request_project_id_ignores_header(monkeypatch): + """X-Project-Id header is not used — only query params.""" + monkeypatch.delenv("DOMINO_PROJECT_ID", raising=False) request = _make_request(headers={"X-Project-Id": "header-proj"}) - assert resolve_request_project_id(request) == "header-proj" + assert resolve_request_project_id(request) is None def test_resolve_request_project_id_reads_camel_case_query_param(monkeypatch):