feat: separate image profiles in tui

This commit is contained in:
2026-07-10 07:04:50 +08:00
parent c52afce684
commit e4b7fc679e
4 changed files with 338 additions and 121 deletions
+3 -2
View File
@@ -89,7 +89,7 @@ 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`
- 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,6 +97,7 @@ Controls:
- `2` requests
- `3` logs
- `4` profiles
- `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
@@ -111,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. Both the text upstream and optional image upstream are preserved; image routing applies to `/images/*` and `/v1/images/*`. Manual secrets are always blank in the draft, so a blank value keeps the existing secret file.
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.
+285 -107
View File
@@ -23,7 +23,7 @@ from typing import Any
APP_NAME = "codex-retry-gateway-tui"
FALLBACK_VERSION = "0.1.5"
FALLBACK_VERSION = "0.1.6"
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
@@ -537,6 +537,7 @@ def normalize_gateway_url(api_url: str) -> str:
"/api/logs",
"/api/requests",
"/api/profiles",
"/api/image-profiles",
"/api/config",
"/api/restore",
):
@@ -1261,11 +1262,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 +1278,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 +1295,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}"
)
@@ -1493,14 +1496,6 @@ def normalize_profile_rows(payload: dict[str, Any], filter_text: str = "") -> li
"auth_file": str(summary.get("auth_file") or ""),
"auth_json_path": str(summary.get("auth_json_path") or ""),
"auth_json_key": str(summary.get("auth_json_key") or "-"),
"image_base_url": str(summary.get("image_base_url") or "-"),
"image_auth_mode": str(summary.get("image_auth_mode") or "-"),
"image_auth_env": str(summary.get("image_auth_env") or "-"),
"image_auth_file": str(summary.get("image_auth_file") or ""),
"image_manual_secret_file": str(summary.get("image_manual_secret_file") or ""),
"image_auth_json_path": str(summary.get("image_auth_json_path") or ""),
"image_auth_json_key": str(summary.get("image_auth_json_key") or "-"),
"image_auth_source": str(summary.get("image_auth_source") or "disabled"),
"request_history_limit": summary.get("request_history_limit"),
"model_remap": str(summary.get("model_remap") or ""),
"auth_source": str(summary.get("auth_source") or "-"),
@@ -1512,6 +1507,39 @@ 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']}",
@@ -1520,11 +1548,6 @@ def render_profile_detail(row: dict[str, Any]) -> str:
f"text upstream {short_text(row['upstream_base_url'], 64)}",
f"text auth {row['auth_mode']}/{row['auth_source']}",
]
if row.get("image_base_url") and row["image_base_url"] != "-":
bits.append(f"image upstream {short_text(row['image_base_url'], 64)}")
bits.append(f"image auth {row['image_auth_mode']}/{row['image_auth_source']}")
else:
bits.append("image upstream disabled")
if row.get("request_history_limit") is not None:
bits.append(f"history {row['request_history_limit']}")
if row.get("reasoning_equals"):
@@ -1538,6 +1561,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 {}
@@ -1554,15 +1591,6 @@ def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
"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 "",
"image_base_url": form.get("image_base_url") or "",
"image_auth_mode": form.get("image_auth_mode") or "fixed_bearer",
"image_auth_env": form.get("image_auth_env") or "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
"image_auth_file": form.get("image_auth_file") or "",
"image_manual_secret": "",
"image_manual_secret_file": form.get("image_manual_secret_file") or "",
"image_manual_secret_configured": parse_bool_value(form.get("image_manual_secret_configured"), False),
"image_auth_json_path": form.get("image_auth_json_path") or "",
"image_auth_json_key": form.get("image_auth_json_key") or "OPENAI_API_KEY",
"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,
@@ -1598,15 +1626,6 @@ def profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]:
"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(),
"image_base_url": str(state.get("image_base_url") or "").strip(),
"image_auth_mode": str(state.get("image_auth_mode") or "fixed_bearer").strip(),
"image_auth_env": str(state.get("image_auth_env") or "").strip(),
"image_auth_file": str(state.get("image_auth_file") or "").strip(),
"image_manual_secret": "",
"image_manual_secret_file": str(state.get("image_manual_secret_file") or "").strip(),
"image_manual_secret_configured": bool(state.get("image_manual_secret_configured")),
"image_auth_json_path": str(state.get("image_auth_json_path") or "").strip(),
"image_auth_json_key": str(state.get("image_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 [],
@@ -1629,8 +1648,6 @@ def profile_editor_document(row: dict[str, Any]) -> str:
[
"# Edit the selected codex-retry-gateway profile and save.",
"# Leave manual_secret empty to keep the current secret file.",
"# image_base_url applies to both /images/* and /v1/images/*.",
"# Leave image_manual_secret empty to keep the current image secret file.",
"# Changing name creates a new profile file; it does not delete the old one.",
"",
f"name = {toml_string(state.get('name'))}",
@@ -1646,17 +1663,6 @@ def profile_editor_document(row: dict[str, Any]) -> str:
f"auth_json_path = {toml_string(state.get('auth_json_path'))}",
f"auth_json_key = {toml_string(state.get('auth_json_key'))}",
"",
"# Optional image-specific upstream and authentication.",
f"image_base_url = {toml_string(state.get('image_base_url'))}",
f"image_auth_mode = {toml_string(state.get('image_auth_mode'))}",
f"image_auth_env = {toml_string(state.get('image_auth_env'))}",
f"image_auth_file = {toml_string(state.get('image_auth_file'))}",
'image_manual_secret = ""',
f"image_manual_secret_file = {toml_string(state.get('image_manual_secret_file'))}",
f"image_manual_secret_configured = {toml_bool(state.get('image_manual_secret_configured'))}",
f"image_auth_json_path = {toml_string(state.get('image_auth_json_path'))}",
f"image_auth_json_key = {toml_string(state.get('image_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,15 +1693,6 @@ def profile_payload_from_editor_text(text: str) -> dict[str, Any]:
"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(),
"image_base_url": str(data.get("image_base_url") or "").strip(),
"image_auth_mode": str(data.get("image_auth_mode") or "fixed_bearer").strip(),
"image_auth_env": str(data.get("image_auth_env") or "").strip(),
"image_auth_file": str(data.get("image_auth_file") or "").strip(),
"image_manual_secret": str(data.get("image_manual_secret") or "").strip(),
"image_manual_secret_file": str(data.get("image_manual_secret_file") or "").strip(),
"image_manual_secret_configured": parse_bool_value(data.get("image_manual_secret_configured"), False),
"image_auth_json_path": str(data.get("image_auth_json_path") or "").strip(),
"image_auth_json_key": str(data.get("image_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")),
@@ -1744,6 +1741,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()
@@ -1824,12 +1923,13 @@ def fetch_dashboard_snapshot(
"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": 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)
with ThreadPoolExecutor(max_workers=4) as pool:
with ThreadPoolExecutor(max_workers=5) as pool:
futures = {
name: pool.submit(fetch_optional, url)
for name, url in endpoints.items()
@@ -1838,12 +1938,14 @@ def fetch_dashboard_snapshot(
logs_payload, logs_error = futures["logs"].result()
requests_payload, requests_error = futures["requests"].result()
profiles_payload, profiles_error = futures["profiles"].result()
image_profiles_payload, image_profiles_error = futures["image_profiles"].result()
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,
@@ -1854,6 +1956,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")
@@ -1862,6 +1968,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()
),
}
@@ -1903,6 +2017,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"),
@@ -1924,8 +2039,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"
@@ -1934,6 +2051,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",
@@ -1975,18 +2094,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",
"Text Upstream",
"Text Auth",
"Image Upstream",
"Image Auth",
"History",
"Reasoning",
"Source",
)
self._rebuild_profile_table_columns()
self._set_view("overview")
self.refresh_data(refresh=True)
@@ -2002,7 +2110,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()
@@ -2019,7 +2127,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)
@@ -2094,6 +2202,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":
@@ -2110,10 +2247,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)
@@ -2130,9 +2268,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)
@@ -2180,12 +2319,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))
@@ -2217,7 +2358,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")
@@ -2251,31 +2392,46 @@ def run_textual(
def render_profiles(self) -> None:
table = self.query_one("#profiles_table", DataTable)
table.clear()
self.profile_by_key = {}
for row in self.profiles:
rows = self._profile_rows()
if self.profile_kind == "image":
self.image_profile_by_key = {}
else:
self.profile_by_key = {}
for row in rows:
key = row["name"]
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),
f"{row['auth_mode']}/{row['auth_source']}",
short_text(row["image_base_url"], 24),
f"{row['image_auth_mode']}/{row['image_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),
key=key,
)
if self.current_view == "profiles" and self.active_profile_name:
self._select_profile_row(self.active_profile_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"], 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"], 32),
key=key,
)
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}")
@@ -2297,9 +2453,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")
@@ -2393,7 +2562,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)
@@ -2408,11 +2578,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:
@@ -2424,7 +2598,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,
@@ -2439,7 +2613,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,
@@ -2453,9 +2627,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:
@@ -2466,7 +2640,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:
@@ -2490,9 +2668,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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "codex-retry-gateway-tui"
version = "0.1.5"
version = "0.1.6"
description = "Terminal UI for codex-retry-gateway monitoring and control"
readme = "README.md"
requires-python = ">=3.11"
+49 -11
View File
@@ -28,7 +28,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 +57,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"))
@@ -395,7 +407,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",
@@ -424,22 +436,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.assertEqual(payload["image_base_url"], "https://images.example/v1")
self.assertEqual(payload["image_auth_mode"], "manual_bearer")
self.assertEqual(payload["image_manual_secret_file"], "/run/secrets/images-token")
self.assertTrue(payload["image_manual_secret_configured"])
self.assertEqual(payload["image_manual_secret"], "")
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.assertEqual(saved_payload["image_base_url"], "https://images.example/v1")
self.assertEqual(saved_payload["image_auth_mode"], "manual_bearer")
self.assertEqual(saved_payload["image_manual_secret_file"], "/run/secrets/images-token")
self.assertEqual(saved_payload["image_manual_secret"], "")
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: