feat: add profile editing and richer request ids

This commit is contained in:
2026-06-30 11:25:36 +08:00
parent 35780329c4
commit dac72f67e6
4 changed files with 398 additions and 32 deletions
+4 -1
View File
@@ -61,7 +61,7 @@ uv run codex-retry-gateway-tui
Views: Views:
- overview: current gateway status and summary counts - overview: current gateway status and summary counts
- requests: recent requests, request ID, timing, usage, attempts, and `usage_last_updated_at` - requests: recent requests, response/request/thread IDs, timing, usage, cached ratio, attempts, and `usage_last_updated_at`
- logs: recent gateway logs - logs: recent gateway logs
- profiles: saved profiles, active profile default selection, profile actions - profiles: saved profiles, active profile default selection, profile actions
@@ -71,6 +71,7 @@ Controls:
- `2` requests - `2` requests
- `3` logs - `3` logs
- `4` profiles - `4` profiles
- `e` edit selected profile in `$VISUAL` / `$EDITOR`
- `/` filter - `/` filter
- `r` refresh - `r` refresh
- `p` probe selected profile - `p` probe selected profile
@@ -80,3 +81,5 @@ Controls:
- `o` open selected profile upstream URL - `o` open selected profile upstream URL
This client uses only the public gateway admin API and does not need SSH or secrets. This client uses only the public gateway admin API and does not need SSH or secrets.
Profile editing uses a temp TOML draft opened in `$VISUAL` or `$EDITOR` and then saves it back through the gateway profiles API.
+347 -28
View File
@@ -9,8 +9,12 @@ import importlib.metadata
import json import json
import os import os
import re import re
import shlex
import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import tomllib
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -30,6 +34,21 @@ DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/codex-retry-gatew
DEFAULT_REFRESH_SECONDS = 10 DEFAULT_REFRESH_SECONDS = 10
DEFAULT_TIMEOUT_SECONDS = 5 DEFAULT_TIMEOUT_SECONDS = 5
DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS = 2 DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS = 2
DEFAULT_PROFILE_REASONING_EQUALS = [516, 1034, 1552]
DEFAULT_PROFILE_RETRYABLE_STATUS_CODES = [429, 503]
DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES = [
"Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
]
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350
DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0
DEFAULT_PROFILE_ENDPOINTS = [
"/responses",
"/chat/completions",
"/v1/responses",
"/v1/chat/completions",
]
INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git" INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git"
INSTALL_COMMAND_ARGS = [ INSTALL_COMMAND_ARGS = [
"uv", "uv",
@@ -312,6 +331,39 @@ def format_percent(value: Any) -> str:
return f"{rounded}%" return f"{rounded}%"
def cached_ratio(input_tokens: Any, cached_tokens: Any) -> float | None:
total = as_float(input_tokens)
if total <= 0:
return None
cached = max(0.0, as_float(cached_tokens))
return max(0.0, min(1.0, cached / total))
def effective_input_tokens(input_tokens: Any, cached_tokens: Any) -> int | None:
total = parse_int_value(input_tokens)
if total is None:
return None
cached = max(0, as_int(cached_tokens))
return max(0, total - cached)
def primary_request_id(row: dict[str, Any]) -> str:
return str(row.get("response_id") or row.get("request_id") or "").strip() or "-"
def request_usage_summary(row: dict[str, Any]) -> str:
effective_in = effective_input_tokens(row.get("input_tokens"), row.get("cached_tokens"))
output_tokens = row.get("output_tokens")
cached_tokens = row.get("cached_tokens")
ratio = cached_ratio(row.get("input_tokens"), cached_tokens)
in_text = format_count(effective_in) if effective_in is not None else "-"
out_text = format_count(output_tokens) if output_tokens is not None else "-"
cached_text = format_count(cached_tokens) if cached_tokens is not None else "-"
if ratio is not None:
cached_text = f"{cached_text} ({format_percent(ratio * 100)})"
return f"in {in_text} | out {out_text} | cached {cached_text}"
def format_count(value: Any) -> str: def format_count(value: Any) -> str:
number = as_int(value) number = as_int(value)
if abs(number) >= 1_000_000: if abs(number) >= 1_000_000:
@@ -416,6 +468,93 @@ def format_bool(value: Any) -> str:
return "yes" if bool(value) else "no" 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: def summary_line(payload: dict[str, Any]) -> str:
config = payload.get("config") if isinstance(payload.get("config"), dict) else {} config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
state = payload.get("state") if isinstance(payload.get("state"), dict) else {} state = payload.get("state") if isinstance(payload.get("state"), dict) else {}
@@ -466,6 +605,8 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
str(row.get(key) or "") str(row.get(key) or "")
for key in ( for key in (
"request_id", "request_id",
"response_id",
"thread_id",
"profile_name", "profile_name",
"method", "method",
"path", "path",
@@ -520,6 +661,8 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
{ {
"seq": as_int(entry.get("seq")), "seq": as_int(entry.get("seq")),
"request_id": str(entry.get("request_id") or ""), "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 ""), "profile_name": str(entry.get("profile_name") or ""),
"method": str(entry.get("method") or ""), "method": str(entry.get("method") or ""),
"path": str(entry.get("path") or ""), "path": str(entry.get("path") or ""),
@@ -562,7 +705,8 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
def render_request_detail(row: dict[str, Any]) -> str: def render_request_detail(row: dict[str, Any]) -> str:
bits = [ bits = [
f"seq {row['seq']}", f"seq {row['seq']}",
f"id {row['request_id'] or '-'}", f"id {primary_request_id(row)}",
f"thread {row.get('thread_id') or '-'}",
f"{row['method']} {row['path']}", f"{row['method']} {row['path']}",
f"profile {row.get('profile_name') or '-'}", f"profile {row.get('profile_name') or '-'}",
f"status {row['status_code'] or '-'}", f"status {row['status_code'] or '-'}",
@@ -586,12 +730,10 @@ def render_request_detail(row: dict[str, Any]) -> str:
bits.append(f"forwarded {row['forwarded_model']}") bits.append(f"forwarded {row['forwarded_model']}")
if row.get("reasoning_tokens") is not None: if row.get("reasoning_tokens") is not None:
bits.append(f"reasoning {row['reasoning_tokens']}") 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: 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( bits.append(f"usage {request_usage_summary(row)} | total {format_count(row.get('total_tokens'))}")
f"usage in/out/total {row.get('input_tokens') or 0}/{row.get('output_tokens') or 0}/{row.get('total_tokens') or 0}"
)
if row.get("cached_tokens") is not None: if row.get("cached_tokens") is not None:
bits.append(f"cached {row['cached_tokens']}") bits.append(f"cached raw {format_count(row['cached_tokens'])}")
if row.get("error"): if row.get("error"):
bits.append(f"error {row['error']}") bits.append(f"error {row['error']}")
if row.get("upstream_origin"): if row.get("upstream_origin"):
@@ -661,6 +803,171 @@ def render_profile_detail(row: dict[str, Any]) -> str:
return " | ".join(bits) return " | ".join(bits)
def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
return {
"name": row.get("name") or "",
"listen_host": form.get("listen_host") or row.get("listen_host") or "",
"listen_port": parse_int_value(form.get("listen_port") or row.get("listen_port")),
"upstream_base_url": form.get("upstream_base_url") or row.get("upstream_base_url") or "",
"auth_mode": form.get("auth_mode") or row.get("auth_mode") or "passthrough",
"auth_env": form.get("auth_env") or "",
"auth_file": form.get("auth_file") or "",
"manual_secret": "",
"manual_secret_file": form.get("manual_secret_file") or "",
"manual_secret_configured": parse_bool_value(form.get("manual_secret_configured"), False),
"auth_json_path": form.get("auth_json_path") or "",
"auth_json_key": form.get("auth_json_key") or row.get("auth_json_key") or "",
"request_history_limit": parse_int_value(form.get("request_history_limit") or row.get("request_history_limit"))
if parse_int_value(form.get("request_history_limit") or row.get("request_history_limit")) is not None
else DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT,
"model_remap": form.get("model_remap") or row.get("model_remap") or "",
"reasoning_equals": normalize_editor_integer_list(form.get("reasoning_equals") or row.get("reasoning_equals"))
or list(DEFAULT_PROFILE_REASONING_EQUALS),
"retryable_status_codes": normalize_editor_integer_list(form.get("retryable_status_codes"))
or list(DEFAULT_PROFILE_RETRYABLE_STATUS_CODES),
"retryable_error_messages": normalize_editor_phrase_list(form.get("retryable_error_messages"))
or list(DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES),
"upstream_fetch_retry_attempts": parse_int_value(form.get("upstream_fetch_retry_attempts"))
if parse_int_value(form.get("upstream_fetch_retry_attempts")) is not None
else DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS,
"upstream_fetch_retry_backoff_ms": parse_int_value(form.get("upstream_fetch_retry_backoff_ms"))
if parse_int_value(form.get("upstream_fetch_retry_backoff_ms")) is not None
else DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS,
"endpoints": normalize_editor_string_list(form.get("endpoints")) or list(DEFAULT_PROFILE_ENDPOINTS),
}
def profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]:
state = profile_form_state(row)
return {
"name": str(state.get("name") or "").strip(),
"listen_host": str(state.get("listen_host") or "").strip(),
"listen_port": state.get("listen_port"),
"upstream_base_url": str(state.get("upstream_base_url") or "").strip(),
"auth_mode": str(state.get("auth_mode") or "passthrough").strip(),
"auth_env": str(state.get("auth_env") or "").strip(),
"auth_file": str(state.get("auth_file") or "").strip(),
"manual_secret": "",
"manual_secret_file": str(state.get("manual_secret_file") or "").strip(),
"manual_secret_configured": bool(state.get("manual_secret_configured")),
"auth_json_path": str(state.get("auth_json_path") or "").strip(),
"auth_json_key": str(state.get("auth_json_key") or "").strip(),
"request_history_limit": state.get("request_history_limit"),
"model_remap": str(state.get("model_remap") or "").strip(),
"reasoning_equals": state.get("reasoning_equals") or [],
"retryable_status_codes": state.get("retryable_status_codes") or [],
"retryable_error_messages": state.get("retryable_error_messages") or [],
"upstream_fetch_retry_attempts": state.get("upstream_fetch_retry_attempts"),
"upstream_fetch_retry_backoff_ms": state.get("upstream_fetch_retry_backoff_ms"),
"endpoints": state.get("endpoints") or [],
}
def profile_editor_document(row: dict[str, Any]) -> str:
state = profile_form_state(row)
model_remap = str(state.get("model_remap") or "")
if "\n" in model_remap:
model_remap_value = '"""\n' + model_remap.rstrip("\n").replace('"""', '\\"""') + '\n"""'
else:
model_remap_value = toml_string(model_remap)
return "\n".join(
[
"# Edit the selected codex-retry-gateway profile and save.",
"# Leave manual_secret empty to keep the current secret file.",
"# Changing name creates a new profile file; it does not delete the old one.",
"",
f"name = {toml_string(state.get('name'))}",
f"listen_host = {toml_string(state.get('listen_host'))}",
f"listen_port = {state.get('listen_port') if state.get('listen_port') is not None else 4610}",
f"upstream_base_url = {toml_string(state.get('upstream_base_url'))}",
f"auth_mode = {toml_string(state.get('auth_mode'))}",
f"auth_env = {toml_string(state.get('auth_env'))}",
f"auth_file = {toml_string(state.get('auth_file'))}",
'manual_secret = ""',
f"manual_secret_file = {toml_string(state.get('manual_secret_file'))}",
f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}",
f"auth_json_path = {toml_string(state.get('auth_json_path'))}",
f"auth_json_key = {toml_string(state.get('auth_json_key'))}",
f"request_history_limit = {state.get('request_history_limit') if state.get('request_history_limit') is not None else 0}",
f"model_remap = {model_remap_value}",
f"reasoning_equals = {toml_int_list(state.get('reasoning_equals'))}",
f"retryable_status_codes = {toml_int_list(state.get('retryable_status_codes'))}",
f"retryable_error_messages = {toml_phrase_list(state.get('retryable_error_messages'))}",
f"upstream_fetch_retry_attempts = {state.get('upstream_fetch_retry_attempts') if state.get('upstream_fetch_retry_attempts') is not None else 5}",
f"upstream_fetch_retry_backoff_ms = {state.get('upstream_fetch_retry_backoff_ms') if state.get('upstream_fetch_retry_backoff_ms') is not None else 350}",
f"endpoints = {toml_string_list(state.get('endpoints'))}",
"",
]
)
def profile_payload_from_editor_text(text: str) -> dict[str, Any]:
data = tomllib.loads(text)
if not isinstance(data, dict):
raise ValueError("editor payload must be a TOML object")
return {
"name": str(data.get("name") or "").strip(),
"listen_host": str(data.get("listen_host") or "").strip(),
"listen_port": parse_int_value(data.get("listen_port")),
"upstream_base_url": str(data.get("upstream_base_url") or "").strip(),
"auth_mode": str(data.get("auth_mode") or "passthrough").strip(),
"auth_env": str(data.get("auth_env") or "").strip(),
"auth_file": str(data.get("auth_file") or "").strip(),
"manual_secret": str(data.get("manual_secret") or "").strip(),
"manual_secret_file": str(data.get("manual_secret_file") or "").strip(),
"manual_secret_configured": parse_bool_value(data.get("manual_secret_configured"), False),
"auth_json_path": str(data.get("auth_json_path") or "").strip(),
"auth_json_key": str(data.get("auth_json_key") or "").strip(),
"request_history_limit": parse_int_value(data.get("request_history_limit")),
"model_remap": str(data.get("model_remap") or "").strip(),
"reasoning_equals": normalize_editor_integer_list(data.get("reasoning_equals")),
"retryable_status_codes": normalize_editor_integer_list(data.get("retryable_status_codes")),
"retryable_error_messages": normalize_editor_phrase_list(data.get("retryable_error_messages")),
"upstream_fetch_retry_attempts": parse_int_value(data.get("upstream_fetch_retry_attempts")),
"upstream_fetch_retry_backoff_ms": parse_int_value(data.get("upstream_fetch_retry_backoff_ms")),
"endpoints": normalize_editor_string_list(data.get("endpoints")),
}
def resolve_editor_command() -> list[str]:
configured = (os.environ.get("VISUAL") or os.environ.get("EDITOR") or "").strip()
if configured:
return shlex.split(configured)
for candidate in ("nano", "vim", "vi"):
if shutil.which(candidate):
return [candidate]
raise RuntimeError("no editor found; set $VISUAL or $EDITOR")
def edit_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | None:
original_text = profile_editor_document(row)
editor_command = resolve_editor_command()
fd, raw_path = tempfile.mkstemp(prefix=f"codex-retry-profile-{row.get('name') or 'profile'}-", suffix=".toml")
os.close(fd)
temp_path = Path(raw_path)
temp_path.write_text(original_text, encoding="utf-8")
try:
result = subprocess.run(editor_command + [str(temp_path)], check=False)
edited_text = temp_path.read_text(encoding="utf-8")
if edited_text == original_text:
return None
payload = profile_payload_from_editor_text(edited_text)
if result.returncode != 0:
raise RuntimeError(f"editor exited with status {result.returncode}")
return payload
except Exception as exc:
raise RuntimeError(f"{exc}; kept draft at {temp_path}") from exc
finally:
if temp_path.exists():
try:
if temp_path.read_text(encoding="utf-8") == original_text:
temp_path.unlink()
except Exception:
pass
def action_url(api_url: str, suffix: str) -> str: def action_url(api_url: str, suffix: str) -> str:
base = api_url.rstrip("/") base = api_url.rstrip("/")
return f"{base}{suffix}" return f"{base}{suffix}"
@@ -785,6 +1092,12 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
("2", "show_requests", "Requests"), ("2", "show_requests", "Requests"),
("3", "show_logs", "Logs"), ("3", "show_logs", "Logs"),
("4", "show_profiles", "Profiles"), ("4", "show_profiles", "Profiles"),
("e", "edit_profile", "Edit Profile"),
("p", "probe_profile", "Probe Profile"),
("s", "switch_profile", "Switch Profile"),
("w", "save_active_profile", "Save Profile"),
("d", "delete_profile", "Delete Profile"),
("u", "open_upstream", "Open Upstream"),
("/", "focus_filter", "Filter"), ("/", "focus_filter", "Filter"),
] ]
@@ -831,7 +1144,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
requests = self.query_one("#requests_table", DataTable) requests = self.query_one("#requests_table", DataTable)
requests.cursor_type = "row" requests.cursor_type = "row"
requests.zebra_stripes = True requests.zebra_stripes = True
requests.add_columns("Seq", "ID", "Status", "Path", "Model", "Reasoning", "Req", "Resp", "Chunks", "First", "Duration", "Updated", "Note") requests.add_columns("Seq", "ID", "Thread", "Status", "Path", "Model", "Reasoning", "Usage", "Resp", "Chunks", "First", "Duration", "Updated", "Note")
logs = self.query_one("#logs_table", DataTable) logs = self.query_one("#logs_table", DataTable)
logs.cursor_type = "row" logs.cursor_type = "row"
@@ -857,7 +1170,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
self.query_one("#logs_table").display = active_table == "logs" self.query_one("#logs_table").display = active_table == "logs"
self.query_one("#profiles_table").display = active_table == "profiles" self.query_one("#profiles_table").display = active_table == "profiles"
self.query_one("#controls", Static).update( self.query_one("#controls", Static).update(
"views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | enter action | n newest request | p probe | s switch | w save | d delete | u open upstream" "views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | e edit | p probe | s switch | w save | d delete | u open upstream"
) )
self._focus_table() self._focus_table()
self._update_table_headers() self._update_table_headers()
@@ -1005,12 +1318,13 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
self.request_by_key[key] = row self.request_by_key[key] = row
table.add_row( table.add_row(
str(row["seq"]), str(row["seq"]),
short_text(row["request_id"], 18) or "-", short_text(primary_request_id(row), 18) or "-",
short_text(row["thread_id"], 18) or "-",
f"{status_symbol(row)} {row.get('status_code') or '-'}", f"{status_symbol(row)} {row.get('status_code') or '-'}",
short_text(row["path"], 22), short_text(row["path"], 22),
short_text(row["model"] or row["requested_model"] or row["forwarded_model"], 16), short_text(row["model"] or row["requested_model"] or row["forwarded_model"], 16),
row["reasoning_tokens"] if row["reasoning_tokens"] is not None else "-", row["reasoning_tokens"] if row["reasoning_tokens"] is not None else "-",
format_bytes(row["request_body_bytes"]), short_text(request_usage_summary(row), 24),
format_bytes(row["response_bytes_received"]), format_bytes(row["response_bytes_received"]),
request_chunk_progress(row), request_chunk_progress(row),
format_ms(row["first_response_delay_ms"]), format_ms(row["first_response_delay_ms"]),
@@ -1101,6 +1415,26 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
return return
open_url(upstream) open_url(upstream)
def action_edit_profile(self) -> None:
if self.current_view != "profiles":
self._set_status("switch to profiles view first")
return
row = self._selected_profile_row()
if not row:
self._set_status("no profile selected")
return
try:
with self.suspend():
payload = edit_profile_payload_with_editor(row)
if payload is None:
self._set_status(f"edit {row['name']} cancelled (no changes)")
return
status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload)
self._set_status(f"edit {payload.get('name') or row['name']} -> {status} | {short_text(result, 96)}")
self.refresh_data(refresh=True)
except Exception as exc:
self._set_status(f"edit error: {exc}")
def action_probe_profile(self) -> None: def action_probe_profile(self) -> None:
row = self._selected_profile_row() row = self._selected_profile_row()
if not row: if not row:
@@ -1134,22 +1468,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
row = self._selected_profile_row() row = self._selected_profile_row()
if not row: if not row:
return return
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {} payload = profile_payload_from_row(row)
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
payload = {
"name": row["name"],
"listen_host": form.get("listen_host") or row["listen_host"],
"listen_port": as_int(form.get("listen_port") or row["listen_port"]),
"upstream_base_url": form.get("upstream_base_url") or row["upstream_base_url"],
"upstream_auth_mode": form.get("auth_mode") or row["auth_mode"],
"upstream_auth_env": form.get("auth_env") or "",
"upstream_auth_file": form.get("auth_file") or "",
"upstream_auth_json_path": form.get("auth_json_path") or "",
"upstream_auth_json_key": form.get("auth_json_key") or "",
"request_history_limit": as_int(form.get("request_history_limit") or row["request_history_limit"]),
"model_remap": form.get("model_remap") or "",
"reasoning_equals": form.get("reasoning_equals") or "",
}
try: try:
status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload) status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload)
self._set_status(f"save profile -> {status} | {short_text(result, 96)}") self._set_status(f"save profile -> {status} | {short_text(result, 96)}")
@@ -1253,9 +1572,9 @@ def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None:
print("requests") print("requests")
for row in snapshot.get("requests") or []: for row in snapshot.get("requests") or []:
print( print(
f"{row['seq']:>6} {short_text(row['request_id'], 16):<16} {status_symbol(row)} {row.get('status_code') or '-':<4} " f"{row['seq']:>6} {short_text(primary_request_id(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {status_symbol(row)} {row.get('status_code') or '-':<4} "
f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} " f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} "
f"{request_chunk_progress(row):<14} {format_ms(row['duration_ms']):<8} {request_updated_elapsed(row):<8} " f"{short_text(request_usage_summary(row), 26):<26} {request_chunk_progress(row):<14} {format_ms(row['duration_ms']):<8} {request_updated_elapsed(row):<8} "
f"{format_count(row['upstream_attempt_count'] or 0):<4} {short_text(row['error'], 30)}" f"{format_count(row['upstream_attempt_count'] or 0):<4} {short_text(row['error'], 30)}"
) )
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "codex-retry-gateway-tui" name = "codex-retry-gateway-tui"
version = "0.1.2" version = "0.1.3"
description = "Terminal UI for codex-retry-gateway monitoring and control" description = "Terminal UI for codex-retry-gateway monitoring and control"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+46 -2
View File
@@ -35,12 +35,14 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
mod = load_module() mod = load_module()
payload = { payload = {
"entries": [ "entries": [
{"seq": 1, "request_id": "req_a", "path": "/responses"}, {"seq": 1, "request_id": "req_a", "response_id": "resp_a", "thread_id": "thread_a", "path": "/responses"},
{"seq": 2, "request_id": "req_b", "path": "/v1/responses"}, {"seq": 2, "request_id": "req_b", "response_id": "resp_b", "thread_id": "thread_b", "path": "/v1/responses"},
] ]
} }
rows = mod.normalize_request_rows(payload) rows = mod.normalize_request_rows(payload)
self.assertEqual([row["request_id"] for row in rows], ["req_b", "req_a"]) self.assertEqual([row["request_id"] for row in rows], ["req_b", "req_a"])
self.assertEqual(rows[0]["response_id"], "resp_b")
self.assertEqual(rows[0]["thread_id"], "thread_b")
def test_profile_rows_show_active_first(self) -> None: def test_profile_rows_show_active_first(self) -> None:
mod = load_module() mod = load_module()
@@ -75,6 +77,8 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
row = { row = {
"seq": 1, "seq": 1,
"request_id": "r", "request_id": "r",
"response_id": "resp_1",
"thread_id": "thread_1",
"method": "POST", "method": "POST",
"path": "/responses", "path": "/responses",
"status_code": 200, "status_code": 200,
@@ -90,10 +94,50 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"finished_at": "2026-06-30T00:00:01Z", "finished_at": "2026-06-30T00:00:01Z",
"usage_last_updated_at": "2026-06-30T00:00:01Z", "usage_last_updated_at": "2026-06-30T00:00:01Z",
} }
self.assertEqual(mod.primary_request_id(row), "resp_1")
self.assertIn("thread thread_1", mod.render_request_detail(row))
self.assertIn("updated 1.0s", mod.render_request_detail(row)) self.assertIn("updated 1.0s", mod.render_request_detail(row))
self.assertEqual(mod.request_updated_elapsed(row), "1.0s") self.assertEqual(mod.request_updated_elapsed(row), "1.0s")
self.assertEqual(mod.request_chunk_progress(row), "7 / 4.1KB") self.assertEqual(mod.request_chunk_progress(row), "7 / 4.1KB")
def test_request_usage_summary_shows_cached_ratio(self) -> None:
mod = load_module()
row = {
"input_tokens": 100,
"output_tokens": 40,
"cached_tokens": 25,
}
self.assertEqual(mod.request_usage_summary(row), "in 75 | out 40 | cached 25 (25%)")
def test_profile_editor_payload_round_trip(self) -> None:
mod = load_module()
row = {
"name": "pc",
"listen_host": "100.115.235.115",
"listen_port": "4610",
"upstream_base_url": "https://example.com/v1",
"auth_mode": "passthrough",
"auth_json_key": "OPENAI_API_KEY",
"request_history_limit": 0,
"model_remap": "a=b",
"reasoning_equals": [516, 1034],
"raw": {
"form": {
"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",
],
}
},
}
text = mod.profile_editor_document(row)
payload = mod.profile_payload_from_editor_text(text)
self.assertEqual(payload["name"], "pc")
self.assertIn(
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
payload["retryable_error_messages"],
)
def test_default_api_url_discovers_gateway_state(self) -> None: def test_default_api_url_discovers_gateway_state(self) -> None:
mod = load_module() mod = load_module()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir: