diff --git a/smolrouter/app.py b/smolrouter/app.py index 4941b54..2db3028 100644 --- a/smolrouter/app.py +++ b/smolrouter/app.py @@ -2920,6 +2920,7 @@ def _timestamp_now_for_log(timestamp: Optional[datetime]) -> datetime: "identity_display_name", "original_model", "mapped_model", + "model_observation_status", "service_type", "status_code", "duration_ms", @@ -2935,6 +2936,21 @@ def _serialize_request_log_provider_metadata(log_entry: Any) -> dict[str, Any]: return serialize_request_metadata(log_entry, fields=REQUEST_LOG_METADATA_FIELDS) +MODEL_OBSERVATION_NOT_INSPECTED_RATE_LIMIT = "not_inspected_rate_limit" + + +def _derive_model_observation_status(log_entry: Any) -> str | None: + """Explain an absent model without mutating the persisted model fields.""" + + if ( + getattr(log_entry, "upstream_url", None) == "rate-limiter" + and not getattr(log_entry, "original_model", None) + and not getattr(log_entry, "mapped_model", None) + ): + return MODEL_OBSERVATION_NOT_INSPECTED_RATE_LIMIT + return None + + def _serialize_request_log_summary(log_entry: Any) -> dict[str, Any]: timestamp = getattr(log_entry, "timestamp", None) status_code = getattr(log_entry, "status_code", None) @@ -2955,6 +2971,7 @@ def _serialize_request_log_summary(log_entry: Any) -> dict[str, Any]: "service_type": getattr(log_entry, "service_type", None), "original_model": getattr(log_entry, "original_model", None), "mapped_model": getattr(log_entry, "mapped_model", None), + "model_observation_status": _derive_model_observation_status(log_entry), "duration_ms": duration_ms, "request_size": getattr(log_entry, "request_size", 0) or 0, "response_size": getattr(log_entry, "response_size", 0) or 0, @@ -4070,6 +4087,7 @@ async def request_detail(request_id: str, request: Request): "request_detail.html", { "log": log_entry, + "model_observation_status": _derive_model_observation_status(log_entry), "request_identity": _get_request_identity(log_entry), "elapsed_ms": elapsed_ms, "request_body_str": getattr(log_entry, "request_body", b"").decode("utf-8") diff --git a/smolrouter/templates/client_dashboard.html b/smolrouter/templates/client_dashboard.html index c9feb1c..3b14bfb 100644 --- a/smolrouter/templates/client_dashboard.html +++ b/smolrouter/templates/client_dashboard.html @@ -201,7 +201,10 @@

Recent Requests for {{ client_ip }}

return `${statusCode}`; } - function formatModel(originalModel, mappedModel) { + function formatModel(originalModel, mappedModel, observationStatus) { + if (observationStatus === 'not_inspected_rate_limit') { + return 'Not inspected (rate limited before body)'; + } if (mappedModel && mappedModel !== originalModel) { return `
@@ -400,7 +403,7 @@

Recent Requests for {{ client_ip }}

${renderProjectCell(log)} ${log.path || '-'} - ${formatModel(log.original_model, log.mapped_model)} + ${formatModel(log.original_model, log.mapped_model, log.model_observation_status)} ${formatSize(log.request_size)} ${formatDuration(log.duration_ms)} ${formatStatus(log.status_code)} @@ -548,7 +551,7 @@

Recent Requests for {{ client_ip }}

${renderProjectCell(request)} ${request.path || '-'} - ${formatModel(request.original_model, request.mapped_model)} + ${formatModel(request.original_model, request.mapped_model, request.model_observation_status)} ${formatSize(request.request_size)} ${formatDuration(request.duration_ms)} ${formatStatus(request.status_code)} @@ -582,7 +585,7 @@

Recent Requests for {{ client_ip }}

${renderProjectCell(request)} ${request.path || '-'} - ${formatModel(request.original_model, request.mapped_model)} + ${formatModel(request.original_model, request.mapped_model, request.model_observation_status)} ${formatSize(request.request_size)} ${formatDuration(request.duration_ms)} ${formatStatus(request.status_code)} diff --git a/smolrouter/templates/index.html b/smolrouter/templates/index.html index a056d03..f107518 100644 --- a/smolrouter/templates/index.html +++ b/smolrouter/templates/index.html @@ -447,7 +447,10 @@

Recent Requests

`; } - function formatModel(originalModel, mappedModel) { + function formatModel(originalModel, mappedModel, observationStatus) { + if (observationStatus === 'not_inspected_rate_limit') { + return 'Not inspected (rate limited before body)'; + } const resolvedOriginal = originalModel || mappedModel; const resolvedMapped = mappedModel || originalModel; @@ -527,7 +530,7 @@

Recent Requests

${renderProjectCell(log)} ${renderProviderCell(log)} ${escapeHtml(log.path || '-')} - ${formatModel(log.original_model, log.mapped_model)} + ${formatModel(log.original_model, log.mapped_model, log.model_observation_status)} ${formatSize(log.request_size)} ${formatDuration(log.duration_ms)} ${formatStatus(log.status_code)} diff --git a/smolrouter/templates/project_detail.html b/smolrouter/templates/project_detail.html index 6911b5b..9dd87f0 100644 --- a/smolrouter/templates/project_detail.html +++ b/smolrouter/templates/project_detail.html @@ -187,7 +187,13 @@

Recent Facade-Key Requests

{{ log.timestamp[:19] if log.timestamp else "-" }} {{ log.source_ip }} {{ log.path }} - {{ log.mapped_model or log.original_model or "-" }} + + {% if log.model_observation_status == "not_inspected_rate_limit" %} + Not inspected (rate limited before body) + {% else %} + {{ log.mapped_model or log.original_model or "-" }} + {% endif %} + {{ log.status_code if log.status_code else "pending" }} {% else %} diff --git a/smolrouter/templates/request_detail.html b/smolrouter/templates/request_detail.html index 49fd3f4..eb65962 100644 --- a/smolrouter/templates/request_detail.html +++ b/smolrouter/templates/request_detail.html @@ -379,7 +379,14 @@

Response Information

- {% if log.original_model or log.mapped_model %} + {% if model_observation_status == "not_inspected_rate_limit" %} +
+

Model

+
+ Not inspected (rate limited before body) +
+
+ {% elif log.original_model or log.mapped_model %}

Model Mapping

diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index b2eb572..a316575 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -981,7 +981,7 @@ async def test_proxy_backed_routes_enforce_rate_limit_before_body_parsing( monkeypatch.setattr(app_module, "_parse_openai_request_payload", parse_payload) monkeypatch.setattr(app_module, "_route_openai_request", route_upstream) - response = await async_client.post(path, content=b'{"secret":"must-not-be-read"}') + response = await async_client.post(path, content=b'{"model":"must-not-be-inspected","secret":"must-not-be-read"}') assert response.status_code == 429 assert response.json()["error"]["code"] == "anonymous_rate_limit_exceeded" @@ -1033,6 +1033,35 @@ async def test_rate_limiter_failure_returns_stable_503_before_parse_or_upstream( route_upstream.assert_not_awaited() +@pytest.mark.asyncio +async def test_rate_limiter_503_log_derives_model_status_without_reading_body( + async_client, isolated_db, monkeypatch +): + monkeypatch.setattr(app_module, "ENABLE_LOGGING", True) + limiter = SimpleNamespace(check=AsyncMock(side_effect=ConnectionError("redis unavailable"))) + parse_payload = AsyncMock(side_effect=AssertionError("body must not be parsed")) + monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter) + monkeypatch.setattr(app_module, "_parse_openai_request_payload", parse_payload) + + response = await async_client.post( + "/v1/responses", + content=b'{"model":"private-model","input":"must-not-be-read"}', + ) + + assert response.status_code == 503 + records = await app_module.RequestLog.get_recent(1) + assert records[0].status_code == 503 + assert not records[0].original_model + assert not records[0].mapped_model + assert getattr(records[0], "request_body", None) is None + assert getattr(records[0], "request_body_key", None) is None + assert getattr(records[0], "request_body_hash", None) is None + assert app_module._serialize_request_log(records[0])["model_observation_status"] == ( + "not_inspected_rate_limit" + ) + parse_payload.assert_not_awaited() + + @pytest.mark.asyncio async def test_disabled_request_limiter_skips_check(async_client, mock_openai_upstream, disable_logging, monkeypatch): limiter = SimpleNamespace(check=AsyncMock(side_effect=AssertionError("disabled limiter must not run"))) @@ -1266,7 +1295,7 @@ async def fake_get_active_container(_legacy_proxy: bool): response = await async_client.post( "/v1/embeddings", - content=b'{"api_key":"never-store-this"}', + content=b'{"model":"text-embedding-private","api_key":"never-store-this"}', headers={app_module.FACADE_KEY_HEADER: "srk-project-secret"}, ) @@ -1275,6 +1304,29 @@ async def fake_get_active_container(_legacy_proxy: bool): assert records[0].status_code == 429 assert records[0].identity_subject_id == "project-a" assert records[0].request_size == 0 + assert not records[0].original_model + assert not records[0].mapped_model + assert getattr(records[0], "request_body", None) is None + assert getattr(records[0], "request_body_key", None) is None + assert getattr(records[0], "request_body_hash", None) is None + serialized = app_module._serialize_request_log(records[0]) + assert serialized["model_observation_status"] == "not_inspected_rate_limit" + assert serialized["original_model"] in (None, "") + assert serialized["mapped_model"] in (None, "") + main_page = await async_client.get("/") + client_page = await async_client.get(f"/clients/{records[0].source_ip}") + project_page = await async_client.get("/projects/project-a") + request_page = await async_client.get(f"/request/{records[0].id}") + assert main_page.status_code == 200 + assert client_page.status_code == 200 + assert project_page.status_code == 200 + assert request_page.status_code == 200 + expected_message = "Not inspected (rate limited before body)" + assert expected_message in main_page.text + assert expected_message in client_page.text + assert 'font-size: 0.75rem; color: #666;' in client_page.text + assert expected_message in project_page.text + assert expected_message in request_page.text assert "project-secret" not in str(response.headers) fake_container.route_request.assert_not_awaited() @@ -2833,6 +2885,76 @@ def test_body_status_preserves_write_failed_when_archival_failed(): assert app_module._body_status(log_entry, "request_body") == "write_failed" +@pytest.mark.parametrize( + ("status_code", "error_message"), + [(429, "anonymous_rate_limit_exceeded"), (503, "rate_limiter_unavailable")], +) +def test_request_log_summary_derives_uninspected_model_status_for_limiter_terminal_logs( + status_code, error_message +): + log_entry = SimpleNamespace( + id="limited-request", + timestamp=app_module.datetime.now(), + source_ip="127.0.0.1", + path="/v1/chat/completions", + service_type="openai", + upstream_url="rate-limiter", + original_model="", + mapped_model=None, + status_code=status_code, + error_message=error_message, + ) + + summary = app_module._serialize_request_log_summary(log_entry) + detail = app_module._serialize_request_detail_response( + log_entry, + {"is_duplicate": False, "duplicate_count": 0, "request_body_hash": None, "duplicates": []}, + ) + + assert summary["model_observation_status"] == "not_inspected_rate_limit" + assert detail["model_observation_status"] == "not_inspected_rate_limit" + assert summary["original_model"] == "" + assert summary["mapped_model"] is None + + +@pytest.mark.parametrize( + "log_entry", + [ + SimpleNamespace(upstream_url="provider:openai", original_model="", mapped_model=None), + SimpleNamespace(upstream_url="rate-limiter", original_model="gpt-4o", mapped_model=None), + SimpleNamespace(upstream_url="rate-limiter", original_model=None, mapped_model="routed-model"), + ], +) +def test_model_observation_status_does_not_relabel_other_missing_or_known_models(log_entry): + assert app_module._derive_model_observation_status(log_entry) is None + + +def test_model_observation_status_does_not_enter_performance_or_model_filter_fields(): + log_entry = SimpleNamespace( + id="limited-request", + timestamp=app_module.datetime.now(), + upstream_url="rate-limiter", + original_model=None, + mapped_model=None, + service_type="openai", + path="/v1/chat/completions", + status_code=429, + duration_ms=5, + request_size=0, + response_size=0, + prompt_tokens=None, + completion_tokens=None, + total_tokens=None, + ) + + point = app_module._serialize_performance_point(log_entry) + + assert point["model"] is None + assert point["original_model"] is None + assert point["mapped_model"] is None + assert app_module._matches_performance_filters(log_entry, "not_inspected_rate_limit", None) is False + + def test_serialize_request_detail_response_reuses_summary_and_provider_metadata(): timestamp = app_module.datetime.now() log_entry = SimpleNamespace(