← Files Smart Model RouterARCHIVED FILE

skills/smart-model-routing/scripts/client.py

8.17 KB · Oct 2, 2026 · 00:34 UTC

↓ Download file

"""Small stdio client for the installed Codex app-server. Uses Codex login only."""
import json
import os
from pathlib import Path
import queue
import shutil
import subprocess
import threading


def find_codex():
    found = shutil.which("codex.exe") or shutil.which("codex")
    if found and Path(found).suffix.lower() not in (".cmd", ".bat"):
        return found
    base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "OpenAI/Codex/bin"
    candidates = sorted(base.glob("*/codex.exe"), key=lambda p: p.stat().st_mtime, reverse=True)
    if candidates:
        return str(candidates[0])
    raise RuntimeError("找不到 Codex。請先安裝或更新 Codex 桌面版。")


class RpcError(RuntimeError):
    pass


class Client:
    def __init__(self, events=None, command=None):
        self.events = events if events is not None else queue.Queue()
        self.pending = {}
        self.lock = threading.Lock()
        self.next_id = 0
        self.closed = False
        self.process = subprocess.Popen(
            command or [find_codex(), "app-server", "--stdio", "-c", "agents.enabled=false"],
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
            text=True, encoding="utf-8", errors="replace", bufsize=1,
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
        )
        self.reader = threading.Thread(target=self._read, daemon=True)
        self.reader.start()

    def _read(self):
        try:
            for line in self.process.stdout:
                try:
                    msg = json.loads(line)
                except (ValueError, TypeError):
                    continue
                if "method" in msg:
                    self.events.put(msg)
                elif "id" in msg:
                    with self.lock:
                        waiter = self.pending.get(msg["id"])
                    if waiter:
                        waiter.put(msg)
        finally:
            with self.lock:
                for waiter in self.pending.values():
                    waiter.put({"error": {"message": "Codex 連線已關閉。"}})
            if not self.closed:
                self.events.put({"method": "router/disconnected", "params": {}})

    def send(self, message):
        with self.lock:
            if self.closed or self.process.poll() is not None:
                raise RpcError("Codex 連線已關閉,請重新連線。")
            self.process.stdin.write(json.dumps(message, ensure_ascii=False) + "\n")
            self.process.stdin.flush()

    def request(self, method, params=None, timeout=60):
        with self.lock:
            self.next_id += 1
            ident = self.next_id
            waiter = queue.Queue()
            self.pending[ident] = waiter
        try:
            self.send({"id": ident, "method": method, "params": params or {}})
            try:
                msg = waiter.get(timeout=timeout)
            except queue.Empty:
                raise RpcError(f"Codex 回應逾時({method})。不會自動重送工作,請重新連線後確認原工作狀態。")
            if "error" in msg:
                raise RpcError(msg["error"].get("message", "Codex 回應錯誤。"))
            return msg.get("result", {})
        finally:
            with self.lock:
                self.pending.pop(ident, None)

    def initialize(self):
        self.request("initialize", {"clientInfo": {"name": "codex_smart_router", "title": "Smart Model Router", "version": "0.2.1"}})
        self.send({"method": "initialized"})
        account = self.request("account/read", {"refreshToken": False}).get("account")
        if not account:
            raise RpcError("Codex CLI 尚未登入。請按「登入 Codex」,在瀏覽器完成登入後按「重新連線」。")
        if account.get("type") not in ("chatgpt", "chatgptAuthTokens"):
            raise RpcError("這個入口使用 ChatGPT 的 Codex 登入。請先用 ChatGPT 登入 Codex CLI。")
        catalog, cursor = [], None
        while True:
            params = {"limit": 100, "includeHidden": False}
            if cursor:
                params["cursor"] = cursor
            result = self.request("model/list", params)
            catalog.extend(result.get("data", []))
            cursor = result.get("nextCursor")
            if not cursor:
                break
        return catalog

    def respond(self, ident, result):
        self.send({"id": ident, "result": result})

    def close(self):
        if self.closed:
            return
        self.closed = True
        try:
            self.process.stdin.close()
            self.process.wait(timeout=5)
        except (OSError, subprocess.TimeoutExpired):
            # Only this client's own backend, never another project's processes.
            if self.process.poll() is None:
                self.process.terminate()
                try:
                    self.process.wait(timeout=3)
                except subprocess.TimeoutExpired:
                    self.process.kill()
                    self.process.wait(timeout=3)
        finally:
            self.reader.join(timeout=1)
            self.process.stdout.close()


def resource_profile(workspace):
    """Nearest ancestor profile wins; absent profile means no extra OS probe.

    This optional profile never replaces a project's AGENTS.md instructions.
    Only fixed data fields are read; a profile cannot supply shell commands.
    """
    current = Path(workspace).resolve(strict=True)
    for parent in (current, *current.parents):
        file = parent / ".codex" / "smart-router.json"
        if not file.is_file():
            continue
        try:
            if file.stat().st_size > 8192:
                raise ValueError("設定檔過大")
            profile = json.loads(file.read_text(encoding="utf-8-sig"))
            if not isinstance(profile, dict) or set(profile) - {"resource_check", "max_node_repl", "max_private_gib", "skip_busy_project"}:
                raise ValueError("設定欄位不正確")
            enabled = profile.get("resource_check", False)
            skip_busy = profile.get("skip_busy_project", True)
            if not isinstance(enabled, bool) or not isinstance(skip_busy, bool):
                raise ValueError("resource_check 和 skip_busy_project 必須是布林值")
            count = profile.get("max_node_repl", 96)
            memory = profile.get("max_private_gib", 8)
            if type(count) is not int or not 1 <= count <= 10000:
                raise ValueError("max_node_repl 必須是 1–10000 的整數")
            if type(memory) not in (int, float) or not 0 < memory <= 1024:
                raise ValueError("max_private_gib 必須大於 0 且不超過 1024")
        except (OSError, ValueError, TypeError) as exc:
            raise RuntimeError(f"無法讀取專案資源設定 {file}:{exc}") from exc
        return parent, {"resource_check": enabled, "max_node_repl": count, "max_private_gib": memory, "skip_busy_project": skip_busy}
    return current, {"resource_check": False}


def project_preflight(workspace):
    project, profile = resource_profile(workspace)
    if not profile["resource_check"]:
        return
    if os.name != "nt":
        raise RuntimeError("這個資源檢查目前只支援 Windows。")
    script = Path(__file__).with_name("resource-check.ps1")
    result = subprocess.run(
        ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script), "-ProjectRoot", str(project)],
        capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=20,
        creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
    )
    if result.returncode:
        raise RuntimeError("無法完成專案程序/記憶體檢查,這次不啟動工作。請確認系統允許讀取程序資訊。")
    state = json.loads(result.stdout)
    if state["nodeReplCount"] > profile["max_node_repl"] or state["privateBytes"] > profile["max_private_gib"] * 1024**3:
        raise RuntimeError("資源超過這個專案設定的門檻,請先釋放資源或重新啟動 Codex。")
    if profile["skip_busy_project"] and state["projectBusy"]:
        raise RuntimeError("此專案已有 npm/node 工作正在執行,這次跳過;不會終止原程序。")

SHA-256: d64976b7102018f99495f18996666dcc79c1a61e0adc2eb0d662f681afb92da8