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(