#!/usr/bin/env python3 """Textual TUI for codex-retry-gateway monitoring and control.""" from __future__ import annotations import argparse import datetime as dt from concurrent.futures import ThreadPoolExecutor import importlib.metadata import json import os import re import subprocess import sys import urllib.parse import urllib.request from pathlib import Path from typing import Any APP_NAME = "codex-retry-gateway-tui" FALLBACK_VERSION = "0.1.0" 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 DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url" DEFAULT_GATEWAY_STATE_FILE = "~/.codex-retry-gateway/state.json" DEFAULT_GATEWAY_JSON_CONFIG_FILE = "~/.codex-retry-gateway/config/config.json" DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui/raw/branch/main/pyproject.toml" DEFAULT_REFRESH_SECONDS = 10 DEFAULT_TIMEOUT_SECONDS = 5 DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS = 2 INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git" INSTALL_COMMAND_ARGS = [ "uv", "tool", "install", "--force", "git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git", ] def env_int(name: str, default: int, *, minimum: int = 1) -> int: try: return max(minimum, int(os.environ.get(name, default))) except Exception: return default def configured_url(env_names: tuple[str, ...], config_path: str, default: str = "") -> str: for name in env_names: value = os.environ.get(name, "").strip() if value: return value config_file = Path(config_path).expanduser() try: for line in config_file.read_text(encoding="utf-8").splitlines(): value = line.strip() if value and not value.startswith("#"): return value except OSError: pass return default def read_json_object(path: str) -> dict[str, Any]: try: data = json.loads(Path(path).expanduser().read_text(encoding="utf-8")) except Exception: return {} return data if isinstance(data, dict) else {} def gateway_admin_url(base_url: str) -> str: normalized = normalize_gateway_url(base_url) if not normalized: return "" parsed = urllib.parse.urlparse(normalized) path = parsed.path.rstrip("/") if not path: path = DEFAULT_GATEWAY_ADMIN_PATH elif not path.endswith(DEFAULT_GATEWAY_ADMIN_PATH): path = f"{path}{DEFAULT_GATEWAY_ADMIN_PATH}" parsed = parsed._replace(path=path, params="", query="", fragment="") return urllib.parse.urlunparse(parsed).rstrip("/") def normalize_listen_host(value: Any) -> str: host = str(value or "").strip() if host in {"", "0.0.0.0", "::", "[::]", "*"}: return "127.0.0.1" return host def discover_gateway_url() -> str: state = read_json_object(DEFAULT_GATEWAY_STATE_FILE) gateway_base_url = str(state.get("gateway_base_url") or "").strip() if gateway_base_url: return gateway_admin_url(gateway_base_url) config = read_json_object(DEFAULT_GATEWAY_JSON_CONFIG_FILE) listen_host = normalize_listen_host(config.get("listen_host")) listen_port = as_int(config.get("listen_port")) if listen_host and listen_port > 0: return gateway_admin_url(f"http://{listen_host}:{listen_port}") return "" def default_api_url() -> str: configured = configured_url( ("CODEX_RETRY_GATEWAY_TUI_API_URL",), os.environ.get("CODEX_RETRY_GATEWAY_TUI_API_URL_FILE", DEFAULT_CONFIG_FILE), "", ) if configured: return gateway_admin_url(configured) discovered = discover_gateway_url() if discovered: return discovered return gateway_admin_url(DEFAULT_API_URL) def config_file_path() -> Path: return Path(os.environ.get("CODEX_RETRY_GATEWAY_TUI_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser() def default_status_url() -> str: return configured_url( ("CODEX_RETRY_GATEWAY_TUI_STATUS_URL",), os.environ.get("CODEX_RETRY_GATEWAY_TUI_STATUS_URL_FILE", DEFAULT_STATUS_CONFIG_FILE), "", ) def normalize_gateway_url(api_url: str) -> str: value = str(api_url or "").strip() if not value: return "" parsed = urllib.parse.urlparse(value) path = parsed.path.rstrip("/") for suffix in ( "/api/status", "/api/logs", "/api/requests", "/api/profiles", "/api/config", "/api/restore", ): if path.endswith(suffix): path = path[: -len(suffix)] break if path.endswith("/api"): path = path[:-4] normalized = parsed._replace(path=path, params="", query="", fragment="") return urllib.parse.urlunparse(normalized).rstrip("/") def gateway_status_url(gateway_url: str) -> str: return f"{normalize_gateway_url(gateway_url).rstrip('/')}/api/status" def build_api_url(gateway_url: str, suffix: str, params: dict[str, Any] | None = None) -> str: parsed = urllib.parse.urlparse(action_url(gateway_url, suffix)) query_items: list[tuple[str, str]] = [] if parsed.query: query_items.extend(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)) for key, value in (params or {}).items(): if value is None: continue if isinstance(value, str) and not value.strip(): continue query_items.append((key, str(value))) if query_items: parsed = parsed._replace(query=urllib.parse.urlencode(query_items, doseq=True)) return urllib.parse.urlunparse(parsed) def write_api_url_config(api_url: str) -> Path: value = gateway_admin_url(api_url) if not value: raise ValueError("api url is empty") path = config_file_path() path.parent.mkdir(parents=True, exist_ok=True) try: path.parent.chmod(0o700) except OSError: pass path.write_text(value + "\n", encoding="utf-8") try: path.chmod(0o600) except OSError: pass return path def run_install_command() -> int: try: return subprocess.run(INSTALL_COMMAND_ARGS, check=False).returncode except FileNotFoundError: print("uv command not found; install uv first, then run:", INSTALL_COMMAND, file=sys.stderr) return 127 def current_version() -> str: try: return importlib.metadata.version(APP_NAME) except importlib.metadata.PackageNotFoundError: return FALLBACK_VERSION except Exception: return FALLBACK_VERSION def parse_version(value: Any) -> tuple[int, ...]: parts = [] for part in re.split(r"[^0-9]+", str(value or "")): if part: parts.append(int(part)) return tuple(parts) def version_is_newer(latest: str, current: str) -> bool: latest_parts = parse_version(latest) current_parts = parse_version(current) width = max(len(latest_parts), len(current_parts), 1) return latest_parts + (0,) * (width - len(latest_parts)) > current_parts + (0,) * (width - len(current_parts)) def latest_version_from_text(text: str) -> str: match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text) return "" if not match else match.group(1).strip() def fetch_latest_version(url: str, timeout: int) -> str: req = urllib.request.Request(url, headers={"Accept": "text/plain"}) with urllib.request.urlopen(req, timeout=timeout) as response: return latest_version_from_text(response.read(65536).decode("utf-8", errors="replace")) def version_update_message(latest: str, current: str | None = None) -> str: current = current or current_version() if not latest or not version_is_newer(latest, current): return "" return f"update available: {APP_NAME} {current} -> {latest}; run `{INSTALL_COMMAND}`" def check_version_update(url: str, timeout: int, *, disabled: bool = False) -> str: if disabled or os.environ.get("CODEX_RETRY_GATEWAY_TUI_NO_VERSION_CHECK", "").strip().lower() in {"1", "true", "yes", "on"}: return "" try: return version_update_message(fetch_latest_version(url, timeout)) except Exception: return "" def as_float(value: Any) -> float: try: if value is None or str(value).strip() == "": return 0.0 return float(value) except Exception: return 0.0 def as_int(value: Any) -> int: try: if value is None or str(value).strip() == "": return 0 return int(float(value)) except Exception: return 0 def short_text(value: Any, length: int = 48) -> str: text = str(value or "").strip() if not text: return "-" return text if len(text) <= length else f"{text[: length - 3]}..." def short_time(value: Any) -> str: text = str(value or "").strip() if not text: return "-" return text.replace("T", " ")[:19] def format_ms(value: Any) -> str: number = as_int(value) return "-" if number <= 0 else f"{number}ms" def format_bytes(value: Any) -> str: number = as_int(value) if number <= 0: return "-" if number >= 1_000_000: return f"{number / 1_000_000:.1f}MB".rstrip("0").rstrip(".") if number >= 1_000: return f"{number / 1_000:.1f}KB".rstrip("0").rstrip(".") return f"{number}B" def format_percent(value: Any) -> str: if value is None or str(value).strip() == "": return "-" percent = as_float(value) rounded = round(percent, 1) if rounded.is_integer(): return f"{int(rounded)}%" return f"{rounded}%" def format_count(value: Any) -> str: number = as_int(value) if abs(number) >= 1_000_000: return f"{number / 1_000_000:.1f}M".rstrip("0").rstrip(".") if abs(number) >= 1_000: return f"{number / 1_000:.1f}K".rstrip("0").rstrip(".") return str(number) def parse_datetime(value: Any) -> dt.datetime | None: text = str(value or "").strip() if not text: return None try: normalized = text[:-1] + "+00:00" if text.endswith("Z") else text return dt.datetime.fromisoformat(normalized) except Exception: return None def format_elapsed_seconds(value: Any) -> str: number = as_float(value) if number <= 0: return "-" if number >= 100: return f"{number:.0f}s" return f"{number:.1f}s" def elapsed_between(started_at: Any, finished_at: Any) -> str: start = parse_datetime(started_at) finish = parse_datetime(finished_at) if not start or not finish: return "-" seconds = max(0.0, (finish - start).total_seconds()) return format_elapsed_seconds(seconds) def age_since(value: Any) -> str: parsed = parse_datetime(value) if not parsed: return "-" now = dt.datetime.now(parsed.tzinfo) if parsed.tzinfo else dt.datetime.now() seconds = max(0.0, (now - parsed).total_seconds()) return format_elapsed_seconds(seconds) def request_updated_at(row: dict[str, Any]) -> Any: for key in ("last_activity_at", "usage_last_updated_at", "finished_at"): value = row.get(key) if parse_datetime(value): return value return None def request_updated_elapsed(row: dict[str, Any]) -> str: return elapsed_between(row.get("started_at"), request_updated_at(row)) def request_chunk_progress(row: dict[str, Any]) -> str: if not row.get("response_stream"): return "-" return f"{row.get('stream_chunk_count') or 0} / {format_bytes(row.get('response_bytes_received'))}" def status_kind(value: Any) -> str: text = str(value or "").strip().lower() if text in {"ok", "operational", "success"}: return "ok" if text in {"failed", "error", "failure"}: return "failed" return "unknown" def fetch_payload(api_url: str, timeout: int) -> dict[str, Any]: req = urllib.request.Request(api_url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as response: data = json.loads(response.read().decode("utf-8")) if not isinstance(data, dict): raise RuntimeError("API did not return a JSON object") return data def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]: if not str(url or "").strip(): return {}, "" try: return fetch_payload(url, timeout), "" except Exception as exc: return {}, str(exc) def text_tokens(value: Any) -> set[str]: tokens = set() for token in "".join(ch.lower() if ch.isalnum() else " " for ch in str(value or "")).split(): if token and not token.isdigit(): tokens.add(token) return tokens def format_bool(value: Any) -> str: return "yes" if bool(value) else "no" def summary_line(payload: dict[str, Any]) -> str: config = payload.get("config") if isinstance(payload.get("config"), dict) else {} state = payload.get("state") if isinstance(payload.get("state"), dict) else {} metrics = payload.get("metrics") if isinstance(payload.get("metrics"), dict) else {} listen = payload.get("listen") or "-" upstream = config.get("upstream_base_url") or "-" active = config.get("profile_name") or state.get("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}" def state_summary(payload: dict[str, Any]) -> str: state = payload.get("state") if isinstance(payload.get("state"), dict) else {} paths = payload.get("paths") if isinstance(payload.get("paths"), dict) else {} bits = [ f"state {state.get('state_root') or paths.get('state_root') or '-'}", f"config {paths.get('config_path') or '-'}", f"requests {paths.get('requests_path') or '-'}", f"profiles {paths.get('profiles_dir') or '-'}", ] if state.get("gateway_base_url"): bits.append(f"gateway {state.get('gateway_base_url')}") if state.get("codex_current_base_url"): bits.append(f"codex {state.get('codex_current_base_url')}") return " | ".join(bits) def status_summary(status_payload: dict[str, Any], status_error: str = "") -> str: if status_error: return f"status error: {status_error}" 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 {} 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"516 {metrics.get('reasoning_516_count') or 0} | latest seq {metrics.get('total_proxy_request_count') or 0}" ) def request_match_score(row: dict[str, Any], needle: str) -> bool: if not needle: return True haystack = " ".join( str(row.get(key) or "") for key in ( "request_id", "profile_name", "method", "path", "model", "requested_model", "forwarded_model", "error", "upstream_origin", "upstream_path", "status_code", "upstream_status_code", "reasoning_tokens", "input_tokens", "output_tokens", "total_tokens", "cached_tokens", ) ).lower() return needle in haystack def status_symbol(entry: dict[str, Any]) -> str: if entry.get("error"): return "!" if entry.get("matched"): return "*" if entry.get("response_stream"): return "~" return " " def request_lifecycle_label(row: dict[str, Any]) -> str: state = str(row.get("lifecycle_state") or "").strip().lower() if state == "finish": return "done" if state == "receive_first": return "first" if state == "streaming": return "live" return "sent" def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]: needle = filter_text.strip().lower() rows = [] for entry in payload.get("entries") or []: if not isinstance(entry, dict): continue if needle and not request_match_score(entry, needle): continue rows.append( { "seq": as_int(entry.get("seq")), "request_id": str(entry.get("request_id") or ""), "profile_name": str(entry.get("profile_name") or ""), "method": str(entry.get("method") or ""), "path": str(entry.get("path") or ""), "model": str(entry.get("model") or ""), "requested_model": str(entry.get("requested_model") or ""), "forwarded_model": str(entry.get("forwarded_model") or ""), "request_stream": bool(entry.get("request_stream")), "response_stream": bool(entry.get("response_stream")), "matched": bool(entry.get("matched")), "inspected": bool(entry.get("inspected")), "status_code": entry.get("status_code"), "upstream_status_code": entry.get("upstream_status_code"), "reasoning_tokens": entry.get("reasoning_tokens"), "input_tokens": entry.get("input_tokens"), "output_tokens": entry.get("output_tokens"), "total_tokens": entry.get("total_tokens"), "cached_tokens": entry.get("cached_tokens"), "upstream_attempt_count": entry.get("upstream_attempt_count"), "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_delay_ms": entry.get("first_response_delay_ms"), "duration_ms": entry.get("duration_ms"), "started_at": entry.get("started_at"), "finished_at": entry.get("finished_at"), "last_activity_at": entry.get("last_activity_at"), "usage_last_updated_at": entry.get("usage_last_updated_at"), "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_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, } ) rows.sort(key=lambda item: (item["seq"], item["request_id"]), reverse=True) return rows def render_request_detail(row: dict[str, Any]) -> str: bits = [ f"seq {row['seq']}", f"id {row['request_id'] or '-'}", f"{row['method']} {row['path']}", f"profile {row.get('profile_name') or '-'}", f"status {row['status_code'] or '-'}", f"upstream {row['upstream_status_code'] or '-'}", f"attempts {row['upstream_attempt_count'] or 0}", f"first {format_ms(row['first_response_delay_ms'])}", f"duration {format_ms(row['duration_ms'])}", f"request {format_bytes(row['request_body_bytes'])}", f"response {format_bytes(row['response_bytes_received'])}", f"chunks {row['stream_chunk_count'] or 0}", f"life {request_lifecycle_label(row)}", f"started {short_time(row['started_at'])}", f"finished {short_time(row['finished_at'])}", f"updated {request_updated_elapsed(row)}", ] if row.get("model"): bits.append(f"model {row['model']}") if row.get("requested_model") and row.get("requested_model") != row.get("model"): bits.append(f"requested {row['requested_model']}") if row.get("forwarded_model") and row.get("forwarded_model") != row.get("model"): bits.append(f"forwarded {row['forwarded_model']}") if row.get("reasoning_tokens") is not None: bits.append(f"reasoning {row['reasoning_tokens']}") if row.get("input_tokens") is not None or row.get("output_tokens") is not None or row.get("total_tokens") is not None: bits.append( f"usage in/out/total {row.get('input_tokens') or 0}/{row.get('output_tokens') or 0}/{row.get('total_tokens') or 0}" ) if row.get("cached_tokens") is not None: bits.append(f"cached {row['cached_tokens']}") if row.get("error"): bits.append(f"error {row['error']}") 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_auth_mode"): bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}") return " | ".join(bits) def normalize_profile_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]: needle = filter_text.strip().lower() rows = [] for profile in payload.get("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 ""), "listen_host": str(summary.get("listen_host") or "-"), "listen_port": str(summary.get("listen_port") or "-"), "upstream_base_url": str(summary.get("upstream_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 ""), "auth_json_path": str(summary.get("auth_json_path") or ""), "auth_json_key": str(summary.get("auth_json_key") or "-"), "request_history_limit": summary.get("request_history_limit"), "model_remap": str(summary.get("model_remap") or ""), "auth_source": str(summary.get("auth_source") or "-"), "reasoning_equals": summary.get("reasoning_equals"), "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']}", ] if row.get("request_history_limit") is not None: bits.append(f"history {row['request_history_limit']}") if row.get("reasoning_equals"): bits.append(f"reasoning {row['reasoning_equals']}") if row.get("model_remap"): bits.append(f"remap {short_text(row['model_remap'], 96)}") 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 action_url(api_url: str, suffix: str) -> str: base = api_url.rstrip("/") return f"{base}{suffix}" def post_json(url: str, timeout: int, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: req = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json", "Accept": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=timeout) as response: data = json.loads(response.read().decode("utf-8")) if not isinstance(data, dict): raise RuntimeError("API did not return a JSON object") return response.status, data def delete_json(url: str, timeout: int) -> tuple[int, dict[str, Any]]: req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="DELETE") with urllib.request.urlopen(req, timeout=timeout) as response: data = json.loads(response.read().decode("utf-8")) if not isinstance(data, dict): raise RuntimeError("API did not return a JSON object") return response.status, data def open_url(url: str) -> None: if sys.platform == "darwin": command = ["open", url] elif sys.platform == "win32": command = ["cmd", "/c", "start", "", url] else: command = ["xdg-open", url] subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def fetch_dashboard_snapshot( gateway_url: str, status_url: str, timeout: int, *, filter_text: str = "", current_view: str = "overview", ) -> dict[str, Any]: gateway_root = gateway_admin_url(gateway_url) 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"), } def fetch_optional(url: str) -> tuple[dict[str, Any], str]: return fetch_optional_payload(url, timeout) with ThreadPoolExecutor(max_workers=4) as pool: futures = { name: pool.submit(fetch_optional, url) 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() 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] return { "payload": payload, "status_payload": payload, "status_error": " | ".join(errors), "requests": normalize_request_rows(requests_source, filter_text), "logs": normalize_log_rows(logs_source), "profiles": normalize_profile_rows( profiles_source, filter_text if current_view == "profiles" else "", ), "active_profile_name": ( str( payload.get("config", {}).get("profile_name") or payload.get("state", {}).get("profile_name") or profiles_source.get("active_profile") or "" ).strip() ), } def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: int, version_message: str = "") -> int: try: from textual.app import App, ComposeResult from textual.coordinate import Coordinate from textual.widgets import DataTable, Footer, Header, Input, Static except ImportError: print("Textual is required. Run with: uv run --with textual python codex_retry_gateway_tui.py", file=sys.stderr) return 2 gateway_base_url = gateway_admin_url(api_url) status_api_url = status_url or gateway_status_url(gateway_base_url) class CodexRetryGatewayTui(App[None]): CSS = """ Screen { layout: vertical; } #summary { height: 1; padding: 0 1; color: $accent; } #paths { height: 1; padding: 0 1; color: $text-muted; } #controls { height: 1; padding: 0 1; color: $text-muted; } #tables { height: 1; padding: 0 1; color: $text-muted; } #requests_table, #logs_table, #profiles_table { height: 1fr; } #detail { height: 3; padding: 0 1; border-top: solid $panel; } #status { height: 1; padding: 0 1; color: $text-muted; } """ BINDINGS = [ ("q", "quit", "Quit"), ("r", "refresh", "Refresh"), ("1", "show_overview", "Overview"), ("2", "show_requests", "Requests"), ("3", "show_logs", "Logs"), ("4", "show_profiles", "Profiles"), ("/", "focus_filter", "Filter"), ] def __init__(self) -> None: super().__init__() self.payload: dict[str, Any] = {} self.status_payload: dict[str, Any] = {} self.status_error = "" self.requests: list[dict[str, Any]] = [] self.logs: list[dict[str, Any]] = [] self.profiles: list[dict[str, Any]] = [] self.request_by_key: dict[str, dict[str, Any]] = {} self.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" self.last_request_id = "" self.loading = False self.executor = ThreadPoolExecutor(max_workers=1) self.refresh_generation = 0 self.active_profile_name = "" self.visible_tables = { "overview": "requests", "requests": "requests", "logs": "logs", "profiles": "profiles", } def compose(self) -> ComposeResult: yield Header(show_clock=True) yield Static("", id="summary") yield Static("", id="paths") yield Input(placeholder="requests filter", id="filter") yield Static("", id="controls") yield Static("", id="tables") yield DataTable(id="requests_table") yield DataTable(id="logs_table") yield DataTable(id="profiles_table") yield Static("", id="detail") yield Static("", id="status") yield Footer() def on_mount(self) -> None: requests = self.query_one("#requests_table", DataTable) requests.cursor_type = "row" requests.zebra_stripes = True requests.add_columns("Seq", "ID", "Status", "Path", "Model", "Reasoning", "Req", "Resp", "Chunks", "First", "Duration", "Updated", "Note") logs = self.query_one("#logs_table", DataTable) logs.cursor_type = "row" logs.zebra_stripes = True logs.add_columns("Seq", "At", "Message") 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._set_view("overview") self.refresh_data(refresh=True) self.set_interval(refresh_seconds, self.refresh_data) def on_unmount(self) -> None: self.executor.shutdown(wait=False, cancel_futures=True) def _set_view(self, view: str) -> None: self.current_view = view active_table = self.visible_tables.get(view, "requests") self.query_one("#requests_table").display = active_table == "requests" 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 | enter action | n newest request | p probe | s switch | w save | d delete | u open upstream" ) self._focus_table() self._update_table_headers() def _focus_table(self) -> None: table = self._current_table_widget() if table: table.focus() def _update_summary(self) -> None: self.query_one("#summary", Static).update(summary_line(self.payload)) self.query_one("#paths", Static).update(state_summary(self.payload)) def _update_table_headers(self) -> None: counts = f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)}" self.query_one("#tables", Static).update(counts) def _current_filter(self) -> str: return self.query_one("#filter", Input).value.strip() def _current_table_widget(self) -> DataTable | None: table_id = self.visible_tables.get(self.current_view, "requests") if table_id == "logs": return self.query_one("#logs_table", DataTable) if table_id == "profiles": return self.query_one("#profiles_table", DataTable) return self.query_one("#requests_table", DataTable) def _select_table_row(self, table: DataTable, row_index: int) -> bool: if row_index < 0 or row_index >= table.row_count: return False table.cursor_coordinate = Coordinate(row_index, 0) return True def _select_profile_row(self, profile_name: str) -> bool: table = self.query_one("#profiles_table", DataTable) if not self.profiles: return False if profile_name: for index, row in enumerate(self.profiles): if row["name"] == profile_name: return self._select_table_row(table, index) return self._select_table_row(table, 0) def _selected_request_row(self) -> dict[str, Any] | None: table = self.query_one("#requests_table", DataTable) if table.cursor_row is None or table.cursor_row < 0: return None key = str(table.get_row_at(table.cursor_row).key.value) return self.request_by_key.get(key) def _selected_profile_row(self) -> dict[str, Any] | None: table = self.query_one("#profiles_table", DataTable) if table.cursor_row is None or table.cursor_row < 0: return None key = str(table.get_row_at(table.cursor_row).key.value) return self.profile_by_key.get(key) def _selected_log_row(self) -> dict[str, Any] | None: table = self.query_one("#logs_table", DataTable) if table.cursor_row is None or table.cursor_row < 0: return None key = str(table.get_row_at(table.cursor_row).key.value) return self.log_by_key.get(key) def _set_status(self, text: str) -> None: self.query_one("#status", Static).update(text) def refresh_data(self, refresh: bool = False) -> None: if self.loading: return self.loading = True self.refresh_generation += 1 generation = self.refresh_generation self._set_status("refreshing...") current_filter = self._current_filter() self.executor.submit(self._refresh_in_worker, generation, current_filter) def _refresh_in_worker(self, generation: int, current_filter: str) -> None: try: result = fetch_dashboard_snapshot( gateway_base_url, status_api_url, timeout, filter_text=current_filter, current_view=self.current_view, ) result["generation"] = generation self.call_from_thread(self._apply_refresh_result, result) except Exception as exc: self.call_from_thread(self._apply_refresh_error, generation, exc) def _apply_refresh_result(self, result: dict[str, Any]) -> None: if result.get("generation") != self.refresh_generation: return self.payload = result["payload"] self.status_payload = result["status_payload"] self.status_error = result["status_error"] self.requests = result["requests"] self.logs = result["logs"] self.profiles = result["profiles"] self.active_profile_name = result.get("active_profile_name") or "" self.loading = False self.render_all() if self.current_view == "profiles": 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)) self._set_status(" | ".join(bit for bit in version_bits if bit)) def _apply_refresh_error(self, generation: int, exc: Exception) -> None: if generation != self.refresh_generation: return self.loading = False self._set_status(f"error: {exc}") def render_all(self) -> None: self._update_summary() self.render_requests() self.render_logs() self.render_profiles() if self.current_view == "requests": if self.requests: self.render_detail(self.requests[0], kind="request") else: self.query_one("#detail", Static).update("no requests") elif self.current_view == "logs": if self.logs: self.render_detail(self.logs[0], kind="log") else: self.query_one("#detail", Static).update("no logs") elif self.current_view == "profiles": if self.profiles: self.render_detail(self.profiles[0], kind="profile") else: self.query_one("#detail", Static).update("no profiles") def render_requests(self) -> None: table = self.query_one("#requests_table", DataTable) table.clear() self.request_by_key = {} for row in self.requests: key = f"{row['seq']}:{row['request_id']}" self.request_by_key[key] = row table.add_row( str(row["seq"]), short_text(row["request_id"], 18) or "-", f"{status_symbol(row)} {row.get('status_code') or '-'}", short_text(row["path"], 22), short_text(row["model"] or row["requested_model"] or row["forwarded_model"], 16), row["reasoning_tokens"] if row["reasoning_tokens"] is not None else "-", format_bytes(row["request_body_bytes"]), format_bytes(row["response_bytes_received"]), request_chunk_progress(row), format_ms(row["first_response_delay_ms"]), format_ms(row["duration_ms"]), request_updated_elapsed(row), f"{row['upstream_attempt_count'] or 0}x {short_text(row['error'], 18)}", key=key, ) def render_logs(self) -> None: table = self.query_one("#logs_table", DataTable) table.clear() self.log_by_key = {} for row in self.logs: key = str(row["seq"]) self.log_by_key[key] = row table.add_row(str(row["seq"]), short_time(row["at"]), short_text(row["message"], 80), key=key) def render_profiles(self) -> None: table = self.query_one("#profiles_table", DataTable) table.clear() self.profile_by_key = {} for row in self.profiles: 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) 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)) else: message = str(row.get("message") or "").strip() self.query_one("#detail", Static).update(f"seq {row['seq']} | {short_time(row['at'])} | {message}") def action_refresh(self) -> None: self.refresh_data(refresh=True) def action_focus_filter(self) -> None: self.query_one("#filter", Input).focus() def action_show_overview(self) -> None: self._set_view("overview") def action_show_requests(self) -> None: self._set_view("requests") def action_show_logs(self) -> None: self._set_view("logs") def action_show_profiles(self) -> None: self._set_view("profiles") if not self._select_profile_row(self.active_profile_name): self._select_profile_row("") def action_newest_request(self) -> None: if self.requests: self.render_detail(self.requests[0], kind="request") def action_focus_requests(self) -> None: self._set_view("requests") def action_focus_logs(self) -> None: self._set_view("logs") def action_focus_profiles(self) -> None: self._set_view("profiles") def action_open_upstream(self) -> None: row = self._selected_profile_row() if not row: return upstream = str(row.get("upstream_base_url") or "").strip() if not upstream or upstream == "-": return open_url(upstream) def action_probe_profile(self) -> None: row = self._selected_profile_row() if not row: return try: status, payload = post_json( build_api_url(gateway_base_url, "/api/profiles/probe"), timeout, {"profile": row["name"]}, ) self._set_status(f"probe {row['name']} -> {status} | {short_text(payload, 96)}") except Exception as exc: self._set_status(f"probe error: {exc}") def action_switch_profile(self) -> None: row = self._selected_profile_row() if not row: return try: status, payload = post_json( build_api_url(gateway_base_url, "/api/profiles/switch"), timeout, {"profile": row["name"]}, ) self._set_status(f"switch {row['name']} -> {status} | {short_text(payload, 96)}") self.refresh_data(refresh=True) except Exception as exc: self._set_status(f"switch error: {exc}") def action_save_active_profile(self) -> None: row = self._selected_profile_row() if not row: return raw = row.get("raw") if isinstance(row.get("raw"), dict) else {} form = raw.get("form") if isinstance(raw.get("form"), dict) else {} payload = { "name": row["name"], "listen_host": form.get("listen_host") or row["listen_host"], "listen_port": as_int(form.get("listen_port") or row["listen_port"]), "upstream_base_url": form.get("upstream_base_url") or row["upstream_base_url"], "upstream_auth_mode": form.get("auth_mode") or row["auth_mode"], "upstream_auth_env": form.get("auth_env") or "", "upstream_auth_file": form.get("auth_file") or "", "upstream_auth_json_path": form.get("auth_json_path") or "", "upstream_auth_json_key": form.get("auth_json_key") or "", "request_history_limit": as_int(form.get("request_history_limit") or row["request_history_limit"]), "model_remap": form.get("model_remap") or "", "reasoning_equals": form.get("reasoning_equals") or "", } try: status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload) self._set_status(f"save profile -> {status} | {short_text(result, 96)}") self.refresh_data(refresh=True) except Exception as exc: self._set_status(f"save error: {exc}") def action_delete_profile(self) -> None: row = self._selected_profile_row() 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) self._set_status(f"delete {row['name']} -> {status} | {short_text(payload, 96)}") self.refresh_data(refresh=True) except Exception as exc: self._set_status(f"delete error: {exc}") def on_input_changed(self, event: Input.Changed) -> None: if event.input.id == "filter": self.refresh_data(refresh=False) def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: if event.data_table.id == "requests_table": key = str(event.row_key.value) row = self.request_by_key.get(key) if row: self.render_detail(row, kind="request") elif event.data_table.id == "logs_table": key = str(event.row_key.value) row = self.log_by_key.get(key) if row: 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) if row: self.render_detail(row, kind="profile") CodexRetryGatewayTui().run() return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Codex Retry Gateway TUI") parser.add_argument("--api-url", default=default_api_url()) parser.add_argument( "--status-url", default=default_status_url(), help="optional status API URL; leave empty to infer from --api-url", ) parser.add_argument("--save-config", action="store_true", help="persist --api-url to ~/.config/codex-retry-gateway-tui/api-url before running") parser.add_argument("--install", action="store_true", help="persist --api-url, install codex-retry-gateway-tui as a uv tool, then exit") parser.add_argument("--version-check-url", default=os.environ.get("CODEX_RETRY_GATEWAY_TUI_VERSION_CHECK_URL", DEFAULT_VERSION_CHECK_URL)) parser.add_argument( "--version-check-timeout", type=int, default=env_int("CODEX_RETRY_GATEWAY_TUI_VERSION_CHECK_TIMEOUT", DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS), ) parser.add_argument( "--no-version-check", action="store_true", default=os.environ.get("CODEX_RETRY_GATEWAY_TUI_NO_VERSION_CHECK", "").strip().lower() in {"1", "true", "yes", "on"}, ) parser.add_argument( "--refresh-seconds", type=int, default=env_int("CODEX_RETRY_GATEWAY_TUI_REFRESH_SECONDS", DEFAULT_REFRESH_SECONDS), ) parser.add_argument("--timeout", type=int, default=env_int("CODEX_RETRY_GATEWAY_TUI_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)) parser.add_argument("--once", action="store_true", help="print one snapshot and exit") parser.add_argument("--filter", default="", help="initial filter for one-shot text output") return parser def normalize_log_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: rows = [] for entry in payload.get("entries") or []: if not isinstance(entry, dict): continue rows.append( { "seq": as_int(entry.get("seq")), "at": entry.get("at"), "message": str(entry.get("message") or ""), "raw": entry, } ) rows.sort(key=lambda item: item["seq"], reverse=True) return rows def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None: payload = snapshot.get("payload") if isinstance(snapshot, dict) and isinstance(snapshot.get("payload"), dict) else {} status_payload = snapshot.get("status_payload") if isinstance(snapshot, dict) and isinstance(snapshot.get("status_payload"), dict) else {} status_error = str(snapshot.get("status_error") or "") if isinstance(snapshot, dict) else "" print(summary_line(payload)) print(state_summary(payload)) if status_payload or status_error: print(status_summary(status_payload, status_error)) print("requests") for row in snapshot.get("requests") or []: print( f"{row['seq']:>6} {short_text(row['request_id'], 16):<16} {status_symbol(row)} {row.get('status_code') or '-':<4} " f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} " f"{request_chunk_progress(row):<14} {format_ms(row['duration_ms']):<8} {request_updated_elapsed(row):<8} " f"{format_count(row['upstream_attempt_count'] or 0):<4} {short_text(row['error'], 30)}" ) def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) args.api_url = gateway_admin_url(args.api_url) status_url = str(args.status_url or "").strip() or default_status_url() if args.save_config or args.install: config_path = write_api_url_config(args.api_url) print(f"saved api url to {config_path}") if args.install: print("installing codex-retry-gateway-tui with uv tool...") return run_install_command() version_message = check_version_update( args.version_check_url, max(1, args.version_check_timeout), disabled=bool(args.no_version_check), ) if args.once: if version_message: print(version_message) snapshot = fetch_dashboard_snapshot( args.api_url, status_url, args.timeout, filter_text=args.filter, current_view="overview", ) print_once(snapshot, args.filter) return 0 return run_textual(args.api_url, status_url, max(1, args.refresh_seconds), max(1, args.timeout), version_message) if __name__ == "__main__": raise SystemExit(main())