From c52afce6841e3616caa04f64e8b1d4d280e3b7a1 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Fri, 10 Jul 2026 05:26:09 +0800 Subject: [PATCH] feat: preserve image profile configuration --- README.md | 9 ++--- codex_retry_gateway_tui.py | 78 ++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- tests/test_payload.py | 16 ++++++++ 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 49fe212..ac6ccdf 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,12 @@ 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 +- `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 +111,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 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. diff --git a/codex_retry_gateway_tui.py b/codex_retry_gateway_tui.py index e39f339..47ed65d 100644 --- a/codex_retry_gateway_tui.py +++ b/codex_retry_gateway_tui.py @@ -23,7 +23,7 @@ from typing import Any 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_URL = "http://127.0.0.1:4610/__codex_retry_gateway" 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 ""), "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, @@ -1456,6 +1457,8 @@ def render_request_detail(row: dict[str, Any]) -> str: 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) @@ -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_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 "-"), @@ -1506,9 +1517,14 @@ def render_profile_detail(row: dict[str, Any]) -> str: 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("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 +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), "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, @@ -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")), "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 [], @@ -1595,6 +1629,8 @@ 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'))}", @@ -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"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'))}", @@ -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), "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")), @@ -1918,7 +1975,18 @@ 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") + profiles.add_columns( + "Name", + "Active", + "Listen", + "Text Upstream", + "Text Auth", + "Image Upstream", + "Image Auth", + "History", + "Reasoning", + "Source", + ) self._set_view("overview") self.refresh_data(refresh=True) @@ -2193,6 +2261,8 @@ def run_textual( 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), diff --git a/pyproject.toml b/pyproject.toml index 4b5c89d..f058cd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-retry-gateway-tui" -version = "0.1.4" +version = "0.1.5" description = "Terminal UI for codex-retry-gateway monitoring and control" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_payload.py b/tests/test_payload.py index 930dfcf..7d8c59d 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -409,6 +409,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,6 +424,17 @@ 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("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( "stream disconnected before completion: Concurrency limit exceeded for account, please retry later", payload["retryable_error_messages"],