commit eae4df7d514d5374a983469114508fb98891a8e6 Author: richarjiang Date: Thu Sep 24 10:04:41 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8637873 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4df10b --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Laya Router Hook + +本地常驻 [Laya](https://github.com/NandhaKishorM/laya) multilingual 模型,在 Codex 和 Claude Code 每次提交 prompt 时注入一条 System-1 路由提示。它不切换底层模型,只补充任务复杂度、是否需要计划、读仓库、查文档和深度推理的判断。 + +```text +Prompt + -> UserPromptSubmit hook(标准库,失败则放行) + -> http://127.0.0.1:8765 + -> Laya multilingual + -> additionalContext + -> Codex / Claude Code +``` + +Cursor 会加载 Claude Code 的 hook 配置,但当前事件无法把上下文注入对话。Hook 发现 `CURSOR_VERSION` 时直接返回,不增加延迟。 + +## 安装 + +需要 macOS、[uv](https://docs.astral.sh/uv/getting-started/installation/) 和 Python 3.10+。 + +```bash +./install.sh +``` + +脚本会: + +1. 在 `~/.laya-router/.venv` 创建虚拟环境,并把本仓库安装进去 +2. 下载 `convaiinnovations/laya` 的 `multilingual` 权重 +3. 注册 launchd 用户服务 `com.laya-router`(登录自启,进程退出后拉起) +4. 检测到 Codex / Claude Code 时写入 hook + +安装的是一份普通副本,不是指向仓库的可编辑安装。仓库如果在 `~/Documents` 下,launchd 直接 import 那个目录会卡在文件打开上。`laya-router restart` 会先把当前仓库同步进虚拟环境再重启。 + +只注册一边: + +```bash +./install.sh --only codex +./install.sh --only claude +``` + +Codex 第一次会要求信任 hook。启动 `codex` 后运行 `/hooks` 确认。命令行本身不变时,修改 Python 不会再次触发信任审查。 + +## 日常命令 + +命令来自 `~/.laya-router/.venv`。`./install.sh` 之后可以把这个 `bin` 加到 `PATH`,或直接用绝对路径。 + +浏览器打开 [http://127.0.0.1:8765/](http://127.0.0.1:8765/) 可以按会话看每次判断。页面通过服务端事件实时追加,左边是会话,右边是这一路 prompt 的判定过程。 + +```bash +laya-router status +laya-router logs +laya-router logs -f +laya-router route "帮我把这个函数的变量名改掉" +laya-router restart +laya-router stop +laya-router start +``` + +卸载 hook 和服务,但保留运行目录: + +```bash +./uninstall.sh +``` + +连同配置、日志、虚拟环境一起删除: + +```bash +./uninstall.sh --purge +``` + +## 配置 + +`~/.laya-router/config.toml` 在首次安装时生成,之后不会被覆盖。 + +| 键 | 含义 | +| --- | --- | +| `device` | `auto`、`mps`、`cuda` 或 `cpu`。`auto` 预热失败会回退 | +| `log_prompts` | 是否把原文写入 `data/routes.jsonl` | +| `request_timeout` | Hook 等待服务的秒数,默认 2.5 | +| `max_prompt_chars` | 送入模型的最大字符数,超长保留头尾 | + +改完配置或服务端代码后执行 `laya-router restart`。 + +## 迭代 + +改完保存后执行 `laya-router restart`。它会把仓库同步进 `~/.laya-router` 再拉起服务。Hook 是每次 prompt 新起的进程,同步完成后下一条 prompt 就会用到新的 `hook.py` 和 `render.py`。 + +| 文件 | 作用 | 生效方式 | +| --- | --- | --- | +| `src/laya_router/questions.py` | 路由问题。改措辞时把 `QUESTIONS_VERSION` 加一 | `laya-router restart` | +| `src/laya_router/render.py` | 注入给 Agent 的提示文本 | `laya-router restart`,然后下一条 prompt | +| `src/laya_router/hook.py` | 超时、跳过斜杠命令、失败放行 | `laya-router restart`,然后下一条 prompt | +| `src/laya_router/server.py` | 模型加载与 `/route` | `laya-router restart` | + +不要给服务加 `--reload`。开发模式会再起进程,把模型加载多次。 + +路由记录在 `~/.laya-router/data/routes.jsonl`,包含判断、耗时和问题版本,用来以后对照真实任务做微调。`laya-multilingual` 没有自带温度校准,注入文本里的概率只是提示。 + +服务挂掉时 hook 输出 `{}` 并退出 0,Codex 和 Claude Code 照常工作。原因写在 `~/.laya-router/logs/hook.log`。 + +## 开发 + +```bash +PYTHONPATH=src uv run --no-project --with pytest python -m pytest +``` + +单元测试不加载模型。装好服务后用 `laya-router route` 做真实推理检查。 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b76ff61 --- /dev/null +++ b/install.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Create the runtime venv, then register the launchd service and client hooks. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +HOME_DIR="${LAYA_ROUTER_HOME:-$HOME/.laya-router}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "当前安装脚本只支持 macOS(launchd)。" >&2 + exit 1 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "需要 uv。安装: https://docs.astral.sh/uv/getting-started/installation/" >&2 + exit 1 +fi + +mkdir -p "$HOME_DIR" + +if [[ ! -x "$HOME_DIR/.venv/bin/python" ]]; then + created=0 + for version in 3.13 3.12 3.11 3.10; do + rm -rf "$HOME_DIR/.venv" + if uv venv "$HOME_DIR/.venv" --python "$version"; then + created=1 + break + fi + done + if [[ "$created" -ne 1 ]]; then + echo "需要 Python 3.10 或更高版本。可先执行: uv python install 3.13" >&2 + exit 1 + fi +fi + +# Install a real copy. An editable install points at this repo, which often lives +# under ~/Documents. launchd then blocks forever in open() on that directory. +uv pip install --python "$HOME_DIR/.venv/bin/python" --reinstall-package laya-router "$ROOT" +printf '%s\n' "$ROOT" > "$HOME_DIR/source.path" +exec "$HOME_DIR/.venv/bin/laya-router" install "$@" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d60bdf1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "laya-router" +version = "0.1.0" +description = "Local Laya multilingual System-1 router for Codex and Claude Code" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "laya>=0.3.7", + "fastapi>=0.110", + "uvicorn>=0.27", +] + +[project.scripts] +laya-router = "laya_router.cli:main" +laya-hook = "laya_router.hook:main" + +[dependency-groups] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel.force-include] +"src/laya_router/dashboard.html" = "laya_router/dashboard.html" diff --git a/src/laya_router/__init__.py b/src/laya_router/__init__.py new file mode 100644 index 0000000..360b0e2 --- /dev/null +++ b/src/laya_router/__init__.py @@ -0,0 +1,3 @@ +"""Local Laya multilingual router for coding agents.""" + +__version__ = "0.1.0" diff --git a/src/laya_router/cli.py b/src/laya_router/cli.py new file mode 100644 index 0000000..6be055b --- /dev/null +++ b/src/laya_router/cli.py @@ -0,0 +1,330 @@ +"""``laya-router`` command line.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(line_buffering=True) + except Exception: + pass + +from laya_router import __version__ +from laya_router.config import ensure_layout, load_settings, runtime_home +from laya_router.launchd import bootstrap, status_text, stop, uninstall_agent + + +def sync_source(settings) -> None: + """Copy the repo into the venv. launchd must not import a path under ~/Documents.""" + marker = settings.home / "source.path" + if not marker.is_file(): + return + source = Path(marker.read_text(encoding="utf-8").strip()) + if not (source / "pyproject.toml").is_file(): + print(f"跳过代码同步,找不到 {source}", file=sys.stderr) + return + uv = shutil.which("uv") + python = settings.venv_dir / "bin" / "python" + if uv is None or not python.is_file(): + print("跳过代码同步,找不到 uv 或虚拟环境。", file=sys.stderr) + return + print(f"同步代码: {source}", flush=True) + result = subprocess.run( + [ + uv, + "pip", + "install", + "--python", + str(python), + "--reinstall-package", + "laya-router", + str(source), + ], + check=False, + ) + if result.returncode != 0: + raise SystemExit("代码同步失败") + + +def _settings(): + home = runtime_home() + home.mkdir(parents=True, exist_ok=True) + ensure_layout(load_settings(home)) + return load_settings(home) + + +def _clients(only: list[str] | None, *, detected_only: bool): + from laya_router.clients import ClaudeClient, CodexClient + + clients = [CodexClient(), ClaudeClient()] + if only: + return [client for client in clients if client.name in only] + if detected_only: + return [client for client in clients if client.detect()] + return clients + + +def cmd_serve(_: argparse.Namespace) -> int: + os.environ.setdefault("USE_TF", "0") + settings = _settings() + import uvicorn + + print(f"laya-router {__version__} on {settings.base_url}", flush=True) + print(f"看板 {settings.base_url}/", flush=True) + uvicorn.run( + "laya_router.server:app", + host=settings.host, + port=settings.port, + workers=1, + reload=False, + log_level="info", + ) + return 0 + + +def cmd_download(_: argparse.Namespace) -> int: + settings = _settings() + from huggingface_hub import snapshot_download + + print(f"正在下载 {settings.model} / {settings.subfolder} ...", flush=True) + path = snapshot_download(settings.model, allow_patterns=[f"{settings.subfolder}/**"]) + print(f"权重已缓存: {path}") + return 0 + + +def wait_until_healthy(settings, timeout: float) -> dict | None: + deadline = time.time() + timeout + url = f"{settings.base_url}/health" + last_error = "服务尚未监听" + announced = -10.0 + started = time.time() + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as response: + body = json.loads(response.read().decode("utf-8")) + if isinstance(body, dict) and body.get("status") == "ok" and body.get("model") == "laya-multilingual": + return body + last_error = f"意外的健康检查响应: {body}" + except Exception as exc: + last_error = str(exc) + elapsed = time.time() - started + if elapsed - announced >= 10: + announced = elapsed + print(f"仍在等待模型加载({int(elapsed)} 秒): {last_error}", flush=True) + time.sleep(1) + print(f"健康检查超时: {last_error}", file=sys.stderr) + return None + + +def cmd_route(args: argparse.Namespace) -> int: + settings = _settings() + payload = json.dumps({"prompt": args.prompt, "client": "cli"}).encode("utf-8") + request = urllib.request.Request( + f"{settings.base_url}/route", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=args.timeout) as response: + body = json.loads(response.read().decode("utf-8")) + except urllib.error.URLError as exc: + print(f"路由服务不可用: {exc}", file=sys.stderr) + print("先执行 ./install.sh,或查看 `laya-router logs`。", file=sys.stderr) + return 1 + print(json.dumps(body, ensure_ascii=False, indent=2)) + return 0 + + +def _print_log_tail(settings, lines: int = 40) -> None: + path = settings.server_log_path + if not path.is_file(): + return + content = path.read_text(encoding="utf-8", errors="replace").splitlines() + print("--- server.log ---", file=sys.stderr) + print("\n".join(content[-lines:]), file=sys.stderr) + + +def cmd_install(args: argparse.Namespace) -> int: + settings = _settings() + sync_source(settings) + if not settings.hook_bin.is_file() or not settings.router_bin.is_file(): + print( + f"找不到 {settings.hook_bin}。请在仓库里执行 ./install.sh,它会创建虚拟环境。", + file=sys.stderr, + ) + return 1 + if not args.skip_download: + if cmd_download(args) != 0: + return 1 + print("正在安装并启动 launchd 服务...", flush=True) + try: + bootstrap(settings) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + health = wait_until_healthy(settings, args.health_timeout) + if health is None: + _print_log_tail(settings) + return 1 + print(f"服务就绪: {settings.base_url} device={health.get('device')}", flush=True) + + clients = _clients(args.only, detected_only=True) + if not clients: + print("没有检测到 Codex 或 Claude Code。服务已启动,稍后可执行 `laya-router install --only codex`。") + for client in clients: + try: + changed = client.install(settings) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"写入 {client.name} hook 失败: {exc}", file=sys.stderr) + return 1 + state = "已更新" if changed else "已是最新" + print(f"{client.name}: hook {state}") + + print("冒烟测试...", flush=True) + smoke = argparse.Namespace( + prompt="Cocos Creator 微信小游戏加载远程分包时出现 401,请帮我定位整个加载链路的问题", + timeout=60, + ) + if cmd_route(smoke) != 0: + return 1 + print() + print("安装完成。") + print("- Codex: 启动 codex 后运行 /hooks,首次需要信任这条 hook。") + print("- Claude Code: 新会话会自动加载。") + print(f"- 路由记录: {settings.routes_path}") + print("- 查看服务: laya-router status") + return 0 + + +def cmd_uninstall(args: argparse.Namespace) -> int: + settings = _settings() + for client in _clients(args.only, detected_only=False): + try: + client.uninstall() + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"移除 {client.name} hook 失败: {exc}", file=sys.stderr) + return 1 + print(f"{client.name}: 已移除 hook") + uninstall_agent() + print("已停止并移除 launchd 服务。") + if args.purge: + home = settings.home.resolve() + if home == Path.home().resolve() or home == Path("/"): + print("拒绝删除该运行目录。", file=sys.stderr) + return 1 + shutil.rmtree(home) + print(f"已删除 {home}") + else: + print(f"运行目录仍保留: {settings.home}") + return 0 + + +def cmd_start(_: argparse.Namespace) -> int: + settings = _settings() + sync_source(settings) + try: + bootstrap(settings) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + print(status_text()) + return 0 + + +def cmd_stop(_: argparse.Namespace) -> int: + stop() + print("已停止。") + return 0 + + +def cmd_restart(args: argparse.Namespace) -> int: + stop() + return cmd_start(args) + + +def cmd_status(_: argparse.Namespace) -> int: + settings = _settings() + print(status_text()) + try: + with urllib.request.urlopen(f"{settings.base_url}/health", timeout=2) as response: + body = json.loads(response.read().decode("utf-8")) + print(json.dumps(body, ensure_ascii=False)) + return 0 + except Exception as exc: + print(f"健康检查失败: {exc}") + return 1 + + +def cmd_logs(args: argparse.Namespace) -> int: + path = _settings().server_log_path + if not path.is_file(): + print(f"还没有日志: {path}") + return 0 + if args.follow: + os.execvp("tail", ["tail", "-n", str(args.lines), "-f", str(path)]) + content = path.read_text(encoding="utf-8", errors="replace").splitlines() + print("\n".join(content[-args.lines :])) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="laya-router", description="本地 Laya 路由服务") + parser.add_argument("--version", action="version", version=__version__) + sub = parser.add_subparsers(dest="command", required=True) + + install = sub.add_parser("install", help="下载模型、启动服务并注册 hook") + install.add_argument("--only", action="append", choices=("codex", "claude")) + install.add_argument("--skip-download", action="store_true") + install.add_argument("--health-timeout", type=int, default=600) + install.set_defaults(func=cmd_install) + + uninstall = sub.add_parser("uninstall", help="移除 hook 和 launchd 服务") + uninstall.add_argument("--only", action="append", choices=("codex", "claude")) + uninstall.add_argument("--purge", action="store_true", help="同时删除运行目录") + uninstall.set_defaults(func=cmd_uninstall) + + for name, func, help_text in ( + ("serve", cmd_serve, "前台运行服务(launchd 使用)"), + ("start", cmd_start, "通过 launchd 启动"), + ("stop", cmd_stop, "停止 launchd 服务"), + ("restart", cmd_restart, "重启 launchd 服务"), + ("status", cmd_status, "查看服务状态"), + ("download", cmd_download, "预下载 multilingual 权重"), + ): + command = sub.add_parser(name, help=help_text) + command.set_defaults(func=func) + + logs = sub.add_parser("logs", help="查看服务日志") + logs.add_argument("-f", "--follow", action="store_true") + logs.add_argument("-n", "--lines", type=int, default=80) + logs.set_defaults(func=cmd_logs) + + route = sub.add_parser("route", help="对一条 prompt 做路由") + route.add_argument("prompt") + route.add_argument("--timeout", type=float, default=30) + route.set_defaults(func=cmd_route) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/laya_router/clients/__init__.py b/src/laya_router/clients/__init__.py new file mode 100644 index 0000000..4b48342 --- /dev/null +++ b/src/laya_router/clients/__init__.py @@ -0,0 +1,6 @@ +"""Client adapters. Importing this package does not load the model.""" + +from laya_router.clients.claude import ClaudeClient +from laya_router.clients.codex import CodexClient + +__all__ = ["ClaudeClient", "CodexClient"] diff --git a/src/laya_router/clients/base.py b/src/laya_router/clients/base.py new file mode 100644 index 0000000..166ff0e --- /dev/null +++ b/src/laya_router/clients/base.py @@ -0,0 +1,130 @@ +"""Shared hook-config merge. Entries are recognized by the ``laya-hook`` command.""" + +from __future__ import annotations + +import json +import shutil +from datetime import datetime +from pathlib import Path + + +def additional_context_output(context: str) -> dict: + return { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": context, + } + } + + +def is_our_command(command: str) -> bool: + return "laya-hook" in command + + +def backup_file(path: Path) -> Path | None: + if not path.is_file(): + return None + stamp = datetime.now().strftime("%Y%m%d%H%M%S") + dest = path.with_name(f"{path.name}.bak-{stamp}") + shutil.copy2(path, dest) + return dest + + +def write_json(path: Path, data: dict) -> bool: + """Write JSON if it changed. Returns True when the file was updated.""" + text = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + if path.is_file() and path.read_text(encoding="utf-8") == text: + return False + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_file(): + backup_file(path) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + return True + + +def read_json(path: Path) -> dict: + if not path.is_file(): + return {} + loaded = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(loaded, dict): + raise ValueError(f"{path} 的顶层必须是 JSON 对象") + return loaded + + +def _hook_entry(command: str, *, timeout: int, status_message: str | None) -> dict: + entry = { + "type": "command", + "command": command, + "timeout": timeout, + } + if status_message: + entry["statusMessage"] = status_message + return entry + + +def upsert_user_prompt_hook( + config: dict, + command: str, + *, + timeout: int = 5, + status_message: str | None = None, +) -> dict: + """Insert or replace this project's UserPromptSubmit hook. Other hooks stay.""" + hooks = config.setdefault("hooks", {}) + if not isinstance(hooks, dict): + raise ValueError("hooks 字段必须是对象") + event = hooks.get("UserPromptSubmit", []) + if not isinstance(event, list): + raise ValueError("hooks.UserPromptSubmit 必须是数组") + + kept: list = [] + for group in event: + if not isinstance(group, dict): + kept.append(group) + continue + inner = group.get("hooks") + if not isinstance(inner, list): + kept.append(group) + continue + remaining = [ + item + for item in inner + if not (isinstance(item, dict) and is_our_command(str(item.get("command", "")))) + ] + if remaining: + kept.append({**group, "hooks": remaining}) + + kept.append({"hooks": [_hook_entry(command, timeout=timeout, status_message=status_message)]}) + hooks["UserPromptSubmit"] = kept + return config + + +def remove_user_prompt_hook(config: dict) -> dict: + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return config + event = hooks.get("UserPromptSubmit") + if not isinstance(event, list): + return config + + kept: list = [] + for group in event: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + kept.append(group) + continue + remaining = [ + item + for item in group["hooks"] + if not (isinstance(item, dict) and is_our_command(str(item.get("command", "")))) + ] + if remaining: + kept.append({**group, "hooks": remaining}) + if kept: + hooks["UserPromptSubmit"] = kept + else: + hooks.pop("UserPromptSubmit", None) + if not hooks: + config.pop("hooks", None) + return config diff --git a/src/laya_router/clients/claude.py b/src/laya_router/clients/claude.py new file mode 100644 index 0000000..7499744 --- /dev/null +++ b/src/laya_router/clients/claude.py @@ -0,0 +1,36 @@ +"""Merge the router hook into ``~/.claude/settings.json`` without dropping other hooks.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from laya_router.clients.base import read_json, remove_user_prompt_hook, upsert_user_prompt_hook, write_json +from laya_router.config import Settings, hook_command + + +class ClaudeClient: + name = "claude" + + def __init__(self, config_path: Path | None = None): + self.config_path = config_path or Path.home() / ".claude" / "settings.json" + + def detect(self) -> bool: + return self.config_path.parent.is_dir() or shutil.which("claude") is not None + + def install(self, settings: Settings) -> bool: + config = read_json(self.config_path) + upsert_user_prompt_hook( + config, + hook_command(settings, self.name), + timeout=5, + status_message=None, + ) + return write_json(self.config_path, config) + + def uninstall(self) -> bool: + if not self.config_path.is_file(): + return False + config = read_json(self.config_path) + remove_user_prompt_hook(config) + return write_json(self.config_path, config) diff --git a/src/laya_router/clients/codex.py b/src/laya_router/clients/codex.py new file mode 100644 index 0000000..b36950b --- /dev/null +++ b/src/laya_router/clients/codex.py @@ -0,0 +1,80 @@ +"""Install the router hook into ``~/.codex/hooks.json``. + +Codex 0.142 keeps ``hooks`` as a stable feature that defaults to on. This +installer does not rewrite ``config.toml``. It only runs ``codex features enable +hooks`` when the effective flag is off. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +from laya_router.clients.base import read_json, remove_user_prompt_hook, upsert_user_prompt_hook, write_json +from laya_router.config import Settings, hook_command + + +def hooks_feature_enabled(listing: str) -> bool | None: + for line in listing.splitlines(): + parts = line.split() + if len(parts) >= 3 and parts[0] == "hooks": + return parts[-1] == "true" + return None + + +class CodexClient: + name = "codex" + + def __init__(self, codex_dir: Path | None = None): + self.codex_dir = codex_dir or Path.home() / ".codex" + + @property + def hooks_path(self) -> Path: + return self.codex_dir / "hooks.json" + + def detect(self) -> bool: + return self.codex_dir.is_dir() or shutil.which("codex") is not None + + def install(self, settings: Settings) -> bool: + self.ensure_hooks_feature() + config = read_json(self.hooks_path) + config.setdefault("description", "Laya local System-1 router") + upsert_user_prompt_hook( + config, + hook_command(settings, self.name), + timeout=5, + status_message="Laya routing", + ) + return write_json(self.hooks_path, config) + + def uninstall(self) -> bool: + if not self.hooks_path.is_file(): + return False + config = read_json(self.hooks_path) + remove_user_prompt_hook(config) + if config.get("description") == "Laya local System-1 router" and "hooks" not in config: + config.pop("description", None) + if not config: + self.hooks_path.unlink() + return True + return write_json(self.hooks_path, config) + + def ensure_hooks_feature(self) -> None: + codex = shutil.which("codex") + if codex is None: + return + try: + listing = subprocess.run( + [codex, "features", "list"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired): + return + enabled = hooks_feature_enabled(listing.stdout) + if enabled is not False: + return + subprocess.run([codex, "features", "enable", "hooks"], check=False, timeout=30) diff --git a/src/laya_router/config.py b/src/laya_router/config.py new file mode 100644 index 0000000..e657cfa --- /dev/null +++ b/src/laya_router/config.py @@ -0,0 +1,171 @@ +"""Runtime paths and settings. + +The installed service and hooks read ``~/.laya-router/config.toml``. +``LAYA_ROUTER_HOME`` overrides the runtime directory so tests stay isolated. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.11+ has tomllib + import tomli as tomllib # type: ignore + + +DEFAULT_CONFIG_TOML = """\ +# Laya 本地路由服务。改完后执行 `laya-router restart`。 +host = "127.0.0.1" +port = 8765 + +# auto 会按 mps、cuda、cpu 的顺序尝试,预热失败则回退下一项。 +device = "auto" + +model = "convaiinnovations/laya" +subfolder = "multilingual" + +# 为后续微调保留原始 prompt。设为 false 时只记录哈希。 +log_prompts = true + +# Hook 调用本服务的超时(秒)。应小于客户端 hook timeout(5 秒)。 +request_timeout = 2.5 + +# 送进模型的最大字符数。中英混合下大约落在 1024 token 以内,超长时保留头尾。 +max_prompt_chars = 3000 +""" + + +def runtime_home() -> Path: + override = os.environ.get("LAYA_ROUTER_HOME") + if override: + return Path(override).expanduser() + return Path.home() / ".laya-router" + + +@dataclass(frozen=True) +class Settings: + home: Path + host: str = "127.0.0.1" + port: int = 8765 + device: str = "auto" + model: str = "convaiinnovations/laya" + subfolder: str = "multilingual" + log_prompts: bool = True + request_timeout: float = 2.5 + max_prompt_chars: int = 3000 + + @property + def config_path(self) -> Path: + return self.home / "config.toml" + + @property + def log_dir(self) -> Path: + return self.home / "logs" + + @property + def data_dir(self) -> Path: + return self.home / "data" + + @property + def routes_path(self) -> Path: + return self.data_dir / "routes.jsonl" + + @property + def hook_log_path(self) -> Path: + return self.log_dir / "hook.log" + + @property + def server_log_path(self) -> Path: + return self.log_dir / "server.log" + + @property + def venv_dir(self) -> Path: + return self.home / ".venv" + + @property + def hook_bin(self) -> Path: + return self.venv_dir / "bin" / "laya-hook" + + @property + def router_bin(self) -> Path: + return self.venv_dir / "bin" / "laya-router" + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + +def _as_bool(value: object, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return default + + +def load_settings(home: Path | None = None) -> Settings: + home = home or runtime_home() + data: dict = {} + path = home / "config.toml" + if path.is_file(): + with path.open("rb") as handle: + loaded = tomllib.load(handle) + if not isinstance(loaded, dict): + raise ValueError(f"{path} 的顶层必须是表") + data = loaded + + host = str(data.get("host", "127.0.0.1")) + port = int(data.get("port", 8765)) + device = str(data.get("device", "auto")) + model = str(data.get("model", "convaiinnovations/laya")) + subfolder = str(data.get("subfolder", "multilingual")) + log_prompts = _as_bool(data.get("log_prompts", True), True) + request_timeout = float(data.get("request_timeout", 2.5)) + max_prompt_chars = int(data.get("max_prompt_chars", 3000)) + + if os.environ.get("LAYA_ROUTER_HOST"): + host = os.environ["LAYA_ROUTER_HOST"] + if os.environ.get("LAYA_ROUTER_PORT"): + port = int(os.environ["LAYA_ROUTER_PORT"]) + if os.environ.get("LAYA_ROUTER_DEVICE"): + device = os.environ["LAYA_ROUTER_DEVICE"] + if os.environ.get("LAYA_ROUTER_LOG_PROMPTS"): + log_prompts = _as_bool(os.environ["LAYA_ROUTER_LOG_PROMPTS"], log_prompts) + + if port <= 0 or port > 65535: + raise ValueError(f"无效端口: {port}") + if request_timeout <= 0: + raise ValueError(f"request_timeout 必须大于 0: {request_timeout}") + if max_prompt_chars < 200: + raise ValueError(f"max_prompt_chars 过小: {max_prompt_chars}") + + return Settings( + home=home, + host=host, + port=port, + device=device, + model=model, + subfolder=subfolder, + log_prompts=log_prompts, + request_timeout=request_timeout, + max_prompt_chars=max_prompt_chars, + ) + + +def ensure_layout(settings: Settings) -> None: + settings.log_dir.mkdir(parents=True, exist_ok=True) + settings.data_dir.mkdir(parents=True, exist_ok=True) + if not settings.config_path.exists(): + settings.config_path.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8") + + +def hook_command(settings: Settings, client: str) -> str: + """Absolute command written into Codex / Claude hook config. + + The command string stays stable when hook logic changes, so Codex does not + have to re-review the hook after every code edit. + """ + return f'"{settings.hook_bin}" --client {client}' diff --git a/src/laya_router/dashboard.html b/src/laya_router/dashboard.html new file mode 100644 index 0000000..0a33b86 --- /dev/null +++ b/src/laya_router/dashboard.html @@ -0,0 +1,322 @@ + + + + + +Laya 路由看板 + + + + + + +
+
+

路由值班室

+

正在连接本地 Laya…

+
+
+ + 等待事件 + +
+
+
+ +
+
+ + + diff --git a/src/laya_router/feed.py b/src/laya_router/feed.py new file mode 100644 index 0000000..24e2f1d --- /dev/null +++ b/src/laya_router/feed.py @@ -0,0 +1,64 @@ +"""Route history and live subscribers for the dashboard. + +The inference path is synchronous, so new records are handed to asyncio queues +with call_soon_threadsafe. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +from pathlib import Path + + +class LiveHub: + def __init__(self) -> None: + self._lock = threading.Lock() + self._subs: list[tuple[asyncio.AbstractEventLoop, asyncio.Queue]] = [] + + def subscribe(self, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue) -> None: + with self._lock: + self._subs.append((loop, queue)) + + def unsubscribe(self, queue: asyncio.Queue) -> None: + with self._lock: + self._subs = [(loop, item) for loop, item in self._subs if item is not queue] + + def publish(self, record: dict) -> None: + with self._lock: + subs = list(self._subs) + for loop, queue in subs: + loop.call_soon_threadsafe(_offer, queue, record) + + +def _offer(queue: asyncio.Queue, record: dict) -> None: + if queue.full(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(record) + except asyncio.QueueFull: + pass + + +def read_routes(path: Path, limit: int = 300) -> list[dict]: + if limit <= 0 or not path.is_file(): + return [] + records: list[dict] = [] + with path.open(encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + records.append(item) + if len(records) > limit: + return records[-limit:] + return records diff --git a/src/laya_router/hook.py b/src/laya_router/hook.py new file mode 100644 index 0000000..28ff12b --- /dev/null +++ b/src/laya_router/hook.py @@ -0,0 +1,134 @@ +"""UserPromptSubmit hook. Standard library only, so it starts without torch. + +Any failure prints ``{}`` and exits 0. A down router must not block Codex or +Claude Code. Cursor sets ``CURSOR_VERSION`` even when it loads the Claude Code +hook file; that path cannot inject context, so the hook returns immediately. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.request +from collections.abc import Mapping +from datetime import datetime + +from laya_router.clients.base import additional_context_output +from laya_router.config import Settings, load_settings +from laya_router.render import is_slash_command, render_context, truncate_prompt + + +def extract_prompt(payload: Mapping) -> str: + for key in ("prompt", "user_prompt", "text"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value + return "" + + +def extract_meta(payload: Mapping) -> tuple[str | None, str | None]: + cwd = None + for key in ("cwd", "workspace_root"): + value = payload.get(key) + if isinstance(value, str) and value: + cwd = value + break + if cwd is None: + roots = payload.get("workspace_roots") + if isinstance(roots, list) and roots and isinstance(roots[0], str): + cwd = roots[0] + if cwd is None: + cwd = os.getcwd() + + session_id = None + for key in ("session_id", "sessionId", "conversation_id", "thread_id"): + value = payload.get(key) + if isinstance(value, str) and value: + session_id = value + break + return cwd, session_id + + +def call_laya(settings: Settings, body: dict) -> dict: + data = json.dumps(body).encode("utf-8") + request = urllib.request.Request( + f"{settings.base_url}/route", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=settings.request_timeout) as response: + parsed = json.loads(response.read().decode("utf-8")) + if not isinstance(parsed, dict) or "level" not in parsed: + raise ValueError("路由服务返回了无法识别的 JSON") + return parsed + + +def log_hook_error(settings: Settings, exc: BaseException) -> None: + message = f"{datetime.now().isoformat(timespec='seconds')} {type(exc).__name__}: {exc}\n" + try: + settings.log_dir.mkdir(parents=True, exist_ok=True) + with settings.hook_log_path.open("a", encoding="utf-8") as handle: + handle.write(message) + except Exception: + print(f"Laya router unavailable: {exc}", file=sys.stderr) + + +def handle(payload: Mapping, env: Mapping[str, str], client: str) -> str: + if env.get("CURSOR_VERSION"): + return "{}" + + prompt = extract_prompt(payload) + if not prompt or is_slash_command(prompt): + return "{}" + + try: + settings = load_settings() + except Exception as exc: + print(f"Laya router config error: {exc}", file=sys.stderr) + return "{}" + + cwd, session_id = extract_meta(payload) + body = { + "prompt": truncate_prompt(prompt, settings.max_prompt_chars), + "client": client, + "cwd": cwd, + "session_id": session_id, + } + try: + result = call_laya(settings, body) + context = render_context(result) + except Exception as exc: + log_hook_error(settings, exc) + return "{}" + return json.dumps(additional_context_output(context), ensure_ascii=False) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Inject a local Laya routing hint") + parser.add_argument("--client", choices=("codex", "claude"), default="codex") + args = parser.parse_args(argv) + + raw = sys.stdin.read() + try: + payload = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + payload = {} + if not isinstance(payload, dict): + payload = {} + + try: + text = handle(payload, os.environ, args.client) + except Exception as exc: + print(f"Laya router unavailable: {exc}", file=sys.stderr) + text = "{}" + sys.stdout.write(text) + if not text.endswith("\n"): + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/laya_router/launchd.py b/src/laya_router/launchd.py new file mode 100644 index 0000000..d06700f --- /dev/null +++ b/src/laya_router/launchd.py @@ -0,0 +1,118 @@ +"""macOS user agent that keeps the router process alive.""" + +from __future__ import annotations + +import os +import plistlib +import subprocess +import time +from pathlib import Path + +from laya_router.config import Settings + +LABEL = "com.laya-router" + + +def plist_path() -> Path: + return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist" + + +def domain() -> str: + return f"gui/{os.getuid()}" + + +def service_target() -> str: + return f"{domain()}/{LABEL}" + + +def agent_plist(settings: Settings) -> dict: + # Keep PATH small. The agent environment can include app bundles whose + # directories block forever when a daemon opens them. + path = f"{settings.venv_dir / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin" + return { + "Label": LABEL, + "ProgramArguments": [str(settings.router_bin), "serve"], + "WorkingDirectory": str(settings.home), + "RunAtLoad": True, + "KeepAlive": True, + "ThrottleInterval": 15, + "StandardOutPath": str(settings.server_log_path), + "StandardErrorPath": str(settings.server_log_path), + "EnvironmentVariables": { + "USE_TF": "0", + "PYTHONUNBUFFERED": "1", + "HOME": str(Path.home()), + "PATH": path, + "LAYA_ROUTER_HOME": str(settings.home), + }, + } + + +def write_plist(settings: Settings) -> Path: + path = plist_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(plistlib.dumps(agent_plist(settings), sort_keys=False)) + return path + + +def _run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, check=False, capture_output=True, text=True) + + +def bootout() -> None: + _run(["launchctl", "bootout", service_target()]) + + +def _wait_until_unloaded(timeout: float = 20) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if _run(["launchctl", "print", service_target()]).returncode != 0: + return + time.sleep(0.5) + + +def bootstrap(settings: Settings) -> None: + path = write_plist(settings) + bootout() + # bootout returns before a model process finishes exiting. Bootstrap then + # fails with EIO if the label is still registered. + _wait_until_unloaded() + result = None + for _ in range(3): + result = _run(["launchctl", "bootstrap", domain(), str(path)]) + if result.returncode == 0: + break + time.sleep(1) + bootout() + _wait_until_unloaded() + if result is None or result.returncode != 0: + detail = ((result.stderr if result else "") or (result.stdout if result else "")).strip() + raise RuntimeError(f"launchctl bootstrap 失败: {detail}") + _run(["launchctl", "enable", service_target()]) + kick = _run(["launchctl", "kickstart", "-k", service_target()]) + if kick.returncode != 0 and _run(["launchctl", "print", service_target()]).returncode != 0: + detail = (kick.stderr or kick.stdout).strip() + raise RuntimeError(f"launchctl kickstart 失败: {detail}") + + +def stop() -> None: + bootout() + + +def uninstall_agent() -> None: + bootout() + path = plist_path() + if path.is_file(): + path.unlink() + + +def status_text() -> str: + result = _run(["launchctl", "print", service_target()]) + if result.returncode != 0: + return "launchd 服务未加载" + lines = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith(("state =", "pid =", "path =")): + lines.append(stripped) + return "\n".join(lines) if lines else result.stdout.strip() diff --git a/src/laya_router/questions.py b/src/laya_router/questions.py new file mode 100644 index 0000000..8c6478e --- /dev/null +++ b/src/laya_router/questions.py @@ -0,0 +1,61 @@ +"""Routing questions sent to Laya in one forward pass. + +``choice`` is used for task level because Laya's ordinal ``score`` primitive is +the weakest of the three. Bump ``QUESTIONS_VERSION`` when the wording changes +so logged decisions stay interpretable. +""" + +from __future__ import annotations + +QUESTIONS_VERSION = "1" + +QUESTIONS: dict = { + "task_level": { + "type": "choice", + "instructions": ( + "Classify the complexity of this software engineering request." + ), + "criteria": { + "simple": ( + "Small isolated task. Explanation, lookup, tiny code change, " + "simple syntax fix, or obvious bug." + ), + "normal": ( + "Normal engineering task. Requires reading several files, " + "implementing a feature, debugging, refactoring, or several steps." + ), + "complex": ( + "Complex engineering task. Architecture design, difficult debugging, " + "cross-module change, unfamiliar system, performance investigation, " + "large refactor, or substantial reasoning." + ), + }, + }, + "needs_plan": { + "type": "noul", + "instructions": ( + "Should the coding agent make a plan before implementing this request?" + ), + }, + "needs_repo": { + "type": "noul", + "instructions": ( + "Does this request require inspecting multiple files or understanding " + "the existing repository before answering?" + ), + }, + "needs_web": { + "type": "noul", + "instructions": ( + "Does this request likely require current external documentation, " + "library documentation, APIs, or web research?" + ), + }, + "needs_deep_reasoning": { + "type": "noul", + "instructions": ( + "Does this software engineering request require substantial reasoning, " + "architecture analysis, difficult debugging, or evaluating tradeoffs?" + ), + }, +} diff --git a/src/laya_router/render.py b/src/laya_router/render.py new file mode 100644 index 0000000..89d125a --- /dev/null +++ b/src/laya_router/render.py @@ -0,0 +1,59 @@ +"""Text injected into the coding agent as extra developer context. + +Edit this template and restart is not required for the hook process: the hook +is a fresh process per prompt. The resident model is unchanged. +""" + +from __future__ import annotations + +import re + + +def truncate_prompt(text: str, limit: int) -> str: + text = text.strip() + if len(text) <= limit: + return text + marker = "\n\n[...truncated...]\n\n" + tail = min(800, max(100, limit // 4)) + head = limit - tail - len(marker) + if head < 100: + return text[:limit] + return text[:head] + marker + text[-tail:] + + +def is_slash_command(prompt: str) -> bool: + """True for agent commands like ``/hooks``, false for paths like ``/Users``.""" + stripped = prompt.strip() + if not stripped: + return False + first = stripped.split(None, 1)[0] + return re.fullmatch(r"/[A-Za-z][\w:-]*", first) is not None + + +def render_context(result: dict) -> str: + level = result["level"] + confidence = float(result["level_confidence"]) + needs_plan = float(result["needs_plan"]) + needs_repo = float(result["needs_repo"]) + needs_web = float(result["needs_web"]) + needs_deep = float(result["needs_deep_reasoning"]) + return ( + "Local System-1 routing analysis " + "(uncalibrated probabilities from laya-multilingual; hint only, not a model switch):\n" + "\n" + f"Task complexity: {level} (confidence {confidence:.2f})\n" + f"Needs planning: {needs_plan:.2f}\n" + f"Needs repository exploration: {needs_repo:.2f}\n" + f"Needs web/docs research: {needs_web:.2f}\n" + f"Needs deep reasoning: {needs_deep:.2f}\n" + "\n" + "Use this only as a routing hint. These probabilities are not temperature-fitted.\n" + "\n" + "Guidance:\n" + "- simple: solve directly; avoid unnecessary planning or exploration.\n" + "- normal: inspect relevant code and execute normally.\n" + "- complex: investigate first, reason carefully, and create a plan when useful.\n" + "- high needs_repo (around 0.7 or above): inspect the repository before making assumptions.\n" + "- high needs_web (around 0.7 or above): consult current documentation when available.\n" + "- high needs_deep_reasoning (around 0.7 or above): reason through tradeoffs before editing.\n" + ) diff --git a/src/laya_router/result.py b/src/laya_router/result.py new file mode 100644 index 0000000..cc9af2c --- /dev/null +++ b/src/laya_router/result.py @@ -0,0 +1,29 @@ +"""Turn a Laya ``answers`` payload into the stable router response.""" + +from __future__ import annotations + +from laya_router.questions import QUESTIONS_VERSION + + +def _noul(answers: dict, name: str) -> float: + entry = answers[name] + if "noul" not in entry: + raise KeyError(f"问题 {name} 缺少 noul 字段") + return float(entry["noul"]) + + +def normalize_answers(answers: dict) -> dict: + level = answers["task_level"] + if "choice" not in level: + raise KeyError("task_level 缺少 choice 字段") + return { + "level": level["choice"], + "level_confidence": float(level.get("confidence", 0.0)), + "level_probabilities": dict(level.get("probabilities") or {}), + "needs_plan": _noul(answers, "needs_plan"), + "needs_repo": _noul(answers, "needs_repo"), + "needs_web": _noul(answers, "needs_web"), + "needs_deep_reasoning": _noul(answers, "needs_deep_reasoning"), + "questions_version": QUESTIONS_VERSION, + "raw": answers, + } diff --git a/src/laya_router/server.py b/src/laya_router/server.py new file mode 100644 index 0000000..238c86e --- /dev/null +++ b/src/laya_router/server.py @@ -0,0 +1,211 @@ +"""Local HTTP front for the resident Laya multilingual checkpoint. + +Importing this module does not load torch. The model is loaded in the FastAPI +lifespan so a failed device can be discarded before the next candidate is tried. +""" + +from __future__ import annotations + +import asyncio +import gc +import hashlib +import json +import os +import threading +import time +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path + +# Transformers can stall while probing TensorFlow. Set this before laya imports it. +os.environ.setdefault("USE_TF", "0") + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse, StreamingResponse +from pydantic import BaseModel, Field + +from laya_router.config import Settings, load_settings +from laya_router.feed import LiveHub, read_routes +from laya_router.questions import QUESTIONS, QUESTIONS_VERSION +from laya_router.result import normalize_answers + +_log_lock = threading.Lock() +_hub = LiveHub() +_DASHBOARD = Path(__file__).with_name("dashboard.html") + + +class RouteRequest(BaseModel): + prompt: str = Field(min_length=1) + client: str | None = None + cwd: str | None = None + session_id: str | None = None + + +def candidate_devices(preference: str) -> list[str]: + import torch + + if preference != "auto": + return [preference] + + devices: list[str] = [] + if torch.backends.mps.is_available(): + devices.append("mps") + if torch.cuda.is_available(): + devices.append("cuda") + devices.append("cpu") + return devices + + +def _release_agent(agent: object, device: str) -> None: + del agent + gc.collect() + try: + import torch + + if device == "mps" and hasattr(torch, "mps"): + torch.mps.empty_cache() + if device == "cuda" and torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + pass + + +def load_agent(settings: Settings): + import laya + + errors: list[str] = [] + for device in candidate_devices(settings.device): + agent = None + try: + print(f"Loading Laya multilingual on {device}...", flush=True) + agent = laya.load(settings.model, subfolder=settings.subfolder, device=device) + agent.predict({"request": "Rename a local variable in one function."}, QUESTIONS) + print(f"Laya loaded on {device}.", flush=True) + return agent, device + except Exception as exc: + errors.append(f"{device}: {exc}") + print(f"Failed to use {device}: {exc}", flush=True) + if agent is not None: + _release_agent(agent, device) + raise RuntimeError("无法加载 Laya: " + "; ".join(errors)) + + +def append_route_log(settings: Settings, record: dict) -> None: + settings.data_dir.mkdir(parents=True, exist_ok=True) + line = json.dumps(record, ensure_ascii=False) + with _log_lock: + with settings.routes_path.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or load_settings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + agent, device = load_agent(settings) + app.state.agent = agent + app.state.device = device + app.state.settings = settings + try: + yield + finally: + app.state.agent = None + + app = FastAPI(title="laya-router", lifespan=lifespan) + app.state.settings = settings + app.state.agent = None + app.state.device = None + + @app.get("/health") + def health(): + if app.state.agent is None: + raise HTTPException(status_code=503, detail="model not loaded") + return { + "status": "ok", + "device": app.state.device, + "model": "laya-multilingual", + "questions_version": QUESTIONS_VERSION, + } + + @app.post("/route") + def route(req: RouteRequest): + if app.state.agent is None: + raise HTTPException(status_code=503, detail="model not loaded") + started = time.perf_counter() + try: + predicted = app.state.agent.predict({"request": req.prompt}, QUESTIONS) + result = normalize_answers(predicted["answers"]) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"predict failed: {exc}") from exc + elapsed_ms = round((time.perf_counter() - started) * 1000, 1) + prompt_hash = hashlib.sha256(req.prompt.encode("utf-8")).hexdigest() + record = { + "ts": datetime.now(timezone.utc).isoformat(), + "client": req.client, + "cwd": req.cwd, + "session_id": req.session_id, + "prompt": req.prompt if settings.log_prompts else None, + "prompt_sha256": prompt_hash, + "elapsed_ms": elapsed_ms, + "device": app.state.device, + "questions_version": QUESTIONS_VERSION, + "level": result["level"], + "level_confidence": result["level_confidence"], + "level_probabilities": result["level_probabilities"], + "needs_plan": result["needs_plan"], + "needs_repo": result["needs_repo"], + "needs_web": result["needs_web"], + "needs_deep_reasoning": result["needs_deep_reasoning"], + } + try: + append_route_log(settings, record) + except Exception as exc: + print(f"Failed to write route log: {exc}", flush=True) + _hub.publish(record) + result["elapsed_ms"] = elapsed_ms + result["device"] = app.state.device + return result + + @app.get("/", response_class=HTMLResponse) + def dashboard(): + return HTMLResponse(_DASHBOARD.read_text(encoding="utf-8")) + + @app.get("/api/feed") + def feed(): + return { + "device": app.state.device, + "model": "laya-multilingual", + "questions_version": QUESTIONS_VERSION, + "questions": { + name: {"type": spec["type"], "instructions": spec["instructions"]} + for name, spec in QUESTIONS.items() + }, + "records": read_routes(settings.routes_path), + } + + @app.get("/events") + async def events(): + queue: asyncio.Queue = asyncio.Queue(maxsize=200) + loop = asyncio.get_running_loop() + _hub.subscribe(loop, queue) + + async def generate(): + try: + yield ": ok\n\n" + while True: + try: + record = await asyncio.wait_for(queue.get(), timeout=15) + except asyncio.TimeoutError: + yield ": ping\n\n" + continue + yield f"data: {json.dumps(record, ensure_ascii=False)}\n\n" + finally: + _hub.unsubscribe(queue) + + return StreamingResponse(generate(), media_type="text/event-stream") + + return app + + +app = create_app() diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..3e95ace --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,99 @@ +import json + +from laya_router.clients.base import read_json +from laya_router.clients.claude import ClaudeClient +from laya_router.clients.codex import CodexClient, hooks_feature_enabled +from laya_router.config import Settings + + +def _settings(tmp_path) -> Settings: + return Settings(home=tmp_path) + + +def test_claude_merge_is_idempotent_and_keeps_existing_hooks(tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "env": {"ANTHROPIC_AUTH_TOKEN": "secret-token"}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": "rtk hook claude"}], + } + ] + }, + } + ), + encoding="utf-8", + ) + client = ClaudeClient(path) + settings = _settings(tmp_path / "runtime") + + assert client.install(settings) is True + assert client.install(settings) is False + + saved = read_json(path) + assert saved["env"]["ANTHROPIC_AUTH_TOKEN"] == "secret-token" + assert saved["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + commands = [ + item["command"] + for group in saved["hooks"]["UserPromptSubmit"] + for item in group["hooks"] + ] + assert commands == [f'"{tmp_path / "runtime" / ".venv" / "bin" / "laya-hook"}" --client claude'] + assert list(path.parent.glob("settings.json.bak-*")) + + assert client.uninstall() is True + removed = read_json(path) + assert "UserPromptSubmit" not in removed["hooks"] + assert removed["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "rtk hook claude" + assert removed["env"]["ANTHROPIC_AUTH_TOKEN"] == "secret-token" + + +def test_codex_uninstall_drops_file_when_it_only_contains_our_hook(tmp_path): + codex_dir = tmp_path / ".codex" + client = CodexClient(codex_dir) + settings = _settings(tmp_path / "runtime") + assert client.install(settings) is True + assert client.install(settings) is False + saved = read_json(client.hooks_path) + assert saved["hooks"]["UserPromptSubmit"][0]["hooks"][0]["statusMessage"] == "Laya routing" + assert client.uninstall() is True + assert not client.hooks_path.exists() + + +def test_codex_uninstall_keeps_foreign_hooks(tmp_path): + codex_dir = tmp_path / ".codex" + hooks = codex_dir / "hooks.json" + hooks.parent.mkdir() + hooks.write_text( + json.dumps( + { + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": "other-hook"}]} + ] + } + } + ), + encoding="utf-8", + ) + client = CodexClient(codex_dir) + client.install(_settings(tmp_path / "runtime")) + client.uninstall() + saved = read_json(hooks) + commands = [ + item["command"] + for group in saved["hooks"]["UserPromptSubmit"] + for item in group["hooks"] + ] + assert commands == ["other-hook"] + + +def test_hooks_feature_parser(): + listing = "hooks stable true\nplugin_hooks removed false\n" + assert hooks_feature_enabled(listing) is True + assert hooks_feature_enabled("hooks experimental false") is False + assert hooks_feature_enabled("no hooks here") is None diff --git a/tests/test_feed.py b/tests/test_feed.py new file mode 100644 index 0000000..9ef0ec1 --- /dev/null +++ b/tests/test_feed.py @@ -0,0 +1,11 @@ +import json + +from laya_router.feed import read_routes + + +def test_read_routes_keeps_the_tail(tmp_path): + path = tmp_path / "routes.jsonl" + lines = [json.dumps({"ts": str(i), "level": "simple"}) for i in range(5)] + path.write_text("\n".join(lines) + "\nbad\n", encoding="utf-8") + records = read_routes(path, limit=2) + assert [item["ts"] for item in records] == ["3", "4"] diff --git a/tests/test_hook.py b/tests/test_hook.py new file mode 100644 index 0000000..7fd6823 --- /dev/null +++ b/tests/test_hook.py @@ -0,0 +1,90 @@ +import json +from urllib.error import URLError + +from laya_router.hook import handle + + +def _ok_response(): + body = { + "level": "complex", + "level_confidence": 0.81, + "needs_plan": 0.9, + "needs_repo": 0.72, + "needs_web": 0.2, + "needs_deep_reasoning": 0.88, + } + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps(body).encode() + + return Response() + + +def test_cursor_is_a_noop(): + text = handle({"prompt": "帮我改这个函数"}, {"CURSOR_VERSION": "1.0"}, "claude") + assert text == "{}" + + +def test_slash_command_is_skipped(): + assert handle({"prompt": "/hooks"}, {}, "codex") == "{}" + + +def test_filesystem_path_is_not_treated_as_slash_command(monkeypatch, tmp_path): + monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path)) + monkeypatch.setattr("laya_router.hook.call_laya", lambda settings, body: _payload()) + text = handle({"prompt": "/Users/richard/proj 里的入口在哪"}, {}, "codex") + assert json.loads(text)["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit" + + +def test_fail_open_when_server_is_down(monkeypatch, tmp_path): + monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path)) + + def boom(settings, body): + raise URLError("connection refused") + + monkeypatch.setattr("laya_router.hook.call_laya", boom) + assert handle({"prompt": "解释这个报错"}, {}, "claude") == "{}" + assert (tmp_path / "logs" / "hook.log").is_file() + + +def test_success_output(monkeypatch, tmp_path): + monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path)) + monkeypatch.setattr( + "laya_router.hook.call_laya", + lambda settings, body: { + "level": "simple", + "level_confidence": 0.66, + "needs_plan": 0.1, + "needs_repo": 0.2, + "needs_web": 0.05, + "needs_deep_reasoning": 0.08, + }, + ) + payload = handle( + {"prompt": "把这个变量改名", "session_id": "s1", "cwd": "/tmp/repo"}, + {}, + "codex", + ) + parsed = json.loads(payload) + context = parsed["hookSpecificOutput"]["additionalContext"] + assert "Task complexity: simple" in context + assert "uncalibrated" in context + assert "hint only" in context + + +def _payload(): + return { + "level": "normal", + "level_confidence": 0.5, + "needs_plan": 0.4, + "needs_repo": 0.4, + "needs_web": 0.1, + "needs_deep_reasoning": 0.3, + } diff --git a/tests/test_result.py b/tests/test_result.py new file mode 100644 index 0000000..0fcf32b --- /dev/null +++ b/tests/test_result.py @@ -0,0 +1,65 @@ +import pytest + +from laya_router.config import load_settings +from laya_router.launchd import agent_plist +from laya_router.render import is_slash_command, truncate_prompt +from laya_router.result import normalize_answers + + +def test_normalize_answers_reads_choice_and_noul(): + result = normalize_answers( + { + "task_level": { + "type": "choice", + "choice": "normal", + "confidence": 0.42, + "probabilities": {"simple": 0.2, "normal": 0.5, "complex": 0.3}, + }, + "needs_plan": {"type": "noul", "noul": 0.61, "confidence": 0.61}, + "needs_repo": {"type": "noul", "noul": 0.7, "confidence": 0.7}, + "needs_web": {"type": "noul", "noul": 0.1, "confidence": 0.9}, + "needs_deep_reasoning": {"type": "noul", "noul": 0.33, "confidence": 0.67}, + } + ) + assert result["level"] == "normal" + assert result["needs_plan"] == pytest.approx(0.61) + assert result["level_probabilities"]["normal"] == pytest.approx(0.5) + assert result["questions_version"] == "1" + + +def test_truncate_keeps_head_and_tail(): + text = "HEAD" + ("中" * 5000) + "TAIL" + clipped = truncate_prompt(text, 1000) + assert clipped.startswith("HEAD") + assert clipped.endswith("TAIL") + assert "[...truncated...]" in clipped + assert len(clipped) < len(text) + + +def test_slash_command_detection(): + assert is_slash_command("/hooks") + assert is_slash_command("/model gpt-5") + assert not is_slash_command("/Users/richard/proj 看一下") + assert not is_slash_command("帮我 /review 一下") + + +def test_config_file_and_env(monkeypatch, tmp_path): + monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path)) + (tmp_path / "config.toml").write_text('port = 9999\ndevice = "cpu"\n', encoding="utf-8") + monkeypatch.setenv("LAYA_ROUTER_PORT", "7777") + settings = load_settings() + assert settings.port == 7777 + assert settings.device == "cpu" + assert settings.model == "convaiinnovations/laya" + + +def test_launchd_plist_is_a_single_process_without_reload(tmp_path): + settings = load_settings(tmp_path) + plist = agent_plist(settings) + assert plist["ProgramArguments"] == [str(tmp_path / ".venv" / "bin" / "laya-router"), "serve"] + assert "--reload" not in plist["ProgramArguments"] + assert plist["EnvironmentVariables"]["USE_TF"] == "0" + assert plist["EnvironmentVariables"]["PYTHONUNBUFFERED"] == "1" + assert plist["EnvironmentVariables"]["PATH"].endswith("/usr/bin:/bin:/usr/sbin:/sbin") + assert plist["KeepAlive"] is True + assert plist["ThrottleInterval"] >= 10 diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..03cb060 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOME_DIR="${LAYA_ROUTER_HOME:-$HOME/.laya-router}" +BIN="$HOME_DIR/.venv/bin/laya-router" + +if [[ ! -x "$BIN" ]]; then + echo "未找到 $BIN。如果服务仍在运行,可执行: launchctl bootout gui/$(id -u)/com.laya-router" >&2 + exit 1 +fi + +exec "$BIN" uninstall "$@"