feat: preserve image profile configuration

This commit is contained in:
2026-07-10 05:26:09 +08:00
parent 58638d9fe3
commit c52afce684
4 changed files with 94 additions and 11 deletions
+3 -6
View File
@@ -97,15 +97,12 @@ Controls:
- `2` requests - `2` requests
- `3` logs - `3` logs
- `4` profiles - `4` profiles
- `,` / `.` select request-table column for visibility/sort actions - `c` edit request-table column visibility, order, and widths
- `v` toggle the selected requests column visible/hidden
- `c` sort requests by the selected column
- `C` toggle requests sort ascending/descending
- `e` edit selected profile in `$VISUAL` / `$EDITOR` - `e` edit selected profile in `$VISUAL` / `$EDITOR`
- `/` filter - `/` filter
- `r` refresh - `r` refresh
- `m` switch request table compact/wide width profile - `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 - `p` probe selected profile
- `s` switch to selected profile - `s` switch to selected profile
- `w` save selected profile snapshot - `w` save selected profile snapshot
@@ -114,4 +111,4 @@ Controls:
This client uses only the public gateway admin API and does not need SSH or secrets. This client uses only the public gateway admin API and does not need SSH or secrets.
Profile editing uses a temp TOML draft opened in `$VISUAL` or `$EDITOR` and then saves it back through the gateway profiles API. 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.
+74 -4
View File
@@ -23,7 +23,7 @@ from typing import Any
APP_NAME = "codex-retry-gateway-tui" APP_NAME = "codex-retry-gateway-tui"
FALLBACK_VERSION = "0.1.4" FALLBACK_VERSION = "0.1.5"
DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway" DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway"
DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway" DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway"
DEFAULT_API_URL = DEFAULT_GATEWAY_URL DEFAULT_API_URL = DEFAULT_GATEWAY_URL
@@ -1403,6 +1403,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
"error": str(entry.get("error") or ""), "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_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_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_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 ""), "upstream_auth_source": str(entry.get("upstream")["auth_source"] if isinstance(entry.get("upstream"), dict) else entry.get("upstream_auth_source") or ""),
"raw": entry, "raw": entry,
@@ -1456,6 +1457,8 @@ def render_request_detail(row: dict[str, Any]) -> str:
bits.append(f"origin {row['upstream_origin']}") bits.append(f"origin {row['upstream_origin']}")
if row.get("upstream_path"): if row.get("upstream_path"):
bits.append(f"upstream path {row['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"): if row.get("upstream_auth_mode"):
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}") bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
retry_wave = request_retry_wave_summary(row) retry_wave = request_retry_wave_summary(row)
@@ -1490,6 +1493,14 @@ def normalize_profile_rows(payload: dict[str, Any], filter_text: str = "") -> li
"auth_file": str(summary.get("auth_file") or ""), "auth_file": str(summary.get("auth_file") or ""),
"auth_json_path": str(summary.get("auth_json_path") or ""), "auth_json_path": str(summary.get("auth_json_path") or ""),
"auth_json_key": str(summary.get("auth_json_key") 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"), "request_history_limit": summary.get("request_history_limit"),
"model_remap": str(summary.get("model_remap") or ""), "model_remap": str(summary.get("model_remap") or ""),
"auth_source": str(summary.get("auth_source") or "-"), "auth_source": str(summary.get("auth_source") or "-"),
@@ -1506,9 +1517,14 @@ def render_profile_detail(row: dict[str, Any]) -> str:
f"{row['name']}", f"{row['name']}",
"active" if row["active"] else "inactive", "active" if row["active"] else "inactive",
f"listen {row['listen_host']}:{row['listen_port']}", f"listen {row['listen_host']}:{row['listen_port']}",
f"upstream {short_text(row['upstream_base_url'], 64)}", f"text upstream {short_text(row['upstream_base_url'], 64)}",
f"auth {row['auth_mode']}/{row['auth_source']}", 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: if row.get("request_history_limit") is not None:
bits.append(f"history {row['request_history_limit']}") bits.append(f"history {row['request_history_limit']}")
if row.get("reasoning_equals"): if row.get("reasoning_equals"):
@@ -1538,6 +1554,15 @@ def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
"manual_secret_configured": parse_bool_value(form.get("manual_secret_configured"), False), "manual_secret_configured": parse_bool_value(form.get("manual_secret_configured"), False),
"auth_json_path": form.get("auth_json_path") or "", "auth_json_path": form.get("auth_json_path") or "",
"auth_json_key": form.get("auth_json_key") or row.get("auth_json_key") 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")) "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 if parse_int_value(form.get("request_history_limit") or row.get("request_history_limit")) is not None
else DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT, else DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT,
@@ -1573,6 +1598,15 @@ def profile_payload_from_row(row: dict[str, Any]) -> dict[str, Any]:
"manual_secret_configured": bool(state.get("manual_secret_configured")), "manual_secret_configured": bool(state.get("manual_secret_configured")),
"auth_json_path": str(state.get("auth_json_path") or "").strip(), "auth_json_path": str(state.get("auth_json_path") or "").strip(),
"auth_json_key": str(state.get("auth_json_key") 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"), "request_history_limit": state.get("request_history_limit"),
"model_remap": str(state.get("model_remap") or "").strip(), "model_remap": str(state.get("model_remap") or "").strip(),
"reasoning_equals": state.get("reasoning_equals") or [], "reasoning_equals": state.get("reasoning_equals") or [],
@@ -1595,6 +1629,8 @@ def profile_editor_document(row: dict[str, Any]) -> str:
[ [
"# Edit the selected codex-retry-gateway profile and save.", "# Edit the selected codex-retry-gateway profile and save.",
"# Leave manual_secret empty to keep the current secret file.", "# 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.", "# Changing name creates a new profile file; it does not delete the old one.",
"", "",
f"name = {toml_string(state.get('name'))}", f"name = {toml_string(state.get('name'))}",
@@ -1609,6 +1645,18 @@ def profile_editor_document(row: dict[str, Any]) -> str:
f"manual_secret_configured = {toml_bool(state.get('manual_secret_configured'))}", 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_path = {toml_string(state.get('auth_json_path'))}",
f"auth_json_key = {toml_string(state.get('auth_json_key'))}", 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"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"model_remap = {model_remap_value}",
f"reasoning_equals = {toml_int_list(state.get('reasoning_equals'))}", f"reasoning_equals = {toml_int_list(state.get('reasoning_equals'))}",
@@ -1639,6 +1687,15 @@ def profile_payload_from_editor_text(text: str) -> dict[str, Any]:
"manual_secret_configured": parse_bool_value(data.get("manual_secret_configured"), False), "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_path": str(data.get("auth_json_path") or "").strip(),
"auth_json_key": str(data.get("auth_json_key") 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")), "request_history_limit": parse_int_value(data.get("request_history_limit")),
"model_remap": str(data.get("model_remap") or "").strip(), "model_remap": str(data.get("model_remap") or "").strip(),
"reasoning_equals": normalize_editor_integer_list(data.get("reasoning_equals")), "reasoning_equals": normalize_editor_integer_list(data.get("reasoning_equals")),
@@ -1918,7 +1975,18 @@ def run_textual(
profiles = self.query_one("#profiles_table", DataTable) profiles = self.query_one("#profiles_table", DataTable)
profiles.cursor_type = "row" profiles.cursor_type = "row"
profiles.zebra_stripes = True profiles.zebra_stripes = True
profiles.add_columns("Name", "Active", "Listen", "Upstream", "Auth", "History", "Reasoning", "Source") profiles.add_columns(
"Name",
"Active",
"Listen",
"Text Upstream",
"Text Auth",
"Image Upstream",
"Image Auth",
"History",
"Reasoning",
"Source",
)
self._set_view("overview") self._set_view("overview")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
@@ -2193,6 +2261,8 @@ def run_textual(
f"{row['listen_host']}:{row['listen_port']}", f"{row['listen_host']}:{row['listen_port']}",
short_text(row["upstream_base_url"], 24), short_text(row["upstream_base_url"], 24),
f"{row['auth_mode']}/{row['auth_source']}", 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 "-"), str(row["request_history_limit"] if row["request_history_limit"] is not None else "-"),
short_text(row["reasoning_equals"], 12), short_text(row["reasoning_equals"], 12),
short_text(row["file_path"], 28), short_text(row["file_path"], 28),
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "codex-retry-gateway-tui" name = "codex-retry-gateway-tui"
version = "0.1.4" version = "0.1.5"
description = "Terminal UI for codex-retry-gateway monitoring and control" description = "Terminal UI for codex-retry-gateway monitoring and control"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+16
View File
@@ -409,6 +409,11 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"reasoning_equals": [516, 1034], "reasoning_equals": [516, 1034],
"raw": { "raw": {
"form": { "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": [ "retryable_error_messages": [
"Selected model is at capacity. Please try a different model.", "Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
@@ -419,6 +424,17 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
text = mod.profile_editor_document(row) text = mod.profile_editor_document(row)
payload = mod.profile_payload_from_editor_text(text) payload = mod.profile_payload_from_editor_text(text)
self.assertEqual(payload["name"], "pc") 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("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.assertIn( self.assertIn(
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
payload["retryable_error_messages"], payload["retryable_error_messages"],