#!/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 shlex import shutil import subprocess import sys import tempfile import tomllib 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 DEFAULT_PROFILE_REASONING_EQUALS = [516, 1034, 1552] DEFAULT_PROFILE_RETRYABLE_STATUS_CODES = [429, 503] DEFAULT_PROFILE_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", ] DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5 DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350 DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0 DEFAULT_PROFILE_ENDPOINTS = [ "/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions", ] 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 cached_ratio(input_tokens: Any, cached_tokens: Any) -> float | None: total = as_float(input_tokens) if total <= 0: return None cached = max(0.0, as_float(cached_tokens)) return max(0.0, min(1.0, cached / total)) def effective_input_tokens(input_tokens: Any, cached_tokens: Any) -> int | None: total = parse_int_value(input_tokens) if total is None: return None cached = max(0, as_int(cached_tokens)) return max(0, total - cached) def primary_request_id(row: dict[str, Any]) -> str: return str(row.get("response_id") or row.get("request_id") or "").strip() or "-" def request_usage_summary(row: dict[str, Any]) -> str: effective_in = effective_input_tokens(row.get("input_tokens"), row.get("cached_tokens")) output_tokens = row.get("output_tokens") cached_tokens = row.get("cached_tokens") ratio = cached_ratio(row.get("input_tokens"), cached_tokens) in_text = format_count(effective_in) if effective_in is not None else "-" out_text = format_count(output_tokens) if output_tokens is not None else "-" cached_text = format_count(cached_tokens) if cached_tokens is not None else "-" if ratio is not None: cached_text = f"{cached_text} ({format_percent(ratio * 100)})" return f"in {in_text} | out {out_text} | cached {cached_text}" 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 parse_bool_value(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value if value is None: return default text = str(value).strip().lower() if text in {"1", "true", "yes", "on"}: return True if text in {"0", "false", "no", "off", ""}: return False return bool(value) def parse_int_value(value: Any) -> int | None: if value is None or str(value).strip() == "": return None try: return int(str(value).strip()) except Exception: try: return int(float(str(value).strip())) except Exception: return None def normalize_editor_integer_list(value: Any) -> list[int]: if isinstance(value, (list, tuple, set)): raw_values = list(value) else: raw_values = re.split(r"[\s,]+", str(value or "")) numbers: list[int] = [] for item in raw_values: parsed = parse_int_value(item) if parsed is not None: numbers.append(parsed) return numbers def normalize_editor_string_list(value: Any) -> list[str]: if isinstance(value, (list, tuple, set)): raw_values = list(value) else: raw_values = re.split(r"[\r\n,]+", str(value or "")) values = [] for item in raw_values: text = str(item or "").strip() if text: values.append(text) return values def normalize_editor_phrase_list(value: Any) -> list[str]: if isinstance(value, (list, tuple, set)): raw_values = list(value) else: raw_values = str(value or "").splitlines() values = [] for item in raw_values: text = str(item or "").strip() if text: values.append(text) return values def toml_string(value: Any) -> str: return json.dumps(str(value or ""), ensure_ascii=False) def toml_bool(value: Any) -> str: return "true" if parse_bool_value(value) else "false" def toml_int_list(values: Any) -> str: numbers = normalize_editor_integer_list(values) return "[" + ", ".join(str(value) for value in numbers) + "]" def toml_string_list(values: Any) -> str: items = [toml_string(value) for value in normalize_editor_string_list(values)] return "[" + ", ".join(items) + "]" def toml_phrase_list(values: Any) -> str: items = [toml_string(value) for value in normalize_editor_phrase_list(values)] return "[" + ", ".join(items) + "]" 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", "response_id", "thread_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 ""), "response_id": str(entry.get("response_id") or ""), "thread_id": str(entry.get("thread_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 {primary_request_id(row)}", f"thread {row.get('thread_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 or row.get("cached_tokens") is not None: bits.append(f"usage {request_usage_summary(row)} | total {format_count(row.get('total_tokens'))}") if row.get("cached_tokens") is not None: bits.append(f"cached raw {format_count(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 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 "", "listen_host": form.get("listen_host") or row.get("listen_host") or "", "listen_port": parse_int_value(form.get("listen_port") or row.get("listen_port")), "upstream_base_url": form.get("upstream_base_url") or row.get("upstream_base_url") or "", "auth_mode": form.get("auth_mode") or row.get("auth_mode") or "passthrough", "auth_env": form.get("auth_env") or "", "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 row.get("auth_json_key") or "", "request_history_limit": parse_int_value(form.get("request_history_limit") or row.get("request_history_limit")) if parse_int_value(form.get("request_history_limit") or row.get("request_history_limit")) is not None else DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT, "model_remap": form.get("model_remap") or row.get("model_remap") or "", "reasoning_equals": normalize_editor_integer_list(form.get("reasoning_equals") or row.get("reasoning_equals")) or list(DEFAULT_PROFILE_REASONING_EQUALS), "retryable_status_codes": normalize_editor_integer_list(form.get("retryable_status_codes")) or list(DEFAULT_PROFILE_RETRYABLE_STATUS_CODES), "retryable_error_messages": normalize_editor_phrase_list(form.get("retryable_error_messages")) or list(DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES), "upstream_fetch_retry_attempts": parse_int_value(form.get("upstream_fetch_retry_attempts")) if parse_int_value(form.get("upstream_fetch_retry_attempts")) is not None else DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS, "upstream_fetch_retry_backoff_ms": parse_int_value(form.get("upstream_fetch_retry_backoff_ms")) if parse_int_value(form.get("upstream_fetch_retry_backoff_ms")) is not None else DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS, "endpoints": normalize_editor_string_list(form.get("endpoints")) or list(DEFAULT_PROFILE_ENDPOINTS), } def profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]: state = profile_form_state(row) return { "name": str(state.get("name") or "").strip(), "listen_host": str(state.get("listen_host") or "").strip(), "listen_port": state.get("listen_port"), "upstream_base_url": str(state.get("upstream_base_url") or "").strip(), "auth_mode": str(state.get("auth_mode") or "passthrough").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(), "request_history_limit": state.get("request_history_limit"), "model_remap": str(state.get("model_remap") or "").strip(), "reasoning_equals": state.get("reasoning_equals") or [], "retryable_status_codes": state.get("retryable_status_codes") or [], "retryable_error_messages": state.get("retryable_error_messages") or [], "upstream_fetch_retry_attempts": state.get("upstream_fetch_retry_attempts"), "upstream_fetch_retry_backoff_ms": state.get("upstream_fetch_retry_backoff_ms"), "endpoints": state.get("endpoints") or [], } def profile_editor_document(row: dict[str, Any]) -> str: state = profile_form_state(row) model_remap = str(state.get("model_remap") or "") if "\n" in model_remap: model_remap_value = '"""\n' + model_remap.rstrip("\n").replace('"""', '\\"""') + '\n"""' else: model_remap_value = toml_string(model_remap) return "\n".join( [ "# Edit the selected codex-retry-gateway profile and save.", "# Leave manual_secret empty to keep the current secret file.", "# Changing name creates a new profile file; it does not delete the old one.", "", f"name = {toml_string(state.get('name'))}", f"listen_host = {toml_string(state.get('listen_host'))}", f"listen_port = {state.get('listen_port') if state.get('listen_port') is not None else 4610}", f"upstream_base_url = {toml_string(state.get('upstream_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'))}", 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'))}", f"retryable_status_codes = {toml_int_list(state.get('retryable_status_codes'))}", f"retryable_error_messages = {toml_phrase_list(state.get('retryable_error_messages'))}", f"upstream_fetch_retry_attempts = {state.get('upstream_fetch_retry_attempts') if state.get('upstream_fetch_retry_attempts') is not None else 5}", f"upstream_fetch_retry_backoff_ms = {state.get('upstream_fetch_retry_backoff_ms') if state.get('upstream_fetch_retry_backoff_ms') is not None else 350}", f"endpoints = {toml_string_list(state.get('endpoints'))}", "", ] ) def 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(), "listen_host": str(data.get("listen_host") or "").strip(), "listen_port": parse_int_value(data.get("listen_port")), "upstream_base_url": str(data.get("upstream_base_url") or "").strip(), "auth_mode": str(data.get("auth_mode") or "passthrough").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(), "request_history_limit": parse_int_value(data.get("request_history_limit")), "model_remap": str(data.get("model_remap") or "").strip(), "reasoning_equals": normalize_editor_integer_list(data.get("reasoning_equals")), "retryable_status_codes": normalize_editor_integer_list(data.get("retryable_status_codes")), "retryable_error_messages": normalize_editor_phrase_list(data.get("retryable_error_messages")), "upstream_fetch_retry_attempts": parse_int_value(data.get("upstream_fetch_retry_attempts")), "upstream_fetch_retry_backoff_ms": parse_int_value(data.get("upstream_fetch_retry_backoff_ms")), "endpoints": normalize_editor_string_list(data.get("endpoints")), } def resolve_editor_command() -> list[str]: configured = (os.environ.get("VISUAL") or os.environ.get("EDITOR") or "").strip() if configured: return shlex.split(configured) for candidate in ("nano", "vim", "vi"): if shutil.which(candidate): return [candidate] raise RuntimeError("no editor found; set $VISUAL or $EDITOR") def edit_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | None: original_text = profile_editor_document(row) editor_command = resolve_editor_command() fd, raw_path = tempfile.mkstemp(prefix=f"codex-retry-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 = 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 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"), ("e", "edit_profile", "Edit Profile"), ("p", "probe_profile", "Probe Profile"), ("s", "switch_profile", "Switch Profile"), ("w", "save_active_profile", "Save Profile"), ("d", "delete_profile", "Delete Profile"), ("u", "open_upstream", "Open Upstream"), ("/", "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", "Thread", "Status", "Path", "Model", "Reasoning", "Usage", "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 | e edit | 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(primary_request_id(row), 18) or "-", short_text(row["thread_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 "-", short_text(request_usage_summary(row), 24), 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_edit_profile(self) -> None: if self.current_view != "profiles": self._set_status("switch to profiles view first") return row = self._selected_profile_row() if not row: self._set_status("no profile selected") return try: with self.suspend(): payload = 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) 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: self._set_status(f"edit error: {exc}") 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 payload = profile_payload_from_row(row) 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 []: try: print( f"{row['seq']:>6} {short_text(primary_request_id(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {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"{short_text(request_usage_summary(row), 26):<26} {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)}" ) except BrokenPipeError: return 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())