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