diff --git a/README.md b/README.md index c988b4b..78b6b46 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,22 @@ Install as a user command: ```bash uv tool install git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git -codex-retry-gateway-tui --api-url http://127.0.0.1:4610/__codex_retry_gateway +codex-retry-gateway-tui ``` -Default gateway URL: +Default behavior: ```text -http://127.0.0.1:4610/__codex_retry_gateway +1. reuse saved ~/.config/codex-retry-gateway-tui/api-url when present +2. otherwise auto-discover ~/.codex-retry-gateway/state.json gateway_base_url +3. otherwise fall back to ~/.codex-retry-gateway/config/config.json listen_host/listen_port +4. otherwise use http://127.0.0.1:4610/__codex_retry_gateway +``` + +Manual override example: + +```bash +codex-retry-gateway-tui --api-url http://100.115.235.115:4610 ``` Configuration: diff --git a/codex_retry_gateway_tui.py b/codex_retry_gateway_tui.py index 5408f53..70b45bf 100644 --- a/codex_retry_gateway_tui.py +++ b/codex_retry_gateway_tui.py @@ -19,10 +19,13 @@ from typing import Any APP_NAME = "codex-retry-gateway-tui" FALLBACK_VERSION = "0.1.0" +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 DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url" +DEFAULT_GATEWAY_STATE_FILE = "~/.codex-retry-gateway/state.json" +DEFAULT_GATEWAY_JSON_CONFIG_FILE = "~/.codex-retry-gateway/config/config.json" DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui/raw/branch/main/pyproject.toml" DEFAULT_REFRESH_SECONDS = 10 DEFAULT_TIMEOUT_SECONDS = 5 @@ -60,14 +63,61 @@ def configured_url(env_names: tuple[str, ...], config_path: str, default: str = return default +def read_json_object(path: str) -> dict[str, Any]: + try: + data = json.loads(Path(path).expanduser().read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def gateway_admin_url(base_url: str) -> str: + normalized = normalize_gateway_url(base_url) + if not normalized: + return "" + parsed = urllib.parse.urlparse(normalized) + path = parsed.path.rstrip("/") + if not path: + path = DEFAULT_GATEWAY_ADMIN_PATH + elif not path.endswith(DEFAULT_GATEWAY_ADMIN_PATH): + path = f"{path}{DEFAULT_GATEWAY_ADMIN_PATH}" + parsed = parsed._replace(path=path, params="", query="", fragment="") + return urllib.parse.urlunparse(parsed).rstrip("/") + + +def normalize_listen_host(value: Any) -> str: + host = str(value or "").strip() + if host in {"", "0.0.0.0", "::", "[::]", "*"}: + return "127.0.0.1" + return host + + +def discover_gateway_url() -> str: + state = read_json_object(DEFAULT_GATEWAY_STATE_FILE) + gateway_base_url = str(state.get("gateway_base_url") or "").strip() + if gateway_base_url: + return gateway_admin_url(gateway_base_url) + + config = read_json_object(DEFAULT_GATEWAY_JSON_CONFIG_FILE) + listen_host = normalize_listen_host(config.get("listen_host")) + listen_port = as_int(config.get("listen_port")) + if listen_host and listen_port > 0: + return gateway_admin_url(f"http://{listen_host}:{listen_port}") + return "" + + def default_api_url() -> str: - return normalize_gateway_url( - configured_url( - ("CODEX_RETRY_GATEWAY_TUI_API_URL",), - os.environ.get("CODEX_RETRY_GATEWAY_TUI_API_URL_FILE", DEFAULT_CONFIG_FILE), - DEFAULT_API_URL, - ) + configured = configured_url( + ("CODEX_RETRY_GATEWAY_TUI_API_URL",), + os.environ.get("CODEX_RETRY_GATEWAY_TUI_API_URL_FILE", DEFAULT_CONFIG_FILE), + "", ) + if configured: + return gateway_admin_url(configured) + discovered = discover_gateway_url() + if discovered: + return discovered + return gateway_admin_url(DEFAULT_API_URL) def config_file_path() -> Path: @@ -128,7 +178,7 @@ def build_api_url(gateway_url: str, suffix: str, params: dict[str, Any] | None = def write_api_url_config(api_url: str) -> Path: - value = str(api_url or "").strip() + value = gateway_admin_url(api_url) if not value: raise ValueError("api url is empty") path = config_file_path() @@ -639,7 +689,7 @@ def fetch_dashboard_snapshot( filter_text: str = "", current_view: str = "overview", ) -> dict[str, Any]: - gateway_root = normalize_gateway_url(gateway_url) + gateway_root = gateway_admin_url(gateway_url) endpoints = { "status": status_url or gateway_status_url(gateway_root), "logs": build_api_url(gateway_root, "/api/logs", {"limit": 200}), @@ -695,7 +745,7 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in print("Textual is required. Run with: uv run --with textual python codex_retry_gateway_tui.py", file=sys.stderr) return 2 - gateway_base_url = normalize_gateway_url(api_url) + gateway_base_url = gateway_admin_url(api_url) status_api_url = status_url or gateway_status_url(gateway_base_url) class CodexRetryGatewayTui(App[None]): @@ -1193,6 +1243,7 @@ def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) + args.api_url = gateway_admin_url(args.api_url) status_url = str(args.status_url or "").strip() or default_status_url() if args.save_config or args.install: config_path = write_api_url_config(args.api_url) diff --git a/pyproject.toml b/pyproject.toml index 6b1d674..7827d2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-retry-gateway-tui" -version = "0.1.0" +version = "0.1.1" 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 a71da6c..8488d23 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -1,9 +1,13 @@ from __future__ import annotations import importlib.util +import json +import os import sys from pathlib import Path +import tempfile import unittest +from unittest import mock def load_module(): @@ -56,6 +60,10 @@ class CodexRetryGatewayTUITests(unittest.TestCase): def test_gateway_url_helpers_and_request_age(self) -> None: mod = load_module() + self.assertEqual( + mod.gateway_admin_url("http://127.0.0.1:4610"), + "http://127.0.0.1:4610/__codex_retry_gateway", + ) self.assertEqual( mod.normalize_gateway_url("http://127.0.0.1:4610/__codex_retry_gateway/api/status"), "http://127.0.0.1:4610/__codex_retry_gateway", @@ -66,6 +74,40 @@ class CodexRetryGatewayTUITests(unittest.TestCase): ) self.assertIn("updated", mod.render_request_detail({"seq": 1, "request_id": "r", "method": "POST", "path": "/responses", "status_code": 200, "upstream_status_code": 200, "upstream_attempt_count": 1, "first_response_delay_ms": 10, "duration_ms": 20, "request_body_bytes": 3, "response_bytes_received": 4, "stream_chunk_count": 1, "started_at": "2026-06-30T00:00:00Z", "finished_at": "2026-06-30T00:00:01Z", "usage_last_updated_at": "2026-06-30T00:00:01Z"})) + def test_default_api_url_discovers_gateway_state(self) -> None: + mod = load_module() + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "state.json" + config_path = Path(tmpdir) / "config.json" + api_url_path = Path(tmpdir) / "api-url" + state_path.write_text(json.dumps({"gateway_base_url": "http://100.115.235.115:4610"}), encoding="utf-8") + config_path.write_text("{}", encoding="utf-8") + mod.DEFAULT_GATEWAY_STATE_FILE = str(state_path) + mod.DEFAULT_GATEWAY_JSON_CONFIG_FILE = str(config_path) + with mock.patch.dict(os.environ, {"CODEX_RETRY_GATEWAY_TUI_API_URL_FILE": str(api_url_path)}, clear=False): + self.assertEqual( + mod.default_api_url(), + "http://100.115.235.115:4610/__codex_retry_gateway", + ) + + def test_default_api_url_discovers_gateway_config_when_state_missing(self) -> None: + mod = load_module() + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "missing-state.json" + config_path = Path(tmpdir) / "config.json" + api_url_path = Path(tmpdir) / "api-url" + config_path.write_text( + json.dumps({"listen_host": "0.0.0.0", "listen_port": 4610}), + encoding="utf-8", + ) + mod.DEFAULT_GATEWAY_STATE_FILE = str(state_path) + mod.DEFAULT_GATEWAY_JSON_CONFIG_FILE = str(config_path) + with mock.patch.dict(os.environ, {"CODEX_RETRY_GATEWAY_TUI_API_URL_FILE": str(api_url_path)}, clear=False): + self.assertEqual( + mod.default_api_url(), + "http://127.0.0.1:4610/__codex_retry_gateway", + ) + if __name__ == "__main__": unittest.main()