From e4b7fc679e4625f3188d48cc54de90886d9bd3cd Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Fri, 10 Jul 2026 07:04:50 +0800 Subject: [PATCH] feat: separate image profiles in tui --- README.md | 5 +- codex_retry_gateway_tui.py | 392 +++++++++++++++++++++++++++---------- pyproject.toml | 2 +- tests/test_payload.py | 60 ++++-- 4 files changed, 338 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index ac6ccdf..7875e5c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/codex_retry_gateway_tui.py b/codex_retry_gateway_tui.py index 47ed65d..1339418 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.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 diff --git a/pyproject.toml b/pyproject.toml index f058cd5..e5b5288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_payload.py b/tests/test_payload.py index 7d8c59d..3734bae 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -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: