feat: clarify request lifecycle status
This commit is contained in:
@@ -87,7 +87,7 @@ uv run codex-retry-gateway-tui
|
||||
Views:
|
||||
|
||||
- overview: current gateway status and summary counts
|
||||
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at`
|
||||
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and lifecycle status: `waiting` before the first response, `streaming` after it, final `HTTP <code>`, red `stalled` after no activity, or red `discarded` when the client cancels
|
||||
- logs: recent gateway logs
|
||||
- profiles: independently managed text profiles and image profiles, each with its own active selection and actions
|
||||
|
||||
|
||||
+104
-27
@@ -23,7 +23,7 @@ from typing import Any
|
||||
|
||||
|
||||
APP_NAME = "codex-retry-gateway-tui"
|
||||
FALLBACK_VERSION = "0.1.6"
|
||||
FALLBACK_VERSION = "0.1.7"
|
||||
DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway"
|
||||
DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway"
|
||||
DEFAULT_API_URL = DEFAULT_GATEWAY_URL
|
||||
@@ -47,6 +47,8 @@ DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES = [
|
||||
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5
|
||||
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350
|
||||
DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0
|
||||
REQUEST_STATUS_SLOW_AFTER_SECONDS = 20
|
||||
REQUEST_STATUS_STALLED_AFTER_SECONDS = 120
|
||||
DEFAULT_PROFILE_ENDPOINTS = [
|
||||
"/responses",
|
||||
"/chat/completions",
|
||||
@@ -102,7 +104,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
|
||||
"resp_id": 18,
|
||||
"thread": 18,
|
||||
"started": 19,
|
||||
"status": 7,
|
||||
"status": 12,
|
||||
"path": 22,
|
||||
"model": 16,
|
||||
"effort": 8,
|
||||
@@ -123,7 +125,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
|
||||
"resp_id": 24,
|
||||
"thread": 36,
|
||||
"started": 19,
|
||||
"status": 7,
|
||||
"status": 14,
|
||||
"path": 40,
|
||||
"model": 24,
|
||||
"effort": 8,
|
||||
@@ -986,7 +988,7 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None =
|
||||
short_text(response_id_text(row), limits.get("resp_id", 18)) or "-",
|
||||
short_text(row["thread_id"], limits.get("thread", 18)) or "-",
|
||||
short_time(row["started_at"]),
|
||||
f"{status_symbol(row)} {row.get('status_code') or '-'}",
|
||||
request_status_label(row),
|
||||
short_text(row["path"], limits.get("path", 22)),
|
||||
short_text(request_model_text(row), limits.get("model", 16)),
|
||||
short_text(request_effort_text(row), limits.get("effort", 10)),
|
||||
@@ -1020,16 +1022,16 @@ def request_visible_cells(
|
||||
visible_column_keys: list[str],
|
||||
) -> list[Any]:
|
||||
visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in visible_column_keys]
|
||||
thread_color = request_thread_color(row.get("thread_id"))
|
||||
if not thread_color:
|
||||
return visible_cells
|
||||
try:
|
||||
from rich.text import Text
|
||||
except ImportError:
|
||||
return visible_cells
|
||||
thread_color = request_thread_color(row.get("thread_id"))
|
||||
styled_cells: list[Any] = []
|
||||
for column_key, cell in zip(visible_column_keys, visible_cells):
|
||||
if column_key in THREAD_COLOR_COLUMN_KEYS:
|
||||
if column_key == "status":
|
||||
styled_cells.append(Text(str(cell), style=request_status_style(row)))
|
||||
elif thread_color and column_key in THREAD_COLOR_COLUMN_KEYS:
|
||||
styled_cells.append(Text(str(cell), style=f"bold {thread_color}"))
|
||||
else:
|
||||
styled_cells.append(cell)
|
||||
@@ -1316,6 +1318,8 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
|
||||
"requested_model",
|
||||
"forwarded_model",
|
||||
"reasoning_effort",
|
||||
"lifecycle_state",
|
||||
"discard_reason",
|
||||
"error",
|
||||
"upstream_origin",
|
||||
"upstream_path",
|
||||
@@ -1335,25 +1339,92 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
|
||||
return needle in haystack
|
||||
|
||||
|
||||
def status_symbol(entry: dict[str, Any]) -> str:
|
||||
if entry.get("error"):
|
||||
return "!"
|
||||
if entry.get("matched"):
|
||||
return "*"
|
||||
if entry.get("response_stream"):
|
||||
return "~"
|
||||
return " "
|
||||
|
||||
|
||||
def request_lifecycle_label(row: dict[str, Any]) -> str:
|
||||
state = str(row.get("lifecycle_state") or "").strip().lower()
|
||||
if state == "finish":
|
||||
return "done"
|
||||
if state == "receive_first":
|
||||
return "first"
|
||||
if state == "streaming":
|
||||
return "live"
|
||||
return "sent"
|
||||
if request_is_discarded(row):
|
||||
return "discarded"
|
||||
if state in {"finish", "finished", "complete", "completed"} or parse_datetime(row.get("finished_at")):
|
||||
return "finished"
|
||||
if state in {"receive_first", "streaming"} or parse_datetime(row.get("first_response_at")):
|
||||
return "streaming"
|
||||
return "waiting"
|
||||
|
||||
|
||||
def request_is_discarded(row: dict[str, Any]) -> bool:
|
||||
state = str(row.get("lifecycle_state") or "").strip().lower()
|
||||
if bool(row.get("discarded")) or state in {"discarded", "abandoned", "cancelled", "canceled"}:
|
||||
return True
|
||||
error = str(row.get("error") or "").strip().lower()
|
||||
return any(
|
||||
marker in error
|
||||
for marker in (
|
||||
"client disconnected",
|
||||
"client closed",
|
||||
"cancelled by client",
|
||||
"canceled by client",
|
||||
"request aborted",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def request_activity_at(row: dict[str, Any]) -> Any:
|
||||
for key in ("last_activity_at", "first_response_at", "started_at"):
|
||||
value = row.get(key)
|
||||
if parse_datetime(value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def request_activity_age_seconds(row: dict[str, Any], now: dt.datetime | None = None) -> float | None:
|
||||
activity_at = parse_datetime(request_activity_at(row))
|
||||
if not activity_at:
|
||||
return None
|
||||
current = now or dt.datetime.now(activity_at.tzinfo)
|
||||
if current.tzinfo is None and activity_at.tzinfo is not None:
|
||||
current = current.replace(tzinfo=activity_at.tzinfo)
|
||||
return max(0.0, (current - activity_at).total_seconds())
|
||||
|
||||
|
||||
def request_status_label(row: dict[str, Any], now: dt.datetime | None = None) -> str:
|
||||
if request_is_discarded(row):
|
||||
return "discarded"
|
||||
status_code = parse_int_value(row.get("status_code"))
|
||||
if status_code is not None:
|
||||
return f"HTTP {status_code}"
|
||||
lifecycle = request_lifecycle_label(row)
|
||||
if lifecycle == "finished":
|
||||
return "finished"
|
||||
age_seconds = request_activity_age_seconds(row, now)
|
||||
if age_seconds is not None and age_seconds >= REQUEST_STATUS_STALLED_AFTER_SECONDS:
|
||||
return f"stalled {format_elapsed_seconds(age_seconds)}"
|
||||
if age_seconds is not None and age_seconds >= REQUEST_STATUS_SLOW_AFTER_SECONDS:
|
||||
return f"{lifecycle} {format_elapsed_seconds(age_seconds)}"
|
||||
return lifecycle
|
||||
|
||||
|
||||
def request_status_style(row: dict[str, Any], now: dt.datetime | None = None) -> str:
|
||||
label = request_status_label(row, now).lower()
|
||||
if label.startswith(("discarded", "stalled")):
|
||||
return "bold #ff5f5f"
|
||||
if label.startswith("waiting"):
|
||||
return "bold #ffaf5f"
|
||||
if label.startswith("streaming"):
|
||||
return "bold #5fd7ff"
|
||||
if label.startswith("http "):
|
||||
status_code = parse_int_value(row.get("status_code")) or 0
|
||||
if 200 <= status_code < 400:
|
||||
return "bold #87d75f"
|
||||
if 400 <= status_code < 500:
|
||||
return "bold #ffaf5f"
|
||||
return "bold #ff5f5f"
|
||||
if row.get("error"):
|
||||
return "bold #ff5f5f"
|
||||
return "#b0b0b0"
|
||||
|
||||
|
||||
def status_symbol(entry: dict[str, Any]) -> str:
|
||||
"""Compatibility helper for callers that previously consumed a status marker."""
|
||||
return request_status_label(entry)
|
||||
|
||||
|
||||
def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
|
||||
@@ -1381,6 +1452,9 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
"reasoning_summary": str(entry.get("reasoning_summary") or ""),
|
||||
"request_stream": bool(entry.get("request_stream")),
|
||||
"response_stream": bool(entry.get("response_stream")),
|
||||
"lifecycle_state": str(entry.get("lifecycle_state") or ""),
|
||||
"discarded": bool(entry.get("discarded")),
|
||||
"discard_reason": str(entry.get("discard_reason") or ""),
|
||||
"matched": bool(entry.get("matched")),
|
||||
"inspected": bool(entry.get("inspected")),
|
||||
"status_code": entry.get("status_code"),
|
||||
@@ -1394,6 +1468,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
"request_body_bytes": entry.get("request_body_bytes"),
|
||||
"response_bytes_received": entry.get("response_bytes_received"),
|
||||
"stream_chunk_count": entry.get("stream_chunk_count"),
|
||||
"first_response_at": entry.get("first_response_at"),
|
||||
"first_response_delay_ms": entry.get("first_response_delay_ms"),
|
||||
"reasoning_retry_current_round": parse_int_value(entry.get("reasoning_retry_current_round")),
|
||||
"reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")),
|
||||
@@ -1425,7 +1500,7 @@ def render_request_detail(row: dict[str, Any]) -> str:
|
||||
f"started {short_time(row['started_at'])}",
|
||||
f"{row['method']} {row['path']}",
|
||||
f"profile {row.get('profile_name') or '-'}",
|
||||
f"status {row['status_code'] or '-'}",
|
||||
f"status {request_status_label(row)}",
|
||||
f"upstream {row['upstream_status_code'] or '-'}",
|
||||
f"attempts {row['upstream_attempt_count'] or 0}",
|
||||
f"first {request_first_text(row)}",
|
||||
@@ -1456,6 +1531,8 @@ def render_request_detail(row: dict[str, Any]) -> str:
|
||||
bits.append(f"cached raw {format_count(row['cached_tokens'])}")
|
||||
if row.get("error"):
|
||||
bits.append(f"error {row['error']}")
|
||||
if row.get("discard_reason"):
|
||||
bits.append(f"discard {row['discard_reason']}")
|
||||
if row.get("upstream_origin"):
|
||||
bits.append(f"origin {row['upstream_origin']}")
|
||||
if row.get("upstream_path"):
|
||||
@@ -2742,7 +2819,7 @@ def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None:
|
||||
for row in snapshot.get("requests") or []:
|
||||
try:
|
||||
print(
|
||||
f"{row['seq']:>6} {short_text(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {status_symbol(row)} {row.get('status_code') or '-':<4} "
|
||||
f"{row['seq']:>6} {short_text(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {short_text(request_status_label(row), 14):<14} "
|
||||
f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} {short_text(request_effort_text(row), 6):<6} {request_reasoning_tokens_text(row):<6} "
|
||||
f"{short_text(request_usage_summary(row), 26):<26} {format_bytes(row.get('request_body_bytes')):<8} {request_chunk_progress(row):<14} {format_duration_ms_as_seconds(row['duration_ms']):<8} {request_updated_elapsed(row):<8} "
|
||||
f"{short_text(request_retry_note(row), 30)}"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "codex-retry-gateway-tui"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
description = "Terminal UI for codex-retry-gateway monitoring and control"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+42
-1
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -129,6 +130,45 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual(mod.request_usage_summary(row), "in 75 | out 40 | cached 25 (25%)")
|
||||
|
||||
def test_request_status_labels_follow_lifecycle_and_age(self) -> None:
|
||||
mod = load_module()
|
||||
now = dt.datetime(2026, 7, 10, 12, 0, tzinfo=dt.timezone.utc)
|
||||
waiting = {
|
||||
"lifecycle_state": "sent",
|
||||
"started_at": "2026-07-10T11:59:50Z",
|
||||
}
|
||||
streaming = {
|
||||
"lifecycle_state": "receive_first",
|
||||
"started_at": "2026-07-10T11:59:00Z",
|
||||
"first_response_at": "2026-07-10T11:59:55Z",
|
||||
"last_activity_at": "2026-07-10T11:59:55Z",
|
||||
}
|
||||
complete = {"lifecycle_state": "finish", "status_code": 200}
|
||||
discarded = {
|
||||
"lifecycle_state": "finish",
|
||||
"status_code": 502,
|
||||
"error": "client disconnected before reasoning retry completed",
|
||||
}
|
||||
slow_waiting = {
|
||||
"lifecycle_state": "sent",
|
||||
"started_at": "2026-07-10T11:59:30Z",
|
||||
}
|
||||
stalled = {
|
||||
"lifecycle_state": "streaming",
|
||||
"first_response_at": "2026-07-10T11:57:50Z",
|
||||
"last_activity_at": "2026-07-10T11:57:50Z",
|
||||
}
|
||||
|
||||
self.assertEqual(mod.request_status_label(waiting, now), "waiting")
|
||||
self.assertEqual(mod.request_status_label(streaming, now), "streaming")
|
||||
self.assertEqual(mod.request_status_label(complete, now), "HTTP 200")
|
||||
self.assertEqual(mod.request_status_label(discarded, now), "discarded")
|
||||
self.assertEqual(mod.request_status_label(slow_waiting, now), "waiting 30.0s")
|
||||
self.assertEqual(mod.request_status_label(stalled, now), "stalled 130s")
|
||||
self.assertNotEqual(mod.request_status_style(waiting, now), mod.request_status_style(streaming, now))
|
||||
self.assertEqual(mod.request_status_style(discarded, now), "bold #ff5f5f")
|
||||
self.assertEqual(mod.request_status_style(stalled, now), "bold #ff5f5f")
|
||||
|
||||
def test_request_row_cells_expand_with_wider_limits(self) -> None:
|
||||
mod = load_module()
|
||||
row = {
|
||||
@@ -316,7 +356,8 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
self.assertEqual(visible[1].plain, "req_1")
|
||||
self.assertEqual(visible[2].plain, "resp_1")
|
||||
self.assertEqual(visible[3].plain, "thread_same")
|
||||
self.assertEqual(visible[4], "· 200")
|
||||
self.assertIsInstance(visible[4], Text)
|
||||
self.assertEqual(visible[4].plain, "HTTP 200")
|
||||
self.assertEqual(mod.request_thread_color("thread_same"), mod.request_thread_color("thread_same"))
|
||||
self.assertIsNone(mod.request_thread_color(""))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user