diff --git a/README.md b/README.md index 78b6b46..5c72a9d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/codex_retry_gateway_tui.py b/codex_retry_gateway_tui.py index 10a5df4..75079d7 100644 --- a/codex_retry_gateway_tui.py +++ b/codex_retry_gateway_tui.py @@ -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)}" ) diff --git a/pyproject.toml b/pyproject.toml index e9a2795..368e614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_payload.py b/tests/test_payload.py index 860b11f..077db38 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -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: