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 - overview: current gateway status and summary counts
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at` - requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at`
- logs: recent gateway logs - 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: Controls:
@@ -97,6 +97,7 @@ Controls:
- `2` requests - `2` requests
- `3` logs - `3` logs
- `4` profiles - `4` profiles
- `i` switch the profiles table between text and image profiles
- `c` edit request-table column visibility, order, and widths - `c` edit request-table column visibility, order, and widths
- `e` edit selected profile in `$VISUAL` / `$EDITOR` - `e` edit selected profile in `$VISUAL` / `$EDITOR`
- `/` filter - `/` filter
@@ -111,4 +112,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. 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.
+274 -96
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.5" FALLBACK_VERSION = "0.1.6"
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
@@ -537,6 +537,7 @@ def normalize_gateway_url(api_url: str) -> str:
"/api/logs", "/api/logs",
"/api/requests", "/api/requests",
"/api/profiles", "/api/profiles",
"/api/image-profiles",
"/api/config", "/api/config",
"/api/restore", "/api/restore",
): ):
@@ -1261,11 +1262,12 @@ def summary_line(payload: dict[str, Any]) -> str:
listen = payload.get("listen") or "-" listen = payload.get("listen") or "-"
upstream = config.get("upstream_base_url") or "-" upstream = config.get("upstream_base_url") or "-"
active = config.get("profile_name") or state.get("profile_name") 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")) request_total = format_count(metrics.get("total_proxy_request_count"))
inspected = format_count(metrics.get("inspected_response_count")) inspected = format_count(metrics.get("inspected_response_count"))
matched = format_count(metrics.get("matched_response_count")) matched = format_count(metrics.get("matched_response_count"))
reasoning_516 = format_count(metrics.get("reasoning_516_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: 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"config {paths.get('config_path') or '-'}",
f"requests {paths.get('requests_path') or '-'}", f"requests {paths.get('requests_path') or '-'}",
f"profiles {paths.get('profiles_dir') or '-'}", f"profiles {paths.get('profiles_dir') or '-'}",
f"image profiles {paths.get('image_profiles_dir') or '-'}",
] ]
if state.get("gateway_base_url"): if state.get("gateway_base_url"):
bits.append(f"gateway {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 {} metrics = status_payload.get("metrics") if isinstance(status_payload.get("metrics"), dict) else {}
listen = status_payload.get("listen") or "-" listen = status_payload.get("listen") or "-"
return ( 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}" 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_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 "-"),
@@ -1512,6 +1507,39 @@ def normalize_profile_rows(payload: dict[str, Any], filter_text: str = "") -> li
return rows 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: def render_profile_detail(row: dict[str, Any]) -> str:
bits = [ bits = [
f"{row['name']}", 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 upstream {short_text(row['upstream_base_url'], 64)}",
f"text 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 +1561,20 @@ def render_profile_detail(row: dict[str, Any]) -> str:
return " | ".join(bits) 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]: def profile_form_state(row: dict[str, Any]) -> dict[str, Any]:
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {} raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
form = raw.get("form") if isinstance(raw.get("form"), 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), "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,
@@ -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")), "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 [],
@@ -1629,8 +1648,6 @@ 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'))}",
@@ -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_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'))}",
@@ -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), "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")),
@@ -1744,6 +1741,108 @@ def edit_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | No
pass 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: def edit_request_table_preferences_with_editor(preferences: dict[str, Any]) -> dict[str, Any] | None:
original_text = request_table_preferences_document(preferences) original_text = request_table_preferences_document(preferences)
editor_command = resolve_editor_command() editor_command = resolve_editor_command()
@@ -1824,12 +1923,13 @@ def fetch_dashboard_snapshot(
"logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}), "logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}),
"requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}), "requests": build_api_url(gateway_root, "/api/requests", {"limit": 200}),
"profiles": build_api_url(gateway_root, "/api/profiles"), "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]: def fetch_optional(url: str) -> tuple[dict[str, Any], str]:
return fetch_optional_payload(url, timeout, access_key) return fetch_optional_payload(url, timeout, access_key)
with ThreadPoolExecutor(max_workers=4) as pool: with ThreadPoolExecutor(max_workers=5) as pool:
futures = { futures = {
name: pool.submit(fetch_optional, url) name: pool.submit(fetch_optional, url)
for name, url in endpoints.items() for name, url in endpoints.items()
@@ -1838,12 +1938,14 @@ def fetch_dashboard_snapshot(
logs_payload, logs_error = futures["logs"].result() logs_payload, logs_error = futures["logs"].result()
requests_payload, requests_error = futures["requests"].result() requests_payload, requests_error = futures["requests"].result()
profiles_payload, profiles_error = futures["profiles"].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 {} payload = status_payload if isinstance(status_payload, dict) else {}
requests_source = requests_payload if isinstance(requests_payload, dict) else {} requests_source = requests_payload if isinstance(requests_payload, dict) else {}
logs_source = logs_payload if isinstance(logs_payload, dict) else {} logs_source = logs_payload if isinstance(logs_payload, dict) else {}
profiles_source = profiles_payload if isinstance(profiles_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 { return {
"payload": payload, "payload": payload,
"status_payload": payload, "status_payload": payload,
@@ -1854,6 +1956,10 @@ def fetch_dashboard_snapshot(
profiles_source, profiles_source,
filter_text if current_view == "profiles" else "", 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": ( "active_profile_name": (
str( str(
payload.get("config", {}).get("profile_name") payload.get("config", {}).get("profile_name")
@@ -1862,6 +1968,14 @@ def fetch_dashboard_snapshot(
or "" or ""
).strip() ).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"), ("2", "show_requests", "Requests"),
("3", "show_logs", "Logs"), ("3", "show_logs", "Logs"),
("4", "show_profiles", "Profiles"), ("4", "show_profiles", "Profiles"),
("i", "toggle_profile_kind", "Text/Image"),
("m", "toggle_request_table_density", "Wide/Compact"), ("m", "toggle_request_table_density", "Wide/Compact"),
("c", "edit_request_columns", "Columns"), ("c", "edit_request_columns", "Columns"),
("shift+left", "scroll_table_left", "Scroll Left"), ("shift+left", "scroll_table_left", "Scroll Left"),
@@ -1924,8 +2039,10 @@ def run_textual(
self.requests: list[dict[str, Any]] = [] self.requests: list[dict[str, Any]] = []
self.logs: list[dict[str, Any]] = [] self.logs: list[dict[str, Any]] = []
self.profiles: 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.request_by_key: dict[str, dict[str, Any]] = {}
self.profile_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.log_by_key: dict[str, dict[str, Any]] = {}
self.current_view = "overview" self.current_view = "overview"
self.filter_mode = "requests" self.filter_mode = "requests"
@@ -1934,6 +2051,8 @@ def run_textual(
self.executor = ThreadPoolExecutor(max_workers=1) self.executor = ThreadPoolExecutor(max_workers=1)
self.refresh_generation = 0 self.refresh_generation = 0
self.active_profile_name = "" self.active_profile_name = ""
self.active_image_profile_name = ""
self.profile_kind = "text"
self.visible_tables = { self.visible_tables = {
"overview": "requests", "overview": "requests",
"requests": "requests", "requests": "requests",
@@ -1975,18 +2094,7 @@ 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( self._rebuild_profile_table_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)
@@ -2002,7 +2110,7 @@ def run_textual(
self.query_one("#logs_table").display = active_table == "logs" self.query_one("#logs_table").display = active_table == "logs"
self.query_one("#profiles_table").display = active_table == "profiles" self.query_one("#profiles_table").display = active_table == "profiles"
self.query_one("#controls", Static).update( 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._focus_table()
self._update_table_headers() self._update_table_headers()
@@ -2019,7 +2127,7 @@ def run_textual(
def _update_table_headers(self) -> None: def _update_table_headers(self) -> None:
visible_columns = request_table_visible_columns(self.request_table_preferences) visible_columns = request_table_visible_columns(self.request_table_preferences)
counts = ( 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" f"req cols {len(visible_columns)}/{len(REQUEST_TABLE_COLUMNS)} | c edit columns"
) )
self.query_one("#tables", Static).update(counts) self.query_one("#tables", Static).update(counts)
@@ -2094,6 +2202,35 @@ def run_textual(
else: else:
self.request_by_key = {} 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: def _current_table_widget(self) -> DataTable | None:
table_id = self.visible_tables.get(self.current_view, "requests") table_id = self.visible_tables.get(self.current_view, "requests")
if table_id == "logs": if table_id == "logs":
@@ -2110,10 +2247,11 @@ def run_textual(
def _select_profile_row(self, profile_name: str) -> bool: def _select_profile_row(self, profile_name: str) -> bool:
table = self.query_one("#profiles_table", DataTable) table = self.query_one("#profiles_table", DataTable)
if not self.profiles: rows = self._profile_rows()
if not rows:
return False return False
if profile_name: if profile_name:
for index, row in enumerate(self.profiles): for index, row in enumerate(rows):
if row["name"] == profile_name: if row["name"] == profile_name:
return self._select_table_row(table, index) return self._select_table_row(table, index)
return self._select_table_row(table, 0) return self._select_table_row(table, 0)
@@ -2130,9 +2268,10 @@ def run_textual(
table = self.query_one("#profiles_table", DataTable) table = self.query_one("#profiles_table", DataTable)
if table.cursor_row is None or table.cursor_row < 0: if table.cursor_row is None or table.cursor_row < 0:
return None return None
if table.cursor_row >= len(self.profiles): rows = self._profile_rows()
if table.cursor_row >= len(rows):
return None return None
return self.profiles[table.cursor_row] return rows[table.cursor_row]
def _selected_log_row(self) -> dict[str, Any] | None: def _selected_log_row(self) -> dict[str, Any] | None:
table = self.query_one("#logs_table", DataTable) table = self.query_one("#logs_table", DataTable)
@@ -2180,12 +2319,14 @@ def run_textual(
self.requests = result["requests"] self.requests = result["requests"]
self.logs = result["logs"] self.logs = result["logs"]
self.profiles = result["profiles"] self.profiles = result["profiles"]
self.image_profiles = result["image_profiles"]
self.active_profile_name = result.get("active_profile_name") or "" 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.last_request_key = selected_request_key
self.loading = False self.loading = False
self.render_all() self.render_all()
if self.current_view == "profiles": 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("") self._select_profile_row("")
version_bits = [version_message] if version_message else [] version_bits = [version_message] if version_message else []
version_bits.append(status_summary(self.payload, self.status_error)) version_bits.append(status_summary(self.payload, self.status_error))
@@ -2217,7 +2358,7 @@ def run_textual(
elif self.current_view == "profiles": elif self.current_view == "profiles":
row = self._selected_profile_row() row = self._selected_profile_row()
if row: if row:
self.render_detail(row, kind="profile") self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
else: else:
self.query_one("#detail", Static).update("no profiles") self.query_one("#detail", Static).update("no profiles")
@@ -2251,31 +2392,46 @@ def run_textual(
def render_profiles(self) -> None: def render_profiles(self) -> None:
table = self.query_one("#profiles_table", DataTable) table = self.query_one("#profiles_table", DataTable)
table.clear() table.clear()
rows = self._profile_rows()
if self.profile_kind == "image":
self.image_profile_by_key = {}
else:
self.profile_by_key = {} self.profile_by_key = {}
for row in self.profiles: for row in rows:
key = row["name"] key = row["name"]
if self.profile_kind == "image":
self.image_profile_by_key[key] = row
table.add_row(
row["name"],
"yes" if row["active"] else "",
short_text(row["base_url"], 36),
f"{row['auth_mode']}/{row['auth_source']}",
short_text(row["file_path"], 36),
key=key,
)
else:
self.profile_by_key[key] = row self.profile_by_key[key] = row
table.add_row( table.add_row(
row["name"], row["name"],
"yes" if row["active"] else "", "yes" if row["active"] else "",
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"], 28),
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"], 32),
key=key, key=key,
) )
if self.current_view == "profiles" and self.active_profile_name: if self.current_view == "profiles" and self._active_profile_name():
self._select_profile_row(self.active_profile_name) self._select_profile_row(self._active_profile_name())
def render_detail(self, row: dict[str, Any], *, kind: str) -> None: def render_detail(self, row: dict[str, Any], *, kind: str) -> None:
if kind == "request": if kind == "request":
self.query_one("#detail", Static).update(render_request_detail(row)) self.query_one("#detail", Static).update(render_request_detail(row))
elif kind == "profile": elif kind == "profile":
self.query_one("#detail", Static).update(render_profile_detail(row)) 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: else:
message = str(row.get("message") or "").strip() message = str(row.get("message") or "").strip()
self.query_one("#detail", Static).update(f"seq {row['seq']} | {short_time(row['at'])} | {message}") 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: def action_show_profiles(self) -> None:
self._set_view("profiles") 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("") 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: def action_newest_request(self) -> None:
if self.requests: if self.requests:
self.render_detail(self.requests[0], kind="request") self.render_detail(self.requests[0], kind="request")
@@ -2393,7 +2562,8 @@ def run_textual(
row = self._selected_profile_row() row = self._selected_profile_row()
if not row: if not row:
return 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 == "-": if not upstream or upstream == "-":
return return
open_url(upstream) open_url(upstream)
@@ -2408,11 +2578,15 @@ def run_textual(
return return
try: try:
with self.suspend(): 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: if payload is None:
self._set_status(f"edit {row['name']} cancelled (no changes)") self._set_status(f"edit {row['name']} cancelled (no changes)")
return 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._set_status(f"edit {payload.get('name') or row['name']} -> {status} | {short_text(result, 96)}")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
except Exception as exc: except Exception as exc:
@@ -2424,7 +2598,7 @@ def run_textual(
return return
try: try:
status, payload = post_json( 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, timeout,
{"profile": row["name"]}, {"profile": row["name"]},
access_key, access_key,
@@ -2439,7 +2613,7 @@ def run_textual(
return return
try: try:
status, payload = post_json( 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, timeout,
{"profile": row["name"]}, {"profile": row["name"]},
access_key, access_key,
@@ -2453,9 +2627,9 @@ def run_textual(
row = self._selected_profile_row() row = self._selected_profile_row()
if not row: if not row:
return 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: 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._set_status(f"save profile -> {status} | {short_text(result, 96)}")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
except Exception as exc: except Exception as exc:
@@ -2466,7 +2640,11 @@ def run_textual(
if not row or row["active"]: if not row or row["active"]:
return return
try: 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._set_status(f"delete {row['name']} -> {status} | {short_text(payload, 96)}")
self.refresh_data(refresh=True) self.refresh_data(refresh=True)
except Exception as exc: except Exception as exc:
@@ -2490,9 +2668,9 @@ def run_textual(
self.render_detail(row, kind="log") self.render_detail(row, kind="log")
elif event.data_table.id == "profiles_table": elif event.data_table.id == "profiles_table":
key = str(event.row_key.value) 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: if row:
self.render_detail(row, kind="profile") self.render_detail(row, kind="image_profile" if self.profile_kind == "image" else "profile")
CodexRetryGatewayTui().run() CodexRetryGatewayTui().run()
return 0 return 0
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "codex-retry-gateway-tui" name = "codex-retry-gateway-tui"
version = "0.1.5" version = "0.1.6"
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"
+49 -11
View File
@@ -28,7 +28,7 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"config": {"profile_name": "pc", "upstream_base_url": "https://example.com/v1"}, "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}, "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)) self.assertIn("req 11", mod.summary_line(payload))
def test_request_rows_sort_desc_and_keep_request_id(self) -> None: 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) rows = mod.normalize_profile_rows(payload)
self.assertEqual([row["name"] for row in rows], ["alpha", "beta"]) 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: def test_version_update_message_only_for_newer_versions(self) -> None:
mod = load_module() mod = load_module()
self.assertIn("0.1.0 -> 0.1.1", mod.version_update_message("0.1.1", "0.1.0")) 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(1532), "1.5s")
self.assertEqual(mod.format_duration_ms_as_seconds(120000), "120s") 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() mod = load_module()
row = { row = {
"name": "pc", "name": "pc",
@@ -424,22 +436,48 @@ 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.assertNotIn("image_base_url", payload)
self.assertEqual(payload["image_auth_mode"], "manual_bearer") self.assertNotIn("image_base_url", text)
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) self.assertNotIn("test-image-profile-secret", text)
saved_payload = mod.profile_payload_from_row(row) saved_payload = mod.profile_payload_from_row(row)
self.assertEqual(saved_payload["image_base_url"], "https://images.example/v1") self.assertNotIn("image_base_url", saved_payload)
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"],
) )
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: def test_default_api_url_discovers_gateway_state(self) -> None:
mod = load_module() mod = load_module()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir: