2930 lines
120 KiB
Python
2930 lines
120 KiB
Python
#!/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 hashlib
|
|
import importlib.metadata
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import tomllib
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
APP_NAME = "codex-retry-gateway-tui"
|
|
FALLBACK_VERSION = "0.1.10"
|
|
DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway"
|
|
DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway"
|
|
DEFAULT_API_URL = DEFAULT_GATEWAY_URL
|
|
DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url"
|
|
DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url"
|
|
DEFAULT_ACCESS_KEY_FILE = "~/.config/codex-retry-gateway-tui/access-key"
|
|
DEFAULT_REQUEST_TABLE_PREFERENCES_FILE = "~/.config/codex-retry-gateway-tui/request-table-columns.txt"
|
|
LEGACY_REQUEST_TABLE_PREFERENCES_FILE = "~/.config/codex-retry-gateway-tui/request-table.json"
|
|
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
|
|
REQUEST_STATUS_SLOW_AFTER_SECONDS = 20
|
|
REQUEST_STATUS_STALLED_AFTER_SECONDS = 120
|
|
DEFAULT_PROFILE_ENDPOINTS = [
|
|
"/responses",
|
|
"/chat/completions",
|
|
"/v1/responses",
|
|
"/v1/chat/completions",
|
|
]
|
|
REQUEST_TABLE_COLUMNS = (
|
|
{"key": "seq", "label": "Seq", "width_key": "seq"},
|
|
{"key": "req_id", "label": "Req ID", "width_key": "req_id"},
|
|
{"key": "resp_id", "label": "Resp ID", "width_key": "resp_id"},
|
|
{"key": "thread", "label": "Thread", "width_key": "thread"},
|
|
{"key": "started", "label": "Started", "width_key": "started"},
|
|
{"key": "status", "label": "Status", "width_key": "status"},
|
|
{"key": "path", "label": "Path", "width_key": "path"},
|
|
{"key": "model", "label": "Model", "width_key": "model"},
|
|
{"key": "effort", "label": "Effort", "width_key": "effort"},
|
|
{"key": "reasoning", "label": "R.Tok", "width_key": "reasoning"},
|
|
{"key": "in", "label": "In", "width_key": "token_col"},
|
|
{"key": "out", "label": "Out", "width_key": "token_col"},
|
|
{"key": "cache", "label": "Cache", "width_key": "token_col"},
|
|
{"key": "req_bytes", "label": "Req Size", "width_key": "req"},
|
|
{"key": "resp_bytes", "label": "Resp Size", "width_key": "resp"},
|
|
{"key": "chunks", "label": "Chunks", "width_key": "chunks"},
|
|
{"key": "first", "label": "First", "width_key": "first"},
|
|
{"key": "duration", "label": "Duration", "width_key": "duration"},
|
|
{"key": "updated", "label": "Updated", "width_key": "updated"},
|
|
{"key": "note", "label": "Note", "width_key": "note"},
|
|
{"key": "round", "label": "Round", "width_key": "round"},
|
|
)
|
|
REQUEST_TABLE_COLUMN_BY_KEY = {column["key"]: column for column in REQUEST_TABLE_COLUMNS}
|
|
REQUEST_TABLE_COLUMN_KEYS = tuple(column["key"] for column in REQUEST_TABLE_COLUMNS)
|
|
REQUEST_TABLE_COLUMN_KEY_SET = set(REQUEST_TABLE_COLUMN_KEYS)
|
|
REQUEST_TABLE_CELL_INDEX = {key: index for index, key in enumerate(REQUEST_TABLE_COLUMN_KEYS)}
|
|
DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS
|
|
DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq"
|
|
THREAD_COLOR_PALETTE = (
|
|
"#5fd7ff",
|
|
"#87d75f",
|
|
"#ffaf5f",
|
|
"#d787ff",
|
|
"#5fafff",
|
|
"#ff87d7",
|
|
"#d7af5f",
|
|
"#5fd7af",
|
|
"#af87ff",
|
|
"#87afd7",
|
|
)
|
|
THREAD_COLOR_COLUMN_KEYS = frozenset({"req_id", "resp_id", "thread"})
|
|
REQUEST_TABLE_WIDTH_PROFILES = {
|
|
"compact": {
|
|
"seq": 7,
|
|
"req_id": 18,
|
|
"resp_id": 18,
|
|
"thread": 18,
|
|
"started": 19,
|
|
"status": 12,
|
|
"path": 22,
|
|
"model": 16,
|
|
"effort": 8,
|
|
"reasoning": 8,
|
|
"token_col": 8,
|
|
"req": 10,
|
|
"resp": 10,
|
|
"chunks": 14,
|
|
"first": 12,
|
|
"duration": 8,
|
|
"updated": 8,
|
|
"note": 22,
|
|
"round": 10,
|
|
},
|
|
"wide": {
|
|
"seq": 7,
|
|
"req_id": 24,
|
|
"resp_id": 24,
|
|
"thread": 36,
|
|
"started": 19,
|
|
"status": 14,
|
|
"path": 40,
|
|
"model": 24,
|
|
"effort": 8,
|
|
"reasoning": 8,
|
|
"token_col": 8,
|
|
"req": 12,
|
|
"resp": 12,
|
|
"chunks": 16,
|
|
"first": 14,
|
|
"duration": 8,
|
|
"updated": 8,
|
|
"note": 48,
|
|
"round": 10,
|
|
},
|
|
}
|
|
REQUEST_TABLE_MIN_COLUMN_WIDTH = 4
|
|
REQUEST_TABLE_COLUMN_ALIASES = {
|
|
"seq": ("seq",),
|
|
"req_id": ("req id", "request id", "request_id"),
|
|
"resp_id": ("resp id", "response id", "response_id"),
|
|
"thread": ("thread", "thread id", "thread_id"),
|
|
"started": ("started", "start", "started at"),
|
|
"status": ("status", "code", "status code"),
|
|
"path": ("path", "endpoint"),
|
|
"model": ("model",),
|
|
"effort": ("effort", "reasoning effort"),
|
|
"reasoning": ("reasoning", "r tok", "rtok", "reasoning tokens", "reasoning_tokens"),
|
|
"in": ("in", "input", "input tokens", "input_tokens"),
|
|
"out": ("out", "output", "output tokens", "output_tokens"),
|
|
"cache": ("cache", "cached", "cached tokens", "cached_tokens"),
|
|
"req_bytes": ("req", "req size", "request size", "request bytes", "request_bytes"),
|
|
"resp_bytes": ("resp", "resp size", "response size", "response bytes", "response_bytes"),
|
|
"chunks": ("chunks", "stream chunks", "chunk count"),
|
|
"first": ("first", "first response", "first delay"),
|
|
"duration": ("duration", "elapsed"),
|
|
"updated": ("updated", "last update"),
|
|
"note": ("note", "retry note"),
|
|
"round": ("round", "retry round"),
|
|
}
|
|
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 access_key_file_path() -> Path:
|
|
return Path(os.environ.get("CODEX_RETRY_GATEWAY_TUI_ACCESS_KEY_FILE", DEFAULT_ACCESS_KEY_FILE)).expanduser()
|
|
|
|
|
|
def request_table_preferences_file_path() -> Path:
|
|
return Path(
|
|
os.environ.get(
|
|
"CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE",
|
|
DEFAULT_REQUEST_TABLE_PREFERENCES_FILE,
|
|
)
|
|
).expanduser()
|
|
|
|
|
|
def legacy_request_table_preferences_file_path() -> Path:
|
|
return Path(LEGACY_REQUEST_TABLE_PREFERENCES_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 default_access_key() -> str:
|
|
return configured_url(
|
|
("CODEX_RETRY_GATEWAY_TUI_ACCESS_KEY",),
|
|
os.environ.get("CODEX_RETRY_GATEWAY_TUI_ACCESS_KEY_FILE", DEFAULT_ACCESS_KEY_FILE),
|
|
"",
|
|
)
|
|
|
|
|
|
def request_table_column_token(value: Any) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())
|
|
|
|
|
|
def build_request_table_column_token_map() -> dict[str, str]:
|
|
token_map: dict[str, str] = {}
|
|
for column in REQUEST_TABLE_COLUMNS:
|
|
key = column["key"]
|
|
aliases = set(REQUEST_TABLE_COLUMN_ALIASES.get(key, ()))
|
|
aliases.add(key)
|
|
aliases.add(column["label"])
|
|
for alias in aliases:
|
|
token = request_table_column_token(alias)
|
|
if token:
|
|
token_map[token] = key
|
|
return token_map
|
|
|
|
|
|
REQUEST_TABLE_COLUMN_TOKEN_MAP = build_request_table_column_token_map()
|
|
|
|
|
|
def request_table_column_key(value: Any) -> str:
|
|
token = request_table_column_token(value)
|
|
return REQUEST_TABLE_COLUMN_TOKEN_MAP.get(token, "")
|
|
|
|
|
|
def request_table_column_keys(value: Any) -> list[str]:
|
|
token = request_table_column_token(value)
|
|
if token == "usage":
|
|
return ["in", "out", "cache"]
|
|
column_key = REQUEST_TABLE_COLUMN_TOKEN_MAP.get(token, "")
|
|
return [column_key] if column_key else []
|
|
|
|
|
|
def request_table_column_order(value: Any) -> list[str]:
|
|
requested = value if isinstance(value, (list, tuple, set)) else []
|
|
columns = []
|
|
seen: set[str] = set()
|
|
for item in requested:
|
|
for column_key in request_table_column_keys(item):
|
|
if not column_key or column_key in seen:
|
|
continue
|
|
columns.append(column_key)
|
|
seen.add(column_key)
|
|
for column_key in REQUEST_TABLE_COLUMN_KEYS:
|
|
if column_key not in seen:
|
|
columns.append(column_key)
|
|
return columns or list(DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS)
|
|
|
|
|
|
def request_table_hidden_columns(value: Any, columns: list[str]) -> list[str]:
|
|
requested = value if isinstance(value, (list, tuple, set)) else []
|
|
allowed = set(columns)
|
|
hidden = []
|
|
seen: set[str] = set()
|
|
for item in requested:
|
|
for column_key in request_table_column_keys(item):
|
|
if not column_key or column_key not in allowed or column_key in seen:
|
|
continue
|
|
hidden.append(column_key)
|
|
seen.add(column_key)
|
|
return hidden
|
|
|
|
|
|
def request_table_column_width_value(value: Any) -> int | None:
|
|
try:
|
|
width = int(str(value or "").strip())
|
|
except Exception:
|
|
return None
|
|
if width <= 0:
|
|
return None
|
|
return max(REQUEST_TABLE_MIN_COLUMN_WIDTH, width)
|
|
|
|
|
|
def request_table_width_overrides(value: Any) -> dict[str, int]:
|
|
requested = value if isinstance(value, dict) else {}
|
|
overrides: dict[str, int] = {}
|
|
for raw_key, raw_width in requested.items():
|
|
width = request_table_column_width_value(raw_width)
|
|
if width is None:
|
|
continue
|
|
for column_key in request_table_column_keys(raw_key):
|
|
if column_key:
|
|
overrides[column_key] = width
|
|
return overrides
|
|
|
|
|
|
def normalize_request_table_sort_column(value: Any) -> str:
|
|
column_key = str(value or "").strip()
|
|
if column_key in REQUEST_TABLE_COLUMN_KEY_SET:
|
|
return column_key
|
|
return DEFAULT_REQUEST_TABLE_SORT_COLUMN
|
|
|
|
|
|
def normalize_request_table_preferences(data: Any) -> dict[str, Any]:
|
|
source = data if isinstance(data, dict) else {}
|
|
if isinstance(source.get("visible_columns"), (list, tuple, set)):
|
|
legacy_visible = []
|
|
seen: set[str] = set()
|
|
for item in source.get("visible_columns") or []:
|
|
for column_key in request_table_column_keys(item):
|
|
if not column_key or column_key in seen:
|
|
continue
|
|
legacy_visible.append(column_key)
|
|
seen.add(column_key)
|
|
columns = request_table_column_order(legacy_visible)
|
|
hidden_columns = [column_key for column_key in columns if column_key not in seen]
|
|
else:
|
|
columns = request_table_column_order(source.get("columns"))
|
|
hidden_columns = request_table_hidden_columns(source.get("hidden_columns"), columns)
|
|
if len(hidden_columns) >= len(columns) and columns:
|
|
hidden_columns = [column_key for column_key in hidden_columns if column_key != columns[0]]
|
|
width_overrides = request_table_width_overrides(source.get("widths"))
|
|
return {
|
|
"columns": columns,
|
|
"hidden_columns": hidden_columns,
|
|
"widths": {column_key: width for column_key, width in width_overrides.items() if column_key in columns},
|
|
}
|
|
|
|
|
|
def request_table_visible_columns(preferences: dict[str, Any]) -> list[str]:
|
|
normalized = normalize_request_table_preferences(preferences)
|
|
hidden = set(normalized["hidden_columns"])
|
|
visible = [column_key for column_key in normalized["columns"] if column_key not in hidden]
|
|
if visible:
|
|
return visible
|
|
return [normalized["columns"][0]]
|
|
|
|
|
|
def request_table_preferences_from_text(text: str) -> dict[str, Any]:
|
|
columns = []
|
|
hidden_columns = []
|
|
width_overrides: dict[str, int] = {}
|
|
seen: set[str] = set()
|
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
stripped = raw_line.strip()
|
|
if not stripped:
|
|
continue
|
|
hidden = False
|
|
token = stripped
|
|
if token.startswith("#"):
|
|
hidden = True
|
|
token = token[1:].strip()
|
|
if not token:
|
|
continue
|
|
width = None
|
|
width_match = re.match(r"^(.*?)(?:\s+(\d+))?$", token)
|
|
if width_match:
|
|
token = (width_match.group(1) or "").strip()
|
|
width = request_table_column_width_value(width_match.group(2))
|
|
column_keys = request_table_column_keys(token)
|
|
if not column_keys:
|
|
if hidden:
|
|
continue
|
|
raise ValueError(f"line {line_number}: unknown request column {token!r}")
|
|
for column_key in column_keys:
|
|
if column_key in seen:
|
|
continue
|
|
seen.add(column_key)
|
|
columns.append(column_key)
|
|
if hidden:
|
|
hidden_columns.append(column_key)
|
|
if width is not None:
|
|
width_overrides[column_key] = width
|
|
if not columns:
|
|
raise ValueError("no request columns found in editor document")
|
|
missing = [column_key for column_key in REQUEST_TABLE_COLUMN_KEYS if column_key not in seen]
|
|
return normalize_request_table_preferences(
|
|
{
|
|
"columns": columns + missing,
|
|
"hidden_columns": hidden_columns + missing,
|
|
"widths": width_overrides,
|
|
}
|
|
)
|
|
|
|
|
|
def request_table_preferences_document(preferences: dict[str, Any]) -> str:
|
|
normalized = normalize_request_table_preferences(preferences)
|
|
hidden = set(normalized["hidden_columns"])
|
|
widths = normalized["widths"]
|
|
lines = [
|
|
"# Request table columns.",
|
|
"# One column key per line.",
|
|
"# Prefix with # to hide a column.",
|
|
"# Optional: append an integer width, e.g. `first 14` or `in 10`.",
|
|
"# Move lines up or down to change display order.",
|
|
"# Common keys: in, out, cache, req_id, resp_id, req_bytes, resp_bytes, round.",
|
|
"",
|
|
]
|
|
for column_key in normalized["columns"]:
|
|
prefix = "# " if column_key in hidden else ""
|
|
width_suffix = f" {widths[column_key]}" if column_key in widths else ""
|
|
lines.append(f"{prefix}{column_key}{width_suffix}")
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def load_request_table_preferences() -> dict[str, Any]:
|
|
configured_path = request_table_preferences_file_path()
|
|
configured_env = os.environ.get("CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE", "").strip()
|
|
candidate_paths = [configured_path]
|
|
if not configured_env:
|
|
candidate_paths.append(legacy_request_table_preferences_file_path())
|
|
for path in candidate_paths:
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
continue
|
|
stripped = text.strip()
|
|
if not stripped:
|
|
continue
|
|
try:
|
|
if stripped.startswith("{"):
|
|
return normalize_request_table_preferences(json.loads(text))
|
|
return request_table_preferences_from_text(text)
|
|
except Exception:
|
|
continue
|
|
return normalize_request_table_preferences({})
|
|
|
|
|
|
def write_request_table_preferences(preferences: dict[str, Any]) -> Path:
|
|
path = request_table_preferences_file_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
path.parent.chmod(0o700)
|
|
except OSError:
|
|
pass
|
|
normalized = normalize_request_table_preferences(preferences)
|
|
path.write_text(request_table_preferences_document(normalized), encoding="utf-8")
|
|
try:
|
|
path.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
return path
|
|
|
|
|
|
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/image-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 write_access_key_config(access_key: str) -> Path:
|
|
path = access_key_file_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
path.parent.chmod(0o700)
|
|
except OSError:
|
|
pass
|
|
path.write_text(str(access_key or "").strip() + "\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_duration_ms_as_seconds(value: Any) -> str:
|
|
number = as_float(value)
|
|
if number <= 0:
|
|
return "-"
|
|
return format_elapsed_seconds(number / 1000.0)
|
|
|
|
|
|
def format_bytes(value: Any) -> str:
|
|
if value is None or str(value).strip() == "":
|
|
return "-"
|
|
number = as_int(value)
|
|
if number < 0:
|
|
return "-"
|
|
if number == 0:
|
|
return "0B"
|
|
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 request_id_text(row: dict[str, Any]) -> str:
|
|
return str(row.get("request_id") or "").strip() or "-"
|
|
|
|
|
|
def response_id_text(row: dict[str, Any]) -> str:
|
|
return str(row.get("response_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 request_input_tokens_text(row: dict[str, Any]) -> str:
|
|
effective_in = effective_input_tokens(row.get("input_tokens"), row.get("cached_tokens"))
|
|
return format_count(effective_in) if effective_in is not None else "-"
|
|
|
|
|
|
def request_output_tokens_text(row: dict[str, Any]) -> str:
|
|
output_tokens = row.get("output_tokens")
|
|
return format_count(output_tokens) if output_tokens is not None else "-"
|
|
|
|
|
|
def request_cached_tokens_text(row: dict[str, Any]) -> str:
|
|
cached_tokens = row.get("cached_tokens")
|
|
return format_count(cached_tokens) if cached_tokens is not None else "-"
|
|
|
|
|
|
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 request_effort_text(row: dict[str, Any]) -> str:
|
|
return str(row.get("reasoning_effort") or "").strip() or "-"
|
|
|
|
|
|
def request_model_text(row: dict[str, Any]) -> str:
|
|
return str(row.get("model") or row.get("requested_model") or row.get("forwarded_model") or "").strip()
|
|
|
|
|
|
def normalize_request_retry_firsts(value: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(value, (list, tuple)):
|
|
return []
|
|
firsts = []
|
|
for item in value:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
firsts.append(
|
|
{
|
|
"round": parse_int_value(item.get("round")),
|
|
"slot": parse_int_value(item.get("slot")),
|
|
"first_response_at": item.get("first_response_at"),
|
|
"first_delay_ms": item.get("first_response_delay_ms", item.get("first_delay_ms", item.get("first_ms"))),
|
|
"outcome": str(item.get("outcome") or "").strip(),
|
|
"status_code": item.get("status_code", item.get("upstream_status_code", item.get("status"))),
|
|
"reasoning_effort": str(item.get("reasoning_effort") or "").strip(),
|
|
"reasoning_tokens": item.get("reasoning_tokens"),
|
|
"reasoning": str(item.get("reasoning") or item.get("reason") or item.get("error") or "").strip(),
|
|
}
|
|
)
|
|
return firsts
|
|
|
|
|
|
def request_retry_first_label(first: dict[str, Any], fallback_round: Any = None) -> str:
|
|
round_number = parse_int_value(first.get("round"))
|
|
if round_number is None:
|
|
round_number = parse_int_value(fallback_round)
|
|
slot = parse_int_value(first.get("slot"))
|
|
if round_number is not None:
|
|
if slot is not None and slot > 1:
|
|
return f"{round_number}-{slot}"
|
|
return str(round_number)
|
|
if slot is not None:
|
|
return str(slot)
|
|
return "?"
|
|
|
|
|
|
def request_retry_round_text(row: dict[str, Any]) -> str:
|
|
round_number = parse_int_value(row.get("reasoning_retry_current_round"))
|
|
width = parse_int_value(row.get("reasoning_retry_current_width"))
|
|
if round_number is None and width is None:
|
|
return "-"
|
|
if round_number is None:
|
|
return f"-({max(0, width or 0)})"
|
|
if width is None:
|
|
return str(round_number)
|
|
return f"{round_number}({max(0, width)})"
|
|
|
|
|
|
def request_retry_first_summary(first: dict[str, Any], fallback_round: Any = None) -> str:
|
|
bits = []
|
|
round_label = request_retry_first_label(first, fallback_round)
|
|
if round_label != "?":
|
|
bits.append(f"round {round_label}")
|
|
slot = parse_int_value(first.get("slot"))
|
|
if slot is not None:
|
|
bits.append(f"slot {slot}")
|
|
first_delay = format_duration_ms_as_seconds(first.get("first_delay_ms"))
|
|
if first_delay != "-":
|
|
bits.append(f"first {first_delay}")
|
|
outcome = str(first.get("outcome") or "").strip()
|
|
status_code = first.get("status_code")
|
|
status_bits = [bit for bit in (outcome, str(status_code).strip() if status_code not in (None, "") else "") if bit]
|
|
if status_bits:
|
|
bits.append(f"status {'/'.join(status_bits)}")
|
|
reasoning_bits = []
|
|
if first.get("reasoning_effort"):
|
|
reasoning_bits.append(str(first["reasoning_effort"]))
|
|
reasoning_tokens = parse_int_value(first.get("reasoning_tokens"))
|
|
if reasoning_tokens is not None:
|
|
reasoning_bits.append(format_count(reasoning_tokens))
|
|
reasoning = str(first.get("reasoning") or "").strip()
|
|
if reasoning_bits or reasoning:
|
|
detail = "/".join(reasoning_bits)
|
|
if reasoning:
|
|
detail = f"{detail} {short_text(reasoning, 40)}".strip()
|
|
bits.append(f"reasoning {detail}".strip())
|
|
return " ".join(bits)
|
|
|
|
|
|
def request_retry_wave_summary(row: dict[str, Any]) -> str:
|
|
summaries = []
|
|
fallback_round = row.get("reasoning_retry_current_round")
|
|
for first in row.get("reasoning_retry_current_firsts") or []:
|
|
if not isinstance(first, dict):
|
|
continue
|
|
summary = request_retry_first_summary(first, fallback_round)
|
|
if summary:
|
|
summaries.append(summary)
|
|
return "; ".join(summaries)
|
|
|
|
|
|
def request_retry_firsts_compact_text(row: dict[str, Any]) -> str:
|
|
parts = []
|
|
for first in row.get("reasoning_retry_current_firsts") or []:
|
|
if not isinstance(first, dict):
|
|
continue
|
|
delay = format_duration_ms_as_seconds(first.get("first_delay_ms"))
|
|
parts.append(delay if delay != "-" else "?")
|
|
return " ".join(parts)
|
|
|
|
|
|
def request_first_text(row: dict[str, Any]) -> str:
|
|
retry_firsts = request_retry_firsts_compact_text(row)
|
|
if retry_firsts:
|
|
return retry_firsts
|
|
return format_duration_ms_as_seconds(row["first_response_delay_ms"])
|
|
|
|
|
|
def request_retry_note(row: dict[str, Any]) -> str:
|
|
attempts = as_int(row.get("upstream_attempt_count"))
|
|
if attempts <= 1:
|
|
return ""
|
|
return str(row.get("error") or "").strip()
|
|
|
|
|
|
def request_reasoning_tokens_text(row: dict[str, Any]) -> str:
|
|
return format_count(row["reasoning_tokens"]) if row["reasoning_tokens"] is not None else "-"
|
|
|
|
|
|
def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None = None) -> tuple[Any, ...]:
|
|
limits = text_limits or {}
|
|
note_width = max(4, limits.get("note", 22))
|
|
retry_note = request_retry_note(row)
|
|
return (
|
|
str(row["seq"]),
|
|
short_text(request_id_text(row), limits.get("req_id", 18)) or "-",
|
|
short_text(response_id_text(row), limits.get("resp_id", 18)) or "-",
|
|
short_text(row["thread_id"], limits.get("thread", 18)) or "-",
|
|
short_time(row["started_at"]),
|
|
request_status_label(row),
|
|
short_text(row["path"], limits.get("path", 22)),
|
|
short_text(request_model_text(row), limits.get("model", 16)),
|
|
short_text(request_effort_text(row), limits.get("effort", 10)),
|
|
request_reasoning_tokens_text(row),
|
|
request_input_tokens_text(row),
|
|
request_output_tokens_text(row),
|
|
request_cached_tokens_text(row),
|
|
format_bytes(row.get("request_body_bytes")),
|
|
format_bytes(row.get("response_bytes_received")),
|
|
request_chunk_progress(row),
|
|
short_text(request_first_text(row), limits.get("first", 10)),
|
|
format_duration_ms_as_seconds(row["duration_ms"]),
|
|
request_updated_elapsed(row),
|
|
short_text(retry_note, note_width),
|
|
short_text(request_retry_round_text(row), limits.get("round", 10)),
|
|
)
|
|
|
|
|
|
def request_thread_color(thread_id: Any) -> str | None:
|
|
text = str(thread_id or "").strip()
|
|
if not text:
|
|
return None
|
|
digest = hashlib.blake2b(text.encode("utf-8"), digest_size=4).digest()
|
|
color_index = int.from_bytes(digest[:2], "big") % len(THREAD_COLOR_PALETTE)
|
|
return THREAD_COLOR_PALETTE[color_index]
|
|
|
|
|
|
def request_visible_cells(
|
|
row: dict[str, Any],
|
|
cells: tuple[Any, ...],
|
|
visible_column_keys: list[str],
|
|
) -> list[Any]:
|
|
visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in visible_column_keys]
|
|
try:
|
|
from rich.text import Text
|
|
except ImportError:
|
|
return visible_cells
|
|
thread_color = request_thread_color(row.get("thread_id"))
|
|
styled_cells: list[Any] = []
|
|
for column_key, cell in zip(visible_column_keys, visible_cells):
|
|
if column_key == "status":
|
|
styled_cells.append(Text(str(cell), style=request_status_style(row)))
|
|
elif thread_color and column_key in THREAD_COLOR_COLUMN_KEYS:
|
|
styled_cells.append(Text(str(cell), style=f"bold {thread_color}"))
|
|
else:
|
|
styled_cells.append(cell)
|
|
return styled_cells
|
|
|
|
|
|
def request_table_column_label(column_key: str) -> str:
|
|
column = REQUEST_TABLE_COLUMN_BY_KEY.get(column_key)
|
|
return str(column["label"]) if column else column_key
|
|
|
|
|
|
def request_sort_value(row: dict[str, Any], column_key: str) -> Any:
|
|
if column_key == "seq":
|
|
return parse_int_value(row.get("seq"))
|
|
if column_key == "req_id":
|
|
text = request_id_text(row)
|
|
return None if text == "-" else text.lower()
|
|
if column_key == "resp_id":
|
|
text = response_id_text(row)
|
|
return None if text == "-" else text.lower()
|
|
if column_key == "thread":
|
|
text = str(row.get("thread_id") or "").strip()
|
|
return text.lower() if text else None
|
|
if column_key == "started":
|
|
started = parse_datetime(row.get("started_at"))
|
|
return started.timestamp() if started else None
|
|
if column_key == "status":
|
|
return parse_int_value(row.get("status_code"))
|
|
if column_key == "path":
|
|
text = str(row.get("path") or "").strip()
|
|
return text.lower() if text else None
|
|
if column_key == "model":
|
|
text = request_model_text(row)
|
|
return text.lower() if text else None
|
|
if column_key == "effort":
|
|
text = str(row.get("reasoning_effort") or "").strip()
|
|
return text.lower() if text else None
|
|
if column_key == "reasoning":
|
|
return parse_int_value(row.get("reasoning_tokens"))
|
|
if column_key == "usage":
|
|
total_tokens = parse_int_value(row.get("total_tokens"))
|
|
if total_tokens is not None:
|
|
return total_tokens
|
|
input_tokens = parse_int_value(row.get("input_tokens")) or 0
|
|
output_tokens = parse_int_value(row.get("output_tokens")) or 0
|
|
cached_tokens = parse_int_value(row.get("cached_tokens")) or 0
|
|
combined = input_tokens + output_tokens + cached_tokens
|
|
return combined if combined else None
|
|
if column_key == "req_bytes":
|
|
return parse_int_value(row.get("request_body_bytes"))
|
|
if column_key == "resp_bytes":
|
|
return parse_int_value(row.get("response_bytes_received"))
|
|
if column_key == "chunks":
|
|
return parse_int_value(row.get("stream_chunk_count"))
|
|
if column_key == "first":
|
|
return parse_int_value(row.get("first_response_delay_ms"))
|
|
if column_key == "duration":
|
|
return parse_int_value(row.get("duration_ms"))
|
|
if column_key == "updated":
|
|
updated = parse_datetime(request_updated_at(row))
|
|
return updated.timestamp() if updated else None
|
|
if column_key == "note":
|
|
text = request_retry_note(row)
|
|
return text.lower() if text else None
|
|
if column_key == "round":
|
|
round_number = parse_int_value(row.get("reasoning_retry_current_round"))
|
|
width = parse_int_value(row.get("reasoning_retry_current_width"))
|
|
if round_number is None and width is None:
|
|
return None
|
|
return (round_number or 0, width or 0)
|
|
return None
|
|
|
|
|
|
def sort_request_rows(
|
|
rows: list[dict[str, Any]],
|
|
sort_column: str = DEFAULT_REQUEST_TABLE_SORT_COLUMN,
|
|
*,
|
|
reverse: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
column_key = normalize_request_table_sort_column(sort_column)
|
|
known: list[tuple[Any, dict[str, Any]]] = []
|
|
missing: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
value = request_sort_value(row, column_key)
|
|
if value is None or value == "":
|
|
missing.append(row)
|
|
continue
|
|
known.append((value, row))
|
|
known.sort(key=lambda item: item[0], reverse=reverse)
|
|
return [row for _, row in known] + missing
|
|
|
|
|
|
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 request_headers(access_key: str = "", *, accept: str = "application/json") -> dict[str, str]:
|
|
headers = {"Accept": accept}
|
|
if str(access_key or "").strip():
|
|
headers["x-codex-retry-gateway-key"] = str(access_key).strip()
|
|
return headers
|
|
|
|
|
|
def fetch_payload(api_url: str, timeout: int, access_key: str = "") -> dict[str, Any]:
|
|
req = urllib.request.Request(api_url, headers=request_headers(access_key))
|
|
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,
|
|
access_key: str = "",
|
|
*,
|
|
allow_not_found: bool = False,
|
|
) -> tuple[dict[str, Any], str]:
|
|
if not str(url or "").strip():
|
|
return {}, ""
|
|
try:
|
|
return fetch_payload(url, timeout, access_key), ""
|
|
except urllib.error.HTTPError as exc:
|
|
if allow_not_found and exc.code == 404:
|
|
return {}, ""
|
|
return {}, str(exc)
|
|
except Exception as exc:
|
|
return {}, str(exc)
|
|
|
|
|
|
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 "-"
|
|
image_active = config.get("image_profile_name") or state.get("image_profile_name") or "-"
|
|
request_total = format_count(metrics.get("total_proxy_request_count"))
|
|
inspected = format_count(metrics.get("inspected_response_count"))
|
|
matched = format_count(metrics.get("matched_response_count"))
|
|
reasoning_516 = format_count(metrics.get("reasoning_516_count"))
|
|
return f"{listen} | text {active} | image {image_active} | upstream {upstream} | req {request_total} | inspected {inspected} | matched {matched} | 516 {reasoning_516}"
|
|
|
|
|
|
def state_summary(payload: dict[str, Any]) -> str:
|
|
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 '-'}",
|
|
f"image profiles {paths.get('image_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} | text {config.get('profile_name') or state.get('profile_name') or '-'} | image {config.get('image_profile_name') or state.get('image_profile_name') or '-'} | "
|
|
f"516 {metrics.get('reasoning_516_count') or 0} | latest seq {metrics.get('total_proxy_request_count') or 0}"
|
|
)
|
|
|
|
|
|
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",
|
|
"reasoning_effort",
|
|
"lifecycle_state",
|
|
"discard_reason",
|
|
"error",
|
|
"upstream_origin",
|
|
"upstream_path",
|
|
"status_code",
|
|
"upstream_status_code",
|
|
"reasoning_tokens",
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"total_tokens",
|
|
"cached_tokens",
|
|
"reasoning_retry_current_round",
|
|
"reasoning_retry_current_width",
|
|
)
|
|
).lower()
|
|
current_firsts = json.dumps(row.get("reasoning_retry_current_firsts") or [], ensure_ascii=False).lower()
|
|
haystack = f"{haystack} {current_firsts}"
|
|
return needle in haystack
|
|
|
|
|
|
def request_retry_first_received_at(row: dict[str, Any]) -> Any:
|
|
latest_value = None
|
|
latest_at = None
|
|
for first in row.get("reasoning_retry_current_firsts") or []:
|
|
if not isinstance(first, dict):
|
|
continue
|
|
value = first.get("first_response_at")
|
|
parsed = parse_datetime(value)
|
|
if parsed and (latest_at is None or parsed > latest_at):
|
|
latest_value = value
|
|
latest_at = parsed
|
|
return latest_value
|
|
|
|
|
|
def request_has_received_first(row: dict[str, Any]) -> bool:
|
|
if parse_datetime(row.get("first_response_at")):
|
|
return True
|
|
for first in row.get("reasoning_retry_current_firsts") or []:
|
|
if not isinstance(first, dict):
|
|
continue
|
|
if parse_datetime(first.get("first_response_at")) or parse_int_value(first.get("first_delay_ms")) is not None:
|
|
return True
|
|
return False
|
|
|
|
|
|
def request_lifecycle_label(row: dict[str, Any]) -> str:
|
|
state = str(row.get("lifecycle_state") or "").strip().lower()
|
|
if request_is_discarded(row):
|
|
return "discarded"
|
|
if state in {"finish", "finished", "complete", "completed"} or parse_datetime(row.get("finished_at")):
|
|
return "finished"
|
|
if state in {"receive_first", "streaming"} or request_has_received_first(row):
|
|
return "streaming"
|
|
return "waiting"
|
|
|
|
|
|
def request_is_discarded(row: dict[str, Any]) -> bool:
|
|
state = str(row.get("lifecycle_state") or "").strip().lower()
|
|
if bool(row.get("discarded")) or state in {"discarded", "abandoned", "cancelled", "canceled"}:
|
|
return True
|
|
error = str(row.get("error") or "").strip().lower()
|
|
return any(
|
|
marker in error
|
|
for marker in (
|
|
"client disconnected",
|
|
"client closed",
|
|
"cancelled by client",
|
|
"canceled by client",
|
|
"request aborted",
|
|
)
|
|
)
|
|
|
|
|
|
def request_activity_at(row: dict[str, Any]) -> Any:
|
|
candidates = [row.get("last_activity_at"), row.get("first_response_at"), request_retry_first_received_at(row), row.get("started_at")]
|
|
parsed_candidates = [(parse_datetime(value), value) for value in candidates]
|
|
valid_candidates = [(parsed, value) for parsed, value in parsed_candidates if parsed]
|
|
if not valid_candidates:
|
|
return None
|
|
return max(valid_candidates, key=lambda item: item[0])[1]
|
|
|
|
|
|
def request_activity_age_seconds(row: dict[str, Any], now: dt.datetime | None = None) -> float | None:
|
|
activity_at = parse_datetime(request_activity_at(row))
|
|
if not activity_at:
|
|
return None
|
|
current = now or dt.datetime.now(activity_at.tzinfo)
|
|
if current.tzinfo is None and activity_at.tzinfo is not None:
|
|
current = current.replace(tzinfo=activity_at.tzinfo)
|
|
return max(0.0, (current - activity_at).total_seconds())
|
|
|
|
|
|
def request_status_label(row: dict[str, Any], now: dt.datetime | None = None) -> str:
|
|
if request_is_discarded(row):
|
|
return "discarded"
|
|
status_code = parse_int_value(row.get("status_code"))
|
|
if status_code is not None:
|
|
return f"HTTP {status_code}"
|
|
lifecycle = request_lifecycle_label(row)
|
|
if lifecycle == "finished":
|
|
return "finished"
|
|
age_seconds = request_activity_age_seconds(row, now)
|
|
if age_seconds is not None and age_seconds >= REQUEST_STATUS_STALLED_AFTER_SECONDS:
|
|
return f"stalled {format_elapsed_seconds(age_seconds)}"
|
|
if age_seconds is not None and age_seconds >= REQUEST_STATUS_SLOW_AFTER_SECONDS:
|
|
return f"{lifecycle} {format_elapsed_seconds(age_seconds)}"
|
|
return lifecycle
|
|
|
|
|
|
def request_status_style(row: dict[str, Any], now: dt.datetime | None = None) -> str:
|
|
label = request_status_label(row, now).lower()
|
|
if label.startswith(("discarded", "stalled")):
|
|
return "bold #ff5f5f"
|
|
if label.startswith("waiting"):
|
|
return "bold #ffaf5f"
|
|
if label.startswith("streaming"):
|
|
return "bold #5fd7ff"
|
|
if label.startswith("http "):
|
|
status_code = parse_int_value(row.get("status_code")) or 0
|
|
if 200 <= status_code < 400:
|
|
return "bold #87d75f"
|
|
if 400 <= status_code < 500:
|
|
return "bold #ffaf5f"
|
|
return "bold #ff5f5f"
|
|
if row.get("error"):
|
|
return "bold #ff5f5f"
|
|
return "#b0b0b0"
|
|
|
|
|
|
def status_symbol(entry: dict[str, Any]) -> str:
|
|
"""Compatibility helper for callers that previously consumed a status marker."""
|
|
return request_status_label(entry)
|
|
|
|
|
|
def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
|
|
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
|
|
usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
|
|
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 ""),
|
|
"reasoning_effort": str(entry.get("reasoning_effort") or ""),
|
|
"reasoning_summary": str(entry.get("reasoning_summary") or ""),
|
|
"request_stream": bool(entry.get("request_stream")),
|
|
"response_stream": bool(entry.get("response_stream")),
|
|
"lifecycle_state": str(entry.get("lifecycle_state") or ""),
|
|
"discarded": bool(entry.get("discarded")),
|
|
"discard_reason": str(entry.get("discard_reason") or ""),
|
|
"matched": bool(entry.get("matched")),
|
|
"inspected": bool(entry.get("inspected")),
|
|
"status_code": entry.get("status_code"),
|
|
"upstream_status_code": entry.get("upstream_status_code"),
|
|
"reasoning_tokens": entry.get("reasoning_tokens", usage.get("reasoning_tokens")),
|
|
"input_tokens": entry.get("input_tokens", usage.get("input_tokens")),
|
|
"output_tokens": entry.get("output_tokens", usage.get("output_tokens")),
|
|
"total_tokens": entry.get("total_tokens", usage.get("total_tokens")),
|
|
"cached_tokens": entry.get("cached_tokens", usage.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_at": entry.get("first_response_at"),
|
|
"first_response_delay_ms": entry.get("first_response_delay_ms"),
|
|
"reasoning_retry_current_round": parse_int_value(entry.get("reasoning_retry_current_round")),
|
|
"reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")),
|
|
"reasoning_retry_current_firsts": normalize_request_retry_firsts(entry.get("reasoning_retry_current_firsts")),
|
|
"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_route": str(entry.get("upstream")["route"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_route") or ""),
|
|
"upstream_auth_mode": str(entry.get("upstream")["auth_mode"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_mode") or ""),
|
|
"upstream_auth_source": str(entry.get("upstream")["auth_source"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_source") or ""),
|
|
"raw": entry,
|
|
}
|
|
)
|
|
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"req {request_id_text(row)}",
|
|
f"resp {response_id_text(row)}",
|
|
f"thread {row.get('thread_id') or '-'}",
|
|
f"started {short_time(row['started_at'])}",
|
|
f"{row['method']} {row['path']}",
|
|
f"profile {row.get('profile_name') or '-'}",
|
|
f"status {request_status_label(row)}",
|
|
f"upstream {row['upstream_status_code'] or '-'}",
|
|
f"attempts {row['upstream_attempt_count'] or 0}",
|
|
f"first {request_first_text(row)}",
|
|
f"duration {format_duration_ms_as_seconds(row['duration_ms'])}",
|
|
f"request {format_bytes(row.get('request_body_bytes'))}",
|
|
f"response {format_bytes(row.get('response_bytes_received'))}",
|
|
f"chunks {row['stream_chunk_count'] or 0}",
|
|
f"life {request_lifecycle_label(row)}",
|
|
f"finished {short_time(row['finished_at'])}",
|
|
f"updated {request_updated_elapsed(row)}",
|
|
]
|
|
retry_round = request_retry_round_text(row)
|
|
if retry_round != "-":
|
|
bits.append(f"retry round {retry_round}")
|
|
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_effort"):
|
|
bits.append(f"effort {row['reasoning_effort']}")
|
|
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("discard_reason"):
|
|
bits.append(f"discard {row['discard_reason']}")
|
|
if row.get("upstream_origin"):
|
|
bits.append(f"origin {row['upstream_origin']}")
|
|
if row.get("upstream_path"):
|
|
bits.append(f"upstream path {row['upstream_path']}")
|
|
if row.get("upstream_route"):
|
|
bits.append(f"route {row['upstream_route']}")
|
|
if row.get("upstream_auth_mode"):
|
|
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
|
|
retry_wave = request_retry_wave_summary(row)
|
|
if retry_wave:
|
|
bits.append(f"current wave {retry_wave}")
|
|
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 normalize_image_profile_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
|
|
needle = filter_text.strip().lower()
|
|
rows = []
|
|
for profile in payload.get("image_profiles") or []:
|
|
if not isinstance(profile, dict):
|
|
continue
|
|
summary = profile.get("summary") if isinstance(profile.get("summary"), dict) else {}
|
|
haystack = " ".join(
|
|
str(profile.get(key) or "")
|
|
for key in ("name", "file_path", "active")
|
|
).lower() + " " + " ".join(str(summary.get(key) or "") for key in summary.keys()).lower()
|
|
if needle and needle not in haystack:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"name": str(profile.get("name") or ""),
|
|
"active": bool(profile.get("active")),
|
|
"file_path": str(profile.get("file_path") or ""),
|
|
"base_url": str(summary.get("base_url") or "-"),
|
|
"auth_mode": str(summary.get("auth_mode") or "-"),
|
|
"auth_env": str(summary.get("auth_env") or "-"),
|
|
"auth_file": str(summary.get("auth_file") or ""),
|
|
"manual_secret_file": str(summary.get("manual_secret_file") or ""),
|
|
"auth_json_path": str(summary.get("auth_json_path") or ""),
|
|
"auth_json_key": str(summary.get("auth_json_key") or "-"),
|
|
"auth_source": str(summary.get("auth_source") or "disabled"),
|
|
"raw": profile,
|
|
}
|
|
)
|
|
rows.sort(key=lambda item: (not item["active"], item["name"].lower()))
|
|
return rows
|
|
|
|
|
|
def render_profile_detail(row: dict[str, Any]) -> str:
|
|
bits = [
|
|
f"{row['name']}",
|
|
"active" if row["active"] else "inactive",
|
|
f"listen {row['listen_host']}:{row['listen_port']}",
|
|
f"text upstream {short_text(row['upstream_base_url'], 64)}",
|
|
f"text auth {row['auth_mode']}/{row['auth_source']}",
|
|
]
|
|
if row.get("request_history_limit") is not None:
|
|
bits.append(f"history {row['request_history_limit']}")
|
|
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 render_image_profile_detail(row: dict[str, Any]) -> str:
|
|
bits = [
|
|
f"image {row['name']}",
|
|
"active" if row["active"] else "inactive",
|
|
f"upstream {short_text(row['base_url'], 64)}",
|
|
f"auth {row['auth_mode']}/{row['auth_source']}",
|
|
]
|
|
if row.get("auth_file"):
|
|
bits.append("auth file configured")
|
|
if row.get("auth_json_path"):
|
|
bits.append("auth json configured")
|
|
return " | ".join(bits)
|
|
|
|
|
|
def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
|
|
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
|
|
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
|
|
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 image_profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
|
|
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
|
|
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
|
|
return {
|
|
"name": row.get("name") or "",
|
|
"base_url": form.get("base_url") or row.get("base_url") or "",
|
|
"auth_mode": form.get("auth_mode") or row.get("auth_mode") or "fixed_bearer",
|
|
"auth_env": form.get("auth_env") or "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
|
|
"auth_file": form.get("auth_file") or "",
|
|
"manual_secret": "",
|
|
"manual_secret_file": form.get("manual_secret_file") or "",
|
|
"manual_secret_configured": parse_bool_value(form.get("manual_secret_configured"), False),
|
|
"auth_json_path": form.get("auth_json_path") or "",
|
|
"auth_json_key": form.get("auth_json_key") or "OPENAI_API_KEY",
|
|
}
|
|
|
|
|
|
def image_profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
state = image_profile_form_state(row)
|
|
return {
|
|
"name": str(state.get("name") or "").strip(),
|
|
"base_url": str(state.get("base_url") or "").strip(),
|
|
"auth_mode": str(state.get("auth_mode") or "fixed_bearer").strip(),
|
|
"auth_env": str(state.get("auth_env") or "").strip(),
|
|
"auth_file": str(state.get("auth_file") or "").strip(),
|
|
"manual_secret": "",
|
|
"manual_secret_file": str(state.get("manual_secret_file") or "").strip(),
|
|
"manual_secret_configured": bool(state.get("manual_secret_configured")),
|
|
"auth_json_path": str(state.get("auth_json_path") or "").strip(),
|
|
"auth_json_key": str(state.get("auth_json_key") or "").strip(),
|
|
}
|
|
|
|
|
|
def image_profile_editor_document(row: dict[str, Any]) -> str:
|
|
state = image_profile_form_state(row)
|
|
return "\n".join(
|
|
[
|
|
"# Edit the selected codex-retry-gateway image profile and save.",
|
|
"# base_url applies to both /images/* and /v1/images/*.",
|
|
"# Leave manual_secret empty to keep the current image secret file.",
|
|
"# Changing name creates a new image profile file; it does not delete the old one.",
|
|
"",
|
|
f"name = {toml_string(state.get('name'))}",
|
|
f"base_url = {toml_string(state.get('base_url'))}",
|
|
f"auth_mode = {toml_string(state.get('auth_mode'))}",
|
|
f"auth_env = {toml_string(state.get('auth_env'))}",
|
|
f"auth_file = {toml_string(state.get('auth_file'))}",
|
|
'manual_secret = ""',
|
|
f"manual_secret_file = {toml_string(state.get('manual_secret_file'))}",
|
|
f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}",
|
|
f"auth_json_path = {toml_string(state.get('auth_json_path'))}",
|
|
f"auth_json_key = {toml_string(state.get('auth_json_key'))}",
|
|
"",
|
|
]
|
|
)
|
|
|
|
|
|
def image_profile_payload_from_editor_text(text: str) -> dict[str, Any]:
|
|
data = tomllib.loads(text)
|
|
if not isinstance(data, dict):
|
|
raise ValueError("editor payload must be a TOML object")
|
|
return {
|
|
"name": str(data.get("name") or "").strip(),
|
|
"base_url": str(data.get("base_url") or "").strip(),
|
|
"auth_mode": str(data.get("auth_mode") or "fixed_bearer").strip(),
|
|
"auth_env": str(data.get("auth_env") or "").strip(),
|
|
"auth_file": str(data.get("auth_file") or "").strip(),
|
|
"manual_secret": str(data.get("manual_secret") or "").strip(),
|
|
"manual_secret_file": str(data.get("manual_secret_file") or "").strip(),
|
|
"manual_secret_configured": parse_bool_value(data.get("manual_secret_configured"), False),
|
|
"auth_json_path": str(data.get("auth_json_path") or "").strip(),
|
|
"auth_json_key": str(data.get("auth_json_key") or "").strip(),
|
|
}
|
|
|
|
|
|
def edit_image_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | None:
|
|
original_text = image_profile_editor_document(row)
|
|
editor_command = resolve_editor_command()
|
|
fd, raw_path = tempfile.mkstemp(prefix=f"codex-retry-image-profile-{row.get('name') or 'profile'}-", suffix=".toml")
|
|
os.close(fd)
|
|
temp_path = Path(raw_path)
|
|
temp_path.write_text(original_text, encoding="utf-8")
|
|
try:
|
|
result = subprocess.run(editor_command + [str(temp_path)], check=False)
|
|
edited_text = temp_path.read_text(encoding="utf-8")
|
|
if edited_text == original_text:
|
|
return None
|
|
payload = image_profile_payload_from_editor_text(edited_text)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"editor exited with status {result.returncode}")
|
|
return payload
|
|
except Exception as exc:
|
|
raise RuntimeError(f"{exc}; kept draft at {temp_path}") from exc
|
|
finally:
|
|
if temp_path.exists():
|
|
try:
|
|
if temp_path.read_text(encoding="utf-8") == original_text:
|
|
temp_path.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def edit_request_table_preferences_with_editor(preferences: dict[str, Any]) -> dict[str, Any] | None:
|
|
original_text = request_table_preferences_document(preferences)
|
|
editor_command = resolve_editor_command()
|
|
fd, raw_path = tempfile.mkstemp(prefix="codex-retry-columns-", suffix=".txt")
|
|
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
|
|
preferences = request_table_preferences_from_text(edited_text)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"editor exited with status {result.returncode}")
|
|
return preferences
|
|
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], access_key: str = "") -> tuple[int, dict[str, Any]]:
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={**request_headers(access_key), "Content-Type": "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, access_key: str = "") -> tuple[int, dict[str, Any]]:
|
|
req = urllib.request.Request(url, headers=request_headers(access_key), 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 gateway_supports_image_profiles(status_payload: dict[str, Any]) -> bool:
|
|
config = status_payload.get("config") if isinstance(status_payload.get("config"), dict) else {}
|
|
state = status_payload.get("state") if isinstance(status_payload.get("state"), dict) else {}
|
|
paths = status_payload.get("paths") if isinstance(status_payload.get("paths"), dict) else {}
|
|
return (
|
|
"image_profile_name" in config
|
|
or "image_profile_name" in state
|
|
or "image_profiles_dir" in paths
|
|
)
|
|
|
|
|
|
def fetch_dashboard_snapshot(
|
|
gateway_url: str,
|
|
status_url: str,
|
|
timeout: int,
|
|
*,
|
|
access_key: str = "",
|
|
filter_text: str = "",
|
|
current_view: str = "overview",
|
|
) -> dict[str, Any]:
|
|
gateway_root = gateway_admin_url(gateway_url)
|
|
status_endpoint = status_url or gateway_status_url(gateway_root)
|
|
|
|
status_payload, status_error = fetch_optional_payload(status_endpoint, timeout, access_key)
|
|
payload = status_payload if isinstance(status_payload, dict) else {}
|
|
endpoints = {
|
|
"logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}),
|
|
"requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}),
|
|
"profiles": build_api_url(gateway_root, "/api/profiles"),
|
|
}
|
|
image_profiles_supported = gateway_supports_image_profiles(payload)
|
|
if image_profiles_supported:
|
|
endpoints["image_profiles"] = build_api_url(gateway_root, "/api/image-profiles")
|
|
|
|
def fetch_optional(url: str, *, allow_not_found: bool = False) -> tuple[dict[str, Any], str]:
|
|
return fetch_optional_payload(url, timeout, access_key, allow_not_found=allow_not_found)
|
|
|
|
with ThreadPoolExecutor(max_workers=len(endpoints)) as pool:
|
|
futures = {
|
|
name: pool.submit(fetch_optional, url, allow_not_found=name == "image_profiles")
|
|
for name, url in endpoints.items()
|
|
}
|
|
logs_payload, logs_error = futures["logs"].result()
|
|
requests_payload, requests_error = futures["requests"].result()
|
|
profiles_payload, profiles_error = futures["profiles"].result()
|
|
if image_profiles_supported:
|
|
image_profiles_payload, image_profiles_error = futures["image_profiles"].result()
|
|
else:
|
|
image_profiles_payload, image_profiles_error = {}, ""
|
|
|
|
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 {}
|
|
image_profiles_source = image_profiles_payload if isinstance(image_profiles_payload, dict) else {}
|
|
errors = [message for message in (status_error, logs_error, requests_error, profiles_error, image_profiles_error) if message]
|
|
return {
|
|
"payload": payload,
|
|
"status_payload": payload,
|
|
"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 "",
|
|
),
|
|
"image_profiles": normalize_image_profile_rows(
|
|
image_profiles_source,
|
|
filter_text if current_view == "profiles" else "",
|
|
),
|
|
"active_profile_name": (
|
|
str(
|
|
payload.get("config", {}).get("profile_name")
|
|
or payload.get("state", {}).get("profile_name")
|
|
or profiles_source.get("active_profile")
|
|
or ""
|
|
).strip()
|
|
),
|
|
"active_image_profile_name": (
|
|
str(
|
|
payload.get("config", {}).get("image_profile_name")
|
|
or payload.get("state", {}).get("image_profile_name")
|
|
or image_profiles_source.get("active_image_profile")
|
|
or ""
|
|
).strip()
|
|
),
|
|
}
|
|
|
|
|
|
def run_textual(
|
|
api_url: str,
|
|
status_url: str,
|
|
refresh_seconds: int,
|
|
timeout: int,
|
|
version_message: str = "",
|
|
access_key: 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"),
|
|
("i", "toggle_profile_kind", "Text/Image"),
|
|
("m", "toggle_request_table_density", "Wide/Compact"),
|
|
("c", "edit_request_columns", "Columns"),
|
|
("shift+left", "scroll_table_left", "Scroll Left"),
|
|
("shift+right", "scroll_table_right", "Scroll Right"),
|
|
("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.image_profiles: list[dict[str, Any]] = []
|
|
self.request_by_key: dict[str, dict[str, Any]] = {}
|
|
self.profile_by_key: dict[str, dict[str, Any]] = {}
|
|
self.image_profile_by_key: dict[str, dict[str, Any]] = {}
|
|
self.log_by_key: dict[str, dict[str, Any]] = {}
|
|
self.current_view = "overview"
|
|
self.filter_mode = "requests"
|
|
self.last_request_key = ""
|
|
self.loading = False
|
|
self.executor = ThreadPoolExecutor(max_workers=1)
|
|
self.refresh_generation = 0
|
|
self.active_profile_name = ""
|
|
self.active_image_profile_name = ""
|
|
self.profile_kind = "text"
|
|
self.visible_tables = {
|
|
"overview": "requests",
|
|
"requests": "requests",
|
|
"logs": "logs",
|
|
"profiles": "profiles",
|
|
}
|
|
self.request_table_column_keys: dict[str, Any] = {}
|
|
self.request_table_preferences = load_request_table_preferences()
|
|
self.request_table_density = "compact"
|
|
self.request_table_width_profiles = {
|
|
density: dict(widths) for density, widths in REQUEST_TABLE_WIDTH_PROFILES.items()
|
|
}
|
|
|
|
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
|
|
self._rebuild_request_table_columns()
|
|
|
|
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
|
|
self._rebuild_profile_table_columns()
|
|
|
|
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 | i text/image | / filter | r refresh | m compact/wide | c columns | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream"
|
|
)
|
|
self._focus_table()
|
|
self._update_table_headers()
|
|
|
|
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:
|
|
visible_columns = request_table_visible_columns(self.request_table_preferences)
|
|
counts = (
|
|
f"requests {len(self.requests)} | logs {len(self.logs)} | text profiles {len(self.profiles)} | image profiles {len(self.image_profiles)} | active {self.profile_kind} | "
|
|
f"req cols {len(visible_columns)}/{len(REQUEST_TABLE_COLUMNS)} | c edit columns"
|
|
)
|
|
self.query_one("#tables", Static).update(counts)
|
|
|
|
def _current_filter(self) -> str:
|
|
return self.query_one("#filter", Input).value.strip()
|
|
|
|
def _visible_request_column_keys(self) -> list[str]:
|
|
return request_table_visible_columns(self.request_table_preferences)
|
|
|
|
def _request_table_actions_enabled(self) -> bool:
|
|
if self.visible_tables.get(self.current_view, "requests") == "requests":
|
|
return True
|
|
self._set_status("switch to requests or overview first")
|
|
return False
|
|
|
|
def _persist_request_table_preferences(self) -> bool:
|
|
self.request_table_preferences = normalize_request_table_preferences(self.request_table_preferences)
|
|
try:
|
|
write_request_table_preferences(self.request_table_preferences)
|
|
except Exception as exc:
|
|
self._set_status(f"save request table prefs error: {exc}")
|
|
return False
|
|
return True
|
|
|
|
def _request_table_column_width(self, column_key: str, density: str | None = None) -> int:
|
|
normalized = normalize_request_table_preferences(self.request_table_preferences)
|
|
override = request_table_column_width_value(normalized.get("widths", {}).get(column_key))
|
|
if override is not None:
|
|
return override
|
|
active_density = density or self.request_table_density
|
|
width_profile = self.request_table_width_profiles[active_density]
|
|
column = REQUEST_TABLE_COLUMN_BY_KEY[column_key]
|
|
return width_profile[column["width_key"]]
|
|
|
|
def _request_row_key(self, row: dict[str, Any]) -> str:
|
|
return f"{row['seq']}:{row['request_id']}"
|
|
|
|
def _selected_request_key(self) -> str:
|
|
row = self._selected_request_row()
|
|
if row:
|
|
return self._request_row_key(row)
|
|
return self.last_request_key
|
|
|
|
def _select_request_row(self, row_key: str) -> bool:
|
|
if not row_key:
|
|
return False
|
|
table = self.query_one("#requests_table", DataTable)
|
|
for index, row in enumerate(self.requests):
|
|
if self._request_row_key(row) == row_key:
|
|
return self._select_table_row(table, index)
|
|
return False
|
|
|
|
def _rebuild_request_table_columns(self) -> None:
|
|
table = self.query_one("#requests_table", DataTable)
|
|
selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0
|
|
selected_row_key = self._selected_request_key()
|
|
table.clear(columns=True)
|
|
self.request_table_column_keys = {}
|
|
for column_key in self._visible_request_column_keys():
|
|
column = REQUEST_TABLE_COLUMN_BY_KEY[column_key]
|
|
width = self._request_table_column_width(column_key)
|
|
self.request_table_column_keys[column_key] = table.add_column(
|
|
column["label"],
|
|
width=width,
|
|
key=column_key,
|
|
)
|
|
if self.requests:
|
|
self.render_requests()
|
|
if not self._select_request_row(selected_row_key):
|
|
self._select_table_row(table, min(selected_row_index, len(self.requests) - 1))
|
|
else:
|
|
self.request_by_key = {}
|
|
|
|
def _profile_rows(self) -> list[dict[str, Any]]:
|
|
return self.image_profiles if self.profile_kind == "image" else self.profiles
|
|
|
|
def _active_profile_name(self) -> str:
|
|
return self.active_image_profile_name if self.profile_kind == "image" else self.active_profile_name
|
|
|
|
def _profile_api_base(self) -> str:
|
|
return "/api/image-profiles" if self.profile_kind == "image" else "/api/profiles"
|
|
|
|
def _profile_kind_label(self) -> str:
|
|
return "image profile" if self.profile_kind == "image" else "text profile"
|
|
|
|
def _rebuild_profile_table_columns(self) -> None:
|
|
table = self.query_one("#profiles_table", DataTable)
|
|
table.clear(columns=True)
|
|
if self.profile_kind == "image":
|
|
table.add_columns("Name", "Active", "Image Upstream", "Image Auth", "Source")
|
|
else:
|
|
table.add_columns(
|
|
"Name",
|
|
"Active",
|
|
"Listen",
|
|
"Text Upstream",
|
|
"Text Auth",
|
|
"History",
|
|
"Reasoning",
|
|
"Source",
|
|
)
|
|
|
|
def _current_table_widget(self) -> DataTable | None:
|
|
table_id = self.visible_tables.get(self.current_view, "requests")
|
|
if table_id == "logs":
|
|
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)
|
|
rows = self._profile_rows()
|
|
if not rows:
|
|
return False
|
|
if profile_name:
|
|
for index, row in enumerate(rows):
|
|
if row["name"] == profile_name:
|
|
return self._select_table_row(table, index)
|
|
return self._select_table_row(table, 0)
|
|
|
|
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
|
|
if table.cursor_row >= len(self.requests):
|
|
return None
|
|
return self.requests[table.cursor_row]
|
|
|
|
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
|
|
rows = self._profile_rows()
|
|
if table.cursor_row >= len(rows):
|
|
return None
|
|
return rows[table.cursor_row]
|
|
|
|
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
|
|
if table.cursor_row >= len(self.logs):
|
|
return None
|
|
return self.logs[table.cursor_row]
|
|
|
|
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,
|
|
access_key=access_key,
|
|
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
|
|
selected_request_key = self._selected_request_key()
|
|
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.image_profiles = result["image_profiles"]
|
|
self.active_profile_name = result.get("active_profile_name") or ""
|
|
self.active_image_profile_name = result.get("active_image_profile_name") or ""
|
|
self.last_request_key = selected_request_key
|
|
self.loading = False
|
|
self.render_all()
|
|
if self.current_view == "profiles":
|
|
if not self._select_profile_row(self._active_profile_name()):
|
|
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":
|
|
row = self._selected_request_row()
|
|
if row:
|
|
self.render_detail(row, kind="request")
|
|
else:
|
|
self.query_one("#detail", Static).update("no requests")
|
|
elif self.current_view == "logs":
|
|
row = self._selected_log_row()
|
|
if row:
|
|
self.render_detail(row, kind="log")
|
|
else:
|
|
self.query_one("#detail", Static).update("no logs")
|
|
elif self.current_view == "profiles":
|
|
row = self._selected_profile_row()
|
|
if row:
|
|
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
|
|
else:
|
|
self.query_one("#detail", Static).update("no profiles")
|
|
|
|
def render_requests(self) -> None:
|
|
table = self.query_one("#requests_table", DataTable)
|
|
selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0
|
|
selected_row_key = self._selected_request_key()
|
|
text_limits = self._request_text_limits(table)
|
|
visible_column_keys = self._visible_request_column_keys()
|
|
table.clear()
|
|
self.request_by_key = {}
|
|
for row in self.requests:
|
|
key = self._request_row_key(row)
|
|
self.request_by_key[key] = row
|
|
cells = request_row_cells(row, text_limits)
|
|
visible_cells = request_visible_cells(row, cells, visible_column_keys)
|
|
table.add_row(*visible_cells, key=key)
|
|
if self.requests:
|
|
if not self._select_request_row(selected_row_key):
|
|
self._select_table_row(table, min(selected_row_index, len(self.requests) - 1))
|
|
|
|
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()
|
|
rows = self._profile_rows()
|
|
if self.profile_kind == "image":
|
|
self.image_profile_by_key = {}
|
|
else:
|
|
self.profile_by_key = {}
|
|
for row in rows:
|
|
key = row["name"]
|
|
if self.profile_kind == "image":
|
|
self.image_profile_by_key[key] = row
|
|
table.add_row(
|
|
row["name"],
|
|
"yes" if row["active"] else "",
|
|
short_text(row["base_url"], 36),
|
|
f"{row['auth_mode']}/{row['auth_source']}",
|
|
short_text(row["file_path"], 36),
|
|
key=key,
|
|
)
|
|
else:
|
|
self.profile_by_key[key] = row
|
|
table.add_row(
|
|
row["name"],
|
|
"yes" if row["active"] else "",
|
|
f"{row['listen_host']}:{row['listen_port']}",
|
|
short_text(row["upstream_base_url"], 28),
|
|
f"{row['auth_mode']}/{row['auth_source']}",
|
|
str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"),
|
|
short_text(row["reasoning_equals"], 12),
|
|
short_text(row["file_path"], 32),
|
|
key=key,
|
|
)
|
|
if self.current_view == "profiles" and self._active_profile_name():
|
|
self._select_profile_row(self._active_profile_name())
|
|
|
|
def render_detail(self, row: dict[str, Any], *, kind: str) -> None:
|
|
if kind == "request":
|
|
self.query_one("#detail", Static).update(render_request_detail(row))
|
|
elif kind == "profile":
|
|
self.query_one("#detail", Static).update(render_profile_detail(row))
|
|
elif kind == "image_profile":
|
|
self.query_one("#detail", Static).update(render_image_profile_detail(row))
|
|
else:
|
|
message = str(row.get("message") or "").strip()
|
|
self.query_one("#detail", Static).update(f"seq {row['seq']} | {short_time(row['at'])} | {message}")
|
|
|
|
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_toggle_profile_kind(self) -> None:
|
|
self.profile_kind = "image" if self.profile_kind == "text" else "text"
|
|
self._rebuild_profile_table_columns()
|
|
self.render_profiles()
|
|
if self.current_view == "profiles":
|
|
if not self._select_profile_row(self._active_profile_name()):
|
|
self._select_profile_row("")
|
|
row = self._selected_profile_row()
|
|
if row:
|
|
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
|
|
self._update_table_headers()
|
|
self._set_status(f"profile view {self.profile_kind}")
|
|
|
|
def action_newest_request(self) -> None:
|
|
if self.requests:
|
|
self.render_detail(self.requests[0], kind="request")
|
|
|
|
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 _request_text_limits(self, table: DataTable) -> dict[str, int]:
|
|
width_profile = self.request_table_width_profiles[self.request_table_density]
|
|
limits = {
|
|
"req_id": width_profile["req_id"],
|
|
"resp_id": width_profile["resp_id"],
|
|
"thread": width_profile["thread"],
|
|
"path": width_profile["path"],
|
|
"model": width_profile["model"],
|
|
"effort": width_profile["effort"],
|
|
"first": width_profile["first"],
|
|
"note": width_profile["note"],
|
|
"round": width_profile["round"],
|
|
}
|
|
for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "first", "note", "round"):
|
|
key = self.request_table_column_keys.get(name)
|
|
column = table.columns.get(key) if key is not None else None
|
|
if column is None or not getattr(column, "width", 0):
|
|
continue
|
|
limits[name] = int(column.width)
|
|
return limits
|
|
|
|
def _rerender_requests_after_layout_change(self) -> None:
|
|
self.render_requests()
|
|
self._update_table_headers()
|
|
if self.current_view not in {"overview", "requests"}:
|
|
return
|
|
row = self._selected_request_row()
|
|
if row:
|
|
self.render_detail(row, kind="request")
|
|
else:
|
|
self.query_one("#detail", Static).update("no requests")
|
|
|
|
def _apply_request_table_width_profile(self, density: str) -> None:
|
|
table = self.query_one("#requests_table", DataTable)
|
|
for name, key in self.request_table_column_keys.items():
|
|
column = table.columns.get(key)
|
|
if column is None:
|
|
continue
|
|
column.width = self._request_table_column_width(name, density)
|
|
self.request_table_density = density
|
|
self._rerender_requests_after_layout_change()
|
|
self._set_status(f"request table mode {density}")
|
|
|
|
def action_toggle_request_table_density(self) -> None:
|
|
next_density = "wide" if self.request_table_density == "compact" else "compact"
|
|
self._apply_request_table_width_profile(next_density)
|
|
|
|
def action_edit_request_columns(self) -> None:
|
|
try:
|
|
with self.suspend():
|
|
updated = edit_request_table_preferences_with_editor(self.request_table_preferences)
|
|
if updated is None:
|
|
self._set_status("request columns unchanged")
|
|
return
|
|
self.request_table_preferences = updated
|
|
if not self._persist_request_table_preferences():
|
|
return
|
|
self._rebuild_request_table_columns()
|
|
self._rerender_requests_after_layout_change()
|
|
self._set_status(
|
|
f"request columns saved -> {request_table_preferences_file_path()} | "
|
|
f"{len(self._visible_request_column_keys())}/{len(REQUEST_TABLE_COLUMNS)} shown"
|
|
)
|
|
except Exception as exc:
|
|
self._set_status(f"request columns error: {exc}")
|
|
|
|
def action_scroll_table_left(self) -> None:
|
|
table = self._current_table_widget()
|
|
if table:
|
|
table.action_scroll_left()
|
|
|
|
def action_scroll_table_right(self) -> None:
|
|
table = self._current_table_widget()
|
|
if table:
|
|
table.action_scroll_right()
|
|
|
|
def action_open_upstream(self) -> None:
|
|
row = self._selected_profile_row()
|
|
if not row:
|
|
return
|
|
upstream_value = row.get("base_url") if self.profile_kind == "image" else row.get("upstream_base_url")
|
|
upstream = str(upstream_value or "").strip()
|
|
if not upstream or upstream == "-":
|
|
return
|
|
open_url(upstream)
|
|
|
|
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_image_profile_payload_with_editor(row)
|
|
if self.profile_kind == "image"
|
|
else edit_profile_payload_with_editor(row)
|
|
)
|
|
if payload is None:
|
|
self._set_status(f"edit {row['name']} cancelled (no changes)")
|
|
return
|
|
status, result = post_json(build_api_url(gateway_base_url, self._profile_api_base()), timeout, payload, access_key)
|
|
self._set_status(f"edit {payload.get('name') or row['name']} -> {status} | {short_text(result, 96)}")
|
|
self.refresh_data(refresh=True)
|
|
except Exception as exc:
|
|
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, f"{self._profile_api_base()}/probe"),
|
|
timeout,
|
|
{"profile": row["name"]},
|
|
access_key,
|
|
)
|
|
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, f"{self._profile_api_base()}/switch"),
|
|
timeout,
|
|
{"profile": row["name"]},
|
|
access_key,
|
|
)
|
|
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 = image_profile_payload_from_row(row) if self.profile_kind == "image" else profile_payload_from_row(row)
|
|
try:
|
|
status, result = post_json(build_api_url(gateway_base_url, self._profile_api_base()), timeout, payload, access_key)
|
|
self._set_status(f"save profile -> {status} | {short_text(result, 96)}")
|
|
self.refresh_data(refresh=True)
|
|
except Exception as exc:
|
|
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"{self._profile_api_base()}/{urllib.parse.quote(row['name'])}"),
|
|
timeout,
|
|
access_key,
|
|
)
|
|
self._set_status(f"delete {row['name']} -> {status} | {short_text(payload, 96)}")
|
|
self.refresh_data(refresh=True)
|
|
except Exception as exc:
|
|
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)
|
|
self.last_request_key = key
|
|
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.image_profile_by_key.get(key) if self.profile_kind == "image" else self.profile_by_key.get(key)
|
|
if row:
|
|
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "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(
|
|
"--access-key",
|
|
default=default_access_key(),
|
|
help="optional management access key for protected gateway admin pages and APIs",
|
|
)
|
|
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(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {short_text(request_status_label(row), 14):<14} "
|
|
f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} {short_text(request_effort_text(row), 6):<6} {request_reasoning_tokens_text(row):<6} "
|
|
f"{short_text(request_usage_summary(row), 26):<26} {format_bytes(row.get('request_body_bytes')):<8} {request_chunk_progress(row):<14} {format_duration_ms_as_seconds(row['duration_ms']):<8} {request_updated_elapsed(row):<8} "
|
|
f"{short_text(request_retry_note(row), 30)}"
|
|
)
|
|
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)
|
|
access_key = str(args.access_key or "").strip()
|
|
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 access_key:
|
|
key_path = write_access_key_config(access_key)
|
|
print(f"saved access key to {key_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,
|
|
access_key=access_key,
|
|
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,
|
|
access_key,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|