From 02ffe81d1740812ffbfbeba2f897f120a01d74a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=B2=D1=83defin85?= Date: Fri, 13 Mar 2026 09:22:33 +0300 Subject: [PATCH] feat(request-logs): add request kind and session correlation --- .../versions/20260213_000000_base_schema.py | 2 + ..._add_request_logs_kind_and_session_hash.py | 51 ++++++++++++++ app/db/models.py | 2 + app/modules/proxy/service.py | 62 +++++++++++++++++ app/modules/request_logs/api.py | 10 +++ app/modules/request_logs/mappers.py | 2 + app/modules/request_logs/repository.py | 30 +++++++- app/modules/request_logs/schemas.py | 4 ++ app/modules/request_logs/service.py | 30 +++++++- .../__integration__/dashboard-flow.test.tsx | 2 + frontend/src/features/dashboard/api.ts | 8 +++ .../dashboard/components/dashboard-page.tsx | 33 +++++++++ .../components/filters/request-filters.tsx | 20 ++++++ .../components/recent-requests-table.test.tsx | 6 ++ .../components/recent-requests-table.tsx | 38 ++++++++++- .../dashboard/hooks/use-request-logs.test.ts | 18 ++++- .../dashboard/hooks/use-request-logs.ts | 16 ++++- .../src/features/dashboard/schemas.test.ts | 40 +++++++++++ frontend/src/features/dashboard/schemas.ts | 6 ++ frontend/src/test/mocks/factories.ts | 4 ++ frontend/src/test/mocks/handlers.ts | 20 ++++++ .../design.md | 52 ++++++++++++++ .../proposal.md | 20 ++++++ .../specs/frontend-architecture/spec.md | 26 +++++++ .../specs/responses-api-compat/spec.md | 23 +++++++ .../tasks.md | 19 ++++++ openspec/specs/frontend-architecture/spec.md | 17 ++++- .../specs/responses-api-compat/context.md | 2 + openspec/specs/responses-api-compat/spec.md | 22 ++++++ tests/integration/test_migrations.py | 2 + tests/integration/test_proxy_compact.py | 56 ++++++++++----- tests/integration/test_proxy_responses.py | 11 ++- .../test_proxy_websocket_responses.py | 7 ++ tests/integration/test_request_logs_api.py | 68 +++++++++++++++++++ tests/test_request_logs_options_api.py | 28 +++++++- 35 files changed, 728 insertions(+), 29 deletions(-) create mode 100644 app/db/alembic/versions/20260311_000000_add_request_logs_kind_and_session_hash.py create mode 100644 openspec/changes/add-request-log-kind-and-session-correlation/design.md create mode 100644 openspec/changes/add-request-log-kind-and-session-correlation/proposal.md create mode 100644 openspec/changes/add-request-log-kind-and-session-correlation/specs/frontend-architecture/spec.md create mode 100644 openspec/changes/add-request-log-kind-and-session-correlation/specs/responses-api-compat/spec.md create mode 100644 openspec/changes/add-request-log-kind-and-session-correlation/tasks.md diff --git a/app/db/alembic/versions/20260213_000000_base_schema.py b/app/db/alembic/versions/20260213_000000_base_schema.py index 26499efe..bca07622 100644 --- a/app/db/alembic/versions/20260213_000000_base_schema.py +++ b/app/db/alembic/versions/20260213_000000_base_schema.py @@ -114,6 +114,8 @@ def upgrade() -> None: server_default=sa.text("CURRENT_TIMESTAMP"), ), sa.Column("model", sa.String(), nullable=False), + sa.Column("request_kind", sa.String(), nullable=True), + sa.Column("session_id_hash", sa.String(), nullable=True), sa.Column("transport", sa.String(), nullable=True), sa.Column("input_tokens", sa.Integer(), nullable=True), sa.Column("output_tokens", sa.Integer(), nullable=True), diff --git a/app/db/alembic/versions/20260311_000000_add_request_logs_kind_and_session_hash.py b/app/db/alembic/versions/20260311_000000_add_request_logs_kind_and_session_hash.py new file mode 100644 index 00000000..1b73f9d5 --- /dev/null +++ b/app/db/alembic/versions/20260311_000000_add_request_logs_kind_and_session_hash.py @@ -0,0 +1,51 @@ +"""add request kind and session hash to request_logs + +Revision ID: 20260311_000000_add_request_logs_kind_and_session_hash +Revises: 20260312_120000_add_dashboard_upstream_stream_transport +Create Date: 2026-03-11 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "20260311_000000_add_request_logs_kind_and_session_hash" +down_revision = "20260312_120000_add_dashboard_upstream_stream_transport" +branch_labels = None +depends_on = None + + +def _columns(connection: Connection, table_name: str) -> set[str]: + inspector = sa.inspect(connection) + if not inspector.has_table(table_name): + return set() + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + columns = _columns(bind, "request_logs") + if not columns: + return + + with op.batch_alter_table("request_logs") as batch_op: + if "request_kind" not in columns: + batch_op.add_column(sa.Column("request_kind", sa.String(), nullable=True)) + if "session_id_hash" not in columns: + batch_op.add_column(sa.Column("session_id_hash", sa.String(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + columns = _columns(bind, "request_logs") + if not columns: + return + + with op.batch_alter_table("request_logs") as batch_op: + if "session_id_hash" in columns: + batch_op.drop_column("session_id_hash") + if "request_kind" in columns: + batch_op.drop_column("request_kind") diff --git a/app/db/models.py b/app/db/models.py index 2d434165..bc4119ad 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -116,6 +116,8 @@ class RequestLog(Base): request_id: Mapped[str] = mapped_column(String, nullable=False) requested_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) model: Mapped[str] = mapped_column(String, nullable=False) + request_kind: Mapped[str | None] = mapped_column(String, nullable=True) + session_id_hash: Mapped[str | None] = mapped_column(String, nullable=True) transport: Mapped[str | None] = mapped_column(String, nullable=True) service_tier: Mapped[str | None] = mapped_column(String, nullable=True) input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/app/modules/proxy/service.py b/app/modules/proxy/service.py index 872a40e9..39f16682 100644 --- a/app/modules/proxy/service.py +++ b/app/modules/proxy/service.py @@ -106,6 +106,10 @@ _TEXT_DONE_CONTENT_PART_TYPES = frozenset({"output_text", "refusal"}) _REQUEST_TRANSPORT_HTTP = "http" _REQUEST_TRANSPORT_WEBSOCKET = "websocket" +_REQUEST_KIND_RESPONSES = "responses" +_REQUEST_KIND_COMPACT = "compact" +_REQUEST_KIND_TRANSCRIPTION = "transcription" +_COMPACT_UPSTREAM_ENDPOINT = "/codex/responses/compact" _COMPACT_SAME_CONTRACT_RETRY_BUDGET = 1 _ACCOUNT_RECOVERY_RETRY_CODES = frozenset( { @@ -194,6 +198,7 @@ async def compact_responses( settings = await get_settings_cache().get() prefer_earlier_reset = settings.prefer_earlier_reset_accounts + session_id_hash = _session_id_hash_from_headers(headers) affinity = _sticky_key_for_compact_request( payload, headers, @@ -373,6 +378,8 @@ async def _call_compact(target: Account) -> CompactResponsePayload: usage.output_tokens_details.reasoning_tokens if usage and usage.output_tokens_details else None ), reasoning_effort=reasoning_effort, + request_kind=_REQUEST_KIND_COMPACT, + session_id_hash=session_id_hash, transport=_REQUEST_TRANSPORT_HTTP, service_tier=_service_tier_from_response(response) or _service_tier_from_compact_payload(payload), ) @@ -406,6 +413,7 @@ async def transcribe( settings = await get_settings_cache().get() prefer_earlier_reset = settings.prefer_earlier_reset_accounts routing_strategy = _routing_strategy(settings) + session_id_hash = _session_id_hash_from_headers(headers) try: selection = await self._select_account_with_budget( deadline, @@ -540,6 +548,8 @@ async def _call_transcribe(target: Account) -> dict[str, JsonValue]: status=log_status, error_code=log_error_code, error_message=log_error_message, + request_kind=_REQUEST_KIND_TRANSCRIPTION, + session_id_hash=session_id_hash, transport=_REQUEST_TRANSPORT_HTTP, ) @@ -834,6 +844,8 @@ async def _prepare_websocket_response_create_request( reasoning_effort=responses_payload.reasoning.effort if responses_payload.reasoning else None, api_key_reservation=reservation, started_at=time.monotonic(), + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=_session_id_hash_from_headers(headers), awaiting_response_created=True, ), affinity_policy=_sticky_key_for_responses_request( @@ -1461,6 +1473,8 @@ async def _finalize_websocket_request_state( cached_input_tokens=cached_input_tokens, reasoning_tokens=reasoning_tokens, reasoning_effort=request_state.reasoning_effort, + request_kind=request_state.request_kind, + session_id_hash=request_state.session_id_hash, transport=_REQUEST_TRANSPORT_WEBSOCKET, service_tier=response_service_tier, ) @@ -1581,6 +1595,8 @@ async def _fail_pending_websocket_requests( error_code=error_code, error_message=error_message, reasoning_effort=request_state.reasoning_effort, + request_kind=request_state.request_kind, + session_id_hash=request_state.session_id_hash, transport=_REQUEST_TRANSPORT_WEBSOCKET, service_tier=request_state.service_tier, ) @@ -1827,6 +1843,7 @@ async def _stream_with_retry( openai_cache_affinity_max_age_seconds=settings.openai_cache_affinity_max_age_seconds, sticky_threads_enabled=settings.sticky_threads_enabled, ) + session_id_hash = _session_id_hash_from_headers(headers) routing_strategy = _routing_strategy(settings) max_attempts = 3 settled = False @@ -1850,6 +1867,8 @@ async def _stream_with_retry( error_code="upstream_request_timeout", error_message="Proxy request budget exhausted", reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -1882,6 +1901,8 @@ async def _stream_with_retry( error_code="upstream_request_timeout", error_message="Proxy request budget exhausted", reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -1916,6 +1937,8 @@ async def _stream_with_retry( error_code=error_code, error_message=no_accounts_msg, reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, transport=request_transport, service_tier=payload.service_tier, ) @@ -1941,6 +1964,8 @@ async def _stream_with_retry( error_code="upstream_request_timeout", error_message="Proxy request budget exhausted", reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -1966,6 +1991,8 @@ async def _stream_with_retry( error_code="upstream_unavailable", error_message=message, reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -1996,6 +2023,8 @@ async def _stream_with_retry( error_code="upstream_request_timeout", error_message="Proxy request budget exhausted", reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -2014,6 +2043,8 @@ async def _stream_with_retry( suppress_text_done_events=suppress_text_done_events, upstream_stream_transport=upstream_stream_transport, request_transport=request_transport, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, ): yield line finally: @@ -2060,6 +2091,8 @@ async def _stream_with_retry( error_code="upstream_request_timeout", error_message="Proxy request budget exhausted", reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -2093,6 +2126,8 @@ async def _stream_with_retry( error_code="upstream_unavailable", error_message=message, reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -2122,6 +2157,8 @@ async def _stream_with_retry( error_code="upstream_request_timeout", error_message="Proxy request budget exhausted", reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, service_tier=payload.service_tier, transport=request_transport, ) @@ -2140,6 +2177,8 @@ async def _stream_with_retry( suppress_text_done_events=suppress_text_done_events, upstream_stream_transport=upstream_stream_transport, request_transport=request_transport, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, ): yield line finally: @@ -2218,6 +2257,8 @@ async def _stream_with_retry( error_code="no_accounts", error_message=retries_exhausted_msg, reasoning_effort=payload.reasoning.effort if payload.reasoning else None, + request_kind=_REQUEST_KIND_RESPONSES, + session_id_hash=session_id_hash, transport=request_transport, service_tier=payload.service_tier, ) @@ -2251,6 +2292,8 @@ async def _stream_once( suppress_text_done_events: bool, upstream_stream_transport: str | None, request_transport: str, + request_kind: str, + session_id_hash: str | None, ) -> AsyncIterator[str]: account_id_value = account.id access_token = self._encryptor.decrypt(account.access_token_encrypted) @@ -2425,6 +2468,8 @@ async def _stream_once( cached_input_tokens=cached_input_tokens, reasoning_tokens=reasoning_tokens, reasoning_effort=reasoning_effort, + request_kind=request_kind, + session_id_hash=session_id_hash, transport=request_transport, service_tier=service_tier, ) @@ -2450,6 +2495,8 @@ async def _write_request_log( cached_input_tokens: int | None = None, reasoning_tokens: int | None = None, reasoning_effort: str | None = None, + request_kind: str | None = None, + session_id_hash: str | None = None, transport: str | None = None, service_tier: str | None = None, ) -> None: @@ -2466,6 +2513,8 @@ async def _write_request_log( cached_input_tokens=cached_input_tokens, reasoning_tokens=reasoning_tokens, reasoning_effort=reasoning_effort, + request_kind=request_kind, + session_id_hash=session_id_hash, transport=transport, service_tier=service_tier, latency_ms=latency_ms, @@ -2493,6 +2542,8 @@ async def _write_stream_preflight_error( error_message: str, reasoning_effort: str | None, service_tier: str | None, + request_kind: str | None = None, + session_id_hash: str | None = None, transport: str = _REQUEST_TRANSPORT_HTTP, ) -> None: await self._write_request_log( @@ -2505,6 +2556,8 @@ async def _write_stream_preflight_error( error_code=error_code, error_message=error_message, reasoning_effort=reasoning_effort, + request_kind=request_kind, + session_id_hash=session_id_hash, transport=transport, service_tier=service_tier, ) @@ -2829,6 +2882,8 @@ class _WebSocketRequestState: started_at: float response_id: str | None = None awaiting_response_created: bool = False + request_kind: str = _REQUEST_KIND_RESPONSES + session_id_hash: str | None = None @dataclass(slots=True) @@ -3267,6 +3322,13 @@ def _sticky_key_from_session_header(headers: Mapping[str, str]) -> str | None: return None +def _session_id_hash_from_headers(headers: Mapping[str, str]) -> str | None: + session_id = _sticky_key_from_session_header(headers) + if session_id is None: + return None + return _hash_identifier(session_id) + + def _sticky_key_for_responses_request( payload: ResponsesRequest, headers: Mapping[str, str], diff --git a/app/modules/request_logs/api.py b/app/modules/request_logs/api.py index d08a48e6..6ce34913 100644 --- a/app/modules/request_logs/api.py +++ b/app/modules/request_logs/api.py @@ -42,6 +42,8 @@ async def list_request_logs( offset: int = Query(0, ge=0), search: str | None = Query(default=None), account_id: list[str] | None = Query(default=None, alias="accountId"), + request_kind: list[str] | None = Query(default=None, alias="requestKind"), + transport: list[str] | None = Query(default=None), status: list[str] | None = Query(default=None), model: list[str] | None = Query(default=None), reasoning_effort: list[str] | None = Query(default=None, alias="reasoningEffort"), @@ -62,6 +64,8 @@ async def list_request_logs( until=until, account_ids=account_id, model_options=parsed_options, + request_kinds=request_kind, + transports=transport, models=model, reasoning_efforts=reasoning_effort, status=status, @@ -77,6 +81,8 @@ async def list_request_logs( async def list_request_log_filter_options( status: list[str] | None = Query(default=None), account_id: list[str] | None = Query(default=None, alias="accountId"), + request_kind: list[str] | None = Query(default=None, alias="requestKind"), + transport: list[str] | None = Query(default=None), model: list[str] | None = Query(default=None), reasoning_effort: list[str] | None = Query(default=None, alias="reasoningEffort"), model_option: list[str] | None = Query(default=None, alias="modelOption"), @@ -94,6 +100,8 @@ async def list_request_log_filter_options( until=until, account_ids=account_id, model_options=parsed_options, + request_kinds=request_kind, + transports=transport, models=model, reasoning_efforts=reasoning_effort, ) @@ -103,5 +111,7 @@ async def list_request_log_filter_options( RequestLogModelOption(model=option.model, reasoning_effort=option.reasoning_effort) for option in options.model_options ], + request_kinds=options.request_kinds, + transports=options.transports, statuses=options.statuses, ) diff --git a/app/modules/request_logs/mappers.py b/app/modules/request_logs/mappers.py index 6fb83727..6c01ec96 100644 --- a/app/modules/request_logs/mappers.py +++ b/app/modules/request_logs/mappers.py @@ -29,6 +29,8 @@ def to_request_log_entry(log: RequestLog, *, api_key_name: str | None = None) -> api_key_name=api_key_name, request_id=log.request_id, model=log.model, + request_kind=log.request_kind, + session_id_hash=log.session_id_hash, transport=log.transport, service_tier=log.service_tier, reasoning_effort=log.reasoning_effort, diff --git a/app/modules/request_logs/repository.py b/app/modules/request_logs/repository.py index a1b0efaa..04b5a282 100644 --- a/app/modules/request_logs/repository.py +++ b/app/modules/request_logs/repository.py @@ -92,6 +92,8 @@ async def add_log( reasoning_effort: str | None = None, service_tier: str | None = None, transport: str | None = None, + request_kind: str | None = None, + session_id_hash: str | None = None, api_key_id: str | None = None, ) -> RequestLog: resolved_request_id = ensure_request_id(request_id) @@ -100,6 +102,8 @@ async def add_log( api_key_id=api_key_id, request_id=resolved_request_id, model=model, + request_kind=request_kind, + session_id_hash=session_id_hash, transport=transport, service_tier=service_tier, input_tokens=input_tokens, @@ -133,6 +137,8 @@ async def list_recent( until: datetime | None = None, account_ids: list[str] | None = None, model_options: list[tuple[str, str | None]] | None = None, + request_kinds: list[str] | None = None, + transports: list[str] | None = None, models: list[str] | None = None, reasoning_efforts: list[str] | None = None, include_success: bool = True, @@ -146,6 +152,8 @@ async def list_recent( until=until, account_ids=account_ids, model_options=model_options, + request_kinds=request_kinds, + transports=transports, models=models, reasoning_efforts=reasoning_efforts, include_success=include_success, @@ -185,14 +193,18 @@ async def list_filter_options( until: datetime | None = None, account_ids: list[str] | None = None, model_options: list[tuple[str, str | None]] | None = None, + request_kinds: list[str] | None = None, + transports: list[str] | None = None, models: list[str] | None = None, reasoning_efforts: list[str] | None = None, - ) -> tuple[list[str], list[tuple[str, str | None]], list[tuple[str, str | None]]]: + ) -> tuple[list[str], list[tuple[str, str | None]], list[str], list[str], list[tuple[str, str | None]]]: filters = self._build_filters( since=since, until=until, account_ids=account_ids, model_options=model_options, + request_kinds=request_kinds, + transports=transports, models=models, reasoning_efforts=reasoning_efforts, include_success=True, @@ -207,6 +219,8 @@ async def list_filter_options( .distinct() .order_by(RequestLog.model.asc(), RequestLog.reasoning_effort.asc()) ) + request_kind_stmt = select(RequestLog.request_kind).distinct().order_by(RequestLog.request_kind.asc()) + transport_stmt = select(RequestLog.transport).distinct().order_by(RequestLog.transport.asc()) status_stmt = ( select(RequestLog.status, RequestLog.error_code) .distinct() @@ -216,16 +230,22 @@ async def list_filter_options( clause = and_(*filters.conditions) account_stmt = account_stmt.where(clause) model_stmt = model_stmt.where(clause) + request_kind_stmt = request_kind_stmt.where(clause) + transport_stmt = transport_stmt.where(clause) status_stmt = status_stmt.where(clause) account_rows = await self._session.execute(account_stmt) model_rows = await self._session.execute(model_stmt) + request_kind_rows = await self._session.execute(request_kind_stmt) + transport_rows = await self._session.execute(transport_stmt) status_rows = await self._session.execute(status_stmt) account_ids = [row[0] for row in account_rows.all() if row[0]] model_options = [(row[0], row[1]) for row in model_rows.all() if row[0]] + request_kinds = [row[0] for row in request_kind_rows.all() if row[0]] + transports = [row[0] for row in transport_rows.all() if row[0]] status_values = [(row[0], row[1]) for row in status_rows.all() if row[0]] - return account_ids, model_options, status_values + return account_ids, model_options, request_kinds, transports, status_values async def get_api_key_names_by_ids(self, api_key_ids: list[str]) -> dict[str, str]: unique_ids = sorted({key_id for key_id in api_key_ids if key_id}) @@ -242,6 +262,8 @@ def _build_filters( until: datetime | None = None, account_ids: list[str] | None = None, model_options: list[tuple[str, str | None]] | None = None, + request_kinds: list[str] | None = None, + transports: list[str] | None = None, models: list[str] | None = None, reasoning_efforts: list[str] | None = None, include_success: bool = True, @@ -256,6 +278,10 @@ def _build_filters( conditions.append(RequestLog.requested_at <= until) if account_ids: conditions.append(RequestLog.account_id.in_(account_ids)) + if request_kinds: + conditions.append(RequestLog.request_kind.in_(request_kinds)) + if transports: + conditions.append(RequestLog.transport.in_(transports)) if model_options: pair_conditions = [] diff --git a/app/modules/request_logs/schemas.py b/app/modules/request_logs/schemas.py index 31322e2f..bd044f5e 100644 --- a/app/modules/request_logs/schemas.py +++ b/app/modules/request_logs/schemas.py @@ -13,6 +13,8 @@ class RequestLogEntry(DashboardModel): api_key_name: str | None = None request_id: str model: str + request_kind: str | None = None + session_id_hash: str | None = None transport: str | None = None service_tier: str | None = None status: str @@ -39,4 +41,6 @@ class RequestLogModelOption(DashboardModel): class RequestLogFilterOptionsResponse(DashboardModel): account_ids: list[str] = Field(default_factory=list) model_options: list[RequestLogModelOption] = Field(default_factory=list) + request_kinds: list[str] = Field(default_factory=list) + transports: list[str] = Field(default_factory=list) statuses: list[str] = Field(default_factory=list) diff --git a/app/modules/request_logs/service.py b/app/modules/request_logs/service.py index 09de14d1..b21e655d 100644 --- a/app/modules/request_logs/service.py +++ b/app/modules/request_logs/service.py @@ -12,6 +12,9 @@ from app.modules.request_logs.repository import RequestLogsRepository from app.modules.request_logs.schemas import RequestLogEntry +_REQUEST_KIND_ORDER = ("responses", "compact", "transcription") +_TRANSPORT_ORDER = ("http", "websocket") + @dataclass(frozen=True, slots=True) class RequestLogModelOption: @@ -31,6 +34,8 @@ class RequestLogStatusFilter: class RequestLogFilterOptions: account_ids: list[str] model_options: list[RequestLogModelOption] + request_kinds: list[str] + transports: list[str] statuses: list[str] @@ -54,6 +59,8 @@ async def list_recent( until: datetime | None = None, account_ids: list[str] | None = None, model_options: list[RequestLogModelOption] | None = None, + request_kinds: list[str] | None = None, + transports: list[str] | None = None, models: list[str] | None = None, reasoning_efforts: list[str] | None = None, status: list[str] | None = None, @@ -70,6 +77,8 @@ async def list_recent( until=until, account_ids=account_ids, model_options=normalized_model_options, + request_kinds=request_kinds, + transports=transports, models=models, reasoning_efforts=reasoning_efforts, include_success=status_filter.include_success, @@ -98,17 +107,27 @@ async def list_filter_options( until: datetime | None = None, account_ids: list[str] | None = None, model_options: list[RequestLogModelOption] | None = None, + request_kinds: list[str] | None = None, + transports: list[str] | None = None, models: list[str] | None = None, reasoning_efforts: list[str] | None = None, ) -> RequestLogFilterOptions: normalized_model_options = ( [(option.model, option.reasoning_effort) for option in model_options] if model_options else None ) - option_account_ids, option_model_options, status_values = await self._repo.list_filter_options( + ( + option_account_ids, + option_model_options, + option_request_kinds, + option_transports, + status_values, + ) = await self._repo.list_filter_options( since=since, until=until, account_ids=account_ids, model_options=normalized_model_options, + request_kinds=request_kinds, + transports=transports, models=models, reasoning_efforts=reasoning_efforts, ) @@ -118,6 +137,8 @@ async def list_filter_options( RequestLogModelOption(model=model, reasoning_effort=reasoning_effort) for model, reasoning_effort in option_model_options ], + request_kinds=_normalize_ordered_values(option_request_kinds, _REQUEST_KIND_ORDER), + transports=_normalize_ordered_values(option_transports, _TRANSPORT_ORDER), statuses=_normalize_status_values(status_values), ) @@ -162,3 +183,10 @@ def _normalize_status_values(values: list[tuple[str, str | None]]) -> list[str]: normalized = {normalize_log_status(status, error_code) for status, error_code in values} ordered = ["ok", "rate_limit", "quota", "error"] return [status for status in ordered if status in normalized] + + +def _normalize_ordered_values(values: list[str], preferred_order: tuple[str, ...]) -> list[str]: + normalized = {value for value in values if value} + ordered = [value for value in preferred_order if value in normalized] + remaining = sorted(normalized - set(preferred_order)) + return ordered + remaining diff --git a/frontend/src/__integration__/dashboard-flow.test.tsx b/frontend/src/__integration__/dashboard-flow.test.tsx index 106c3523..90260666 100644 --- a/frontend/src/__integration__/dashboard-flow.test.tsx +++ b/frontend/src/__integration__/dashboard-flow.test.tsx @@ -44,6 +44,8 @@ describe("dashboard flow integration", () => { expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); expect(await screen.findByText("Request Logs")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Routes" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Transports" })).toBeInTheDocument(); await waitFor(() => { expect(overviewCalls).toBeGreaterThan(0); diff --git a/frontend/src/features/dashboard/api.ts b/frontend/src/features/dashboard/api.ts index f0c85fc0..7c0bc053 100644 --- a/frontend/src/features/dashboard/api.ts +++ b/frontend/src/features/dashboard/api.ts @@ -14,6 +14,8 @@ export type RequestLogsListFilters = { offset?: number; search?: string; accountIds?: string[]; + requestKinds?: string[]; + transports?: string[]; statuses?: string[]; modelOptions?: string[]; since?: string; @@ -25,6 +27,8 @@ export type RequestLogFacetFilters = { until?: string; accountIds?: string[]; modelOptions?: string[]; + requestKinds?: string[]; + transports?: string[]; }; function appendMany(params: URLSearchParams, key: string, values?: string[]): void { @@ -54,6 +58,8 @@ export function getRequestLogs(params: RequestLogsListFilters = {}) { query.set("search", params.search); } appendMany(query, "accountId", params.accountIds); + appendMany(query, "requestKind", params.requestKinds); + appendMany(query, "transport", params.transports); appendMany(query, "status", params.statuses); appendMany(query, "modelOption", params.modelOptions); if (params.since) { @@ -76,6 +82,8 @@ export function getRequestLogOptions(params: RequestLogFacetFilters = {}) { } appendMany(query, "accountId", params.accountIds); appendMany(query, "modelOption", params.modelOptions); + appendMany(query, "requestKind", params.requestKinds); + appendMany(query, "transport", params.transports); const suffix = query.size > 0 ? `?${query.toString()}` : ""; return get(`${REQUEST_LOGS_PATH}/options${suffix}`, RequestLogFilterOptionsSchema); } diff --git a/frontend/src/features/dashboard/components/dashboard-page.tsx b/frontend/src/features/dashboard/components/dashboard-page.tsx index fd59ee55..bb9816a1 100644 --- a/frontend/src/features/dashboard/components/dashboard-page.tsx +++ b/frontend/src/features/dashboard/components/dashboard-page.tsx @@ -20,6 +20,15 @@ import { REQUEST_STATUS_LABELS } from "@/utils/constants"; import { formatModelLabel, formatSlug } from "@/utils/formatters"; const MODEL_OPTION_DELIMITER = ":::"; +const REQUEST_KIND_FILTER_LABELS: Record = { + responses: "Response", + compact: "Compact", + transcription: "Audio", +}; +const TRANSPORT_FILTER_LABELS: Record = { + http: "HTTP", + websocket: "WS", +}; export function DashboardPage() { const navigate = useNavigate(); @@ -97,6 +106,24 @@ export function DashboardPage() { [optionsQuery.data?.statuses], ); + const requestKindOptions = useMemo( + () => + (optionsQuery.data?.requestKinds ?? []).map((requestKind) => ({ + value: requestKind, + label: REQUEST_KIND_FILTER_LABELS[requestKind] ?? formatSlug(requestKind), + })), + [optionsQuery.data?.requestKinds], + ); + + const transportOptions = useMemo( + () => + (optionsQuery.data?.transports ?? []).map((transport) => ({ + value: transport, + label: TRANSPORT_FILTER_LABELS[transport] ?? formatSlug(transport), + })), + [optionsQuery.data?.transports], + ); + const errorMessage = (dashboardQuery.error instanceof Error && dashboardQuery.error.message) || (logsQuery.error instanceof Error && logsQuery.error.message) || @@ -160,6 +187,8 @@ export function DashboardPage() { filters={filters} accountOptions={accountOptions} modelOptions={modelOptions} + requestKindOptions={requestKindOptions} + transportOptions={transportOptions} statusOptions={statusOptions} onSearchChange={(search) => updateFilters({ search, offset: 0 })} onTimeframeChange={(timeframe) => updateFilters({ timeframe, offset: 0 })} @@ -167,6 +196,8 @@ export function DashboardPage() { onModelChange={(modelOptionsSelected) => updateFilters({ modelOptions: modelOptionsSelected, offset: 0 }) } + onRequestKindChange={(requestKinds) => updateFilters({ requestKinds, offset: 0 })} + onTransportChange={(transports) => updateFilters({ transports, offset: 0 })} onStatusChange={(statuses) => updateFilters({ statuses, offset: 0 })} onReset={() => updateFilters({ @@ -174,6 +205,8 @@ export function DashboardPage() { timeframe: "all", accountIds: [], modelOptions: [], + requestKinds: [], + transports: [], statuses: [], offset: 0, }) diff --git a/frontend/src/features/dashboard/components/filters/request-filters.tsx b/frontend/src/features/dashboard/components/filters/request-filters.tsx index 1e040eef..db961d35 100644 --- a/frontend/src/features/dashboard/components/filters/request-filters.tsx +++ b/frontend/src/features/dashboard/components/filters/request-filters.tsx @@ -10,11 +10,15 @@ export type RequestFiltersProps = { filters: FilterState; accountOptions: MultiSelectOption[]; modelOptions: MultiSelectOption[]; + requestKindOptions: MultiSelectOption[]; + transportOptions: MultiSelectOption[]; statusOptions: MultiSelectOption[]; onSearchChange: (value: string) => void; onTimeframeChange: (value: FilterState["timeframe"]) => void; onAccountChange: (values: string[]) => void; onModelChange: (values: string[]) => void; + onRequestKindChange: (values: string[]) => void; + onTransportChange: (values: string[]) => void; onStatusChange: (values: string[]) => void; onReset: () => void; }; @@ -23,11 +27,15 @@ export function RequestFilters({ filters, accountOptions, modelOptions, + requestKindOptions, + transportOptions, statusOptions, onSearchChange, onTimeframeChange, onAccountChange, onModelChange, + onRequestKindChange, + onTransportChange, onStatusChange, onReset, }: RequestFiltersProps) { @@ -60,6 +68,18 @@ export function RequestFilters({ options={modelOptions} onChange={onModelChange} /> + + { apiKeyName: "Key Alpha", requestId: "req-1", model: "gpt-5.1", + requestKind: "compact", + sessionIdHash: "sha256:abc123def456", serviceTier: "priority", transport: "websocket", status: "rate_limit", @@ -58,6 +60,8 @@ describe("RecentRequestsTable", () => { expect(screen.getByText("Primary Account")).toBeInTheDocument(); expect(screen.getByText("Key Alpha")).toBeInTheDocument(); expect(screen.getByText("gpt-5.1 (high, priority)")).toBeInTheDocument(); + expect(screen.getByText("Compact")).toBeInTheDocument(); + expect(screen.getByText("sha256:abc123def456")).toBeInTheDocument(); expect(screen.getByText("WS")).toBeInTheDocument(); expect(screen.getByText("Rate limit")).toBeInTheDocument(); @@ -86,6 +90,8 @@ describe("RecentRequestsTable", () => { apiKeyName: null, requestId: "req-legacy", model: "gpt-5.1", + requestKind: null, + sessionIdHash: null, serviceTier: null, transport: null, status: "ok", diff --git a/frontend/src/features/dashboard/components/recent-requests-table.tsx b/frontend/src/features/dashboard/components/recent-requests-table.tsx index 7afe0733..2f2a5ba0 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.tsx @@ -44,6 +44,18 @@ const TRANSPORT_LABELS: Record = { websocket: "WS", }; +const REQUEST_KIND_LABELS: Record = { + responses: "Response", + compact: "Compact", + transcription: "Audio", +}; + +const REQUEST_KIND_CLASS_MAP: Record = { + responses: "bg-violet-500/10 text-violet-700 border-violet-500/20 hover:bg-violet-500/15 dark:text-violet-300", + compact: "bg-amber-500/15 text-amber-700 border-amber-500/20 hover:bg-amber-500/20 dark:text-amber-300", + transcription: "bg-teal-500/15 text-teal-700 border-teal-500/20 hover:bg-teal-500/20 dark:text-teal-300", +}; + const TRANSPORT_CLASS_MAP: Record = { http: "bg-slate-500/10 text-slate-700 border-slate-500/20 hover:bg-slate-500/15 dark:text-slate-300", websocket: "bg-sky-500/15 text-sky-700 border-sky-500/20 hover:bg-sky-500/20 dark:text-sky-300", @@ -107,13 +119,14 @@ export function RecentRequestsTable({
- +
Time Account API Key Model + Route Transport Status Tokens @@ -152,6 +165,29 @@ export function RecentRequestsTable({ {formatModelLabel(request.model, request.reasoningEffort, request.serviceTier)} + + {request.requestKind || request.sessionIdHash ? ( +
+ {request.requestKind ? ( + + {REQUEST_KIND_LABELS[request.requestKind] ?? request.requestKind} + + ) : null} + {request.sessionIdHash ? ( +
+ {request.sessionIdHash} +
+ ) : null} +
+ ) : ( + -- + )} +
{request.transport ? ( { const queryClient = createTestQueryClient(); const wrapper = createWrapper( queryClient, - "/dashboard?search=rate&timeframe=24h&accountId=acc_primary&modelOption=gpt-5.1:::high&status=rate_limit&limit=10&offset=20", + "/dashboard?search=rate&timeframe=24h&accountId=acc_primary&modelOption=gpt-5.1:::high&requestKind=compact&transport=websocket&status=rate_limit&limit=10&offset=20", ); const { result } = renderHook(() => useRequestLogs(), { wrapper }); @@ -46,6 +46,8 @@ describe("useRequestLogs", () => { timeframe: "24h", accountIds: ["acc_primary"], modelOptions: ["gpt-5.1:::high"], + requestKinds: ["compact"], + transports: ["websocket"], statuses: ["rate_limit"], limit: 10, offset: 20, @@ -88,6 +90,8 @@ describe("useRequestLogs", () => { statuses: string[]; accountIds: string[]; modelOptions: string[]; + requestKinds: string[]; + transports: string[]; since: string | null; }> = []; server.use( @@ -97,11 +101,15 @@ describe("useRequestLogs", () => { statuses: url.searchParams.getAll("status"), accountIds: url.searchParams.getAll("accountId"), modelOptions: url.searchParams.getAll("modelOption"), + requestKinds: url.searchParams.getAll("requestKind"), + transports: url.searchParams.getAll("transport"), since: url.searchParams.get("since"), }); return HttpResponse.json({ accountIds: [], modelOptions: [], + requestKinds: [], + transports: [], statuses: ["ok", "rate_limit", "quota", "error"], }); }), @@ -110,7 +118,7 @@ describe("useRequestLogs", () => { const queryClient = createTestQueryClient(); const wrapper = createWrapper( queryClient, - "/dashboard?timeframe=24h&accountId=acc_primary&modelOption=gpt-5.1:::high&status=ok", + "/dashboard?timeframe=24h&accountId=acc_primary&modelOption=gpt-5.1:::high&requestKind=compact&transport=websocket&status=ok", ); const { result } = renderHook(() => useRequestLogs(), { wrapper }); @@ -121,10 +129,14 @@ describe("useRequestLogs", () => { const matchingCall = calls.find( (call) => call.accountIds.includes("acc_primary") && - call.modelOptions.includes("gpt-5.1:::high"), + call.modelOptions.includes("gpt-5.1:::high") && + call.requestKinds.includes("compact") && + call.transports.includes("websocket"), ); expect(matchingCall).toBeDefined(); expect(matchingCall?.statuses).toEqual([]); + expect(matchingCall?.requestKinds).toEqual(["compact"]); + expect(matchingCall?.transports).toEqual(["websocket"]); expect(matchingCall?.since).toMatch(/T/); }); diff --git a/frontend/src/features/dashboard/hooks/use-request-logs.ts b/frontend/src/features/dashboard/hooks/use-request-logs.ts index 98c3e462..3cbc7f68 100644 --- a/frontend/src/features/dashboard/hooks/use-request-logs.ts +++ b/frontend/src/features/dashboard/hooks/use-request-logs.ts @@ -15,6 +15,8 @@ const DEFAULT_FILTER_STATE: FilterState = { timeframe: "all", accountIds: [], modelOptions: [], + requestKinds: [], + transports: [], statuses: [], limit: 25, offset: 0, @@ -34,6 +36,8 @@ function parseFilterState(params: URLSearchParams): FilterState { timeframe: params.get("timeframe") ?? "all", accountIds: params.getAll("accountId"), modelOptions: params.getAll("modelOption"), + requestKinds: params.getAll("requestKind"), + transports: params.getAll("transport"), statuses: params.getAll("status"), limit: parseNumber(params.get("limit"), DEFAULT_FILTER_STATE.limit), offset: parseNumber(params.get("offset"), DEFAULT_FILTER_STATE.offset), @@ -59,6 +63,12 @@ function writeFilterState(state: FilterState): URLSearchParams { for (const value of state.modelOptions) { params.append("modelOption", value); } + for (const value of state.requestKinds) { + params.append("requestKind", value); + } + for (const value of state.transports) { + params.append("transport", value); + } for (const value of state.statuses) { params.append("status", value); } @@ -91,6 +101,8 @@ export function useRequestLogs() { limit: filters.limit, offset: filters.offset, accountIds: filters.accountIds, + requestKinds: filters.requestKinds, + transports: filters.transports, statuses: filters.statuses, modelOptions: filters.modelOptions, since, @@ -102,8 +114,10 @@ export function useRequestLogs() { since, accountIds: filters.accountIds, modelOptions: filters.modelOptions, + requestKinds: filters.requestKinds, + transports: filters.transports, }), - [filters.accountIds, filters.modelOptions, since], + [filters.accountIds, filters.modelOptions, filters.requestKinds, filters.transports, since], ); const logsQuery = useQuery({ diff --git a/frontend/src/features/dashboard/schemas.test.ts b/frontend/src/features/dashboard/schemas.test.ts index d2c7e8da..f8799bde 100644 --- a/frontend/src/features/dashboard/schemas.test.ts +++ b/frontend/src/features/dashboard/schemas.test.ts @@ -5,6 +5,8 @@ import { AccountAdditionalQuotaSchema, DashboardOverviewSchema, DepletionSchema, + FilterStateSchema, + RequestLogFilterOptionsSchema, RequestLogsResponseSchema, UsageWindowSchema, } from "@/features/dashboard/schemas"; @@ -122,6 +124,8 @@ describe("RequestLogsResponseSchema", () => { apiKeyName: "Key A", requestId: "req-1", model: "gpt-5.1", + requestKind: "compact", + sessionIdHash: "sha256:abc123def456", transport: "websocket", status: "ok", errorCode: null, @@ -138,10 +142,46 @@ describe("RequestLogsResponseSchema", () => { }); expect(parsed.requests[0]?.apiKeyName).toBe("Key A"); + expect(parsed.requests[0]?.requestKind).toBe("compact"); + expect(parsed.requests[0]?.sessionIdHash).toBe("sha256:abc123def456"); expect(parsed.requests[0]?.transport).toBe("websocket"); }); }); +describe("RequestLogFilterOptionsSchema", () => { + it("parses request kind and transport facets", () => { + const parsed = RequestLogFilterOptionsSchema.parse({ + accountIds: ["acc-1"], + modelOptions: [{ model: "gpt-5.1", reasoningEffort: "high" }], + requestKinds: ["responses", "compact"], + transports: ["http", "websocket"], + statuses: ["ok", "rate_limit"], + }); + + expect(parsed.requestKinds).toEqual(["responses", "compact"]); + expect(parsed.transports).toEqual(["http", "websocket"]); + }); +}); + +describe("FilterStateSchema", () => { + it("accepts request kind and transport filters", () => { + const parsed = FilterStateSchema.parse({ + search: "", + timeframe: "24h", + accountIds: ["acc-1"], + modelOptions: ["gpt-5.1:::high"], + requestKinds: ["compact"], + transports: ["websocket"], + statuses: ["ok"], + limit: 25, + offset: 0, + }); + + expect(parsed.requestKinds).toEqual(["compact"]); + expect(parsed.transports).toEqual(["websocket"]); + }); +}); + describe("UsageWindowSchema", () => { it("parses usage window payload", () => { const parsed = UsageWindowSchema.parse({ diff --git a/frontend/src/features/dashboard/schemas.ts b/frontend/src/features/dashboard/schemas.ts index 273aa21c..24c823c0 100644 --- a/frontend/src/features/dashboard/schemas.ts +++ b/frontend/src/features/dashboard/schemas.ts @@ -87,6 +87,8 @@ export const RequestLogSchema = z.object({ apiKeyName: z.string().nullable(), requestId: z.string(), model: z.string(), + requestKind: z.string().nullable().optional().default(null), + sessionIdHash: z.string().nullable().optional().default(null), transport: z.string().nullable().optional().default(null), serviceTier: z.string().nullable().optional().default(null), status: z.string(), @@ -113,6 +115,8 @@ export const RequestLogModelOptionSchema = z.object({ export const RequestLogFilterOptionsSchema = z.object({ accountIds: z.array(z.string()), modelOptions: z.array(RequestLogModelOptionSchema), + requestKinds: z.array(z.string()), + transports: z.array(z.string()), statuses: z.array(z.string()), }); @@ -121,6 +125,8 @@ export const FilterStateSchema = z.object({ timeframe: z.enum(["all", "1h", "24h", "7d"]), accountIds: z.array(z.string()), modelOptions: z.array(z.string()), + requestKinds: z.array(z.string()), + transports: z.array(z.string()), statuses: z.array(z.string()), limit: z.number().int().positive(), offset: z.number().int().nonnegative(), diff --git a/frontend/src/test/mocks/factories.ts b/frontend/src/test/mocks/factories.ts index baad3fd2..5c333a24 100644 --- a/frontend/src/test/mocks/factories.ts +++ b/frontend/src/test/mocks/factories.ts @@ -193,6 +193,8 @@ export function createRequestLogEntry(overrides: Partial = {}): apiKeyName: "Primary Key", requestId: "req_1", model: "gpt-5.1", + requestKind: "responses", + sessionIdHash: null, transport: "http", serviceTier: null, status: "ok", @@ -257,6 +259,8 @@ export function createRequestLogFilterOptions( { model: "gpt-5.1", reasoningEffort: null }, { model: "gpt-5.1", reasoningEffort: "high" }, ], + requestKinds: ["responses", "compact"], + transports: ["http", "websocket"], statuses: ["ok", "rate_limit", "quota"], ...overrides, }); diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts index 0c293a1c..4918f83d 100644 --- a/frontend/src/test/mocks/handlers.ts +++ b/frontend/src/test/mocks/handlers.ts @@ -26,7 +26,9 @@ import { } from "@/test/mocks/factories"; const MODEL_OPTION_DELIMITER = ":::"; +const REQUEST_KIND_ORDER = ["responses", "compact", "transcription"] as const; const STATUS_ORDER = ["ok", "rate_limit", "quota", "error"] as const; +const TRANSPORT_ORDER = ["http", "websocket"] as const; // ── Zod schemas for mock request bodies ── @@ -128,7 +130,9 @@ function parseDateValue(value: string | null): number | null { function filterRequestLogs(url: URL, options?: { includeStatuses?: boolean }): RequestLogEntry[] { const includeStatuses = options?.includeStatuses ?? true; const accountIds = new Set(url.searchParams.getAll("accountId")); + const requestKinds = new Set(url.searchParams.getAll("requestKind")); const statuses = new Set(url.searchParams.getAll("status").map((value) => value.toLowerCase())); + const transports = new Set(url.searchParams.getAll("transport")); const models = new Set(url.searchParams.getAll("model")); const reasoningEfforts = new Set(url.searchParams.getAll("reasoningEffort")); const modelOptions = new Set(url.searchParams.getAll("modelOption")); @@ -141,10 +145,18 @@ function filterRequestLogs(url: URL, options?: { includeStatuses?: boolean }): R return false; } + if (requestKinds.size > 0 && (!entry.requestKind || !requestKinds.has(entry.requestKind))) { + return false; + } + if (includeStatuses && statuses.size > 0 && !statuses.has("all") && !statuses.has(entry.status)) { return false; } + if (transports.size > 0 && (!entry.transport || !transports.has(entry.transport))) { + return false; + } + if (models.size > 0 && !models.has(entry.model)) { return false; } @@ -178,6 +190,8 @@ function filterRequestLogs(url: URL, options?: { includeStatuses?: boolean }): R entry.apiKeyName, entry.requestId, entry.model, + entry.requestKind, + entry.sessionIdHash, entry.reasoningEffort, entry.errorCode, entry.errorMessage, @@ -217,10 +231,16 @@ function requestLogOptionsFromEntries(entries: RequestLogEntry[]) { const presentStatuses = new Set(entries.map((entry) => entry.status)); const statuses = STATUS_ORDER.filter((status) => presentStatuses.has(status)); + const presentRequestKinds = new Set(entries.map((entry) => entry.requestKind).filter((value): value is string => value != null)); + const requestKinds = REQUEST_KIND_ORDER.filter((requestKind) => presentRequestKinds.has(requestKind)); + const presentTransports = new Set(entries.map((entry) => entry.transport).filter((value): value is string => value != null)); + const transports = TRANSPORT_ORDER.filter((transport) => presentTransports.has(transport)); return createRequestLogFilterOptions({ accountIds, modelOptions: modelOptionsList, + requestKinds: [...requestKinds], + transports: [...transports], statuses: [...statuses], }); } diff --git a/openspec/changes/add-request-log-kind-and-session-correlation/design.md b/openspec/changes/add-request-log-kind-and-session-correlation/design.md new file mode 100644 index 00000000..b957b701 --- /dev/null +++ b/openspec/changes/add-request-log-kind-and-session-correlation/design.md @@ -0,0 +1,52 @@ +## Summary + +Request-log `transport` alone is not enough to debug compact regressions because both standard Responses and compact currently land as `HTTP` rows. We need one additional semantic discriminator plus one safe correlation key that lets operators match a compact row with the next follow-up response row on the same Codex thread. + +## Decisions + +### 1. Persist semantic request kind + +We persist a new `request_kind` column on `request_logs` and expose it through `/api/request-logs`. + +Chosen values: +- `responses` +- `compact` +- `transcription` + +This keeps the contract explicit without overloading `transport`. + +### 2. Persist only hashed session correlation + +We persist `session_id_hash` when an inbound request carries `session_id`, but we never store the raw `session_id`. + +Why: +- operators only need correlation, not the original secret-ish identifier +- the same hash is stable across compact and follow-up response calls +- the request-log table remains safe to expose in the dashboard + +### 3. Reuse existing proxy ownership + +`ProxyService` already owns request-log writes for HTTP Responses, compact, websocket Responses, and transcription flows. We keep the new fields in that layer instead of creating a separate request-log enrichment pipeline. + +### 4. Show correlation directly in the recent requests table and filters + +The dashboard recent requests table renders: +- a route badge from `requestKind` +- a small monospace `sessionIdHash` when present +- filter facets for `requestKind` and `transport` + +This keeps the debugging signal where operators already inspect compact incidents, without introducing a separate drill-down screen. + +## Rejected alternatives + +### Reuse `transport` for semantic meaning + +Rejected because `HTTP` does not distinguish `/responses` from `/responses/compact`. + +### Store raw `session_id` + +Rejected because the UI only needs a correlation key, not the original identifier. + +### Add a separate correlation table + +Rejected because request-log persistence already sits at the right boundary and the additional fields are lightweight. diff --git a/openspec/changes/add-request-log-kind-and-session-correlation/proposal.md b/openspec/changes/add-request-log-kind-and-session-correlation/proposal.md new file mode 100644 index 00000000..f62de8b1 --- /dev/null +++ b/openspec/changes/add-request-log-kind-and-session-correlation/proposal.md @@ -0,0 +1,20 @@ +## Why + +Operators can now see request `transport` in the dashboard, but compact incidents still require cross-checking raw server logs because the recent requests table does not tell them whether a row is a normal Responses call or a compact call, and it does not expose any safe correlation key for the Codex `session_id` thread. + +This makes compact regressions harder to prove from the UI: a row can show `HTTP` while still being either `/responses` or `/responses/compact`, and it is hard to tell whether a compact request and the next follow-up response stayed on the same session thread. + +## What Changes + +- persist a stable `request_kind` on `request_logs` for proxied requests +- persist a safe hashed `session_id` correlation key on `request_logs` when the inbound request carries `session_id` +- expose both fields through `GET /api/request-logs` +- render the request kind and session correlation key in the dashboard recent requests table +- add dashboard request-log filters and facet options for `requestKind` and `transport` + +## Impact + +- adds request-log schema fields and a DB migration +- updates proxy request-log writes for Responses, compact, websocket Responses, and transcription flows +- extends request-log filter contracts in both backend and frontend +- improves compact incident debugging without exposing raw session identifiers diff --git a/openspec/changes/add-request-log-kind-and-session-correlation/specs/frontend-architecture/spec.md b/openspec/changes/add-request-log-kind-and-session-correlation/specs/frontend-architecture/spec.md new file mode 100644 index 00000000..bcdd402b --- /dev/null +++ b/openspec/changes/add-request-log-kind-and-session-correlation/specs/frontend-architecture/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Request kind and session correlation are visible in the dashboard +The Dashboard recent requests table SHALL render each row's recorded `requestKind` so operators can distinguish standard Responses traffic from compact traffic without leaving the UI. When `/api/request-logs` includes a `sessionIdHash`, the same row SHALL show that hashed value as a safe correlation key. The table SHALL remain renderable for legacy rows whose `requestKind` or `sessionIdHash` is missing. + +#### Scenario: Compact request row is visible in the dashboard +- **WHEN** `/api/request-logs` returns a request row with `requestKind = "compact"` and `sessionIdHash = "sha256:abc123def456"` +- **THEN** the recent requests table shows a visible compact request indicator for that row +- **AND** the row shows the hashed session correlation value without exposing the raw `session_id` + +#### Scenario: Legacy request row without kind or session hash still renders +- **WHEN** `/api/request-logs` returns a request row with `requestKind = null` and `sessionIdHash = null` +- **THEN** the recent requests table still renders the row +- **AND** it shows neutral placeholders instead of breaking layout + +### Requirement: Request kind and transport are filterable in the dashboard +The Dashboard request-log filters SHALL expose `requestKind` and `transport` as selectable facets so operators can narrow recent request rows by the same route and transport signals shown in the table. The request-log filter-options API response SHALL include distinct `requestKinds` and `transports` values for the current non-status filter scope. + +#### Scenario: Request kind and transport facets narrow the request-log query +- **WHEN** the user selects `requestKind = "compact"` and `transport = "websocket"` in the dashboard filters +- **THEN** the frontend refetches `GET /api/request-logs` with `requestKind=compact` and `transport=websocket` +- **AND** the visible recent requests table only shows rows matching both selections + +#### Scenario: Filter options expose request kind and transport facets +- **WHEN** the frontend fetches `GET /api/request-logs/options` for the current non-status filter scope +- **THEN** the response includes `requestKinds` and `transports` alongside the existing account, model, and status options diff --git a/openspec/changes/add-request-log-kind-and-session-correlation/specs/responses-api-compat/spec.md b/openspec/changes/add-request-log-kind-and-session-correlation/specs/responses-api-compat/spec.md new file mode 100644 index 00000000..4d176f6b --- /dev/null +++ b/openspec/changes/add-request-log-kind-and-session-correlation/specs/responses-api-compat/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Persist request kind and session correlation in request logs +The service MUST persist a stable `request_kind` value on `request_logs` for proxied request-log rows and MUST expose the same value through `/api/request-logs`. At minimum, standard Responses requests on `/backend-api/codex/responses`, `/v1/responses`, and their websocket equivalents MUST persist `request_kind = "responses"`, compact requests on `/backend-api/codex/responses/compact` and `/v1/responses/compact` MUST persist `request_kind = "compact"`, and transcription requests on `/backend-api/transcribe` or `/v1/audio/transcriptions` MUST persist `request_kind = "transcription"`. + +When an inbound proxied request includes a non-empty `session_id` header, the service MUST persist a deterministic hashed `session_id_hash` value on the corresponding request-log row instead of storing the raw `session_id`. The same request-log API response MUST expose that hashed value for correlation. When no `session_id` header is present, the request-log row MAY keep `session_id_hash = null`. + +#### Scenario: HTTP compact request log exposes compact kind and hashed session id +- **WHEN** a client completes `/backend-api/codex/responses/compact` with a non-empty `session_id` header +- **THEN** the persisted request log has `request_kind = "compact"` +- **AND** the persisted request log has a non-null `session_id_hash` +- **AND** `/api/request-logs` returns that row with `requestKind = "compact"` and the same `sessionIdHash` + +#### Scenario: WebSocket Responses request log exposes responses kind and hashed session id +- **WHEN** a client completes a Responses request over WebSocket on `/backend-api/codex/responses` with a non-empty `session_id` header +- **THEN** the persisted request log has `request_kind = "responses"` +- **AND** the persisted request log has a non-null `session_id_hash` +- **AND** `/api/request-logs` returns that row with `requestKind = "responses"` and the same `sessionIdHash` + +#### Scenario: Request without session header leaves session hash empty +- **WHEN** a proxied request completes without any inbound `session_id` header +- **THEN** the persisted request log keeps `session_id_hash = null` +- **AND** `/api/request-logs` returns that row with `sessionIdHash = null` diff --git a/openspec/changes/add-request-log-kind-and-session-correlation/tasks.md b/openspec/changes/add-request-log-kind-and-session-correlation/tasks.md new file mode 100644 index 00000000..32902529 --- /dev/null +++ b/openspec/changes/add-request-log-kind-and-session-correlation/tasks.md @@ -0,0 +1,19 @@ +## 1. Spec + +- [x] 1.1 Add `responses-api-compat` delta for persisted `request_kind` and hashed `session_id` correlation on request logs +- [x] 1.2 Add `frontend-architecture` delta for rendering request kind and session correlation in the recent requests table + +## 2. Backend + +- [x] 2.1 Add `request_kind` and `session_id_hash` columns to `request_logs` via Alembic and base schema updates +- [x] 2.2 Persist `request_kind` and `session_id_hash` from proxy flows without storing raw `session_id` +- [x] 2.3 Expose the new fields through request-log repository, mapper, and API schemas +- [x] 2.4 Add regression coverage for Responses, compact, and websocket request-log rows +- [x] 2.5 Add request-log API filtering and facet options for `request_kind` and `transport` + +## 3. Frontend + +- [x] 3.1 Extend dashboard request-log schema/types with `requestKind` and `sessionIdHash` +- [x] 3.2 Render request kind and session correlation in the recent requests table while keeping legacy null rows safe +- [x] 3.3 Update frontend tests and mock factories for the new fields +- [x] 3.4 Add dashboard filter controls for `requestKind` and `transport` and wire them into request-log queries diff --git a/openspec/specs/frontend-architecture/spec.md b/openspec/specs/frontend-architecture/spec.md index ab7ec51d..11bdecc9 100644 --- a/openspec/specs/frontend-architecture/spec.md +++ b/openspec/specs/frontend-architecture/spec.md @@ -77,7 +77,7 @@ The Dashboard page SHALL display: summary metric cards (requests 7d, tokens, cos #### Scenario: Request log filtering -- **WHEN** a user applies filters (search, timeframe, account, model, status) to the request logs table +- **WHEN** a user applies filters (search, timeframe, account, model, route, transport, status) to the request logs table - **THEN** only the request logs query refetches from `/api/request-logs` with the applied filter parameters; the dashboard overview is NOT refetched #### Scenario: Request log pagination @@ -107,6 +107,19 @@ The Dashboard recent requests table SHALL display each row's recorded request tr - **WHEN** `/api/request-logs` returns a request row with `transport = null` - **THEN** the recent requests table still renders the row and shows a neutral placeholder instead of breaking layout +### Requirement: Request kind and session correlation are visible in the dashboard +The Dashboard recent requests table SHALL render each row's recorded `requestKind` so operators can distinguish standard Responses traffic from compact traffic without leaving the UI. When `/api/request-logs` includes a `sessionIdHash`, the same row SHALL show that hashed value as a safe correlation key. The table SHALL remain renderable for legacy rows whose `requestKind` or `sessionIdHash` is missing. + +#### Scenario: Compact request row is visible in the dashboard +- **WHEN** `/api/request-logs` returns a request row with `requestKind = "compact"` and `sessionIdHash = "sha256:abc123def456"` +- **THEN** the recent requests table shows a visible compact request indicator for that row +- **AND** the row shows the hashed session correlation value without exposing the raw `session_id` + +#### Scenario: Legacy request row without kind or session hash still renders +- **WHEN** `/api/request-logs` returns a request row with `requestKind = null` and `sessionIdHash = null` +- **THEN** the recent requests table still renders the row +- **AND** it shows neutral placeholders instead of breaking layout + ### Requirement: Accounts page The Accounts page SHALL display a two-column layout: left panel with searchable account list, import button, and add account button; right panel with selected account details including usage, token info, and actions (pause/resume/delete/re-authenticate). @@ -228,7 +241,7 @@ The backend API response schemas SHALL be optimized to eliminate over-fetching a #### Scenario: Filter options with statuses - **WHEN** the frontend fetches `GET /api/request-logs/options` -- **THEN** the response includes `statuses` (list of available status values) alongside `account_ids` and `model_options` +- **THEN** the response includes `statuses` (list of available status values) alongside `account_ids`, `model_options`, `request_kinds`, and `transports` ### Requirement: Frontend test infrastructure diff --git a/openspec/specs/responses-api-compat/context.md b/openspec/specs/responses-api-compat/context.md index d94dc50e..475bbafe 100644 --- a/openspec/specs/responses-api-compat/context.md +++ b/openspec/specs/responses-api-compat/context.md @@ -71,4 +71,6 @@ Non-streaming request/response: - Post-deploy: monitor `no_accounts`, `upstream_unavailable`, compact retry attempts, and compact failure phases, especially on direct compact requests. - When tracing compact incidents, confirm that request logs and upstream logs show direct `/codex/responses/compact` usage without surrogate `/codex/responses` fallback. - Post-deploy: monitor `no_accounts`, `stream_incomplete`, and `upstream_unavailable`. +- Use dashboard request logs to distinguish `responses` vs `compact` rows via `requestKind`, and use `sessionIdHash` as the safe thread-correlation key when verifying a compact call and its follow-up response stayed on the same Codex `session_id`. +- In ChatGPT-authenticated Codex mode, treat returned `response.service_tier` as an advisory trace signal, not as authoritative proof that Fast routing was or was not applied end-to-end. An OpenAI collaborator clarified on 2026-03-10 that Fast is handled by Codex server-side routing in this mode, so a final `response.service_tier = "default"` does not by itself prove Fast was ignored: https://github.com/openai/codex/issues/14204#issuecomment-4033184620 - Websocket/Codex CLI tier verification runbook: `openspec/specs/responses-api-compat/ops.md` diff --git a/openspec/specs/responses-api-compat/spec.md b/openspec/specs/responses-api-compat/spec.md index f57e6197..1377e5b8 100644 --- a/openspec/specs/responses-api-compat/spec.md +++ b/openspec/specs/responses-api-compat/spec.md @@ -331,6 +331,28 @@ The service MUST persist a stable `transport` value on `request_logs` for Respon - **THEN** the persisted request log has `transport = "websocket"` - **AND** `/api/request-logs` returns that row with `transport = "websocket"` +### Requirement: Persist request kind and session correlation in request logs +The service MUST persist a stable `request_kind` value on `request_logs` for proxied request-log rows and MUST expose the same value through `/api/request-logs`. At minimum, standard Responses requests on `/backend-api/codex/responses`, `/v1/responses`, and their websocket equivalents MUST persist `request_kind = "responses"`, compact requests on `/backend-api/codex/responses/compact` and `/v1/responses/compact` MUST persist `request_kind = "compact"`, and transcription requests on `/backend-api/transcribe` or `/v1/audio/transcriptions` MUST persist `request_kind = "transcription"`. + +When an inbound proxied request includes a non-empty `session_id` header, the service MUST persist a deterministic hashed `session_id_hash` value on the corresponding request-log row instead of storing the raw `session_id`. The same request-log API response MUST expose that hashed value for correlation. When no `session_id` header is present, the request-log row MAY keep `session_id_hash = null`. + +#### Scenario: HTTP compact request log exposes compact kind and hashed session id +- **WHEN** a client completes `/backend-api/codex/responses/compact` with a non-empty `session_id` header +- **THEN** the persisted request log has `request_kind = "compact"` +- **AND** the persisted request log has a non-null `session_id_hash` +- **AND** `/api/request-logs` returns that row with `requestKind = "compact"` and the same `sessionIdHash` + +#### Scenario: WebSocket Responses request log exposes responses kind and hashed session id +- **WHEN** a client completes a Responses request over WebSocket on `/backend-api/codex/responses` with a non-empty `session_id` header +- **THEN** the persisted request log has `request_kind = "responses"` +- **AND** the persisted request log has a non-null `session_id_hash` +- **AND** `/api/request-logs` returns that row with `requestKind = "responses"` and the same `sessionIdHash` + +#### Scenario: Request without session header leaves session hash empty +- **WHEN** a proxied request completes without any inbound `session_id` header +- **THEN** the persisted request log keeps `session_id_hash = null` +- **AND** `/api/request-logs` returns that row with `sessionIdHash = null` + ### Requirement: Emit opt-in safe service-tier trace logs When service-tier trace logging is enabled, the service MUST emit a diagnostic log entry for Responses requests that records `request_id`, request `kind`, `requested_service_tier`, and upstream `actual_service_tier`. The diagnostic log MUST NOT include prompt text, input content, or the full request payload. diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 62598c52..2cb5b545 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -480,6 +480,8 @@ async def test_run_startup_migrations_drops_accounts_email_unique_with_non_casca request_log_columns_rows = (await session.execute(text("PRAGMA table_info(request_logs)"))).fetchall() request_log_columns = {str(row[1]) for row in request_log_columns_rows if len(row) > 1} assert "transport" in request_log_columns + assert "request_kind" in request_log_columns + assert "session_id_hash" in request_log_columns if "routing_strategy" in dashboard_columns: routing_strategy = ( await session.execute(text("SELECT routing_strategy FROM dashboard_settings WHERE id=1")) diff --git a/tests/integration/test_proxy_compact.py b/tests/integration/test_proxy_compact.py index dc51b7b3..0756bf1b 100644 --- a/tests/integration/test_proxy_compact.py +++ b/tests/integration/test_proxy_compact.py @@ -3,8 +3,8 @@ import base64 import json from datetime import timedelta, timezone +from hashlib import sha256 from types import SimpleNamespace -from typing import cast import pytest @@ -18,11 +18,16 @@ from app.db.models import Account, AccountStatus from app.db.session import SessionLocal from app.modules.proxy.rate_limit_cache import get_rate_limit_headers_cache +from app.modules.request_logs.repository import RequestLogsRepository from app.modules.usage.repository import AdditionalUsageRepository, UsageRepository pytestmark = pytest.mark.integration +def _hash_session_id(value: str) -> str: + return f"sha256:{sha256(value.encode('utf-8')).hexdigest()[:12]}" + + def _encode_jwt(payload: dict) -> str: raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") @@ -78,18 +83,14 @@ def post( return self._response -def _session_call_url(session: _JsonSession) -> str: - return cast(str, session.calls[0]["url"]) - - -def _session_call_json(session: _JsonSession) -> dict[str, object]: - return cast(dict[str, object], session.calls[0]["json"]) - - @pytest.mark.asyncio async def test_proxy_compact_no_accounts(async_client): payload = {"model": "gpt-5.1", "instructions": "hi", "input": []} - response = await async_client.post("/backend-api/codex/responses/compact", json=payload) + response = await async_client.post( + "/backend-api/codex/responses/compact", + json=payload, + headers={"session_id": "sid-compact-pass-through"}, + ) assert response.status_code == 503 error = response.json()["error"] assert error["code"] == "no_accounts" @@ -170,7 +171,11 @@ async def fake_compact(payload, headers, access_token, account_id): ) payload = {"model": "gpt-5.1", "instructions": "hi", "input": []} - response = await async_client.post("/backend-api/codex/responses/compact", json=payload) + response = await async_client.post( + "/backend-api/codex/responses/compact", + json=payload, + headers={"session_id": "sid-compact-pass-through"}, + ) assert response.status_code == 200 assert response.json()["output"] == [] assert seen["access_token"] == "access-token" @@ -187,6 +192,7 @@ async def fake_compact(payload, headers, access_token, account_id): async def test_proxy_compact_success_preserves_compaction_payload(async_client, monkeypatch): email = "compact-pass-through@example.com" raw_account_id = "acc_compact_pass_through" + expected_account_id = generate_unique_account_id(raw_account_id, email) auth_json = _make_auth_json(raw_account_id, email) files = {"auth_json": ("auth.json", json.dumps(auth_json), "application/json")} response = await async_client.post("/api/accounts/import", files=files) @@ -207,7 +213,11 @@ async def test_proxy_compact_success_preserves_compaction_payload(async_client, monkeypatch.setattr(proxy_client_module, "get_http_client", lambda: SimpleNamespace(session=session)) payload = {"model": "gpt-5.1", "instructions": "hi", "input": []} - response = await async_client.post("/backend-api/codex/responses/compact", json=payload) + response = await async_client.post( + "/backend-api/codex/responses/compact", + json=payload, + headers={"session_id": "sid-compact-pass-through"}, + ) assert response.status_code == 200 body = response.json() @@ -216,10 +226,24 @@ async def test_proxy_compact_success_preserves_compaction_payload(async_client, "encrypted_content": "enc_compact_summary_1", "summary_text": "condensed thread state", } - assert _session_call_url(session).endswith("/codex/responses/compact") - call_json = _session_call_json(session) - assert "stream" not in call_json - assert "store" not in call_json + call = session.calls[0] + url = call["url"] + compact_json = call["json"] + assert isinstance(url, str) + assert url.endswith("/codex/responses/compact") + assert isinstance(compact_json, dict) + assert "stream" not in compact_json + assert "store" not in compact_json + + async with SessionLocal() as db_session: + logs_repo = RequestLogsRepository(db_session) + logs = await logs_repo.list_since(utcnow() - timedelta(minutes=5)) + + compact_logs = [log for log in logs if log.account_id == expected_account_id and log.request_id] + assert compact_logs + assert compact_logs[-1].transport == "http" + assert compact_logs[-1].request_kind == "compact" + assert compact_logs[-1].session_id_hash == _hash_session_id("sid-compact-pass-through") @pytest.mark.asyncio diff --git a/tests/integration/test_proxy_responses.py b/tests/integration/test_proxy_responses.py index 75f13bed..3f11e828 100644 --- a/tests/integration/test_proxy_responses.py +++ b/tests/integration/test_proxy_responses.py @@ -2,6 +2,7 @@ import base64 import json +from hashlib import sha256 import pytest from httpx import ASGITransport, AsyncClient @@ -15,6 +16,10 @@ pytestmark = pytest.mark.integration +def _hash_session_id(value: str) -> str: + return f"sha256:{sha256(value.encode('utf-8')).hexdigest()[:12]}" + + def _encode_jwt(payload: dict) -> str: raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") @@ -52,7 +57,7 @@ async def test_proxy_responses_no_accounts(async_client): "POST", "/backend-api/codex/responses", json=payload, - headers={"x-request-id": request_id}, + headers={"x-request-id": request_id, "session_id": "sid-http-stream"}, ) as resp: assert resp.status_code == 200 lines = [line async for line in resp.aiter_lines() if line] @@ -221,7 +226,7 @@ async def fake_stream(payload, headers, access_token, account_id, base_url=None, "POST", "/backend-api/codex/responses", json=payload, - headers={"x-request-id": request_id}, + headers={"x-request-id": request_id, "session_id": "sid-http-stream"}, ) as resp: assert resp.status_code == 200 lines = [line async for line in resp.aiter_lines() if line] @@ -241,6 +246,8 @@ async def fake_stream(payload, headers, access_token, account_id, base_url=None, assert log is not None assert log.request_id == request_id assert log.transport == "http" + assert log.request_kind == "responses" + assert log.session_id_hash == _hash_session_id("sid-http-stream") @pytest.mark.asyncio diff --git a/tests/integration/test_proxy_websocket_responses.py b/tests/integration/test_proxy_websocket_responses.py index 0d7d2a19..0737a32a 100644 --- a/tests/integration/test_proxy_websocket_responses.py +++ b/tests/integration/test_proxy_websocket_responses.py @@ -3,6 +3,7 @@ import asyncio import json from collections import deque +from hashlib import sha256 from types import SimpleNamespace from typing import cast @@ -15,6 +16,10 @@ pytestmark = pytest.mark.integration +def _hash_session_id(value: str) -> str: + return f"sha256:{sha256(value.encode('utf-8')).hexdigest()[:12]}" + + class _FakeUpstreamMessage: def __init__( self, @@ -223,6 +228,8 @@ async def fake_write_request_log(self, **kwargs): assert log["model"] == "gpt-5.4" assert log["service_tier"] == "priority" assert log["transport"] == "websocket" + assert log["request_kind"] == "responses" + assert log["session_id_hash"] == _hash_session_id("thread-ws-1") assert log["status"] == "success" assert log["input_tokens"] == 3 assert log["output_tokens"] == 5 diff --git a/tests/integration/test_request_logs_api.py b/tests/integration/test_request_logs_api.py index 26dda791..ec925adf 100644 --- a/tests/integration/test_request_logs_api.py +++ b/tests/integration/test_request_logs_api.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import timedelta +from hashlib import sha256 import pytest @@ -14,6 +15,10 @@ pytestmark = pytest.mark.integration +def _hash_session_id(value: str) -> str: + return f"sha256:{sha256(value.encode('utf-8')).hexdigest()[:12]}" + + def _make_account(account_id: str, email: str) -> Account: encryptor = TokenEncryptor() return Account( @@ -57,6 +62,7 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): error_code=None, requested_at=now - timedelta(minutes=1), transport="http", + request_kind="responses", ) await logs_repo.add_log( account_id="acc_logs", @@ -71,6 +77,8 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): requested_at=now, api_key_id="key_logs_1", transport="websocket", + request_kind="compact", + session_id_hash=_hash_session_id("sid-logs-2"), ) response = await async_client.get("/api/request-logs?limit=2") @@ -87,6 +95,8 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): assert latest["errorCode"] == "rate_limit_exceeded" assert latest["errorMessage"] == "Rate limit reached" assert latest["transport"] == "websocket" + assert latest["requestKind"] == "compact" + assert latest["sessionIdHash"] == _hash_session_id("sid-logs-2") older = payload[1] assert older["status"] == "ok" @@ -94,3 +104,61 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): assert older["tokens"] == 300 assert older["cachedInputTokens"] is None assert older["transport"] == "http" + assert older["requestKind"] == "responses" + assert older["sessionIdHash"] is None + + +@pytest.mark.asyncio +async def test_request_logs_api_filters_by_request_kind_and_transport(async_client, db_setup): + async with SessionLocal() as session: + accounts_repo = AccountsRepository(session) + logs_repo = RequestLogsRepository(session) + await accounts_repo.upsert(_make_account("acc_filter_logs", "filters@example.com")) + + now = utcnow() + await logs_repo.add_log( + account_id="acc_filter_logs", + request_id="req_filter_http", + model="gpt-5.1", + input_tokens=10, + output_tokens=10, + latency_ms=120, + status="success", + error_code=None, + requested_at=now - timedelta(minutes=2), + transport="http", + request_kind="responses", + ) + await logs_repo.add_log( + account_id="acc_filter_logs", + request_id="req_filter_ws", + model="gpt-5.1", + input_tokens=10, + output_tokens=10, + latency_ms=140, + status="success", + error_code=None, + requested_at=now - timedelta(minutes=1), + transport="websocket", + request_kind="compact", + ) + await logs_repo.add_log( + account_id="acc_filter_logs", + request_id="req_filter_compact_http", + model="gpt-5.1", + input_tokens=10, + output_tokens=10, + latency_ms=160, + status="success", + error_code=None, + requested_at=now, + transport="http", + request_kind="compact", + ) + + response = await async_client.get("/api/request-logs?requestKind=compact&transport=websocket") + assert response.status_code == 200 + body = response.json() + assert body["total"] == 1 + assert body["hasMore"] is False + assert [entry["requestId"] for entry in body["requests"]] == ["req_filter_ws"] diff --git a/tests/test_request_logs_options_api.py b/tests/test_request_logs_options_api.py index 5f802cfe..54ab5c6c 100644 --- a/tests/test_request_logs_options_api.py +++ b/tests/test_request_logs_options_api.py @@ -48,6 +48,8 @@ async def test_request_logs_options_returns_distinct_accounts_and_models(async_c status="success", error_code=None, requested_at=now - timedelta(minutes=1), + request_kind="responses", + transport="http", ) await logs_repo.add_log( account_id="acc_opt_b", @@ -60,6 +62,8 @@ async def test_request_logs_options_returns_distinct_accounts_and_models(async_c error_code="rate_limit_exceeded", error_message="Rate limit reached", requested_at=now, + request_kind="compact", + transport="websocket", ) response = await async_client.get("/api/request-logs/options") @@ -70,6 +74,8 @@ async def test_request_logs_options_returns_distinct_accounts_and_models(async_c {"model": "gpt-4o", "reasoningEffort": None}, {"model": "gpt-5.1", "reasoningEffort": None}, ] + assert payload["requestKinds"] == ["responses", "compact"] + assert payload["transports"] == ["http", "websocket"] assert payload["statuses"] == ["ok", "rate_limit"] @@ -92,6 +98,8 @@ async def test_request_logs_options_ignores_status_self_filter(async_client, db_ status="success", error_code=None, requested_at=now, + request_kind="responses", + transport="http", ) await logs_repo.add_log( account_id="acc_opt_err", @@ -103,6 +111,8 @@ async def test_request_logs_options_ignores_status_self_filter(async_client, db_ status="error", error_code="rate_limit_exceeded", requested_at=now, + request_kind="compact", + transport="websocket", ) response = await async_client.get("/api/request-logs/options?status=ok") @@ -113,6 +123,8 @@ async def test_request_logs_options_ignores_status_self_filter(async_client, db_ {"model": "gpt-4o", "reasoningEffort": None}, {"model": "gpt-5.1", "reasoningEffort": None}, ] + assert payload["requestKinds"] == ["responses", "compact"] + assert payload["transports"] == ["http", "websocket"] assert payload["statuses"] == ["ok", "rate_limit"] @@ -135,6 +147,8 @@ async def test_request_logs_options_ignore_status_matches_unfiltered_response(as status="success", error_code=None, requested_at=now, + request_kind="responses", + transport="http", ) await logs_repo.add_log( account_id="acc_opt_quota", @@ -146,6 +160,8 @@ async def test_request_logs_options_ignore_status_matches_unfiltered_response(as status="error", error_code="insufficient_quota", requested_at=now, + request_kind="compact", + transport="websocket", ) base = await async_client.get("/api/request-logs/options") @@ -176,6 +192,8 @@ async def test_request_logs_options_respects_non_status_filters(async_client, db status="success", error_code=None, requested_at=now, + request_kind="responses", + transport="http", ) await logs_repo.add_log( account_id="acc_scope_a", @@ -187,6 +205,8 @@ async def test_request_logs_options_respects_non_status_filters(async_client, db status="error", error_code="rate_limit_exceeded", requested_at=now, + request_kind="compact", + transport="websocket", ) await logs_repo.add_log( account_id="acc_scope_b", @@ -198,12 +218,16 @@ async def test_request_logs_options_respects_non_status_filters(async_client, db status="error", error_code="insufficient_quota", requested_at=old, + request_kind="transcription", + transport="http", ) scoped = await async_client.get( "/api/request-logs/options" "?accountId=acc_scope_a" "&modelOption=gpt-5.1:::" + "&requestKind=compact" + "&transport=websocket" f"&since={(now - timedelta(hours=1)).isoformat()}" ) @@ -211,4 +235,6 @@ async def test_request_logs_options_respects_non_status_filters(async_client, db payload = scoped.json() assert payload["accountIds"] == ["acc_scope_a"] assert payload["modelOptions"] == [{"model": "gpt-5.1", "reasoningEffort": None}] - assert payload["statuses"] == ["ok", "rate_limit"] + assert payload["requestKinds"] == ["compact"] + assert payload["transports"] == ["websocket"] + assert payload["statuses"] == ["rate_limit"]