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
- 文档层级:客户端工具
- 应用性质:客户端 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`.
## Quick Start
@@ -72,9 +87,9 @@ 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: 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:
@@ -82,15 +97,13 @@ Controls:
- `2` requests
- `3` logs
- `4` profiles
- `,` / `.` select request-table column for visibility/sort actions
- `v` toggle the selected requests column visible/hidden
- `c` sort requests by the selected column
- `C` toggle requests sort ascending/descending
- `i` switch the profiles table between text and image profiles
- `c` edit request-table column visibility, order, and widths
- `e` edit selected profile in `$VISUAL` / `$EDITOR`
- `/` filter
- `r` refresh
- `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
- `s` switch to selected profile
- `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.
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.
+497 -71
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import datetime as dt
from concurrent.futures import ThreadPoolExecutor
import hashlib
import importlib.metadata
import json
import os
@@ -15,6 +16,7 @@ import subprocess
import sys
import tempfile
import tomllib
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@@ -22,7 +24,7 @@ from typing import Any
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_URL = "http://127.0.0.1:4610/__codex_retry_gateway"
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_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",
@@ -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)}
DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS
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 = {
"compact": {
"seq": 7,
@@ -88,7 +105,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
"resp_id": 18,
"thread": 18,
"started": 19,
"status": 7,
"status": 12,
"path": 22,
"model": 16,
"effort": 8,
@@ -109,7 +126,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
"resp_id": 24,
"thread": 36,
"started": 19,
"status": 7,
"status": 14,
"path": 40,
"model": 24,
"effort": 8,
@@ -523,6 +540,7 @@ def normalize_gateway_url(api_url: str) -> str:
"/api/logs",
"/api/requests",
"/api/profiles",
"/api/image-profiles",
"/api/config",
"/api/restore",
):
@@ -853,6 +871,7 @@ def normalize_request_retry_firsts(value: Any) -> list[dict[str, Any]]:
{
"round": parse_int_value(item.get("round")),
"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"))),
"outcome": str(item.get("outcome") or "").strip(),
"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(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)),
@@ -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:
column = REQUEST_TABLE_COLUMN_BY_KEY.get(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
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():
return {}, ""
try:
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:
return {}, str(exc)
@@ -1216,11 +1276,12 @@ def summary_line(payload: dict[str, Any]) -> str:
listen = payload.get("listen") or "-"
upstream = config.get("upstream_base_url") 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"))
inspected = format_count(metrics.get("inspected_response_count"))
matched = format_count(metrics.get("matched_response_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:
@@ -1231,6 +1292,7 @@ def state_summary(payload: dict[str, Any]) -> str:
f"config {paths.get('config_path') or '-'}",
f"requests {paths.get('requests_path') or '-'}",
f"profiles {paths.get('profiles_dir') or '-'}",
f"image profiles {paths.get('image_profiles_dir') or '-'}",
]
if 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 {}
listen = status_payload.get("listen") or "-"
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}"
)
@@ -1268,6 +1330,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",
@@ -1287,25 +1351,118 @@ 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_retry_first_received_at(row: dict[str, Any]) -> Any:
latest_value = None
latest_at = None
for first in row.get("reasoning_retry_current_firsts") or []:
if not isinstance(first, dict):
continue
value = first.get("first_response_at")
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:
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 request_has_received_first(row):
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:
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]]:
@@ -1333,6 +1490,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"),
@@ -1346,6 +1506,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")),
@@ -1358,6 +1519,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
"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_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_source": str(entry.get("upstream")["auth_source"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_source") or ""),
"raw": entry,
@@ -1376,7 +1538,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)}",
@@ -1407,10 +1569,14 @@ 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"):
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"):
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
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
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:
bits = [
f"{row['name']}",
"active" if row["active"] else "inactive",
f"listen {row['listen_host']}:{row['listen_port']}",
f"upstream {short_text(row['upstream_base_url'], 64)}",
f"auth {row['auth_mode']}/{row['auth_source']}",
f"text upstream {short_text(row['upstream_base_url'], 64)}",
f"text auth {row['auth_mode']}/{row['auth_source']}",
]
if row.get("request_history_limit") is not None:
bits.append(f"history {row['request_history_limit']}")
@@ -1477,6 +1676,20 @@ def render_profile_detail(row: dict[str, Any]) -> str:
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]:
raw = row.get("raw") if isinstance(row.get("raw"), 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"auth_json_path = {toml_string(state.get('auth_json_path'))}",
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"model_remap = {model_remap_value}",
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
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:
original_text = request_table_preferences_document(preferences)
editor_command = resolve_editor_command()
@@ -1707,6 +2023,17 @@ def open_url(url: str) -> None:
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(
gateway_url: str,
status_url: str,
@@ -1717,31 +2044,40 @@ def fetch_dashboard_snapshot(
current_view: str = "overview",
) -> dict[str, Any]:
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 = {
"status": status_url or gateway_status_url(gateway_root),
"logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}),
"requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}),
"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]:
return fetch_optional_payload(url, timeout, access_key)
def fetch_optional(url: str, *, allow_not_found: bool = False) -> tuple[dict[str, Any], str]:
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 = {
name: pool.submit(fetch_optional, url)
name: pool.submit(fetch_optional, url, allow_not_found=name == "image_profiles")
for name, url in endpoints.items()
}
status_payload, status_error = futures["status"].result()
logs_payload, logs_error = futures["logs"].result()
requests_payload, requests_error = futures["requests"].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 {}
logs_source = logs_payload if isinstance(logs_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 {
"payload": payload,
"status_payload": payload,
@@ -1752,6 +2088,10 @@ def fetch_dashboard_snapshot(
profiles_source,
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": (
str(
payload.get("config", {}).get("profile_name")
@@ -1760,6 +2100,14 @@ def fetch_dashboard_snapshot(
or ""
).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"),
("3", "show_logs", "Logs"),
("4", "show_profiles", "Profiles"),
("i", "toggle_profile_kind", "Text/Image"),
("m", "toggle_request_table_density", "Wide/Compact"),
("c", "edit_request_columns", "Columns"),
("shift+left", "scroll_table_left", "Scroll Left"),
@@ -1822,8 +2171,10 @@ def run_textual(
self.requests: list[dict[str, Any]] = []
self.logs: 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.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.current_view = "overview"
self.filter_mode = "requests"
@@ -1832,6 +2183,8 @@ def run_textual(
self.executor = ThreadPoolExecutor(max_workers=1)
self.refresh_generation = 0
self.active_profile_name = ""
self.active_image_profile_name = ""
self.profile_kind = "text"
self.visible_tables = {
"overview": "requests",
"requests": "requests",
@@ -1873,7 +2226,7 @@ def run_textual(
profiles = self.query_one("#profiles_table", DataTable)
profiles.cursor_type = "row"
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.refresh_data(refresh=True)
@@ -1889,7 +2242,7 @@ def run_textual(
self.query_one("#logs_table").display = active_table == "logs"
self.query_one("#profiles_table").display = active_table == "profiles"
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._update_table_headers()
@@ -1906,7 +2259,7 @@ def run_textual(
def _update_table_headers(self) -> None:
visible_columns = request_table_visible_columns(self.request_table_preferences)
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"
)
self.query_one("#tables", Static).update(counts)
@@ -1981,6 +2334,35 @@ def run_textual(
else:
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:
table_id = self.visible_tables.get(self.current_view, "requests")
if table_id == "logs":
@@ -1997,10 +2379,11 @@ def run_textual(
def _select_profile_row(self, profile_name: str) -> bool:
table = self.query_one("#profiles_table", DataTable)
if not self.profiles:
rows = self._profile_rows()
if not rows:
return False
if profile_name:
for index, row in enumerate(self.profiles):
for index, row in enumerate(rows):
if row["name"] == profile_name:
return self._select_table_row(table, index)
return self._select_table_row(table, 0)
@@ -2017,9 +2400,10 @@ def run_textual(
table = self.query_one("#profiles_table", DataTable)
if table.cursor_row is None or table.cursor_row < 0:
return None
if table.cursor_row >= len(self.profiles):
rows = self._profile_rows()
if table.cursor_row >= len(rows):
return None
return self.profiles[table.cursor_row]
return rows[table.cursor_row]
def _selected_log_row(self) -> dict[str, Any] | None:
table = self.query_one("#logs_table", DataTable)
@@ -2067,12 +2451,14 @@ def run_textual(
self.requests = result["requests"]
self.logs = result["logs"]
self.profiles = result["profiles"]
self.image_profiles = result["image_profiles"]
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.loading = False
self.render_all()
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("")
version_bits = [version_message] if version_message else []
version_bits.append(status_summary(self.payload, self.status_error))
@@ -2104,7 +2490,7 @@ def run_textual(
elif self.current_view == "profiles":
row = self._selected_profile_row()
if row:
self.render_detail(row, kind="profile")
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
else:
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_key = self._selected_request_key()
text_limits = self._request_text_limits(table)
visible_column_keys = self._visible_request_column_keys()
table.clear()
self.request_by_key = {}
for row in self.requests:
key = self._request_row_key(row)
self.request_by_key[key] = row
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)
if self.requests:
if not self._select_request_row(selected_row_key):
@@ -2137,29 +2524,46 @@ def run_textual(
def render_profiles(self) -> None:
table = self.query_one("#profiles_table", DataTable)
table.clear()
self.profile_by_key = {}
for row in self.profiles:
rows = self._profile_rows()
if self.profile_kind == "image":
self.image_profile_by_key = {}
else:
self.profile_by_key = {}
for row in rows:
key = row["name"]
self.profile_by_key[key] = row
table.add_row(
row["name"],
"yes" if row["active"] else "",
f"{row['listen_host']}:{row['listen_port']}",
short_text(row["upstream_base_url"], 24),
f"{row['auth_mode']}/{row['auth_source']}",
str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"),
short_text(row["reasoning_equals"], 12),
short_text(row["file_path"], 28),
key=key,
)
if self.current_view == "profiles" and self.active_profile_name:
self._select_profile_row(self.active_profile_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
table.add_row(
row["name"],
"yes" if row["active"] else "",
f"{row['listen_host']}:{row['listen_port']}",
short_text(row["upstream_base_url"], 28),
f"{row['auth_mode']}/{row['auth_source']}",
str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"),
short_text(row["reasoning_equals"], 12),
short_text(row["file_path"], 32),
key=key,
)
if self.current_view == "profiles" and self._active_profile_name():
self._select_profile_row(self._active_profile_name())
def render_detail(self, row: dict[str, Any], *, kind: str) -> None:
if kind == "request":
self.query_one("#detail", Static).update(render_request_detail(row))
elif kind == "profile":
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:
message = str(row.get("message") or "").strip()
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:
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("")
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:
if self.requests:
self.render_detail(self.requests[0], kind="request")
@@ -2277,7 +2694,8 @@ def run_textual(
row = self._selected_profile_row()
if not row:
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 == "-":
return
open_url(upstream)
@@ -2292,11 +2710,15 @@ def run_textual(
return
try:
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:
self._set_status(f"edit {row['name']} cancelled (no changes)")
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.refresh_data(refresh=True)
except Exception as exc:
@@ -2308,7 +2730,7 @@ def run_textual(
return
try:
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,
{"profile": row["name"]},
access_key,
@@ -2323,7 +2745,7 @@ def run_textual(
return
try:
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,
{"profile": row["name"]},
access_key,
@@ -2337,9 +2759,9 @@ def run_textual(
row = self._selected_profile_row()
if not row:
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:
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.refresh_data(refresh=True)
except Exception as exc:
@@ -2350,7 +2772,11 @@ def run_textual(
if not row or row["active"]:
return
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.refresh_data(refresh=True)
except Exception as exc:
@@ -2374,9 +2800,9 @@ def run_textual(
self.render_detail(row, kind="log")
elif event.data_table.id == "profiles_table":
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:
self.render_detail(row, kind="profile")
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
CodexRetryGatewayTui().run()
return 0
@@ -2448,7 +2874,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
View File
@@ -1,6 +1,6 @@
[project]
name = "codex-retry-gateway-tui"
version = "0.1.4"
version = "0.1.10"
description = "Terminal UI for codex-retry-gateway monitoring and control"
readme = "README.md"
requires-python = ">=3.11"
+203 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import datetime as dt
import importlib.util
import json
import os
@@ -28,7 +29,7 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"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},
}
self.assertIn("profile pc", mod.summary_line(payload))
self.assertIn("text pc", mod.summary_line(payload))
self.assertIn("req 11", mod.summary_line(payload))
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)
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:
mod = load_module()
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_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:
mod = load_module()
row = {
@@ -117,6 +177,59 @@ 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",
}
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:
mod = load_module()
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.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:
mod = load_module()
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(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()
row = {
"name": "pc",
@@ -364,6 +523,11 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"reasoning_equals": [516, 1034],
"raw": {
"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": [
"Selected model is at capacity. Please try a different model.",
"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)
payload = mod.profile_payload_from_editor_text(text)
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(
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
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:
mod = load_module()
with tempfile.TemporaryDirectory() as tmpdir:
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.11"
[[package]]
name = "codex-retry-gateway-tui"
version = "0.1.4"
version = "0.1.10"
source = { editable = "." }
dependencies = [
{ name = "textual" },