Compare commits

..
8 Commits
5 changed files with 723 additions and 83 deletions
+21 -8
View File
@@ -1,5 +1,20 @@
# codex-retry-gateway-tui # codex-retry-gateway-tui
- 文档层级:客户端工具
- 应用性质:客户端 TUI
- 源码来源:git submodule
- 运行关系:客户端工具
- 部署模型:外部安装器
- 对应服务:`services/pc/user-app/codex-retry-gateway-tui/current/README.md`、`services/a100/user-app/codex-retry-gateway-tui/current/README.md`
- 对应 stack:无
- Secret 边界:本地用户配置
- 分发方式:`uvx` / `uv tool install`。
- 包名:`codex-retry-gateway-tui`
- 命令名:`codex-retry-gateway-tui`
- 本地状态路径:`~/.config/codex-retry-gateway-tui/`
- 目标 API:本机或内网 codex-retry-gateway。
- 备注:客户端工具本身不是长期服务;user-app README 只记录具体宿主安装状态。
Terminal UI for `codex-retry-gateway`. Terminal UI for `codex-retry-gateway`.
## Quick Start ## Quick Start
@@ -72,9 +87,9 @@ uv run codex-retry-gateway-tui
Views: Views:
- overview: current gateway status and summary counts - 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 - logs: recent gateway logs
- profiles: saved profiles, active profile default selection, profile actions - profiles: independently managed text profiles and image profiles, each with its own active selection and actions
Controls: Controls:
@@ -82,15 +97,13 @@ Controls:
- `2` requests - `2` requests
- `3` logs - `3` logs
- `4` profiles - `4` profiles
- `,` / `.` select request-table column for visibility/sort actions - `i` switch the profiles table between text and image profiles
- `v` toggle the selected requests column visible/hidden - `c` edit request-table column visibility, order, and widths
- `c` sort requests by the selected column
- `C` toggle requests sort ascending/descending
- `e` edit selected profile in `$VISUAL` / `$EDITOR` - `e` edit selected profile in `$VISUAL` / `$EDITOR`
- `/` filter - `/` filter
- `r` refresh - `r` refresh
- `m` switch request table compact/wide width profile - `m` switch request table compact/wide width profile
- `[` / `]` narrow or widen the requests usage column - `Shift-Left` / `Shift-Right` scroll the active table
- `p` probe selected profile - `p` probe selected profile
- `s` switch to selected profile - `s` switch to selected profile
- `w` save selected profile snapshot - `w` save selected profile snapshot
@@ -99,4 +112,4 @@ Controls:
This client uses only the public gateway admin API and does not need SSH or secrets. This client uses only the public gateway admin API and does not need SSH or secrets.
Profile editing uses a temp TOML draft opened in `$VISUAL` or `$EDITOR` and then saves it back through the gateway profiles API. Profile editing uses a temp TOML draft opened in `$VISUAL` or `$EDITOR` and then saves it back through the matching gateway API. Text drafts only contain text upstream settings; image drafts only contain image upstream settings for `/images/*` and `/v1/images/*`. Manual secrets are always blank in the draft, so a blank value keeps the existing secret file.
+486 -60
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import datetime as dt import datetime as dt
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import hashlib
import importlib.metadata import importlib.metadata
import json import json
import os import os
@@ -15,6 +16,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import tomllib import tomllib
import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -22,7 +24,7 @@ from typing import Any
APP_NAME = "codex-retry-gateway-tui" APP_NAME = "codex-retry-gateway-tui"
FALLBACK_VERSION = "0.1.4" FALLBACK_VERSION = "0.1.10"
DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway" DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway"
DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway" DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway"
DEFAULT_API_URL = DEFAULT_GATEWAY_URL DEFAULT_API_URL = DEFAULT_GATEWAY_URL
@@ -46,6 +48,8 @@ DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES = [
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5 DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350 DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350
DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0 DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0
REQUEST_STATUS_SLOW_AFTER_SECONDS = 20
REQUEST_STATUS_STALLED_AFTER_SECONDS = 120
DEFAULT_PROFILE_ENDPOINTS = [ DEFAULT_PROFILE_ENDPOINTS = [
"/responses", "/responses",
"/chat/completions", "/chat/completions",
@@ -81,6 +85,19 @@ REQUEST_TABLE_COLUMN_KEY_SET = set(REQUEST_TABLE_COLUMN_KEYS)
REQUEST_TABLE_CELL_INDEX = {key: index for index, key in enumerate(REQUEST_TABLE_COLUMN_KEYS)} REQUEST_TABLE_CELL_INDEX = {key: index for index, key in enumerate(REQUEST_TABLE_COLUMN_KEYS)}
DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS
DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq" DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq"
THREAD_COLOR_PALETTE = (
"#5fd7ff",
"#87d75f",
"#ffaf5f",
"#d787ff",
"#5fafff",
"#ff87d7",
"#d7af5f",
"#5fd7af",
"#af87ff",
"#87afd7",
)
THREAD_COLOR_COLUMN_KEYS = frozenset({"req_id", "resp_id", "thread"})
REQUEST_TABLE_WIDTH_PROFILES = { REQUEST_TABLE_WIDTH_PROFILES = {
"compact": { "compact": {
"seq": 7, "seq": 7,
@@ -88,7 +105,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
"resp_id": 18, "resp_id": 18,
"thread": 18, "thread": 18,
"started": 19, "started": 19,
"status": 7, "status": 12,
"path": 22, "path": 22,
"model": 16, "model": 16,
"effort": 8, "effort": 8,
@@ -109,7 +126,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
"resp_id": 24, "resp_id": 24,
"thread": 36, "thread": 36,
"started": 19, "started": 19,
"status": 7, "status": 14,
"path": 40, "path": 40,
"model": 24, "model": 24,
"effort": 8, "effort": 8,
@@ -523,6 +540,7 @@ def normalize_gateway_url(api_url: str) -> str:
"/api/logs", "/api/logs",
"/api/requests", "/api/requests",
"/api/profiles", "/api/profiles",
"/api/image-profiles",
"/api/config", "/api/config",
"/api/restore", "/api/restore",
): ):
@@ -853,6 +871,7 @@ def normalize_request_retry_firsts(value: Any) -> list[dict[str, Any]]:
{ {
"round": parse_int_value(item.get("round")), "round": parse_int_value(item.get("round")),
"slot": parse_int_value(item.get("slot")), "slot": parse_int_value(item.get("slot")),
"first_response_at": item.get("first_response_at"),
"first_delay_ms": item.get("first_response_delay_ms", item.get("first_delay_ms", item.get("first_ms"))), "first_delay_ms": item.get("first_response_delay_ms", item.get("first_delay_ms", item.get("first_ms"))),
"outcome": str(item.get("outcome") or "").strip(), "outcome": str(item.get("outcome") or "").strip(),
"status_code": item.get("status_code", item.get("upstream_status_code", item.get("status"))), "status_code": item.get("status_code", item.get("upstream_status_code", item.get("status"))),
@@ -971,7 +990,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(response_id_text(row), limits.get("resp_id", 18)) or "-",
short_text(row["thread_id"], limits.get("thread", 18)) or "-", short_text(row["thread_id"], limits.get("thread", 18)) or "-",
short_time(row["started_at"]), 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(row["path"], limits.get("path", 22)),
short_text(request_model_text(row), limits.get("model", 16)), short_text(request_model_text(row), limits.get("model", 16)),
short_text(request_effort_text(row), limits.get("effort", 10)), short_text(request_effort_text(row), limits.get("effort", 10)),
@@ -990,6 +1009,37 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None =
) )
def request_thread_color(thread_id: Any) -> str | None:
text = str(thread_id or "").strip()
if not text:
return None
digest = hashlib.blake2b(text.encode("utf-8"), digest_size=4).digest()
color_index = int.from_bytes(digest[:2], "big") % len(THREAD_COLOR_PALETTE)
return THREAD_COLOR_PALETTE[color_index]
def request_visible_cells(
row: dict[str, Any],
cells: tuple[Any, ...],
visible_column_keys: list[str],
) -> list[Any]:
visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in visible_column_keys]
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 == "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)
return styled_cells
def request_table_column_label(column_key: str) -> str: def request_table_column_label(column_key: str) -> str:
column = REQUEST_TABLE_COLUMN_BY_KEY.get(column_key) column = REQUEST_TABLE_COLUMN_BY_KEY.get(column_key)
return str(column["label"]) if column else column_key return str(column["label"]) if column else column_key
@@ -1101,11 +1151,21 @@ def fetch_payload(api_url: str, timeout: int, access_key: str = "") -> dict[str,
return data return data
def fetch_optional_payload(url: str, timeout: int, access_key: str = "") -> tuple[dict[str, Any], str]: def fetch_optional_payload(
url: str,
timeout: int,
access_key: str = "",
*,
allow_not_found: bool = False,
) -> tuple[dict[str, Any], str]:
if not str(url or "").strip(): if not str(url or "").strip():
return {}, "" return {}, ""
try: try:
return fetch_payload(url, timeout, access_key), "" return fetch_payload(url, timeout, access_key), ""
except urllib.error.HTTPError as exc:
if allow_not_found and exc.code == 404:
return {}, ""
return {}, str(exc)
except Exception as exc: except Exception as exc:
return {}, str(exc) return {}, str(exc)
@@ -1216,11 +1276,12 @@ def summary_line(payload: dict[str, Any]) -> str:
listen = payload.get("listen") or "-" listen = payload.get("listen") or "-"
upstream = config.get("upstream_base_url") or "-" upstream = config.get("upstream_base_url") or "-"
active = config.get("profile_name") or state.get("profile_name") or "-" active = config.get("profile_name") or state.get("profile_name") or "-"
image_active = config.get("image_profile_name") or state.get("image_profile_name") or "-"
request_total = format_count(metrics.get("total_proxy_request_count")) request_total = format_count(metrics.get("total_proxy_request_count"))
inspected = format_count(metrics.get("inspected_response_count")) inspected = format_count(metrics.get("inspected_response_count"))
matched = format_count(metrics.get("matched_response_count")) matched = format_count(metrics.get("matched_response_count"))
reasoning_516 = format_count(metrics.get("reasoning_516_count")) reasoning_516 = format_count(metrics.get("reasoning_516_count"))
return f"{listen} | profile {active} | upstream {upstream} | req {request_total} | inspected {inspected} | matched {matched} | 516 {reasoning_516}" return f"{listen} | text {active} | image {image_active} | upstream {upstream} | req {request_total} | inspected {inspected} | matched {matched} | 516 {reasoning_516}"
def state_summary(payload: dict[str, Any]) -> str: def state_summary(payload: dict[str, Any]) -> str:
@@ -1231,6 +1292,7 @@ def state_summary(payload: dict[str, Any]) -> str:
f"config {paths.get('config_path') or '-'}", f"config {paths.get('config_path') or '-'}",
f"requests {paths.get('requests_path') or '-'}", f"requests {paths.get('requests_path') or '-'}",
f"profiles {paths.get('profiles_dir') or '-'}", f"profiles {paths.get('profiles_dir') or '-'}",
f"image profiles {paths.get('image_profiles_dir') or '-'}",
] ]
if state.get("gateway_base_url"): if state.get("gateway_base_url"):
bits.append(f"gateway {state.get('gateway_base_url')}") bits.append(f"gateway {state.get('gateway_base_url')}")
@@ -1247,7 +1309,7 @@ def status_summary(status_payload: dict[str, Any], status_error: str = "") -> st
metrics = status_payload.get("metrics") if isinstance(status_payload.get("metrics"), dict) else {} metrics = status_payload.get("metrics") if isinstance(status_payload.get("metrics"), dict) else {}
listen = status_payload.get("listen") or "-" listen = status_payload.get("listen") or "-"
return ( return (
f"live {listen} | active {config.get('profile_name') or state.get('profile_name') or '-'} | " f"live {listen} | text {config.get('profile_name') or state.get('profile_name') or '-'} | image {config.get('image_profile_name') or state.get('image_profile_name') or '-'} | "
f"516 {metrics.get('reasoning_516_count') or 0} | latest seq {metrics.get('total_proxy_request_count') or 0}" f"516 {metrics.get('reasoning_516_count') or 0} | latest seq {metrics.get('total_proxy_request_count') or 0}"
) )
@@ -1268,6 +1330,8 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
"requested_model", "requested_model",
"forwarded_model", "forwarded_model",
"reasoning_effort", "reasoning_effort",
"lifecycle_state",
"discard_reason",
"error", "error",
"upstream_origin", "upstream_origin",
"upstream_path", "upstream_path",
@@ -1287,25 +1351,118 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
return needle in haystack return needle in haystack
def status_symbol(entry: dict[str, Any]) -> str: def request_retry_first_received_at(row: dict[str, Any]) -> Any:
if entry.get("error"): latest_value = None
return "!" latest_at = None
if entry.get("matched"): for first in row.get("reasoning_retry_current_firsts") or []:
return "*" if not isinstance(first, dict):
if entry.get("response_stream"): continue
return "~" value = first.get("first_response_at")
return " " parsed = parse_datetime(value)
if parsed and (latest_at is None or parsed > latest_at):
latest_value = value
latest_at = parsed
return latest_value
def request_has_received_first(row: dict[str, Any]) -> bool:
if parse_datetime(row.get("first_response_at")):
return True
for first in row.get("reasoning_retry_current_firsts") or []:
if not isinstance(first, dict):
continue
if parse_datetime(first.get("first_response_at")) or parse_int_value(first.get("first_delay_ms")) is not None:
return True
return False
def request_lifecycle_label(row: dict[str, Any]) -> str: def request_lifecycle_label(row: dict[str, Any]) -> str:
state = str(row.get("lifecycle_state") or "").strip().lower() state = str(row.get("lifecycle_state") or "").strip().lower()
if state == "finish": if request_is_discarded(row):
return "done" return "discarded"
if state == "receive_first": if state in {"finish", "finished", "complete", "completed"} or parse_datetime(row.get("finished_at")):
return "first" return "finished"
if state == "streaming": if state in {"receive_first", "streaming"} or request_has_received_first(row):
return "live" return "streaming"
return "sent" 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:
candidates = [row.get("last_activity_at"), row.get("first_response_at"), request_retry_first_received_at(row), row.get("started_at")]
parsed_candidates = [(parse_datetime(value), value) for value in candidates]
valid_candidates = [(parsed, value) for parsed, value in parsed_candidates if parsed]
if not valid_candidates:
return None
return max(valid_candidates, key=lambda item: item[0])[1]
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]]: def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
@@ -1333,6 +1490,9 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
"reasoning_summary": str(entry.get("reasoning_summary") or ""), "reasoning_summary": str(entry.get("reasoning_summary") or ""),
"request_stream": bool(entry.get("request_stream")), "request_stream": bool(entry.get("request_stream")),
"response_stream": bool(entry.get("response_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")), "matched": bool(entry.get("matched")),
"inspected": bool(entry.get("inspected")), "inspected": bool(entry.get("inspected")),
"status_code": entry.get("status_code"), "status_code": entry.get("status_code"),
@@ -1346,6 +1506,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
"request_body_bytes": entry.get("request_body_bytes"), "request_body_bytes": entry.get("request_body_bytes"),
"response_bytes_received": entry.get("response_bytes_received"), "response_bytes_received": entry.get("response_bytes_received"),
"stream_chunk_count": entry.get("stream_chunk_count"), "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"), "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_round": parse_int_value(entry.get("reasoning_retry_current_round")),
"reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")), "reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")),
@@ -1358,6 +1519,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
"error": str(entry.get("error") or ""), "error": str(entry.get("error") or ""),
"upstream_origin": str(entry.get("upstream")["origin"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_origin") or ""), "upstream_origin": str(entry.get("upstream")["origin"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_origin") or ""),
"upstream_path": str(entry.get("upstream")["path"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_path") or ""), "upstream_path": str(entry.get("upstream")["path"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_path") or ""),
"upstream_route": str(entry.get("upstream")["route"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_route") or ""),
"upstream_auth_mode": str(entry.get("upstream")["auth_mode"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_mode") or ""), "upstream_auth_mode": str(entry.get("upstream")["auth_mode"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_mode") or ""),
"upstream_auth_source": str(entry.get("upstream")["auth_source"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_source") or ""), "upstream_auth_source": str(entry.get("upstream")["auth_source"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_source") or ""),
"raw": entry, "raw": entry,
@@ -1376,7 +1538,7 @@ def render_request_detail(row: dict[str, Any]) -> str:
f"started {short_time(row['started_at'])}", f"started {short_time(row['started_at'])}",
f"{row['method']} {row['path']}", f"{row['method']} {row['path']}",
f"profile {row.get('profile_name') or '-'}", 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"upstream {row['upstream_status_code'] or '-'}",
f"attempts {row['upstream_attempt_count'] or 0}", f"attempts {row['upstream_attempt_count'] or 0}",
f"first {request_first_text(row)}", f"first {request_first_text(row)}",
@@ -1407,10 +1569,14 @@ def render_request_detail(row: dict[str, Any]) -> str:
bits.append(f"cached raw {format_count(row['cached_tokens'])}") bits.append(f"cached raw {format_count(row['cached_tokens'])}")
if row.get("error"): if row.get("error"):
bits.append(f"error {row['error']}") bits.append(f"error {row['error']}")
if row.get("discard_reason"):
bits.append(f"discard {row['discard_reason']}")
if row.get("upstream_origin"): if row.get("upstream_origin"):
bits.append(f"origin {row['upstream_origin']}") bits.append(f"origin {row['upstream_origin']}")
if row.get("upstream_path"): if row.get("upstream_path"):
bits.append(f"upstream path {row['upstream_path']}") bits.append(f"upstream path {row['upstream_path']}")
if row.get("upstream_route"):
bits.append(f"route {row['upstream_route']}")
if row.get("upstream_auth_mode"): if row.get("upstream_auth_mode"):
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}") bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
retry_wave = request_retry_wave_summary(row) retry_wave = request_retry_wave_summary(row)
@@ -1456,13 +1622,46 @@ def normalize_profile_rows(payload: dict[str, Any], filter_text: str = "") -> li
return rows return rows
def normalize_image_profile_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
needle = filter_text.strip().lower()
rows = []
for profile in payload.get("image_profiles") or []:
if not isinstance(profile, dict):
continue
summary = profile.get("summary") if isinstance(profile.get("summary"), dict) else {}
haystack = " ".join(
str(profile.get(key) or "")
for key in ("name", "file_path", "active")
).lower() + " " + " ".join(str(summary.get(key) or "") for key in summary.keys()).lower()
if needle and needle not in haystack:
continue
rows.append(
{
"name": str(profile.get("name") or ""),
"active": bool(profile.get("active")),
"file_path": str(profile.get("file_path") or ""),
"base_url": str(summary.get("base_url") or "-"),
"auth_mode": str(summary.get("auth_mode") or "-"),
"auth_env": str(summary.get("auth_env") or "-"),
"auth_file": str(summary.get("auth_file") or ""),
"manual_secret_file": str(summary.get("manual_secret_file") or ""),
"auth_json_path": str(summary.get("auth_json_path") or ""),
"auth_json_key": str(summary.get("auth_json_key") or "-"),
"auth_source": str(summary.get("auth_source") or "disabled"),
"raw": profile,
}
)
rows.sort(key=lambda item: (not item["active"], item["name"].lower()))
return rows
def render_profile_detail(row: dict[str, Any]) -> str: def render_profile_detail(row: dict[str, Any]) -> str:
bits = [ bits = [
f"{row['name']}", f"{row['name']}",
"active" if row["active"] else "inactive", "active" if row["active"] else "inactive",
f"listen {row['listen_host']}:{row['listen_port']}", f"listen {row['listen_host']}:{row['listen_port']}",
f"upstream {short_text(row['upstream_base_url'], 64)}", f"text upstream {short_text(row['upstream_base_url'], 64)}",
f"auth {row['auth_mode']}/{row['auth_source']}", f"text auth {row['auth_mode']}/{row['auth_source']}",
] ]
if row.get("request_history_limit") is not None: if row.get("request_history_limit") is not None:
bits.append(f"history {row['request_history_limit']}") bits.append(f"history {row['request_history_limit']}")
@@ -1477,6 +1676,20 @@ def render_profile_detail(row: dict[str, Any]) -> str:
return " | ".join(bits) return " | ".join(bits)
def render_image_profile_detail(row: dict[str, Any]) -> str:
bits = [
f"image {row['name']}",
"active" if row["active"] else "inactive",
f"upstream {short_text(row['base_url'], 64)}",
f"auth {row['auth_mode']}/{row['auth_source']}",
]
if row.get("auth_file"):
bits.append("auth file configured")
if row.get("auth_json_path"):
bits.append("auth json configured")
return " | ".join(bits)
def profile_form_state(row: dict[str, Any]) -> dict[str, Any]: def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {} raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
form = raw.get("form") if isinstance(raw.get("form"), dict) else {} form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
@@ -1564,6 +1777,7 @@ def profile_editor_document(row: dict[str, Any]) -> str:
f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}", f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}",
f"auth_json_path = {toml_string(state.get('auth_json_path'))}", f"auth_json_path = {toml_string(state.get('auth_json_path'))}",
f"auth_json_key = {toml_string(state.get('auth_json_key'))}", f"auth_json_key = {toml_string(state.get('auth_json_key'))}",
"",
f"request_history_limit = {state.get('request_history_limit') if state.get('request_history_limit') is not None else 0}", f"request_history_limit = {state.get('request_history_limit') if state.get('request_history_limit') is not None else 0}",
f"model_remap = {model_remap_value}", f"model_remap = {model_remap_value}",
f"reasoning_equals = {toml_int_list(state.get('reasoning_equals'))}", f"reasoning_equals = {toml_int_list(state.get('reasoning_equals'))}",
@@ -1642,6 +1856,108 @@ def edit_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | No
pass pass
def image_profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
return {
"name": row.get("name") or "",
"base_url": form.get("base_url") or row.get("base_url") or "",
"auth_mode": form.get("auth_mode") or row.get("auth_mode") or "fixed_bearer",
"auth_env": form.get("auth_env") or "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
"auth_file": form.get("auth_file") or "",
"manual_secret": "",
"manual_secret_file": form.get("manual_secret_file") or "",
"manual_secret_configured": parse_bool_value(form.get("manual_secret_configured"), False),
"auth_json_path": form.get("auth_json_path") or "",
"auth_json_key": form.get("auth_json_key") or "OPENAI_API_KEY",
}
def image_profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]:
state = image_profile_form_state(row)
return {
"name": str(state.get("name") or "").strip(),
"base_url": str(state.get("base_url") or "").strip(),
"auth_mode": str(state.get("auth_mode") or "fixed_bearer").strip(),
"auth_env": str(state.get("auth_env") or "").strip(),
"auth_file": str(state.get("auth_file") or "").strip(),
"manual_secret": "",
"manual_secret_file": str(state.get("manual_secret_file") or "").strip(),
"manual_secret_configured": bool(state.get("manual_secret_configured")),
"auth_json_path": str(state.get("auth_json_path") or "").strip(),
"auth_json_key": str(state.get("auth_json_key") or "").strip(),
}
def image_profile_editor_document(row: dict[str, Any]) -> str:
state = image_profile_form_state(row)
return "\n".join(
[
"# Edit the selected codex-retry-gateway image profile and save.",
"# base_url applies to both /images/* and /v1/images/*.",
"# Leave manual_secret empty to keep the current image secret file.",
"# Changing name creates a new image profile file; it does not delete the old one.",
"",
f"name = {toml_string(state.get('name'))}",
f"base_url = {toml_string(state.get('base_url'))}",
f"auth_mode = {toml_string(state.get('auth_mode'))}",
f"auth_env = {toml_string(state.get('auth_env'))}",
f"auth_file = {toml_string(state.get('auth_file'))}",
'manual_secret = ""',
f"manual_secret_file = {toml_string(state.get('manual_secret_file'))}",
f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}",
f"auth_json_path = {toml_string(state.get('auth_json_path'))}",
f"auth_json_key = {toml_string(state.get('auth_json_key'))}",
"",
]
)
def image_profile_payload_from_editor_text(text: str) -> dict[str, Any]:
data = tomllib.loads(text)
if not isinstance(data, dict):
raise ValueError("editor payload must be a TOML object")
return {
"name": str(data.get("name") or "").strip(),
"base_url": str(data.get("base_url") or "").strip(),
"auth_mode": str(data.get("auth_mode") or "fixed_bearer").strip(),
"auth_env": str(data.get("auth_env") or "").strip(),
"auth_file": str(data.get("auth_file") or "").strip(),
"manual_secret": str(data.get("manual_secret") or "").strip(),
"manual_secret_file": str(data.get("manual_secret_file") or "").strip(),
"manual_secret_configured": parse_bool_value(data.get("manual_secret_configured"), False),
"auth_json_path": str(data.get("auth_json_path") or "").strip(),
"auth_json_key": str(data.get("auth_json_key") or "").strip(),
}
def edit_image_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | None:
original_text = image_profile_editor_document(row)
editor_command = resolve_editor_command()
fd, raw_path = tempfile.mkstemp(prefix=f"codex-retry-image-profile-{row.get('name') or 'profile'}-", suffix=".toml")
os.close(fd)
temp_path = Path(raw_path)
temp_path.write_text(original_text, encoding="utf-8")
try:
result = subprocess.run(editor_command + [str(temp_path)], check=False)
edited_text = temp_path.read_text(encoding="utf-8")
if edited_text == original_text:
return None
payload = image_profile_payload_from_editor_text(edited_text)
if result.returncode != 0:
raise RuntimeError(f"editor exited with status {result.returncode}")
return payload
except Exception as exc:
raise RuntimeError(f"{exc}; kept draft at {temp_path}") from exc
finally:
if temp_path.exists():
try:
if temp_path.read_text(encoding="utf-8") == original_text:
temp_path.unlink()
except Exception:
pass
def edit_request_table_preferences_with_editor(preferences: dict[str, Any]) -> dict[str, Any] | None: def edit_request_table_preferences_with_editor(preferences: dict[str, Any]) -> dict[str, Any] | None:
original_text = request_table_preferences_document(preferences) original_text = request_table_preferences_document(preferences)
editor_command = resolve_editor_command() editor_command = resolve_editor_command()
@@ -1707,6 +2023,17 @@ def open_url(url: str) -> None:
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def gateway_supports_image_profiles(status_payload: dict[str, Any]) -> bool:
config = status_payload.get("config") if isinstance(status_payload.get("config"), dict) else {}
state = status_payload.get("state") if isinstance(status_payload.get("state"), dict) else {}
paths = status_payload.get("paths") if isinstance(status_payload.get("paths"), dict) else {}
return (
"image_profile_name" in config
or "image_profile_name" in state
or "image_profiles_dir" in paths
)
def fetch_dashboard_snapshot( def fetch_dashboard_snapshot(
gateway_url: str, gateway_url: str,
status_url: str, status_url: str,
@@ -1717,31 +2044,40 @@ def fetch_dashboard_snapshot(
current_view: str = "overview", current_view: str = "overview",
) -> dict[str, Any]: ) -> dict[str, Any]:
gateway_root = gateway_admin_url(gateway_url) gateway_root = gateway_admin_url(gateway_url)
status_endpoint = status_url or gateway_status_url(gateway_root)
status_payload, status_error = fetch_optional_payload(status_endpoint, timeout, access_key)
payload = status_payload if isinstance(status_payload, dict) else {}
endpoints = { endpoints = {
"status": status_url or gateway_status_url(gateway_root),
"logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}), "logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}),
"requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}), "requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}),
"profiles": build_api_url(gateway_root, "/api/profiles"), "profiles": build_api_url(gateway_root, "/api/profiles"),
} }
image_profiles_supported = gateway_supports_image_profiles(payload)
if image_profiles_supported:
endpoints["image_profiles"] = build_api_url(gateway_root, "/api/image-profiles")
def fetch_optional(url: str) -> tuple[dict[str, Any], str]: def fetch_optional(url: str, *, allow_not_found: bool = False) -> tuple[dict[str, Any], str]:
return fetch_optional_payload(url, timeout, access_key) return fetch_optional_payload(url, timeout, access_key, allow_not_found=allow_not_found)
with ThreadPoolExecutor(max_workers=4) as pool: with ThreadPoolExecutor(max_workers=len(endpoints)) as pool:
futures = { futures = {
name: pool.submit(fetch_optional, url) name: pool.submit(fetch_optional, url, allow_not_found=name == "image_profiles")
for name, url in endpoints.items() for name, url in endpoints.items()
} }
status_payload, status_error = futures["status"].result()
logs_payload, logs_error = futures["logs"].result() logs_payload, logs_error = futures["logs"].result()
requests_payload, requests_error = futures["requests"].result() requests_payload, requests_error = futures["requests"].result()
profiles_payload, profiles_error = futures["profiles"].result() profiles_payload, profiles_error = futures["profiles"].result()
if image_profiles_supported:
image_profiles_payload, image_profiles_error = futures["image_profiles"].result()
else:
image_profiles_payload, image_profiles_error = {}, ""
payload = status_payload if isinstance(status_payload, dict) else {}
requests_source = requests_payload if isinstance(requests_payload, dict) else {} requests_source = requests_payload if isinstance(requests_payload, dict) else {}
logs_source = logs_payload if isinstance(logs_payload, dict) else {} logs_source = logs_payload if isinstance(logs_payload, dict) else {}
profiles_source = profiles_payload if isinstance(profiles_payload, dict) else {} profiles_source = profiles_payload if isinstance(profiles_payload, dict) else {}
errors = [message for message in (status_error, logs_error, requests_error, profiles_error) if message] image_profiles_source = image_profiles_payload if isinstance(image_profiles_payload, dict) else {}
errors = [message for message in (status_error, logs_error, requests_error, profiles_error, image_profiles_error) if message]
return { return {
"payload": payload, "payload": payload,
"status_payload": payload, "status_payload": payload,
@@ -1752,6 +2088,10 @@ def fetch_dashboard_snapshot(
profiles_source, profiles_source,
filter_text if current_view == "profiles" else "", filter_text if current_view == "profiles" else "",
), ),
"image_profiles": normalize_image_profile_rows(
image_profiles_source,
filter_text if current_view == "profiles" else "",
),
"active_profile_name": ( "active_profile_name": (
str( str(
payload.get("config", {}).get("profile_name") payload.get("config", {}).get("profile_name")
@@ -1760,6 +2100,14 @@ def fetch_dashboard_snapshot(
or "" or ""
).strip() ).strip()
), ),
"active_image_profile_name": (
str(
payload.get("config", {}).get("image_profile_name")
or payload.get("state", {}).get("image_profile_name")
or image_profiles_source.get("active_image_profile")
or ""
).strip()
),
} }
@@ -1801,6 +2149,7 @@ def run_textual(
("2", "show_requests", "Requests"), ("2", "show_requests", "Requests"),
("3", "show_logs", "Logs"), ("3", "show_logs", "Logs"),
("4", "show_profiles", "Profiles"), ("4", "show_profiles", "Profiles"),
("i", "toggle_profile_kind", "Text/Image"),
("m", "toggle_request_table_density", "Wide/Compact"), ("m", "toggle_request_table_density", "Wide/Compact"),
("c", "edit_request_columns", "Columns"), ("c", "edit_request_columns", "Columns"),
("shift+left", "scroll_table_left", "Scroll Left"), ("shift+left", "scroll_table_left", "Scroll Left"),
@@ -1822,8 +2171,10 @@ def run_textual(
self.requests: list[dict[str, Any]] = [] self.requests: list[dict[str, Any]] = []
self.logs: list[dict[str, Any]] = [] self.logs: list[dict[str, Any]] = []
self.profiles: list[dict[str, Any]] = [] self.profiles: list[dict[str, Any]] = []
self.image_profiles: list[dict[str, Any]] = []
self.request_by_key: dict[str, dict[str, Any]] = {} self.request_by_key: dict[str, dict[str, Any]] = {}
self.profile_by_key: dict[str, dict[str, Any]] = {} self.profile_by_key: dict[str, dict[str, Any]] = {}
self.image_profile_by_key: dict[str, dict[str, Any]] = {}
self.log_by_key: dict[str, dict[str, Any]] = {} self.log_by_key: dict[str, dict[str, Any]] = {}
self.current_view = "overview" self.current_view = "overview"
self.filter_mode = "requests" self.filter_mode = "requests"
@@ -1832,6 +2183,8 @@ def run_textual(
self.executor = ThreadPoolExecutor(max_workers=1) self.executor = ThreadPoolExecutor(max_workers=1)
self.refresh_generation = 0 self.refresh_generation = 0
self.active_profile_name = "" self.active_profile_name = ""
self.active_image_profile_name = ""
self.profile_kind = "text"
self.visible_tables = { self.visible_tables = {
"overview": "requests", "overview": "requests",
"requests": "requests", "requests": "requests",
@@ -1873,7 +2226,7 @@ def run_textual(
profiles = self.query_one("#profiles_table", DataTable) profiles = self.query_one("#profiles_table", DataTable)
profiles.cursor_type = "row" profiles.cursor_type = "row"
profiles.zebra_stripes = True profiles.zebra_stripes = True
profiles.add_columns("Name", "Active", "Listen", "Upstream", "Auth", "History", "Reasoning", "Source") self._rebuild_profile_table_columns()
self._set_view("overview") self._set_view("overview")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
@@ -1889,7 +2242,7 @@ def run_textual(
self.query_one("#logs_table").display = active_table == "logs" self.query_one("#logs_table").display = active_table == "logs"
self.query_one("#profiles_table").display = active_table == "profiles" self.query_one("#profiles_table").display = active_table == "profiles"
self.query_one("#controls", Static).update( self.query_one("#controls", Static).update(
"views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | m compact/wide | c columns | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream" "views: 1 overview | 2 requests | 3 logs | 4 profiles | i text/image | / filter | r refresh | m compact/wide | c columns | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream"
) )
self._focus_table() self._focus_table()
self._update_table_headers() self._update_table_headers()
@@ -1906,7 +2259,7 @@ def run_textual(
def _update_table_headers(self) -> None: def _update_table_headers(self) -> None:
visible_columns = request_table_visible_columns(self.request_table_preferences) visible_columns = request_table_visible_columns(self.request_table_preferences)
counts = ( counts = (
f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)} | " f"requests {len(self.requests)} | logs {len(self.logs)} | text profiles {len(self.profiles)} | image profiles {len(self.image_profiles)} | active {self.profile_kind} | "
f"req cols {len(visible_columns)}/{len(REQUEST_TABLE_COLUMNS)} | c edit columns" f"req cols {len(visible_columns)}/{len(REQUEST_TABLE_COLUMNS)} | c edit columns"
) )
self.query_one("#tables", Static).update(counts) self.query_one("#tables", Static).update(counts)
@@ -1981,6 +2334,35 @@ def run_textual(
else: else:
self.request_by_key = {} self.request_by_key = {}
def _profile_rows(self) -> list[dict[str, Any]]:
return self.image_profiles if self.profile_kind == "image" else self.profiles
def _active_profile_name(self) -> str:
return self.active_image_profile_name if self.profile_kind == "image" else self.active_profile_name
def _profile_api_base(self) -> str:
return "/api/image-profiles" if self.profile_kind == "image" else "/api/profiles"
def _profile_kind_label(self) -> str:
return "image profile" if self.profile_kind == "image" else "text profile"
def _rebuild_profile_table_columns(self) -> None:
table = self.query_one("#profiles_table", DataTable)
table.clear(columns=True)
if self.profile_kind == "image":
table.add_columns("Name", "Active", "Image Upstream", "Image Auth", "Source")
else:
table.add_columns(
"Name",
"Active",
"Listen",
"Text Upstream",
"Text Auth",
"History",
"Reasoning",
"Source",
)
def _current_table_widget(self) -> DataTable | None: def _current_table_widget(self) -> DataTable | None:
table_id = self.visible_tables.get(self.current_view, "requests") table_id = self.visible_tables.get(self.current_view, "requests")
if table_id == "logs": if table_id == "logs":
@@ -1997,10 +2379,11 @@ def run_textual(
def _select_profile_row(self, profile_name: str) -> bool: def _select_profile_row(self, profile_name: str) -> bool:
table = self.query_one("#profiles_table", DataTable) table = self.query_one("#profiles_table", DataTable)
if not self.profiles: rows = self._profile_rows()
if not rows:
return False return False
if profile_name: if profile_name:
for index, row in enumerate(self.profiles): for index, row in enumerate(rows):
if row["name"] == profile_name: if row["name"] == profile_name:
return self._select_table_row(table, index) return self._select_table_row(table, index)
return self._select_table_row(table, 0) return self._select_table_row(table, 0)
@@ -2017,9 +2400,10 @@ def run_textual(
table = self.query_one("#profiles_table", DataTable) table = self.query_one("#profiles_table", DataTable)
if table.cursor_row is None or table.cursor_row < 0: if table.cursor_row is None or table.cursor_row < 0:
return None return None
if table.cursor_row >= len(self.profiles): rows = self._profile_rows()
if table.cursor_row >= len(rows):
return None return None
return self.profiles[table.cursor_row] return rows[table.cursor_row]
def _selected_log_row(self) -> dict[str, Any] | None: def _selected_log_row(self) -> dict[str, Any] | None:
table = self.query_one("#logs_table", DataTable) table = self.query_one("#logs_table", DataTable)
@@ -2067,12 +2451,14 @@ def run_textual(
self.requests = result["requests"] self.requests = result["requests"]
self.logs = result["logs"] self.logs = result["logs"]
self.profiles = result["profiles"] self.profiles = result["profiles"]
self.image_profiles = result["image_profiles"]
self.active_profile_name = result.get("active_profile_name") or "" self.active_profile_name = result.get("active_profile_name") or ""
self.active_image_profile_name = result.get("active_image_profile_name") or ""
self.last_request_key = selected_request_key self.last_request_key = selected_request_key
self.loading = False self.loading = False
self.render_all() self.render_all()
if self.current_view == "profiles": if self.current_view == "profiles":
if not self._select_profile_row(self.active_profile_name): if not self._select_profile_row(self._active_profile_name()):
self._select_profile_row("") self._select_profile_row("")
version_bits = [version_message] if version_message else [] version_bits = [version_message] if version_message else []
version_bits.append(status_summary(self.payload, self.status_error)) version_bits.append(status_summary(self.payload, self.status_error))
@@ -2104,7 +2490,7 @@ def run_textual(
elif self.current_view == "profiles": elif self.current_view == "profiles":
row = self._selected_profile_row() row = self._selected_profile_row()
if row: if row:
self.render_detail(row, kind="profile") self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
else: else:
self.query_one("#detail", Static).update("no profiles") self.query_one("#detail", Static).update("no profiles")
@@ -2113,13 +2499,14 @@ def run_textual(
selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0 selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0
selected_row_key = self._selected_request_key() selected_row_key = self._selected_request_key()
text_limits = self._request_text_limits(table) text_limits = self._request_text_limits(table)
visible_column_keys = self._visible_request_column_keys()
table.clear() table.clear()
self.request_by_key = {} self.request_by_key = {}
for row in self.requests: for row in self.requests:
key = self._request_row_key(row) key = self._request_row_key(row)
self.request_by_key[key] = row self.request_by_key[key] = row
cells = request_row_cells(row, text_limits) cells = request_row_cells(row, text_limits)
visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in self._visible_request_column_keys()] visible_cells = request_visible_cells(row, cells, visible_column_keys)
table.add_row(*visible_cells, key=key) table.add_row(*visible_cells, key=key)
if self.requests: if self.requests:
if not self._select_request_row(selected_row_key): if not self._select_request_row(selected_row_key):
@@ -2137,29 +2524,46 @@ def run_textual(
def render_profiles(self) -> None: def render_profiles(self) -> None:
table = self.query_one("#profiles_table", DataTable) table = self.query_one("#profiles_table", DataTable)
table.clear() table.clear()
rows = self._profile_rows()
if self.profile_kind == "image":
self.image_profile_by_key = {}
else:
self.profile_by_key = {} self.profile_by_key = {}
for row in self.profiles: for row in rows:
key = row["name"] key = row["name"]
if self.profile_kind == "image":
self.image_profile_by_key[key] = row
table.add_row(
row["name"],
"yes" if row["active"] else "",
short_text(row["base_url"], 36),
f"{row['auth_mode']}/{row['auth_source']}",
short_text(row["file_path"], 36),
key=key,
)
else:
self.profile_by_key[key] = row self.profile_by_key[key] = row
table.add_row( table.add_row(
row["name"], row["name"],
"yes" if row["active"] else "", "yes" if row["active"] else "",
f"{row['listen_host']}:{row['listen_port']}", f"{row['listen_host']}:{row['listen_port']}",
short_text(row["upstream_base_url"], 24), short_text(row["upstream_base_url"], 28),
f"{row['auth_mode']}/{row['auth_source']}", f"{row['auth_mode']}/{row['auth_source']}",
str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"), str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"),
short_text(row["reasoning_equals"], 12), short_text(row["reasoning_equals"], 12),
short_text(row["file_path"], 28), short_text(row["file_path"], 32),
key=key, key=key,
) )
if self.current_view == "profiles" and self.active_profile_name: if self.current_view == "profiles" and self._active_profile_name():
self._select_profile_row(self.active_profile_name) self._select_profile_row(self._active_profile_name())
def render_detail(self, row: dict[str, Any], *, kind: str) -> None: def render_detail(self, row: dict[str, Any], *, kind: str) -> None:
if kind == "request": if kind == "request":
self.query_one("#detail", Static).update(render_request_detail(row)) self.query_one("#detail", Static).update(render_request_detail(row))
elif kind == "profile": elif kind == "profile":
self.query_one("#detail", Static).update(render_profile_detail(row)) self.query_one("#detail", Static).update(render_profile_detail(row))
elif kind == "image_profile":
self.query_one("#detail", Static).update(render_image_profile_detail(row))
else: else:
message = str(row.get("message") or "").strip() message = str(row.get("message") or "").strip()
self.query_one("#detail", Static).update(f"seq {row['seq']} | {short_time(row['at'])} | {message}") self.query_one("#detail", Static).update(f"seq {row['seq']} | {short_time(row['at'])} | {message}")
@@ -2181,9 +2585,22 @@ def run_textual(
def action_show_profiles(self) -> None: def action_show_profiles(self) -> None:
self._set_view("profiles") self._set_view("profiles")
if not self._select_profile_row(self.active_profile_name): if not self._select_profile_row(self._active_profile_name()):
self._select_profile_row("") self._select_profile_row("")
def action_toggle_profile_kind(self) -> None:
self.profile_kind = "image" if self.profile_kind == "text" else "text"
self._rebuild_profile_table_columns()
self.render_profiles()
if self.current_view == "profiles":
if not self._select_profile_row(self._active_profile_name()):
self._select_profile_row("")
row = self._selected_profile_row()
if row:
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
self._update_table_headers()
self._set_status(f"profile view {self.profile_kind}")
def action_newest_request(self) -> None: def action_newest_request(self) -> None:
if self.requests: if self.requests:
self.render_detail(self.requests[0], kind="request") self.render_detail(self.requests[0], kind="request")
@@ -2277,7 +2694,8 @@ def run_textual(
row = self._selected_profile_row() row = self._selected_profile_row()
if not row: if not row:
return return
upstream = str(row.get("upstream_base_url") or "").strip() upstream_value = row.get("base_url") if self.profile_kind == "image" else row.get("upstream_base_url")
upstream = str(upstream_value or "").strip()
if not upstream or upstream == "-": if not upstream or upstream == "-":
return return
open_url(upstream) open_url(upstream)
@@ -2292,11 +2710,15 @@ def run_textual(
return return
try: try:
with self.suspend(): with self.suspend():
payload = edit_profile_payload_with_editor(row) payload = (
edit_image_profile_payload_with_editor(row)
if self.profile_kind == "image"
else edit_profile_payload_with_editor(row)
)
if payload is None: if payload is None:
self._set_status(f"edit {row['name']} cancelled (no changes)") self._set_status(f"edit {row['name']} cancelled (no changes)")
return return
status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload, access_key) status, result = post_json(build_api_url(gateway_base_url, self._profile_api_base()), timeout, payload, access_key)
self._set_status(f"edit {payload.get('name') or row['name']} -> {status} | {short_text(result, 96)}") self._set_status(f"edit {payload.get('name') or row['name']} -> {status} | {short_text(result, 96)}")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
except Exception as exc: except Exception as exc:
@@ -2308,7 +2730,7 @@ def run_textual(
return return
try: try:
status, payload = post_json( status, payload = post_json(
build_api_url(gateway_base_url, "/api/profiles/probe"), build_api_url(gateway_base_url, f"{self._profile_api_base()}/probe"),
timeout, timeout,
{"profile": row["name"]}, {"profile": row["name"]},
access_key, access_key,
@@ -2323,7 +2745,7 @@ def run_textual(
return return
try: try:
status, payload = post_json( status, payload = post_json(
build_api_url(gateway_base_url, "/api/profiles/switch"), build_api_url(gateway_base_url, f"{self._profile_api_base()}/switch"),
timeout, timeout,
{"profile": row["name"]}, {"profile": row["name"]},
access_key, access_key,
@@ -2337,9 +2759,9 @@ def run_textual(
row = self._selected_profile_row() row = self._selected_profile_row()
if not row: if not row:
return return
payload = profile_payload_from_row(row) payload = image_profile_payload_from_row(row) if self.profile_kind == "image" else profile_payload_from_row(row)
try: try:
status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload, access_key) status, result = post_json(build_api_url(gateway_base_url, self._profile_api_base()), timeout, payload, access_key)
self._set_status(f"save profile -> {status} | {short_text(result, 96)}") self._set_status(f"save profile -> {status} | {short_text(result, 96)}")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
except Exception as exc: except Exception as exc:
@@ -2350,7 +2772,11 @@ def run_textual(
if not row or row["active"]: if not row or row["active"]:
return return
try: try:
status, payload = delete_json(build_api_url(gateway_base_url, f"/api/profiles/{urllib.parse.quote(row['name'])}"), timeout, access_key) status, payload = delete_json(
build_api_url(gateway_base_url, f"{self._profile_api_base()}/{urllib.parse.quote(row['name'])}"),
timeout,
access_key,
)
self._set_status(f"delete {row['name']} -> {status} | {short_text(payload, 96)}") self._set_status(f"delete {row['name']} -> {status} | {short_text(payload, 96)}")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
except Exception as exc: except Exception as exc:
@@ -2374,9 +2800,9 @@ def run_textual(
self.render_detail(row, kind="log") self.render_detail(row, kind="log")
elif event.data_table.id == "profiles_table": elif event.data_table.id == "profiles_table":
key = str(event.row_key.value) key = str(event.row_key.value)
row = self.profile_by_key.get(key) row = self.image_profile_by_key.get(key) if self.profile_kind == "image" else self.profile_by_key.get(key)
if row: if row:
self.render_detail(row, kind="profile") self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
CodexRetryGatewayTui().run() CodexRetryGatewayTui().run()
return 0 return 0
@@ -2448,7 +2874,7 @@ def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None:
for row in snapshot.get("requests") or []: for row in snapshot.get("requests") or []:
try: try:
print( 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(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_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)}" f"{short_text(request_retry_note(row), 30)}"
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "codex-retry-gateway-tui" name = "codex-retry-gateway-tui"
version = "0.1.4" version = "0.1.10"
description = "Terminal UI for codex-retry-gateway monitoring and control" description = "Terminal UI for codex-retry-gateway monitoring and control"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+203 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt
import importlib.util import importlib.util
import json import json
import os import os
@@ -28,7 +29,7 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"config": {"profile_name": "pc", "upstream_base_url": "https://example.com/v1"}, "config": {"profile_name": "pc", "upstream_base_url": "https://example.com/v1"},
"metrics": {"total_proxy_request_count": 11, "inspected_response_count": 7, "matched_response_count": 2, "reasoning_516_count": 1}, "metrics": {"total_proxy_request_count": 11, "inspected_response_count": 7, "matched_response_count": 2, "reasoning_516_count": 1},
} }
self.assertIn("profile pc", mod.summary_line(payload)) self.assertIn("text pc", mod.summary_line(payload))
self.assertIn("req 11", mod.summary_line(payload)) self.assertIn("req 11", mod.summary_line(payload))
def test_request_rows_sort_desc_and_keep_request_id(self) -> None: def test_request_rows_sort_desc_and_keep_request_id(self) -> None:
@@ -57,6 +58,18 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
rows = mod.normalize_profile_rows(payload) rows = mod.normalize_profile_rows(payload)
self.assertEqual([row["name"] for row in rows], ["alpha", "beta"]) self.assertEqual([row["name"] for row in rows], ["alpha", "beta"])
def test_image_profile_rows_show_active_first(self) -> None:
mod = load_module()
payload = {
"image_profiles": [
{"name": "beta", "active": False, "summary": {"base_url": "https://images-b.example/v1", "auth_mode": "fixed_bearer", "auth_source": "env"}},
{"name": "alpha", "active": True, "summary": {"base_url": "https://images-a.example/v1", "auth_mode": "manual_bearer", "auth_source": "manual_file"}},
]
}
rows = mod.normalize_image_profile_rows(payload)
self.assertEqual([row["name"] for row in rows], ["alpha", "beta"])
self.assertEqual(rows[0]["base_url"], "https://images-a.example/v1")
def test_version_update_message_only_for_newer_versions(self) -> None: def test_version_update_message_only_for_newer_versions(self) -> None:
mod = load_module() mod = load_module()
self.assertIn("0.1.0 -> 0.1.1", mod.version_update_message("0.1.1", "0.1.0")) self.assertIn("0.1.0 -> 0.1.1", mod.version_update_message("0.1.1", "0.1.0"))
@@ -108,6 +121,53 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
self.assertEqual(mod.request_updated_elapsed(row), "1.0s") self.assertEqual(mod.request_updated_elapsed(row), "1.0s")
self.assertEqual(mod.request_chunk_progress(row), "7 / 4.1KB") self.assertEqual(mod.request_chunk_progress(row), "7 / 4.1KB")
def test_optional_image_profiles_endpoint_can_be_absent_on_older_gateway(self) -> None:
mod = load_module()
missing_endpoint = mod.urllib.error.HTTPError(
"http://127.0.0.1:4610/__codex_retry_gateway/api/image-profiles",
404,
"Not Found",
{},
None,
)
with mock.patch.object(mod, "fetch_payload", side_effect=missing_endpoint):
payload, error = mod.fetch_optional_payload(
"http://127.0.0.1:4610/__codex_retry_gateway/api/image-profiles",
5,
allow_not_found=True,
)
self.assertEqual(payload, {})
self.assertEqual(error, "")
def test_dashboard_skips_image_profiles_endpoint_when_status_lacks_capability(self) -> None:
mod = load_module()
calls: list[str] = []
def fetch(url: str, timeout: int, access_key: str = "", *, allow_not_found: bool = False):
calls.append(url)
if url.endswith("/api/status"):
return {"config": {"profile_name": "legacy"}, "state": {}, "paths": {}}, ""
if "/api/requests" in url:
return {"entries": []}, ""
if "/api/logs" in url:
return {"entries": []}, ""
if "/api/profiles" in url:
return {"profiles": []}, ""
self.fail(f"unexpected endpoint {url}")
with mock.patch.object(mod, "fetch_optional_payload", side_effect=fetch):
snapshot = mod.fetch_dashboard_snapshot("http://127.0.0.1:4610", "", 5)
self.assertEqual(snapshot["image_profiles"], [])
self.assertEqual(snapshot["status_error"], "")
self.assertFalse(any("/api/image-profiles" in url for url in calls))
def test_gateway_image_profiles_capability_uses_status_field_presence(self) -> None:
mod = load_module()
self.assertFalse(mod.gateway_supports_image_profiles({"config": {"profile_name": "legacy"}}))
self.assertTrue(mod.gateway_supports_image_profiles({"config": {"image_profile_name": ""}}))
self.assertTrue(mod.gateway_supports_image_profiles({"paths": {"image_profiles_dir": "/tmp/profiles"}}))
def test_request_usage_summary_shows_cached_ratio(self) -> None: def test_request_usage_summary_shows_cached_ratio(self) -> None:
mod = load_module() mod = load_module()
row = { row = {
@@ -117,6 +177,59 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
} }
self.assertEqual(mod.request_usage_summary(row), "in 75 | out 40 | cached 25 (25%)") 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",
}
retry_streaming = {
"lifecycle_state": "sent",
"started_at": "2026-07-10T11:59:00Z",
"reasoning_retry_current_firsts": [
{
"round": 1,
"slot": 1,
"first_response_at": "2026-07-10T11:59:58Z",
"first_delay_ms": 2200,
}
],
}
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(retry_streaming, now), "streaming")
self.assertEqual(mod.request_activity_at(retry_streaming), "2026-07-10T11:59:58Z")
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: def test_request_row_cells_expand_with_wider_limits(self) -> None:
mod = load_module() mod = load_module()
row = { row = {
@@ -263,6 +376,52 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
self.assertIn("current wave round 3 slot 1 first 1.2s", mod.render_request_detail(row)) self.assertIn("current wave round 3 slot 1 first 1.2s", mod.render_request_detail(row))
self.assertEqual(mod.request_row_cells(row)[20], "3(2)") self.assertEqual(mod.request_row_cells(row)[20], "3(2)")
def test_request_visible_cells_colorize_thread_identity_columns(self) -> None:
mod = load_module()
try:
from rich.text import Text
except ImportError:
self.skipTest("rich is not installed")
row = {
"seq": 1,
"request_id": "req_1",
"response_id": "resp_1",
"thread_id": "thread_same",
"started_at": "2026-06-30T12:00:00Z",
"status_code": 200,
"path": "/responses",
"model": "gpt-5",
"requested_model": "",
"forwarded_model": "",
"reasoning_tokens": 7,
"reasoning_effort": "xhigh",
"input_tokens": 20,
"output_tokens": 10,
"cached_tokens": 5,
"request_body_bytes": 1536,
"response_bytes_received": 4096,
"response_stream": True,
"stream_chunk_count": 4,
"first_response_delay_ms": 1200,
"duration_ms": 3400,
"upstream_attempt_count": 1,
"error": "",
"finished_at": "2026-06-30T12:00:03Z",
}
cells = mod.request_row_cells(row)
visible = mod.request_visible_cells(row, cells, ["seq", "req_id", "resp_id", "thread", "status"])
self.assertEqual(visible[0], "1")
self.assertIsInstance(visible[1], Text)
self.assertIsInstance(visible[2], Text)
self.assertIsInstance(visible[3], Text)
self.assertEqual(visible[1].plain, "req_1")
self.assertEqual(visible[2].plain, "resp_1")
self.assertEqual(visible[3].plain, "thread_same")
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(""))
def test_request_table_preferences_round_trip(self) -> None: def test_request_table_preferences_round_trip(self) -> None:
mod = load_module() mod = load_module()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@@ -350,7 +509,7 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
self.assertEqual(mod.format_duration_ms_as_seconds(1532), "1.5s") self.assertEqual(mod.format_duration_ms_as_seconds(1532), "1.5s")
self.assertEqual(mod.format_duration_ms_as_seconds(120000), "120s") self.assertEqual(mod.format_duration_ms_as_seconds(120000), "120s")
def test_profile_editor_payload_round_trip(self) -> None: def test_text_profile_editor_payload_does_not_carry_image_fields(self) -> None:
mod = load_module() mod = load_module()
row = { row = {
"name": "pc", "name": "pc",
@@ -364,6 +523,11 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"reasoning_equals": [516, 1034], "reasoning_equals": [516, 1034],
"raw": { "raw": {
"form": { "form": {
"image_base_url": "https://images.example/v1",
"image_auth_mode": "manual_bearer",
"image_manual_secret_file": "/run/secrets/images-token",
"image_manual_secret_configured": True,
"image_auth_json_key": "IMAGE_API_KEY",
"retryable_error_messages": [ "retryable_error_messages": [
"Selected model is at capacity. Please try a different model.", "Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
@@ -374,11 +538,48 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
text = mod.profile_editor_document(row) text = mod.profile_editor_document(row)
payload = mod.profile_payload_from_editor_text(text) payload = mod.profile_payload_from_editor_text(text)
self.assertEqual(payload["name"], "pc") self.assertEqual(payload["name"], "pc")
self.assertNotIn("image_base_url", payload)
self.assertNotIn("image_base_url", text)
self.assertNotIn("test-image-profile-secret", text)
saved_payload = mod.profile_payload_from_row(row)
self.assertNotIn("image_base_url", saved_payload)
self.assertIn( self.assertIn(
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
payload["retryable_error_messages"], payload["retryable_error_messages"],
) )
def test_image_profile_editor_payload_round_trip(self) -> None:
mod = load_module()
row = {
"name": "images",
"base_url": "https://images.example/v1",
"auth_mode": "manual_bearer",
"auth_source": "manual_file",
"raw": {
"form": {
"base_url": "https://images.example/v1",
"auth_mode": "manual_bearer",
"manual_secret_file": "/run/secrets/images-token",
"manual_secret_configured": True,
"auth_json_key": "IMAGE_API_KEY",
}
},
}
text = mod.image_profile_editor_document(row)
payload = mod.image_profile_payload_from_editor_text(text)
self.assertEqual(payload["name"], "images")
self.assertEqual(payload["base_url"], "https://images.example/v1")
self.assertEqual(payload["auth_mode"], "manual_bearer")
self.assertEqual(payload["manual_secret_file"], "/run/secrets/images-token")
self.assertTrue(payload["manual_secret_configured"])
self.assertEqual(payload["manual_secret"], "")
self.assertNotIn("test-image-profile-secret", text)
saved_payload = mod.image_profile_payload_from_row(row)
self.assertEqual(saved_payload["base_url"], "https://images.example/v1")
self.assertEqual(saved_payload["auth_mode"], "manual_bearer")
self.assertEqual(saved_payload["manual_secret_file"], "/run/secrets/images-token")
self.assertEqual(saved_payload["manual_secret"], "")
def test_default_api_url_discovers_gateway_state(self) -> None: def test_default_api_url_discovers_gateway_state(self) -> None:
mod = load_module() mod = load_module()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.11"
[[package]] [[package]]
name = "codex-retry-gateway-tui" name = "codex-retry-gateway-tui"
version = "0.1.4" version = "0.1.10"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "textual" }, { name = "textual" },