init
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
106
README.md
Normal file
106
README.md
Normal file
@@ -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` 做真实推理检查。
|
||||||
39
install.sh
Executable file
39
install.sh
Executable file
@@ -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 "$@"
|
||||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@@ -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"
|
||||||
3
src/laya_router/__init__.py
Normal file
3
src/laya_router/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Local Laya multilingual router for coding agents."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
330
src/laya_router/cli.py
Normal file
330
src/laya_router/cli.py
Normal file
@@ -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())
|
||||||
6
src/laya_router/clients/__init__.py
Normal file
6
src/laya_router/clients/__init__.py
Normal file
@@ -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"]
|
||||||
130
src/laya_router/clients/base.py
Normal file
130
src/laya_router/clients/base.py
Normal file
@@ -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
|
||||||
36
src/laya_router/clients/claude.py
Normal file
36
src/laya_router/clients/claude.py
Normal file
@@ -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)
|
||||||
80
src/laya_router/clients/codex.py
Normal file
80
src/laya_router/clients/codex.py
Normal file
@@ -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)
|
||||||
171
src/laya_router/config.py
Normal file
171
src/laya_router/config.py
Normal file
@@ -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}'
|
||||||
322
src/laya_router/dashboard.html
Normal file
322
src/laya_router/dashboard.html
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Laya 路由看板</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,520;9..144,680&family=IBM+Plex+Mono:wght@400;500&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #12140f;
|
||||||
|
--panel: #1c2118;
|
||||||
|
--paper: #efe7d6;
|
||||||
|
--muted: #b4ad9c;
|
||||||
|
--line: rgba(239, 231, 214, 0.14);
|
||||||
|
--amber: #e2a322;
|
||||||
|
--moss: #8eae6a;
|
||||||
|
--cinnabar: #e06a45;
|
||||||
|
--simple: #8eae6a;
|
||||||
|
--normal: #e2a322;
|
||||||
|
--complex: #e06a45;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 420px at 0% 0%, rgba(226, 163, 34, 0.16), transparent 55%),
|
||||||
|
linear-gradient(180deg, #171b14 0%, var(--ink) 40%);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: "Source Sans 3", "Songti SC", serif;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 22px 28px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: Fraunces, "Songti SC", serif;
|
||||||
|
font-weight: 520;
|
||||||
|
font-size: 34px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sub { color: var(--muted); margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.live {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
background: var(--moss);
|
||||||
|
box-shadow: 0 0 0 0 rgba(142, 174, 106, 0.7);
|
||||||
|
animation: pulse 1.8s infinite;
|
||||||
|
}
|
||||||
|
.dot.off { background: var(--cinnabar); animation: none; }
|
||||||
|
@keyframes pulse {
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(142, 174, 106, 0); }
|
||||||
|
}
|
||||||
|
button.toggle {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.toggle.on { border-color: var(--amber); color: var(--amber); }
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
height: calc(100% - 92px);
|
||||||
|
}
|
||||||
|
aside, section.stage { overflow: auto; }
|
||||||
|
aside { border-right: 1px solid var(--line); padding: 12px; }
|
||||||
|
.session {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.session:hover, .session.active { background: var(--panel); border-color: var(--line); }
|
||||||
|
.session .top, .meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.snippet {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.stage { padding: 22px 28px 48px; }
|
||||||
|
.empty { color: var(--muted); padding-top: 12vh; max-width: 36em; }
|
||||||
|
.turn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.rail { position: relative; }
|
||||||
|
.rail i {
|
||||||
|
display: block;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
.rail::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px; top: 24px; bottom: -22px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
.turn:last-child .rail::before { display: none; }
|
||||||
|
.card {
|
||||||
|
background: rgba(28, 33, 24, 0.88);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
.card.arrive { animation: rise 0.45s ease; }
|
||||||
|
@keyframes rise {
|
||||||
|
from { transform: translateY(8px); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
.prompt { margin: 8px 0 14px; white-space: pre-wrap; line-height: 1.45; }
|
||||||
|
.level {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.bars, .meters { display: grid; gap: 8px; }
|
||||||
|
.bars { margin-bottom: 14px; }
|
||||||
|
.row { display: grid; grid-template-columns: 92px 1fr 52px; gap: 8px; align-items: center; font-size: 13px; }
|
||||||
|
.track { height: 8px; background: rgba(239, 231, 214, 0.08); border-radius: 99px; overflow: hidden; }
|
||||||
|
.fill { height: 100%; width: 0; border-radius: inherit; transition: width 0.45s ease; }
|
||||||
|
.fill.simple { background: var(--simple); }
|
||||||
|
.fill.normal { background: var(--normal); }
|
||||||
|
.fill.complex { background: var(--complex); }
|
||||||
|
.simple { color: var(--simple); }
|
||||||
|
.normal { color: var(--normal); }
|
||||||
|
.complex { color: var(--complex); }
|
||||||
|
.advice { margin: 12px 0 0; color: var(--amber); font-size: 14px; }
|
||||||
|
.num { font-family: "IBM Plex Mono", monospace; text-align: right; color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>路由值班室</h1>
|
||||||
|
<p class="sub" id="status">正在连接本地 Laya…</p>
|
||||||
|
</div>
|
||||||
|
<div class="live">
|
||||||
|
<span class="dot" id="dot"></span>
|
||||||
|
<span id="clock">等待事件</span>
|
||||||
|
<button class="toggle on" id="follow" type="button">跟随最新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<aside id="sessions"></aside>
|
||||||
|
<section class="stage" id="stage"></section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const LEVELS = ["simple", "normal", "complex"];
|
||||||
|
const METERS = [
|
||||||
|
["needs_plan", "需要计划"],
|
||||||
|
["needs_repo", "需要读仓库"],
|
||||||
|
["needs_web", "需要查文档"],
|
||||||
|
["needs_deep_reasoning", "需要深推理"],
|
||||||
|
];
|
||||||
|
const state = { records: [], follow: true, selected: null, connected: false };
|
||||||
|
|
||||||
|
function keyOf(record) {
|
||||||
|
if (record.session_id) return record.session_id;
|
||||||
|
return `loose:${record.client || "unknown"}:${record.cwd || ""}`;
|
||||||
|
}
|
||||||
|
function labelOf(record) {
|
||||||
|
const client = record.client || "未知客户端";
|
||||||
|
if (!record.session_id) return `${client} · 未带会话号`;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
function when(ts) {
|
||||||
|
if (!ts) return "";
|
||||||
|
return new Date(ts).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
function pct(value) {
|
||||||
|
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||||
|
}
|
||||||
|
function advice(record) {
|
||||||
|
const bits = [];
|
||||||
|
if (record.level === "simple") bits.push("直接处理,不必铺开");
|
||||||
|
if (record.level === "normal") bits.push("按常规查看相关代码");
|
||||||
|
if (record.level === "complex") bits.push("先调查,必要时再做计划");
|
||||||
|
if (record.needs_plan >= 0.7) bits.push("计划分数高");
|
||||||
|
if (record.needs_repo >= 0.7) bits.push("先读仓库");
|
||||||
|
if (record.needs_web >= 0.7) bits.push("查当前文档");
|
||||||
|
if (record.needs_deep_reasoning >= 0.7) bits.push("先权衡再改");
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
function sessions() {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const record of state.records) {
|
||||||
|
const key = keyOf(record);
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(record);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].sort((a, b) => {
|
||||||
|
const ta = a[1][a[1].length - 1].ts || "";
|
||||||
|
const tb = b[1][b[1].length - 1].ts || "";
|
||||||
|
return ta < tb ? 1 : -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bar(name, value, klass) {
|
||||||
|
const width = Math.max(0, Math.min(100, Number(value || 0) * 100));
|
||||||
|
return `<div class="row"><span>${name}</span><div class="track"><div class="fill ${klass}" style="width:${width}%"></div></div><span class="num">${pct(value)}</span></div>`;
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
const groups = sessions();
|
||||||
|
if (state.follow && groups.length) state.selected = groups[0][0];
|
||||||
|
const aside = document.getElementById("sessions");
|
||||||
|
aside.innerHTML = groups.map(([key, items]) => {
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const snippet = (last.prompt || "未记录原文").replace(/\s+/g, " ").slice(0, 72);
|
||||||
|
return `<button class="session ${key === state.selected ? "active" : ""}" data-key="${encodeURIComponent(key)}" type="button">
|
||||||
|
<div class="top"><span>${labelOf(last)}</span><span class="${last.level}">${last.level}</span></div>
|
||||||
|
<p class="snippet">${escapeHtml(snippet)}</p>
|
||||||
|
<div class="top"><span>${items.length} 次判断</span><span>${when(last.ts)}</span></div>
|
||||||
|
</button>`;
|
||||||
|
}).join("") || `<p class="empty">还没有记录。</p>`;
|
||||||
|
|
||||||
|
const stage = document.getElementById("stage");
|
||||||
|
const group = groups.find(([key]) => key === state.selected);
|
||||||
|
if (!group) {
|
||||||
|
stage.innerHTML = `<p class="empty">在 Codex 或 Claude Code 里提交一条任务后,这里会按会话展开每一次判断。概率没有温度校准,只作为现场提示。</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [key, items] = group;
|
||||||
|
const head = items[items.length - 1];
|
||||||
|
stage.innerHTML = `<p class="meta"><span>${escapeHtml(labelOf(head))}</span><span>${escapeHtml(head.cwd || "")}</span></p>
|
||||||
|
<p class="sub">${escapeHtml(key)}</p>
|
||||||
|
${items.map((record, index) => `
|
||||||
|
<article class="turn">
|
||||||
|
<div class="rail"><i class="${record.level}" style="background:var(--${record.level})"></i></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="meta"><span>第 ${index + 1} 步 · ${when(record.ts)}</span><span>${record.elapsed_ms ?? "?"} ms · ${record.device || ""}</span></div>
|
||||||
|
<h2 class="level ${record.level}">${record.level}</h2>
|
||||||
|
<p class="prompt">${escapeHtml(record.prompt || "原文未写入日志")}</p>
|
||||||
|
<div class="bars">
|
||||||
|
${LEVELS.map((level) => bar(level, (record.level_probabilities || {})[level], level)).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="meters">
|
||||||
|
${METERS.map(([field, name]) => bar(name, record[field], record[field] >= 0.7 ? "complex" : "normal")).join("")}
|
||||||
|
</div>
|
||||||
|
<p class="advice">${escapeHtml(advice(record))}</p>
|
||||||
|
</div>
|
||||||
|
</article>`).join("")}`;
|
||||||
|
if (state.follow) stage.scrollTop = stage.scrollHeight;
|
||||||
|
}
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
||||||
|
}
|
||||||
|
function remember(record) {
|
||||||
|
const id = `${record.ts}|${record.prompt_sha256}|${record.session_id || ""}`;
|
||||||
|
if (state.records.some((item) => `${item.ts}|${item.prompt_sha256}|${item.session_id || ""}` === id)) return;
|
||||||
|
state.records.push(record);
|
||||||
|
document.getElementById("clock").textContent = when(record.ts);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
document.getElementById("follow").addEventListener("click", (event) => {
|
||||||
|
state.follow = !state.follow;
|
||||||
|
event.currentTarget.classList.toggle("on", state.follow);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById("sessions").addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("button[data-key]");
|
||||||
|
if (!button) return;
|
||||||
|
state.follow = false;
|
||||||
|
document.getElementById("follow").classList.remove("on");
|
||||||
|
state.selected = decodeURIComponent(button.dataset.key);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
async function boot() {
|
||||||
|
const response = await fetch("/api/feed");
|
||||||
|
const body = await response.json();
|
||||||
|
state.records = body.records || [];
|
||||||
|
document.getElementById("status").textContent = `${body.model || "laya"} · ${body.device || "设备未知"} · 问题版本 ${body.questions_version || "?"}`;
|
||||||
|
render();
|
||||||
|
const source = new EventSource("/events");
|
||||||
|
source.onopen = () => {
|
||||||
|
state.connected = true;
|
||||||
|
document.getElementById("dot").classList.remove("off");
|
||||||
|
};
|
||||||
|
source.onerror = () => {
|
||||||
|
state.connected = false;
|
||||||
|
document.getElementById("dot").classList.add("off");
|
||||||
|
};
|
||||||
|
source.onmessage = (event) => remember(JSON.parse(event.data));
|
||||||
|
}
|
||||||
|
boot();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
src/laya_router/feed.py
Normal file
64
src/laya_router/feed.py
Normal file
@@ -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
|
||||||
134
src/laya_router/hook.py
Normal file
134
src/laya_router/hook.py
Normal file
@@ -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())
|
||||||
118
src/laya_router/launchd.py
Normal file
118
src/laya_router/launchd.py
Normal file
@@ -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()
|
||||||
61
src/laya_router/questions.py
Normal file
61
src/laya_router/questions.py
Normal file
@@ -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?"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
59
src/laya_router/render.py
Normal file
59
src/laya_router/render.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
29
src/laya_router/result.py
Normal file
29
src/laya_router/result.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
211
src/laya_router/server.py
Normal file
211
src/laya_router/server.py
Normal file
@@ -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()
|
||||||
99
tests/test_clients.py
Normal file
99
tests/test_clients.py
Normal file
@@ -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
|
||||||
11
tests/test_feed.py
Normal file
11
tests/test_feed.py
Normal file
@@ -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"]
|
||||||
90
tests/test_hook.py
Normal file
90
tests/test_hook.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
65
tests/test_result.py
Normal file
65
tests/test_result.py
Normal file
@@ -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
|
||||||
12
uninstall.sh
Executable file
12
uninstall.sh
Executable file
@@ -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 "$@"
|
||||||
Reference in New Issue
Block a user