feat: add profile editing and richer request ids
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user