Compare commits
6
Commits
58638d9fe3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135dbc845a | ||
|
|
c79e85e635 | ||
|
|
f67cbbda65 | ||
|
|
ccecee2a44 | ||
|
|
e4b7fc679e | ||
|
|
c52afce684 |
@@ -87,9 +87,9 @@ uv run codex-retry-gateway-tui
|
||||
Views:
|
||||
|
||||
- overview: current gateway status and summary counts
|
||||
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at`
|
||||
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and lifecycle status: `waiting` before the first response, `streaming` after it, final `HTTP <code>`, red `stalled` after no activity, or red `discarded` when the client cancels
|
||||
- logs: recent gateway logs
|
||||
- profiles: saved profiles, active profile default selection, profile actions
|
||||
- profiles: independently managed text profiles and image profiles, each with its own active selection and actions
|
||||
|
||||
Controls:
|
||||
|
||||
@@ -97,15 +97,13 @@ Controls:
|
||||
- `2` requests
|
||||
- `3` logs
|
||||
- `4` profiles
|
||||
- `,` / `.` select request-table column for visibility/sort actions
|
||||
- `v` toggle the selected requests column visible/hidden
|
||||
- `c` sort requests by the selected column
|
||||
- `C` toggle requests sort ascending/descending
|
||||
- `i` switch the profiles table between text and image profiles
|
||||
- `c` edit request-table column visibility, order, and widths
|
||||
- `e` edit selected profile in `$VISUAL` / `$EDITOR`
|
||||
- `/` filter
|
||||
- `r` refresh
|
||||
- `m` switch request table compact/wide width profile
|
||||
- `[` / `]` narrow or widen the requests usage column
|
||||
- `Shift-Left` / `Shift-Right` scroll the active table
|
||||
- `p` probe selected profile
|
||||
- `s` switch to selected profile
|
||||
- `w` save selected profile snapshot
|
||||
@@ -114,4 +112,4 @@ Controls:
|
||||
|
||||
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.
|
||||
Profile editing uses a temp TOML draft opened in `$VISUAL` or `$EDITOR` and then saves it back through the matching gateway API. Text drafts only contain text upstream settings; image drafts only contain image upstream settings for `/images/*` and `/v1/images/*`. Manual secrets are always blank in the draft, so a blank value keeps the existing secret file.
|
||||
|
||||
+443
-63
@@ -16,6 +16,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -23,7 +24,7 @@ from typing import Any
|
||||
|
||||
|
||||
APP_NAME = "codex-retry-gateway-tui"
|
||||
FALLBACK_VERSION = "0.1.4"
|
||||
FALLBACK_VERSION = "0.1.10"
|
||||
DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway"
|
||||
DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway"
|
||||
DEFAULT_API_URL = DEFAULT_GATEWAY_URL
|
||||
@@ -47,6 +48,8 @@ DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES = [
|
||||
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5
|
||||
DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350
|
||||
DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0
|
||||
REQUEST_STATUS_SLOW_AFTER_SECONDS = 20
|
||||
REQUEST_STATUS_STALLED_AFTER_SECONDS = 120
|
||||
DEFAULT_PROFILE_ENDPOINTS = [
|
||||
"/responses",
|
||||
"/chat/completions",
|
||||
@@ -102,7 +105,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
|
||||
"resp_id": 18,
|
||||
"thread": 18,
|
||||
"started": 19,
|
||||
"status": 7,
|
||||
"status": 12,
|
||||
"path": 22,
|
||||
"model": 16,
|
||||
"effort": 8,
|
||||
@@ -123,7 +126,7 @@ REQUEST_TABLE_WIDTH_PROFILES = {
|
||||
"resp_id": 24,
|
||||
"thread": 36,
|
||||
"started": 19,
|
||||
"status": 7,
|
||||
"status": 14,
|
||||
"path": 40,
|
||||
"model": 24,
|
||||
"effort": 8,
|
||||
@@ -537,6 +540,7 @@ def normalize_gateway_url(api_url: str) -> str:
|
||||
"/api/logs",
|
||||
"/api/requests",
|
||||
"/api/profiles",
|
||||
"/api/image-profiles",
|
||||
"/api/config",
|
||||
"/api/restore",
|
||||
):
|
||||
@@ -867,6 +871,7 @@ def normalize_request_retry_firsts(value: Any) -> list[dict[str, Any]]:
|
||||
{
|
||||
"round": parse_int_value(item.get("round")),
|
||||
"slot": parse_int_value(item.get("slot")),
|
||||
"first_response_at": item.get("first_response_at"),
|
||||
"first_delay_ms": item.get("first_response_delay_ms", item.get("first_delay_ms", item.get("first_ms"))),
|
||||
"outcome": str(item.get("outcome") or "").strip(),
|
||||
"status_code": item.get("status_code", item.get("upstream_status_code", item.get("status"))),
|
||||
@@ -985,7 +990,7 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None =
|
||||
short_text(response_id_text(row), limits.get("resp_id", 18)) or "-",
|
||||
short_text(row["thread_id"], limits.get("thread", 18)) or "-",
|
||||
short_time(row["started_at"]),
|
||||
f"{status_symbol(row)} {row.get('status_code') or '-'}",
|
||||
request_status_label(row),
|
||||
short_text(row["path"], limits.get("path", 22)),
|
||||
short_text(request_model_text(row), limits.get("model", 16)),
|
||||
short_text(request_effort_text(row), limits.get("effort", 10)),
|
||||
@@ -1019,16 +1024,16 @@ def request_visible_cells(
|
||||
visible_column_keys: list[str],
|
||||
) -> list[Any]:
|
||||
visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in visible_column_keys]
|
||||
thread_color = request_thread_color(row.get("thread_id"))
|
||||
if not thread_color:
|
||||
return visible_cells
|
||||
try:
|
||||
from rich.text import Text
|
||||
except ImportError:
|
||||
return visible_cells
|
||||
thread_color = request_thread_color(row.get("thread_id"))
|
||||
styled_cells: list[Any] = []
|
||||
for column_key, cell in zip(visible_column_keys, visible_cells):
|
||||
if column_key in THREAD_COLOR_COLUMN_KEYS:
|
||||
if column_key == "status":
|
||||
styled_cells.append(Text(str(cell), style=request_status_style(row)))
|
||||
elif thread_color and column_key in THREAD_COLOR_COLUMN_KEYS:
|
||||
styled_cells.append(Text(str(cell), style=f"bold {thread_color}"))
|
||||
else:
|
||||
styled_cells.append(cell)
|
||||
@@ -1146,11 +1151,21 @@ def fetch_payload(api_url: str, timeout: int, access_key: str = "") -> dict[str,
|
||||
return data
|
||||
|
||||
|
||||
def fetch_optional_payload(url: str, timeout: int, access_key: str = "") -> tuple[dict[str, Any], str]:
|
||||
def fetch_optional_payload(
|
||||
url: str,
|
||||
timeout: int,
|
||||
access_key: str = "",
|
||||
*,
|
||||
allow_not_found: bool = False,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
if not str(url or "").strip():
|
||||
return {}, ""
|
||||
try:
|
||||
return fetch_payload(url, timeout, access_key), ""
|
||||
except urllib.error.HTTPError as exc:
|
||||
if allow_not_found and exc.code == 404:
|
||||
return {}, ""
|
||||
return {}, str(exc)
|
||||
except Exception as exc:
|
||||
return {}, str(exc)
|
||||
|
||||
@@ -1261,11 +1276,12 @@ def summary_line(payload: dict[str, Any]) -> str:
|
||||
listen = payload.get("listen") or "-"
|
||||
upstream = config.get("upstream_base_url") or "-"
|
||||
active = config.get("profile_name") or state.get("profile_name") or "-"
|
||||
image_active = config.get("image_profile_name") or state.get("image_profile_name") or "-"
|
||||
request_total = format_count(metrics.get("total_proxy_request_count"))
|
||||
inspected = format_count(metrics.get("inspected_response_count"))
|
||||
matched = format_count(metrics.get("matched_response_count"))
|
||||
reasoning_516 = format_count(metrics.get("reasoning_516_count"))
|
||||
return f"{listen} | profile {active} | upstream {upstream} | req {request_total} | inspected {inspected} | matched {matched} | 516 {reasoning_516}"
|
||||
return f"{listen} | text {active} | image {image_active} | upstream {upstream} | req {request_total} | inspected {inspected} | matched {matched} | 516 {reasoning_516}"
|
||||
|
||||
|
||||
def state_summary(payload: dict[str, Any]) -> str:
|
||||
@@ -1276,6 +1292,7 @@ def state_summary(payload: dict[str, Any]) -> str:
|
||||
f"config {paths.get('config_path') or '-'}",
|
||||
f"requests {paths.get('requests_path') or '-'}",
|
||||
f"profiles {paths.get('profiles_dir') or '-'}",
|
||||
f"image profiles {paths.get('image_profiles_dir') or '-'}",
|
||||
]
|
||||
if state.get("gateway_base_url"):
|
||||
bits.append(f"gateway {state.get('gateway_base_url')}")
|
||||
@@ -1292,7 +1309,7 @@ def status_summary(status_payload: dict[str, Any], status_error: str = "") -> st
|
||||
metrics = status_payload.get("metrics") if isinstance(status_payload.get("metrics"), dict) else {}
|
||||
listen = status_payload.get("listen") or "-"
|
||||
return (
|
||||
f"live {listen} | active {config.get('profile_name') or state.get('profile_name') or '-'} | "
|
||||
f"live {listen} | text {config.get('profile_name') or state.get('profile_name') or '-'} | image {config.get('image_profile_name') or state.get('image_profile_name') or '-'} | "
|
||||
f"516 {metrics.get('reasoning_516_count') or 0} | latest seq {metrics.get('total_proxy_request_count') or 0}"
|
||||
)
|
||||
|
||||
@@ -1313,6 +1330,8 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
|
||||
"requested_model",
|
||||
"forwarded_model",
|
||||
"reasoning_effort",
|
||||
"lifecycle_state",
|
||||
"discard_reason",
|
||||
"error",
|
||||
"upstream_origin",
|
||||
"upstream_path",
|
||||
@@ -1332,25 +1351,118 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
|
||||
return needle in haystack
|
||||
|
||||
|
||||
def status_symbol(entry: dict[str, Any]) -> str:
|
||||
if entry.get("error"):
|
||||
return "!"
|
||||
if entry.get("matched"):
|
||||
return "*"
|
||||
if entry.get("response_stream"):
|
||||
return "~"
|
||||
return " "
|
||||
def request_retry_first_received_at(row: dict[str, Any]) -> Any:
|
||||
latest_value = None
|
||||
latest_at = None
|
||||
for first in row.get("reasoning_retry_current_firsts") or []:
|
||||
if not isinstance(first, dict):
|
||||
continue
|
||||
value = first.get("first_response_at")
|
||||
parsed = parse_datetime(value)
|
||||
if parsed and (latest_at is None or parsed > latest_at):
|
||||
latest_value = value
|
||||
latest_at = parsed
|
||||
return latest_value
|
||||
|
||||
|
||||
def request_has_received_first(row: dict[str, Any]) -> bool:
|
||||
if parse_datetime(row.get("first_response_at")):
|
||||
return True
|
||||
for first in row.get("reasoning_retry_current_firsts") or []:
|
||||
if not isinstance(first, dict):
|
||||
continue
|
||||
if parse_datetime(first.get("first_response_at")) or parse_int_value(first.get("first_delay_ms")) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def request_lifecycle_label(row: dict[str, Any]) -> str:
|
||||
state = str(row.get("lifecycle_state") or "").strip().lower()
|
||||
if state == "finish":
|
||||
return "done"
|
||||
if state == "receive_first":
|
||||
return "first"
|
||||
if state == "streaming":
|
||||
return "live"
|
||||
return "sent"
|
||||
if request_is_discarded(row):
|
||||
return "discarded"
|
||||
if state in {"finish", "finished", "complete", "completed"} or parse_datetime(row.get("finished_at")):
|
||||
return "finished"
|
||||
if state in {"receive_first", "streaming"} or request_has_received_first(row):
|
||||
return "streaming"
|
||||
return "waiting"
|
||||
|
||||
|
||||
def request_is_discarded(row: dict[str, Any]) -> bool:
|
||||
state = str(row.get("lifecycle_state") or "").strip().lower()
|
||||
if bool(row.get("discarded")) or state in {"discarded", "abandoned", "cancelled", "canceled"}:
|
||||
return True
|
||||
error = str(row.get("error") or "").strip().lower()
|
||||
return any(
|
||||
marker in error
|
||||
for marker in (
|
||||
"client disconnected",
|
||||
"client closed",
|
||||
"cancelled by client",
|
||||
"canceled by client",
|
||||
"request aborted",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def request_activity_at(row: dict[str, Any]) -> Any:
|
||||
candidates = [row.get("last_activity_at"), row.get("first_response_at"), request_retry_first_received_at(row), row.get("started_at")]
|
||||
parsed_candidates = [(parse_datetime(value), value) for value in candidates]
|
||||
valid_candidates = [(parsed, value) for parsed, value in parsed_candidates if parsed]
|
||||
if not valid_candidates:
|
||||
return None
|
||||
return max(valid_candidates, key=lambda item: item[0])[1]
|
||||
|
||||
|
||||
def request_activity_age_seconds(row: dict[str, Any], now: dt.datetime | None = None) -> float | None:
|
||||
activity_at = parse_datetime(request_activity_at(row))
|
||||
if not activity_at:
|
||||
return None
|
||||
current = now or dt.datetime.now(activity_at.tzinfo)
|
||||
if current.tzinfo is None and activity_at.tzinfo is not None:
|
||||
current = current.replace(tzinfo=activity_at.tzinfo)
|
||||
return max(0.0, (current - activity_at).total_seconds())
|
||||
|
||||
|
||||
def request_status_label(row: dict[str, Any], now: dt.datetime | None = None) -> str:
|
||||
if request_is_discarded(row):
|
||||
return "discarded"
|
||||
status_code = parse_int_value(row.get("status_code"))
|
||||
if status_code is not None:
|
||||
return f"HTTP {status_code}"
|
||||
lifecycle = request_lifecycle_label(row)
|
||||
if lifecycle == "finished":
|
||||
return "finished"
|
||||
age_seconds = request_activity_age_seconds(row, now)
|
||||
if age_seconds is not None and age_seconds >= REQUEST_STATUS_STALLED_AFTER_SECONDS:
|
||||
return f"stalled {format_elapsed_seconds(age_seconds)}"
|
||||
if age_seconds is not None and age_seconds >= REQUEST_STATUS_SLOW_AFTER_SECONDS:
|
||||
return f"{lifecycle} {format_elapsed_seconds(age_seconds)}"
|
||||
return lifecycle
|
||||
|
||||
|
||||
def request_status_style(row: dict[str, Any], now: dt.datetime | None = None) -> str:
|
||||
label = request_status_label(row, now).lower()
|
||||
if label.startswith(("discarded", "stalled")):
|
||||
return "bold #ff5f5f"
|
||||
if label.startswith("waiting"):
|
||||
return "bold #ffaf5f"
|
||||
if label.startswith("streaming"):
|
||||
return "bold #5fd7ff"
|
||||
if label.startswith("http "):
|
||||
status_code = parse_int_value(row.get("status_code")) or 0
|
||||
if 200 <= status_code < 400:
|
||||
return "bold #87d75f"
|
||||
if 400 <= status_code < 500:
|
||||
return "bold #ffaf5f"
|
||||
return "bold #ff5f5f"
|
||||
if row.get("error"):
|
||||
return "bold #ff5f5f"
|
||||
return "#b0b0b0"
|
||||
|
||||
|
||||
def status_symbol(entry: dict[str, Any]) -> str:
|
||||
"""Compatibility helper for callers that previously consumed a status marker."""
|
||||
return request_status_label(entry)
|
||||
|
||||
|
||||
def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
|
||||
@@ -1378,6 +1490,9 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
"reasoning_summary": str(entry.get("reasoning_summary") or ""),
|
||||
"request_stream": bool(entry.get("request_stream")),
|
||||
"response_stream": bool(entry.get("response_stream")),
|
||||
"lifecycle_state": str(entry.get("lifecycle_state") or ""),
|
||||
"discarded": bool(entry.get("discarded")),
|
||||
"discard_reason": str(entry.get("discard_reason") or ""),
|
||||
"matched": bool(entry.get("matched")),
|
||||
"inspected": bool(entry.get("inspected")),
|
||||
"status_code": entry.get("status_code"),
|
||||
@@ -1391,6 +1506,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
"request_body_bytes": entry.get("request_body_bytes"),
|
||||
"response_bytes_received": entry.get("response_bytes_received"),
|
||||
"stream_chunk_count": entry.get("stream_chunk_count"),
|
||||
"first_response_at": entry.get("first_response_at"),
|
||||
"first_response_delay_ms": entry.get("first_response_delay_ms"),
|
||||
"reasoning_retry_current_round": parse_int_value(entry.get("reasoning_retry_current_round")),
|
||||
"reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")),
|
||||
@@ -1403,6 +1519,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
"error": str(entry.get("error") or ""),
|
||||
"upstream_origin": str(entry.get("upstream")["origin"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_origin") or ""),
|
||||
"upstream_path": str(entry.get("upstream")["path"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_path") or ""),
|
||||
"upstream_route": str(entry.get("upstream")["route"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_route") or ""),
|
||||
"upstream_auth_mode": str(entry.get("upstream")["auth_mode"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_mode") or ""),
|
||||
"upstream_auth_source": str(entry.get("upstream")["auth_source"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_source") or ""),
|
||||
"raw": entry,
|
||||
@@ -1421,7 +1538,7 @@ def render_request_detail(row: dict[str, Any]) -> str:
|
||||
f"started {short_time(row['started_at'])}",
|
||||
f"{row['method']} {row['path']}",
|
||||
f"profile {row.get('profile_name') or '-'}",
|
||||
f"status {row['status_code'] or '-'}",
|
||||
f"status {request_status_label(row)}",
|
||||
f"upstream {row['upstream_status_code'] or '-'}",
|
||||
f"attempts {row['upstream_attempt_count'] or 0}",
|
||||
f"first {request_first_text(row)}",
|
||||
@@ -1452,10 +1569,14 @@ def render_request_detail(row: dict[str, Any]) -> str:
|
||||
bits.append(f"cached raw {format_count(row['cached_tokens'])}")
|
||||
if row.get("error"):
|
||||
bits.append(f"error {row['error']}")
|
||||
if row.get("discard_reason"):
|
||||
bits.append(f"discard {row['discard_reason']}")
|
||||
if row.get("upstream_origin"):
|
||||
bits.append(f"origin {row['upstream_origin']}")
|
||||
if row.get("upstream_path"):
|
||||
bits.append(f"upstream path {row['upstream_path']}")
|
||||
if row.get("upstream_route"):
|
||||
bits.append(f"route {row['upstream_route']}")
|
||||
if row.get("upstream_auth_mode"):
|
||||
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
|
||||
retry_wave = request_retry_wave_summary(row)
|
||||
@@ -1501,13 +1622,46 @@ def normalize_profile_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_image_profile_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
|
||||
needle = filter_text.strip().lower()
|
||||
rows = []
|
||||
for profile in payload.get("image_profiles") or []:
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
summary = profile.get("summary") if isinstance(profile.get("summary"), dict) else {}
|
||||
haystack = " ".join(
|
||||
str(profile.get(key) or "")
|
||||
for key in ("name", "file_path", "active")
|
||||
).lower() + " " + " ".join(str(summary.get(key) or "") for key in summary.keys()).lower()
|
||||
if needle and needle not in haystack:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": str(profile.get("name") or ""),
|
||||
"active": bool(profile.get("active")),
|
||||
"file_path": str(profile.get("file_path") or ""),
|
||||
"base_url": str(summary.get("base_url") or "-"),
|
||||
"auth_mode": str(summary.get("auth_mode") or "-"),
|
||||
"auth_env": str(summary.get("auth_env") or "-"),
|
||||
"auth_file": str(summary.get("auth_file") or ""),
|
||||
"manual_secret_file": str(summary.get("manual_secret_file") or ""),
|
||||
"auth_json_path": str(summary.get("auth_json_path") or ""),
|
||||
"auth_json_key": str(summary.get("auth_json_key") or "-"),
|
||||
"auth_source": str(summary.get("auth_source") or "disabled"),
|
||||
"raw": profile,
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda item: (not item["active"], item["name"].lower()))
|
||||
return rows
|
||||
|
||||
|
||||
def render_profile_detail(row: dict[str, Any]) -> str:
|
||||
bits = [
|
||||
f"{row['name']}",
|
||||
"active" if row["active"] else "inactive",
|
||||
f"listen {row['listen_host']}:{row['listen_port']}",
|
||||
f"upstream {short_text(row['upstream_base_url'], 64)}",
|
||||
f"auth {row['auth_mode']}/{row['auth_source']}",
|
||||
f"text upstream {short_text(row['upstream_base_url'], 64)}",
|
||||
f"text auth {row['auth_mode']}/{row['auth_source']}",
|
||||
]
|
||||
if row.get("request_history_limit") is not None:
|
||||
bits.append(f"history {row['request_history_limit']}")
|
||||
@@ -1522,6 +1676,20 @@ def render_profile_detail(row: dict[str, Any]) -> str:
|
||||
return " | ".join(bits)
|
||||
|
||||
|
||||
def render_image_profile_detail(row: dict[str, Any]) -> str:
|
||||
bits = [
|
||||
f"image {row['name']}",
|
||||
"active" if row["active"] else "inactive",
|
||||
f"upstream {short_text(row['base_url'], 64)}",
|
||||
f"auth {row['auth_mode']}/{row['auth_source']}",
|
||||
]
|
||||
if row.get("auth_file"):
|
||||
bits.append("auth file configured")
|
||||
if row.get("auth_json_path"):
|
||||
bits.append("auth json configured")
|
||||
return " | ".join(bits)
|
||||
|
||||
|
||||
def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
|
||||
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
|
||||
@@ -1609,6 +1777,7 @@ def profile_editor_document(row: dict[str, Any]) -> str:
|
||||
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'))}",
|
||||
@@ -1687,6 +1856,108 @@ def edit_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | No
|
||||
pass
|
||||
|
||||
|
||||
def image_profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
|
||||
form = raw.get("form") if isinstance(raw.get("form"), dict) else {}
|
||||
return {
|
||||
"name": row.get("name") or "",
|
||||
"base_url": form.get("base_url") or row.get("base_url") or "",
|
||||
"auth_mode": form.get("auth_mode") or row.get("auth_mode") or "fixed_bearer",
|
||||
"auth_env": form.get("auth_env") or "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
|
||||
"auth_file": form.get("auth_file") or "",
|
||||
"manual_secret": "",
|
||||
"manual_secret_file": form.get("manual_secret_file") or "",
|
||||
"manual_secret_configured": parse_bool_value(form.get("manual_secret_configured"), False),
|
||||
"auth_json_path": form.get("auth_json_path") or "",
|
||||
"auth_json_key": form.get("auth_json_key") or "OPENAI_API_KEY",
|
||||
}
|
||||
|
||||
|
||||
def image_profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
state = image_profile_form_state(row)
|
||||
return {
|
||||
"name": str(state.get("name") or "").strip(),
|
||||
"base_url": str(state.get("base_url") or "").strip(),
|
||||
"auth_mode": str(state.get("auth_mode") or "fixed_bearer").strip(),
|
||||
"auth_env": str(state.get("auth_env") or "").strip(),
|
||||
"auth_file": str(state.get("auth_file") or "").strip(),
|
||||
"manual_secret": "",
|
||||
"manual_secret_file": str(state.get("manual_secret_file") or "").strip(),
|
||||
"manual_secret_configured": bool(state.get("manual_secret_configured")),
|
||||
"auth_json_path": str(state.get("auth_json_path") or "").strip(),
|
||||
"auth_json_key": str(state.get("auth_json_key") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def image_profile_editor_document(row: dict[str, Any]) -> str:
|
||||
state = image_profile_form_state(row)
|
||||
return "\n".join(
|
||||
[
|
||||
"# Edit the selected codex-retry-gateway image profile and save.",
|
||||
"# base_url applies to both /images/* and /v1/images/*.",
|
||||
"# Leave manual_secret empty to keep the current image secret file.",
|
||||
"# Changing name creates a new image profile file; it does not delete the old one.",
|
||||
"",
|
||||
f"name = {toml_string(state.get('name'))}",
|
||||
f"base_url = {toml_string(state.get('base_url'))}",
|
||||
f"auth_mode = {toml_string(state.get('auth_mode'))}",
|
||||
f"auth_env = {toml_string(state.get('auth_env'))}",
|
||||
f"auth_file = {toml_string(state.get('auth_file'))}",
|
||||
'manual_secret = ""',
|
||||
f"manual_secret_file = {toml_string(state.get('manual_secret_file'))}",
|
||||
f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}",
|
||||
f"auth_json_path = {toml_string(state.get('auth_json_path'))}",
|
||||
f"auth_json_key = {toml_string(state.get('auth_json_key'))}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def image_profile_payload_from_editor_text(text: str) -> dict[str, Any]:
|
||||
data = tomllib.loads(text)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("editor payload must be a TOML object")
|
||||
return {
|
||||
"name": str(data.get("name") or "").strip(),
|
||||
"base_url": str(data.get("base_url") or "").strip(),
|
||||
"auth_mode": str(data.get("auth_mode") or "fixed_bearer").strip(),
|
||||
"auth_env": str(data.get("auth_env") or "").strip(),
|
||||
"auth_file": str(data.get("auth_file") or "").strip(),
|
||||
"manual_secret": str(data.get("manual_secret") or "").strip(),
|
||||
"manual_secret_file": str(data.get("manual_secret_file") or "").strip(),
|
||||
"manual_secret_configured": parse_bool_value(data.get("manual_secret_configured"), False),
|
||||
"auth_json_path": str(data.get("auth_json_path") or "").strip(),
|
||||
"auth_json_key": str(data.get("auth_json_key") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def edit_image_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
original_text = image_profile_editor_document(row)
|
||||
editor_command = resolve_editor_command()
|
||||
fd, raw_path = tempfile.mkstemp(prefix=f"codex-retry-image-profile-{row.get('name') or 'profile'}-", suffix=".toml")
|
||||
os.close(fd)
|
||||
temp_path = Path(raw_path)
|
||||
temp_path.write_text(original_text, encoding="utf-8")
|
||||
try:
|
||||
result = subprocess.run(editor_command + [str(temp_path)], check=False)
|
||||
edited_text = temp_path.read_text(encoding="utf-8")
|
||||
if edited_text == original_text:
|
||||
return None
|
||||
payload = image_profile_payload_from_editor_text(edited_text)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"editor exited with status {result.returncode}")
|
||||
return payload
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"{exc}; kept draft at {temp_path}") from exc
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
try:
|
||||
if temp_path.read_text(encoding="utf-8") == original_text:
|
||||
temp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def edit_request_table_preferences_with_editor(preferences: dict[str, Any]) -> dict[str, Any] | None:
|
||||
original_text = request_table_preferences_document(preferences)
|
||||
editor_command = resolve_editor_command()
|
||||
@@ -1752,6 +2023,17 @@ def open_url(url: str) -> None:
|
||||
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def gateway_supports_image_profiles(status_payload: dict[str, Any]) -> bool:
|
||||
config = status_payload.get("config") if isinstance(status_payload.get("config"), dict) else {}
|
||||
state = status_payload.get("state") if isinstance(status_payload.get("state"), dict) else {}
|
||||
paths = status_payload.get("paths") if isinstance(status_payload.get("paths"), dict) else {}
|
||||
return (
|
||||
"image_profile_name" in config
|
||||
or "image_profile_name" in state
|
||||
or "image_profiles_dir" in paths
|
||||
)
|
||||
|
||||
|
||||
def fetch_dashboard_snapshot(
|
||||
gateway_url: str,
|
||||
status_url: str,
|
||||
@@ -1762,31 +2044,40 @@ def fetch_dashboard_snapshot(
|
||||
current_view: str = "overview",
|
||||
) -> dict[str, Any]:
|
||||
gateway_root = gateway_admin_url(gateway_url)
|
||||
status_endpoint = status_url or gateway_status_url(gateway_root)
|
||||
|
||||
status_payload, status_error = fetch_optional_payload(status_endpoint, timeout, access_key)
|
||||
payload = status_payload if isinstance(status_payload, dict) else {}
|
||||
endpoints = {
|
||||
"status": status_url or gateway_status_url(gateway_root),
|
||||
"logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}),
|
||||
"requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}),
|
||||
"profiles": build_api_url(gateway_root, "/api/profiles"),
|
||||
}
|
||||
image_profiles_supported = gateway_supports_image_profiles(payload)
|
||||
if image_profiles_supported:
|
||||
endpoints["image_profiles"] = build_api_url(gateway_root, "/api/image-profiles")
|
||||
|
||||
def fetch_optional(url: str) -> tuple[dict[str, Any], str]:
|
||||
return fetch_optional_payload(url, timeout, access_key)
|
||||
def fetch_optional(url: str, *, allow_not_found: bool = False) -> tuple[dict[str, Any], str]:
|
||||
return fetch_optional_payload(url, timeout, access_key, allow_not_found=allow_not_found)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
with ThreadPoolExecutor(max_workers=len(endpoints)) as pool:
|
||||
futures = {
|
||||
name: pool.submit(fetch_optional, url)
|
||||
name: pool.submit(fetch_optional, url, allow_not_found=name == "image_profiles")
|
||||
for name, url in endpoints.items()
|
||||
}
|
||||
status_payload, status_error = futures["status"].result()
|
||||
logs_payload, logs_error = futures["logs"].result()
|
||||
requests_payload, requests_error = futures["requests"].result()
|
||||
profiles_payload, profiles_error = futures["profiles"].result()
|
||||
if image_profiles_supported:
|
||||
image_profiles_payload, image_profiles_error = futures["image_profiles"].result()
|
||||
else:
|
||||
image_profiles_payload, image_profiles_error = {}, ""
|
||||
|
||||
payload = status_payload if isinstance(status_payload, dict) else {}
|
||||
requests_source = requests_payload if isinstance(requests_payload, dict) else {}
|
||||
logs_source = logs_payload if isinstance(logs_payload, dict) else {}
|
||||
profiles_source = profiles_payload if isinstance(profiles_payload, dict) else {}
|
||||
errors = [message for message in (status_error, logs_error, requests_error, profiles_error) if message]
|
||||
image_profiles_source = image_profiles_payload if isinstance(image_profiles_payload, dict) else {}
|
||||
errors = [message for message in (status_error, logs_error, requests_error, profiles_error, image_profiles_error) if message]
|
||||
return {
|
||||
"payload": payload,
|
||||
"status_payload": payload,
|
||||
@@ -1797,6 +2088,10 @@ def fetch_dashboard_snapshot(
|
||||
profiles_source,
|
||||
filter_text if current_view == "profiles" else "",
|
||||
),
|
||||
"image_profiles": normalize_image_profile_rows(
|
||||
image_profiles_source,
|
||||
filter_text if current_view == "profiles" else "",
|
||||
),
|
||||
"active_profile_name": (
|
||||
str(
|
||||
payload.get("config", {}).get("profile_name")
|
||||
@@ -1805,6 +2100,14 @@ def fetch_dashboard_snapshot(
|
||||
or ""
|
||||
).strip()
|
||||
),
|
||||
"active_image_profile_name": (
|
||||
str(
|
||||
payload.get("config", {}).get("image_profile_name")
|
||||
or payload.get("state", {}).get("image_profile_name")
|
||||
or image_profiles_source.get("active_image_profile")
|
||||
or ""
|
||||
).strip()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1846,6 +2149,7 @@ def run_textual(
|
||||
("2", "show_requests", "Requests"),
|
||||
("3", "show_logs", "Logs"),
|
||||
("4", "show_profiles", "Profiles"),
|
||||
("i", "toggle_profile_kind", "Text/Image"),
|
||||
("m", "toggle_request_table_density", "Wide/Compact"),
|
||||
("c", "edit_request_columns", "Columns"),
|
||||
("shift+left", "scroll_table_left", "Scroll Left"),
|
||||
@@ -1867,8 +2171,10 @@ def run_textual(
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
self.logs: list[dict[str, Any]] = []
|
||||
self.profiles: list[dict[str, Any]] = []
|
||||
self.image_profiles: list[dict[str, Any]] = []
|
||||
self.request_by_key: dict[str, dict[str, Any]] = {}
|
||||
self.profile_by_key: dict[str, dict[str, Any]] = {}
|
||||
self.image_profile_by_key: dict[str, dict[str, Any]] = {}
|
||||
self.log_by_key: dict[str, dict[str, Any]] = {}
|
||||
self.current_view = "overview"
|
||||
self.filter_mode = "requests"
|
||||
@@ -1877,6 +2183,8 @@ def run_textual(
|
||||
self.executor = ThreadPoolExecutor(max_workers=1)
|
||||
self.refresh_generation = 0
|
||||
self.active_profile_name = ""
|
||||
self.active_image_profile_name = ""
|
||||
self.profile_kind = "text"
|
||||
self.visible_tables = {
|
||||
"overview": "requests",
|
||||
"requests": "requests",
|
||||
@@ -1918,7 +2226,7 @@ def run_textual(
|
||||
profiles = self.query_one("#profiles_table", DataTable)
|
||||
profiles.cursor_type = "row"
|
||||
profiles.zebra_stripes = True
|
||||
profiles.add_columns("Name", "Active", "Listen", "Upstream", "Auth", "History", "Reasoning", "Source")
|
||||
self._rebuild_profile_table_columns()
|
||||
|
||||
self._set_view("overview")
|
||||
self.refresh_data(refresh=True)
|
||||
@@ -1934,7 +2242,7 @@ def run_textual(
|
||||
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 | m compact/wide | c columns | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream"
|
||||
"views: 1 overview | 2 requests | 3 logs | 4 profiles | i text/image | / filter | r refresh | m compact/wide | c columns | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream"
|
||||
)
|
||||
self._focus_table()
|
||||
self._update_table_headers()
|
||||
@@ -1951,7 +2259,7 @@ def run_textual(
|
||||
def _update_table_headers(self) -> None:
|
||||
visible_columns = request_table_visible_columns(self.request_table_preferences)
|
||||
counts = (
|
||||
f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)} | "
|
||||
f"requests {len(self.requests)} | logs {len(self.logs)} | text profiles {len(self.profiles)} | image profiles {len(self.image_profiles)} | active {self.profile_kind} | "
|
||||
f"req cols {len(visible_columns)}/{len(REQUEST_TABLE_COLUMNS)} | c edit columns"
|
||||
)
|
||||
self.query_one("#tables", Static).update(counts)
|
||||
@@ -2026,6 +2334,35 @@ def run_textual(
|
||||
else:
|
||||
self.request_by_key = {}
|
||||
|
||||
def _profile_rows(self) -> list[dict[str, Any]]:
|
||||
return self.image_profiles if self.profile_kind == "image" else self.profiles
|
||||
|
||||
def _active_profile_name(self) -> str:
|
||||
return self.active_image_profile_name if self.profile_kind == "image" else self.active_profile_name
|
||||
|
||||
def _profile_api_base(self) -> str:
|
||||
return "/api/image-profiles" if self.profile_kind == "image" else "/api/profiles"
|
||||
|
||||
def _profile_kind_label(self) -> str:
|
||||
return "image profile" if self.profile_kind == "image" else "text profile"
|
||||
|
||||
def _rebuild_profile_table_columns(self) -> None:
|
||||
table = self.query_one("#profiles_table", DataTable)
|
||||
table.clear(columns=True)
|
||||
if self.profile_kind == "image":
|
||||
table.add_columns("Name", "Active", "Image Upstream", "Image Auth", "Source")
|
||||
else:
|
||||
table.add_columns(
|
||||
"Name",
|
||||
"Active",
|
||||
"Listen",
|
||||
"Text Upstream",
|
||||
"Text Auth",
|
||||
"History",
|
||||
"Reasoning",
|
||||
"Source",
|
||||
)
|
||||
|
||||
def _current_table_widget(self) -> DataTable | None:
|
||||
table_id = self.visible_tables.get(self.current_view, "requests")
|
||||
if table_id == "logs":
|
||||
@@ -2042,10 +2379,11 @@ def run_textual(
|
||||
|
||||
def _select_profile_row(self, profile_name: str) -> bool:
|
||||
table = self.query_one("#profiles_table", DataTable)
|
||||
if not self.profiles:
|
||||
rows = self._profile_rows()
|
||||
if not rows:
|
||||
return False
|
||||
if profile_name:
|
||||
for index, row in enumerate(self.profiles):
|
||||
for index, row in enumerate(rows):
|
||||
if row["name"] == profile_name:
|
||||
return self._select_table_row(table, index)
|
||||
return self._select_table_row(table, 0)
|
||||
@@ -2062,9 +2400,10 @@ def run_textual(
|
||||
table = self.query_one("#profiles_table", DataTable)
|
||||
if table.cursor_row is None or table.cursor_row < 0:
|
||||
return None
|
||||
if table.cursor_row >= len(self.profiles):
|
||||
rows = self._profile_rows()
|
||||
if table.cursor_row >= len(rows):
|
||||
return None
|
||||
return self.profiles[table.cursor_row]
|
||||
return rows[table.cursor_row]
|
||||
|
||||
def _selected_log_row(self) -> dict[str, Any] | None:
|
||||
table = self.query_one("#logs_table", DataTable)
|
||||
@@ -2112,12 +2451,14 @@ def run_textual(
|
||||
self.requests = result["requests"]
|
||||
self.logs = result["logs"]
|
||||
self.profiles = result["profiles"]
|
||||
self.image_profiles = result["image_profiles"]
|
||||
self.active_profile_name = result.get("active_profile_name") or ""
|
||||
self.active_image_profile_name = result.get("active_image_profile_name") or ""
|
||||
self.last_request_key = selected_request_key
|
||||
self.loading = False
|
||||
self.render_all()
|
||||
if self.current_view == "profiles":
|
||||
if not self._select_profile_row(self.active_profile_name):
|
||||
if not self._select_profile_row(self._active_profile_name()):
|
||||
self._select_profile_row("")
|
||||
version_bits = [version_message] if version_message else []
|
||||
version_bits.append(status_summary(self.payload, self.status_error))
|
||||
@@ -2149,7 +2490,7 @@ def run_textual(
|
||||
elif self.current_view == "profiles":
|
||||
row = self._selected_profile_row()
|
||||
if row:
|
||||
self.render_detail(row, kind="profile")
|
||||
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
|
||||
else:
|
||||
self.query_one("#detail", Static).update("no profiles")
|
||||
|
||||
@@ -2183,29 +2524,46 @@ def run_textual(
|
||||
def render_profiles(self) -> None:
|
||||
table = self.query_one("#profiles_table", DataTable)
|
||||
table.clear()
|
||||
rows = self._profile_rows()
|
||||
if self.profile_kind == "image":
|
||||
self.image_profile_by_key = {}
|
||||
else:
|
||||
self.profile_by_key = {}
|
||||
for row in self.profiles:
|
||||
for row in rows:
|
||||
key = row["name"]
|
||||
if self.profile_kind == "image":
|
||||
self.image_profile_by_key[key] = row
|
||||
table.add_row(
|
||||
row["name"],
|
||||
"yes" if row["active"] else "",
|
||||
short_text(row["base_url"], 36),
|
||||
f"{row['auth_mode']}/{row['auth_source']}",
|
||||
short_text(row["file_path"], 36),
|
||||
key=key,
|
||||
)
|
||||
else:
|
||||
self.profile_by_key[key] = row
|
||||
table.add_row(
|
||||
row["name"],
|
||||
"yes" if row["active"] else "",
|
||||
f"{row['listen_host']}:{row['listen_port']}",
|
||||
short_text(row["upstream_base_url"], 24),
|
||||
short_text(row["upstream_base_url"], 28),
|
||||
f"{row['auth_mode']}/{row['auth_source']}",
|
||||
str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"),
|
||||
short_text(row["reasoning_equals"], 12),
|
||||
short_text(row["file_path"], 28),
|
||||
short_text(row["file_path"], 32),
|
||||
key=key,
|
||||
)
|
||||
if self.current_view == "profiles" and self.active_profile_name:
|
||||
self._select_profile_row(self.active_profile_name)
|
||||
if self.current_view == "profiles" and self._active_profile_name():
|
||||
self._select_profile_row(self._active_profile_name())
|
||||
|
||||
def render_detail(self, row: dict[str, Any], *, kind: str) -> None:
|
||||
if kind == "request":
|
||||
self.query_one("#detail", Static).update(render_request_detail(row))
|
||||
elif kind == "profile":
|
||||
self.query_one("#detail", Static).update(render_profile_detail(row))
|
||||
elif kind == "image_profile":
|
||||
self.query_one("#detail", Static).update(render_image_profile_detail(row))
|
||||
else:
|
||||
message = str(row.get("message") or "").strip()
|
||||
self.query_one("#detail", Static).update(f"seq {row['seq']} | {short_time(row['at'])} | {message}")
|
||||
@@ -2227,9 +2585,22 @@ def run_textual(
|
||||
|
||||
def action_show_profiles(self) -> None:
|
||||
self._set_view("profiles")
|
||||
if not self._select_profile_row(self.active_profile_name):
|
||||
if not self._select_profile_row(self._active_profile_name()):
|
||||
self._select_profile_row("")
|
||||
|
||||
def action_toggle_profile_kind(self) -> None:
|
||||
self.profile_kind = "image" if self.profile_kind == "text" else "text"
|
||||
self._rebuild_profile_table_columns()
|
||||
self.render_profiles()
|
||||
if self.current_view == "profiles":
|
||||
if not self._select_profile_row(self._active_profile_name()):
|
||||
self._select_profile_row("")
|
||||
row = self._selected_profile_row()
|
||||
if row:
|
||||
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
|
||||
self._update_table_headers()
|
||||
self._set_status(f"profile view {self.profile_kind}")
|
||||
|
||||
def action_newest_request(self) -> None:
|
||||
if self.requests:
|
||||
self.render_detail(self.requests[0], kind="request")
|
||||
@@ -2323,7 +2694,8 @@ def run_textual(
|
||||
row = self._selected_profile_row()
|
||||
if not row:
|
||||
return
|
||||
upstream = str(row.get("upstream_base_url") or "").strip()
|
||||
upstream_value = row.get("base_url") if self.profile_kind == "image" else row.get("upstream_base_url")
|
||||
upstream = str(upstream_value or "").strip()
|
||||
if not upstream or upstream == "-":
|
||||
return
|
||||
open_url(upstream)
|
||||
@@ -2338,11 +2710,15 @@ def run_textual(
|
||||
return
|
||||
try:
|
||||
with self.suspend():
|
||||
payload = edit_profile_payload_with_editor(row)
|
||||
payload = (
|
||||
edit_image_profile_payload_with_editor(row)
|
||||
if self.profile_kind == "image"
|
||||
else edit_profile_payload_with_editor(row)
|
||||
)
|
||||
if payload is None:
|
||||
self._set_status(f"edit {row['name']} cancelled (no changes)")
|
||||
return
|
||||
status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload, access_key)
|
||||
status, result = post_json(build_api_url(gateway_base_url, self._profile_api_base()), timeout, payload, access_key)
|
||||
self._set_status(f"edit {payload.get('name') or row['name']} -> {status} | {short_text(result, 96)}")
|
||||
self.refresh_data(refresh=True)
|
||||
except Exception as exc:
|
||||
@@ -2354,7 +2730,7 @@ def run_textual(
|
||||
return
|
||||
try:
|
||||
status, payload = post_json(
|
||||
build_api_url(gateway_base_url, "/api/profiles/probe"),
|
||||
build_api_url(gateway_base_url, f"{self._profile_api_base()}/probe"),
|
||||
timeout,
|
||||
{"profile": row["name"]},
|
||||
access_key,
|
||||
@@ -2369,7 +2745,7 @@ def run_textual(
|
||||
return
|
||||
try:
|
||||
status, payload = post_json(
|
||||
build_api_url(gateway_base_url, "/api/profiles/switch"),
|
||||
build_api_url(gateway_base_url, f"{self._profile_api_base()}/switch"),
|
||||
timeout,
|
||||
{"profile": row["name"]},
|
||||
access_key,
|
||||
@@ -2383,9 +2759,9 @@ def run_textual(
|
||||
row = self._selected_profile_row()
|
||||
if not row:
|
||||
return
|
||||
payload = profile_payload_from_row(row)
|
||||
payload = image_profile_payload_from_row(row) if self.profile_kind == "image" else profile_payload_from_row(row)
|
||||
try:
|
||||
status, result = post_json(build_api_url(gateway_base_url, "/api/profiles"), timeout, payload, access_key)
|
||||
status, result = post_json(build_api_url(gateway_base_url, self._profile_api_base()), timeout, payload, access_key)
|
||||
self._set_status(f"save profile -> {status} | {short_text(result, 96)}")
|
||||
self.refresh_data(refresh=True)
|
||||
except Exception as exc:
|
||||
@@ -2396,7 +2772,11 @@ def run_textual(
|
||||
if not row or row["active"]:
|
||||
return
|
||||
try:
|
||||
status, payload = delete_json(build_api_url(gateway_base_url, f"/api/profiles/{urllib.parse.quote(row['name'])}"), timeout, access_key)
|
||||
status, payload = delete_json(
|
||||
build_api_url(gateway_base_url, f"{self._profile_api_base()}/{urllib.parse.quote(row['name'])}"),
|
||||
timeout,
|
||||
access_key,
|
||||
)
|
||||
self._set_status(f"delete {row['name']} -> {status} | {short_text(payload, 96)}")
|
||||
self.refresh_data(refresh=True)
|
||||
except Exception as exc:
|
||||
@@ -2420,9 +2800,9 @@ def run_textual(
|
||||
self.render_detail(row, kind="log")
|
||||
elif event.data_table.id == "profiles_table":
|
||||
key = str(event.row_key.value)
|
||||
row = self.profile_by_key.get(key)
|
||||
row = self.image_profile_by_key.get(key) if self.profile_kind == "image" else self.profile_by_key.get(key)
|
||||
if row:
|
||||
self.render_detail(row, kind="profile")
|
||||
self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
|
||||
|
||||
CodexRetryGatewayTui().run()
|
||||
return 0
|
||||
@@ -2494,7 +2874,7 @@ def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None:
|
||||
for row in snapshot.get("requests") or []:
|
||||
try:
|
||||
print(
|
||||
f"{row['seq']:>6} {short_text(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {status_symbol(row)} {row.get('status_code') or '-':<4} "
|
||||
f"{row['seq']:>6} {short_text(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {short_text(request_status_label(row), 14):<14} "
|
||||
f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} {short_text(request_effort_text(row), 6):<6} {request_reasoning_tokens_text(row):<6} "
|
||||
f"{short_text(request_usage_summary(row), 26):<26} {format_bytes(row.get('request_body_bytes')):<8} {request_chunk_progress(row):<14} {format_duration_ms_as_seconds(row['duration_ms']):<8} {request_updated_elapsed(row):<8} "
|
||||
f"{short_text(request_retry_note(row), 30)}"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "codex-retry-gateway-tui"
|
||||
version = "0.1.4"
|
||||
version = "0.1.10"
|
||||
description = "Terminal UI for codex-retry-gateway monitoring and control"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+159
-3
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -28,7 +29,7 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
"config": {"profile_name": "pc", "upstream_base_url": "https://example.com/v1"},
|
||||
"metrics": {"total_proxy_request_count": 11, "inspected_response_count": 7, "matched_response_count": 2, "reasoning_516_count": 1},
|
||||
}
|
||||
self.assertIn("profile pc", mod.summary_line(payload))
|
||||
self.assertIn("text pc", mod.summary_line(payload))
|
||||
self.assertIn("req 11", mod.summary_line(payload))
|
||||
|
||||
def test_request_rows_sort_desc_and_keep_request_id(self) -> None:
|
||||
@@ -57,6 +58,18 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
rows = mod.normalize_profile_rows(payload)
|
||||
self.assertEqual([row["name"] for row in rows], ["alpha", "beta"])
|
||||
|
||||
def test_image_profile_rows_show_active_first(self) -> None:
|
||||
mod = load_module()
|
||||
payload = {
|
||||
"image_profiles": [
|
||||
{"name": "beta", "active": False, "summary": {"base_url": "https://images-b.example/v1", "auth_mode": "fixed_bearer", "auth_source": "env"}},
|
||||
{"name": "alpha", "active": True, "summary": {"base_url": "https://images-a.example/v1", "auth_mode": "manual_bearer", "auth_source": "manual_file"}},
|
||||
]
|
||||
}
|
||||
rows = mod.normalize_image_profile_rows(payload)
|
||||
self.assertEqual([row["name"] for row in rows], ["alpha", "beta"])
|
||||
self.assertEqual(rows[0]["base_url"], "https://images-a.example/v1")
|
||||
|
||||
def test_version_update_message_only_for_newer_versions(self) -> None:
|
||||
mod = load_module()
|
||||
self.assertIn("0.1.0 -> 0.1.1", mod.version_update_message("0.1.1", "0.1.0"))
|
||||
@@ -108,6 +121,53 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
self.assertEqual(mod.request_updated_elapsed(row), "1.0s")
|
||||
self.assertEqual(mod.request_chunk_progress(row), "7 / 4.1KB")
|
||||
|
||||
def test_optional_image_profiles_endpoint_can_be_absent_on_older_gateway(self) -> None:
|
||||
mod = load_module()
|
||||
missing_endpoint = mod.urllib.error.HTTPError(
|
||||
"http://127.0.0.1:4610/__codex_retry_gateway/api/image-profiles",
|
||||
404,
|
||||
"Not Found",
|
||||
{},
|
||||
None,
|
||||
)
|
||||
with mock.patch.object(mod, "fetch_payload", side_effect=missing_endpoint):
|
||||
payload, error = mod.fetch_optional_payload(
|
||||
"http://127.0.0.1:4610/__codex_retry_gateway/api/image-profiles",
|
||||
5,
|
||||
allow_not_found=True,
|
||||
)
|
||||
self.assertEqual(payload, {})
|
||||
self.assertEqual(error, "")
|
||||
|
||||
def test_dashboard_skips_image_profiles_endpoint_when_status_lacks_capability(self) -> None:
|
||||
mod = load_module()
|
||||
calls: list[str] = []
|
||||
|
||||
def fetch(url: str, timeout: int, access_key: str = "", *, allow_not_found: bool = False):
|
||||
calls.append(url)
|
||||
if url.endswith("/api/status"):
|
||||
return {"config": {"profile_name": "legacy"}, "state": {}, "paths": {}}, ""
|
||||
if "/api/requests" in url:
|
||||
return {"entries": []}, ""
|
||||
if "/api/logs" in url:
|
||||
return {"entries": []}, ""
|
||||
if "/api/profiles" in url:
|
||||
return {"profiles": []}, ""
|
||||
self.fail(f"unexpected endpoint {url}")
|
||||
|
||||
with mock.patch.object(mod, "fetch_optional_payload", side_effect=fetch):
|
||||
snapshot = mod.fetch_dashboard_snapshot("http://127.0.0.1:4610", "", 5)
|
||||
|
||||
self.assertEqual(snapshot["image_profiles"], [])
|
||||
self.assertEqual(snapshot["status_error"], "")
|
||||
self.assertFalse(any("/api/image-profiles" in url for url in calls))
|
||||
|
||||
def test_gateway_image_profiles_capability_uses_status_field_presence(self) -> None:
|
||||
mod = load_module()
|
||||
self.assertFalse(mod.gateway_supports_image_profiles({"config": {"profile_name": "legacy"}}))
|
||||
self.assertTrue(mod.gateway_supports_image_profiles({"config": {"image_profile_name": ""}}))
|
||||
self.assertTrue(mod.gateway_supports_image_profiles({"paths": {"image_profiles_dir": "/tmp/profiles"}}))
|
||||
|
||||
def test_request_usage_summary_shows_cached_ratio(self) -> None:
|
||||
mod = load_module()
|
||||
row = {
|
||||
@@ -117,6 +177,59 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual(mod.request_usage_summary(row), "in 75 | out 40 | cached 25 (25%)")
|
||||
|
||||
def test_request_status_labels_follow_lifecycle_and_age(self) -> None:
|
||||
mod = load_module()
|
||||
now = dt.datetime(2026, 7, 10, 12, 0, tzinfo=dt.timezone.utc)
|
||||
waiting = {
|
||||
"lifecycle_state": "sent",
|
||||
"started_at": "2026-07-10T11:59:50Z",
|
||||
}
|
||||
streaming = {
|
||||
"lifecycle_state": "receive_first",
|
||||
"started_at": "2026-07-10T11:59:00Z",
|
||||
"first_response_at": "2026-07-10T11:59:55Z",
|
||||
"last_activity_at": "2026-07-10T11:59:55Z",
|
||||
}
|
||||
retry_streaming = {
|
||||
"lifecycle_state": "sent",
|
||||
"started_at": "2026-07-10T11:59:00Z",
|
||||
"reasoning_retry_current_firsts": [
|
||||
{
|
||||
"round": 1,
|
||||
"slot": 1,
|
||||
"first_response_at": "2026-07-10T11:59:58Z",
|
||||
"first_delay_ms": 2200,
|
||||
}
|
||||
],
|
||||
}
|
||||
complete = {"lifecycle_state": "finish", "status_code": 200}
|
||||
discarded = {
|
||||
"lifecycle_state": "finish",
|
||||
"status_code": 502,
|
||||
"error": "client disconnected before reasoning retry completed",
|
||||
}
|
||||
slow_waiting = {
|
||||
"lifecycle_state": "sent",
|
||||
"started_at": "2026-07-10T11:59:30Z",
|
||||
}
|
||||
stalled = {
|
||||
"lifecycle_state": "streaming",
|
||||
"first_response_at": "2026-07-10T11:57:50Z",
|
||||
"last_activity_at": "2026-07-10T11:57:50Z",
|
||||
}
|
||||
|
||||
self.assertEqual(mod.request_status_label(waiting, now), "waiting")
|
||||
self.assertEqual(mod.request_status_label(streaming, now), "streaming")
|
||||
self.assertEqual(mod.request_status_label(retry_streaming, now), "streaming")
|
||||
self.assertEqual(mod.request_activity_at(retry_streaming), "2026-07-10T11:59:58Z")
|
||||
self.assertEqual(mod.request_status_label(complete, now), "HTTP 200")
|
||||
self.assertEqual(mod.request_status_label(discarded, now), "discarded")
|
||||
self.assertEqual(mod.request_status_label(slow_waiting, now), "waiting 30.0s")
|
||||
self.assertEqual(mod.request_status_label(stalled, now), "stalled 130s")
|
||||
self.assertNotEqual(mod.request_status_style(waiting, now), mod.request_status_style(streaming, now))
|
||||
self.assertEqual(mod.request_status_style(discarded, now), "bold #ff5f5f")
|
||||
self.assertEqual(mod.request_status_style(stalled, now), "bold #ff5f5f")
|
||||
|
||||
def test_request_row_cells_expand_with_wider_limits(self) -> None:
|
||||
mod = load_module()
|
||||
row = {
|
||||
@@ -304,7 +417,8 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
self.assertEqual(visible[1].plain, "req_1")
|
||||
self.assertEqual(visible[2].plain, "resp_1")
|
||||
self.assertEqual(visible[3].plain, "thread_same")
|
||||
self.assertEqual(visible[4], "· 200")
|
||||
self.assertIsInstance(visible[4], Text)
|
||||
self.assertEqual(visible[4].plain, "HTTP 200")
|
||||
self.assertEqual(mod.request_thread_color("thread_same"), mod.request_thread_color("thread_same"))
|
||||
self.assertIsNone(mod.request_thread_color(""))
|
||||
|
||||
@@ -395,7 +509,7 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
self.assertEqual(mod.format_duration_ms_as_seconds(1532), "1.5s")
|
||||
self.assertEqual(mod.format_duration_ms_as_seconds(120000), "120s")
|
||||
|
||||
def test_profile_editor_payload_round_trip(self) -> None:
|
||||
def test_text_profile_editor_payload_does_not_carry_image_fields(self) -> None:
|
||||
mod = load_module()
|
||||
row = {
|
||||
"name": "pc",
|
||||
@@ -409,6 +523,11 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
"reasoning_equals": [516, 1034],
|
||||
"raw": {
|
||||
"form": {
|
||||
"image_base_url": "https://images.example/v1",
|
||||
"image_auth_mode": "manual_bearer",
|
||||
"image_manual_secret_file": "/run/secrets/images-token",
|
||||
"image_manual_secret_configured": True,
|
||||
"image_auth_json_key": "IMAGE_API_KEY",
|
||||
"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",
|
||||
@@ -419,11 +538,48 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
||||
text = mod.profile_editor_document(row)
|
||||
payload = mod.profile_payload_from_editor_text(text)
|
||||
self.assertEqual(payload["name"], "pc")
|
||||
self.assertNotIn("image_base_url", payload)
|
||||
self.assertNotIn("image_base_url", text)
|
||||
self.assertNotIn("test-image-profile-secret", text)
|
||||
saved_payload = mod.profile_payload_from_row(row)
|
||||
self.assertNotIn("image_base_url", saved_payload)
|
||||
self.assertIn(
|
||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||||
payload["retryable_error_messages"],
|
||||
)
|
||||
|
||||
def test_image_profile_editor_payload_round_trip(self) -> None:
|
||||
mod = load_module()
|
||||
row = {
|
||||
"name": "images",
|
||||
"base_url": "https://images.example/v1",
|
||||
"auth_mode": "manual_bearer",
|
||||
"auth_source": "manual_file",
|
||||
"raw": {
|
||||
"form": {
|
||||
"base_url": "https://images.example/v1",
|
||||
"auth_mode": "manual_bearer",
|
||||
"manual_secret_file": "/run/secrets/images-token",
|
||||
"manual_secret_configured": True,
|
||||
"auth_json_key": "IMAGE_API_KEY",
|
||||
}
|
||||
},
|
||||
}
|
||||
text = mod.image_profile_editor_document(row)
|
||||
payload = mod.image_profile_payload_from_editor_text(text)
|
||||
self.assertEqual(payload["name"], "images")
|
||||
self.assertEqual(payload["base_url"], "https://images.example/v1")
|
||||
self.assertEqual(payload["auth_mode"], "manual_bearer")
|
||||
self.assertEqual(payload["manual_secret_file"], "/run/secrets/images-token")
|
||||
self.assertTrue(payload["manual_secret_configured"])
|
||||
self.assertEqual(payload["manual_secret"], "")
|
||||
self.assertNotIn("test-image-profile-secret", text)
|
||||
saved_payload = mod.image_profile_payload_from_row(row)
|
||||
self.assertEqual(saved_payload["base_url"], "https://images.example/v1")
|
||||
self.assertEqual(saved_payload["auth_mode"], "manual_bearer")
|
||||
self.assertEqual(saved_payload["manual_secret_file"], "/run/secrets/images-token")
|
||||
self.assertEqual(saved_payload["manual_secret"], "")
|
||||
|
||||
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