From bd2ec5ebb4edfe85110181a7460a64c93a1bfecc Mon Sep 17 00:00:00 2001 From: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:22:10 +0800 Subject: [PATCH 1/7] feat: chaneg docker action --- .dockerignore | 13 +++++++++++++ Dockerfile | 18 +++++++++++++---- README.md | 32 ++++++++++++++++++++++++------- backend/server/config.py | 10 ++++++++++ backend/server/laya_adapter.py | 7 ++++++- backend/server/main.py | 15 ++++++++++++--- backend/tests/test_flow.py | 35 ++++++++++++++++++++++++++++++++++ compose.yaml | 6 +----- frontend/src/App.tsx | 8 ++++++-- frontend/src/Playground.tsx | 23 +++++++++++++++++----- frontend/src/i18n.tsx | 10 +++++++--- scripts/download-models.py | 2 +- scripts/smoke-real-model.py | 26 +++++++++++++++++-------- 13 files changed, 166 insertions(+), 39 deletions(-) diff --git a/.dockerignore b/.dockerignore index a993893..be1bdf4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,19 @@ /.git +<<<<<<< Updated upstream /.env /.env.* +======= +!/laya/.git +!/laya/.git/** +/.github +/.agents +/AGENTS.md +/openspec +/.codex +/.claude +/CLAUDE.md +/GEMINI.md +>>>>>>> Stashed changes /.venv /.pnpm-store /.agents diff --git a/Dockerfile b/Dockerfile index 8dab77f..ac5c2d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,10 +15,19 @@ RUN test "$(git -C /upstream rev-parse HEAD)" = "$LAYA_UPSTREAM_SHA" && \ test -z "$(git -C /upstream status --porcelain)" || \ (echo 'Laya checkout must match the pinned SHA and be clean' >&2; exit 1) +FROM python:3.12-slim AS model-download +ENV LAYA_MODEL_DIR=/opt/model-download HF_HOME=/opt/hf-cache +WORKDIR /app +RUN pip install --no-cache-dir huggingface-hub==0.29.3 +COPY scripts/download-models.py /app/scripts/download-models.py +RUN python /app/scripts/download-models.py --model multilingual && \ + test -s /opt/model-download/multilingual/model.safetensors + FROM python:3.12-slim AS app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ - LAYA_DATABASE_PATH=/data/laya.sqlite3 LAYA_MODEL_DIR=/models \ - LAYA_FRONTEND_DIR=/app/frontend/dist HF_HOME=/models/.cache + LAYA_DATABASE_PATH=/data/laya.sqlite3 LAYA_MODEL_DIR=/opt/models \ + LAYA_MODEL_PROFILE=multilingual LAYA_FRONTEND_DIR=/app/frontend/dist \ + HF_HOME=/data/hf-cache HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 WORKDIR /app RUN pip install --no-cache-dir torch==2.5.1 --index-url https://download.pytorch.org/whl/cpu RUN pip install --no-cache-dir transformers==4.48.3 safetensors==0.5.3 huggingface-hub==0.29.3 numpy==1.26.4 @@ -27,9 +36,10 @@ COPY --from=upstream-check /upstream/laya/ /opt/laya/laya/ RUN pip install --no-cache-dir --no-deps /opt/laya COPY backend/ /app/backend/ RUN pip install --no-cache-dir /app/backend -COPY scripts/download-models.py /app/scripts/download-models.py +COPY --from=model-download /opt/model-download/multilingual/ /opt/models/multilingual/ +COPY scripts/smoke-image-model.py /app/scripts/smoke-image-model.py COPY --from=frontend-build /app/frontend/dist/ /app/frontend/dist/ -RUN mkdir -p /data /models +RUN mkdir -p /data && python /app/scripts/smoke-image-model.py EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)" || exit 1 CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", "--proxy-headers"] diff --git a/README.md b/README.md index eeb7feb..e92e90a 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ python3.12 -m venv .venv ## 模型文件 -模型权重不随仓库和镜像分发。`scripts/download-models.py` 将 Hugging Face 仓库固定在提交 `1c5edc17a7acd8701df6fc341c0d179f1c62c982`,把三个 checkpoint 放到持久化模型卷的 `/models/english`、`/models/multilingual`、`/models/typed-decisions`。每个目录至少要有上游模型包里的 `rl_agent_config.json`、`model.safetensors`、`tokenizer/` 和 `encoder/`。文件存在性可通过 `GET /health/ready` 检查;缺失时推理返回 `503 MODEL_UNAVAILABLE`。实际模型是否兼容仍需做推理冒烟测试。 +仓库不跟踪模型权重。Dockerfile 在构建阶段从 Hugging Face 固定提交 `1c5edc17a7acd8701df6fc341c0d179f1c62c982` 下载 **multilingual** checkpoint,只把该模型文件复制到最终镜像的 `/opt/models/multilingual`。构建时在离线模式下分别执行英文和中文推理,失败则不会发布镜像。运行容器无需下载模型,也无需挂载模型卷。`GET /health/ready` 检查镜像中的模型文件。 -本项目的模型推理不在应用启动时自动下载;请在启动前准备模型卷。一个应用进程只加载所需模型,默认最多驻留一个,可用 `LAYA_MAX_LOADED_MODELS` 调整;该值控制模型缓存数量,不限制同时处理的请求数。服务不设置额外的推理并发槽位;实际并发能力取决于运行时线程池、模型和机器资源。CPU 推理镜像使用 PyTorch CPU wheel;若部署 GPU,需按设备改用相应 PyTorch 基础环境并验收。 +发布镜像的 `LAYA_MODEL_PROFILE=multilingual`:`model=auto` 和 `model=multilingual` 都使用此模型;显式请求 `english` 或 `typed-decisions` 返回 `422 MODEL_NOT_AVAILABLE`。Playground 只列出镜像支持的模型。一个应用进程默认最多驻留一个模型,可用 `LAYA_MAX_LOADED_MODELS` 调整;该值控制模型缓存数量,不限制同时处理的请求数。实际并发能力取决于运行时线程池、模型和机器资源。发布镜像使用 PyTorch CPU wheel,当前 Action 构建 `linux/amd64`。 本地先安装上游运行依赖与被忽略的 Laya 检出,再下载三个固定版本模型,运行包含英文、中文显式选型、中文自动路由和 typed-decisions 的真实请求冒烟测试: @@ -46,15 +46,33 @@ LAYA_MODEL_DIR=models .venv/bin/python scripts/smoke-real-model.py ## 启动 -首次启动前,先构建镜像并将固定版本模型下载到模型卷,然后启动应用: +在 GitHub 仓库的 **Settings → Secrets and variables → Actions** 配置 `DOCKERHUB_USERNAME` 和 `DOCKERHUB_TOKEN`(需要有 `1panel/laya-server` 的推送权限)。在 **Actions → Build and push LAYA SERVER → Run workflow** 输入版本标签。正式发布时可同时勾选 `latest`;测试标签保持关闭。工作流会检出被忽略的上游 v0.3.7 源码并校验 SHA,运行后端测试,再构建及推送镜像。 + +拉取已发布镜像并启动: + +```sh +cp .env.example .env +# 编辑 .env,配置管理员账号、密码和 LAYA_PUBLIC_ORIGIN +LAYA_IMAGE_TAG=dev docker compose pull +LAYA_IMAGE_TAG=dev docker compose up -d +``` + +也可用本地已检出的上游源码构建: + +```sh +sh scripts/check-upstream.sh +docker build -t 1panel/laya-server:dev . +LAYA_IMAGE_TAG=dev docker compose up -d +``` + +如使用 `latest`,直接执行: ```sh -docker compose build -docker compose run --rm app python /app/scripts/download-models.py +docker compose pull docker compose up -d ``` -Compose 只启动一个应用服务并将 `127.0.0.1:8080` 暴露给宿主机。公网入口需由外部反向代理提供 HTTPS,并将请求转发到该端口。SQLite 数据在 `laya-data` 卷,模型在 `laya-models` 卷。构建机器需要能访问 PyPI、PyTorch CPU 包索引和 npm registry;已构建镜像启动时无需拉取源码或依赖。 +Compose 只启动一个应用服务并将 `127.0.0.1:8080` 暴露给宿主机。公网入口需由外部反向代理提供 HTTPS,并将请求转发到该端口。SQLite 数据在 `laya-data` 卷;更新容器不会丢失数据库。构建机器需要能访问 PyPI、PyTorch CPU 包索引、npm registry 和 Hugging Face;已构建镜像启动时无需拉取源码、依赖或模型。 如果由现有的 1Panel 反向代理提供公网 HTTPS,将域名请求转发到宿主机的 `127.0.0.1:8080`,并确保 `LAYA_PUBLIC_ORIGIN` 与实际 HTTPS 域名一致。反向代理不属于本项目的应用容器。 @@ -91,7 +109,7 @@ curl -X POST https://console.example.com/v1/systemone \ -d '{"state":{"message":"I was charged twice"},"questions":{"refund":{"type":"noul","instructions":"Does the customer ask for a refund?"}}}' ``` -请求中的 `state` 可为字符串、JSON 对象或数组;`questions` 是非空问题 ID 映射,支持 `noul`、`choice` 和 `score`;`model` 可选 `auto`(默认)、`english`、`multilingual` 或 `typed-decisions`。返回保留上游 `answers`、`model` 和 `usage`。错误使用 `detail.code` 和 `detail.message`。无效密钥为 401,校验失败为 422,模型不可用为 503。控制台 Playground 使用管理员会话,记录为单独用量来源。 +请求中的 `state` 可为字符串、JSON 对象或数组;`questions` 是非空问题 ID 映射,支持 `noul`、`choice` 和 `score`;`model` 可选 `auto`(默认)或 `multilingual`。发布镜像只内置 multilingual;显式请求其他模型返回 422。返回保留上游 `answers`、`model` 和 `usage`。错误使用 `detail.code` 和 `detail.message`。无效密钥为 401,校验失败为 422,模型不可用为 503。控制台 Playground 使用管理员会话,记录为单独用量来源。本地源码开发默认仍可使用全部三个模型,前提是已下载对应权重。 ## 数据与维护 diff --git a/backend/server/config.py b/backend/server/config.py index f3c1571..0e492b0 100644 --- a/backend/server/config.py +++ b/backend/server/config.py @@ -17,6 +17,7 @@ class Settings: device: str | None max_loaded_models: int frontend_dir: Path + model_profile: str = "all" @classmethod def from_env(cls) -> "Settings": @@ -36,8 +37,16 @@ def from_env(cls) -> "Settings": raise RuntimeError("LAYA_PUBLIC_ORIGIN must be an absolute HTTP(S) origin") if origin.startswith("http://") and os.environ.get("LAYA_ALLOW_INSECURE_LOCAL") != "1": raise RuntimeError("HTTP origin requires LAYA_ALLOW_INSECURE_LOCAL=1") +<<<<<<< Updated upstream if any(char in origin.split("://", 1)[1] for char in "/?#"): raise RuntimeError("LAYA_PUBLIC_ORIGIN must not contain a path, query, or fragment") +======= + if "/" in origin.split("://", 1)[1]: + raise RuntimeError("LAYA_PUBLIC_ORIGIN must not contain a path") + model_profile = os.environ.get("LAYA_MODEL_PROFILE", "all") + if model_profile not in ("all", "multilingual"): + raise RuntimeError("LAYA_MODEL_PROFILE must be all or multilingual") +>>>>>>> Stashed changes return cls( username, password_hash, Path(os.environ.get("LAYA_DATABASE_PATH", "/data/laya.sqlite3")), @@ -47,4 +56,5 @@ def from_env(cls) -> "Settings": os.environ.get("LAYA_DEVICE") or None, int(os.environ.get("LAYA_MAX_LOADED_MODELS", "1")), Path(os.environ.get("LAYA_FRONTEND_DIR", "/app/frontend/dist")), + model_profile, ) diff --git a/backend/server/laya_adapter.py b/backend/server/laya_adapter.py index b931821..93f62c1 100644 --- a/backend/server/laya_adapter.py +++ b/backend/server/laya_adapter.py @@ -8,7 +8,7 @@ def predict(self, state: Any, questions: dict[str, Any], model: str | None = Non class LayaAdapter: - def __init__(self, model_dir: Path, device: str | None, max_loaded: int): + def __init__(self, model_dir: Path, device: str | None, max_loaded: int, model_profile: str = "all"): checkout = Path(__file__).resolve().parents[2] / "laya" if (checkout / "laya" / "__init__.py").is_file(): # The repository's ignored checkout shadows the installed package when @@ -22,6 +22,11 @@ def __init__(self, model_dir: Path, device: str | None, max_loaded: int): models = {name: str(model_dir / name) for name in ("english", "multilingual", "typed-decisions")} self.router = Router(models=models, device=device, max_loaded=max_loaded) + self.model_profile = model_profile def predict(self, state: Any, questions: dict[str, Any], model: str | None = None) -> dict[str, Any]: + if self.model_profile == "multilingual": + if model not in (None, "multilingual"): + raise ValueError("Model is not available in this image") + model = "multilingual" return self.router.predict(state, questions, model=model) diff --git a/backend/server/main.py b/backend/server/main.py index 98856ff..af2f350 100644 --- a/backend/server/main.py +++ b/backend/server/main.py @@ -100,11 +100,13 @@ def api_key_id(authorization: Annotated[str | None, Header()] = None) -> int: ApiKeyId = Annotated[int, Depends(api_key_id)] def infer(payload: InferenceRequest, source: str, key_id: int | None) -> dict[str, Any]: + if settings.model_profile == "multilingual" and payload.model not in ("auto", "multilingual"): + raise error(422, "MODEL_NOT_AVAILABLE", "Only the multilingual model is installed") with router_lock: if "router" not in router_holder: try: router_holder["router"] = predictor or LayaAdapter( - settings.model_dir, settings.device, settings.max_loaded_models + settings.model_dir, settings.device, settings.max_loaded_models, settings.model_profile ) except Exception: logger.exception("Failed to initialize model router") @@ -114,7 +116,8 @@ def infer(payload: InferenceRequest, source: str, key_id: int | None) -> dict[st result = router_holder["router"].predict( payload.state, {qid: q.model_dump(exclude_none=True) for qid, q in payload.questions.items()}, - model=None if payload.model == "auto" else payload.model, + model=("multilingual" if settings.model_profile == "multilingual" else None) + if payload.model == "auto" else payload.model, ) if not isinstance(result, dict) or not isinstance(result.get("answers"), dict): raise ValueError("Laya returned invalid answers") @@ -148,7 +151,8 @@ def live() -> dict[str, str]: @app.get("/health/ready") def ready() -> dict[str, str]: - for name in ("english", "multilingual", "typed-decisions"): + names = ("multilingual",) if settings.model_profile == "multilingual" else ("english", "multilingual", "typed-decisions") + for name in names: directory = settings.model_dir / name if (not (directory / "rl_agent_config.json").is_file() or not (directory / "model.safetensors").is_file() @@ -209,6 +213,11 @@ def list_keys(session: Session) -> list[dict[str, Any]]: ).fetchall() return [dict(row) for row in rows] + @app.get("/internal/models") + def available_models(session: Session) -> dict[str, list[str]]: + names = ["auto", "multilingual"] if settings.model_profile == "multilingual" else ["auto", "english", "multilingual", "typed-decisions"] + return {"models": names} + @app.post("/internal/api-keys", status_code=201) def create_key(payload: CreateKeyRequest, session: Session, _: WriteSession) -> dict[str, Any]: token = "laya_" + secrets.token_urlsafe(32) diff --git a/backend/tests/test_flow.py b/backend/tests/test_flow.py index 4692abd..f82139b 100644 --- a/backend/tests/test_flow.py +++ b/backend/tests/test_flow.py @@ -20,9 +20,11 @@ class FakePredictor: def __init__(self): self.calls = 0 + self.models = [] def predict(self, state, questions, model=None): self.calls += 1 + self.models.append(model) assert set(questions) == {"flag", "intent", "score"} return { "model": "laya-rl-agent", @@ -41,6 +43,33 @@ def predict(self, state, questions, model=None): } +def test_multilingual_image_routes_auto_and_rejects_unbundled_models(tmp_path): + model = tmp_path / "models" / "multilingual" + for filename in ("rl_agent_config.json", "model.safetensors", "tokenizer/tokenizer.json", "encoder/config.json"): + path = model / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + fake = FakePredictor() + settings = Settings("admin", PasswordHasher().hash("correct horse battery staple"), tmp_path / "db.sqlite3", + "http://testserver", False, 1, tmp_path / "models", None, 1, tmp_path / "missing-dist", "multilingual") + client = TestClient(create_app(settings, fake)) + assert client.get("/health/ready").status_code == 200 + assert client.get("/internal/models").status_code == 401 + origin = {"Origin": "http://testserver"} + assert client.post("/internal/auth/login", json={"username": "admin", "password": "correct horse battery staple"}, headers=origin).status_code == 200 + assert client.get("/internal/models").json() == {"models": ["auto", "multilingual"]} + csrf = client.get("/internal/auth/session").json()["csrf_token"] + key = client.post("/internal/api-keys", json={"name": "smoke"}, headers={**origin, "X-CSRF-Token": csrf}).json()["key"] + headers = {"Authorization": f"Bearer {key}"} + assert client.post("/v1/systemone", json={**PAYLOAD, "model": "auto"}, headers=headers).status_code == 200 + assert client.post("/v1/systemone", json={**PAYLOAD, "model": "multilingual"}, headers=headers).status_code == 200 + for unsupported in ("english", "typed-decisions"): + response = client.post("/v1/systemone", json={**PAYLOAD, "model": unsupported}, headers=headers) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "MODEL_NOT_AVAILABLE" + assert fake.models == ["multilingual", "multilingual"] + + def test_admin_password_from_environment(tmp_path, monkeypatch): password = "0123456789" monkeypatch.setenv("LAYA_ADMIN_USERNAME", "configured-admin") @@ -50,6 +79,12 @@ def test_admin_password_from_environment(tmp_path, monkeypatch): monkeypatch.setenv("LAYA_ALLOW_INSECURE_LOCAL", "1") monkeypatch.setenv("LAYA_DATABASE_PATH", str(tmp_path / "db.sqlite3")) settings = Settings.from_env() + monkeypatch.setenv("LAYA_MODEL_PROFILE", "multilingual") + assert Settings.from_env().model_profile == "multilingual" + monkeypatch.setenv("LAYA_MODEL_PROFILE", "unknown") + with pytest.raises(RuntimeError, match="LAYA_MODEL_PROFILE"): + Settings.from_env() + monkeypatch.delenv("LAYA_MODEL_PROFILE") assert settings.admin_password_hash != password assert settings.admin_password_hash.startswith("$argon2id$") client = TestClient(create_app(settings, FakePredictor())) diff --git a/compose.yaml b/compose.yaml index 7722e46..e8e8abd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,16 +1,12 @@ services: app: - build: - context: . - dockerfile: Dockerfile + image: 1panel/laya-server:${LAYA_IMAGE_TAG:-latest} env_file: .env ports: - "127.0.0.1:8080:8080" volumes: - laya-data:/data - - laya-models:/models restart: unless-stopped volumes: laya-data: - laya-models: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bea07b2..6c435d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { Activity, BookOpen, ChartNoAxesColumn, CircleHelp, Copy, KeyRound, Lang import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { apiErrorMessage, useI18n, type Locale, type MessageKey, type Translate } from "./i18n" -import { Playground, sampleRequest } from "./Playground" +import { Playground, sampleRequest, type Model } from "./Playground" type ApiKey = { id: number; name: string; mask: string; created_at: string; revoked_at: string | null; last_used_at: string | null } type Usage = { totals: { requests: number; input_tokens: number; output_tokens: number }; daily: { day: string; requests: number; input_tokens: number; output_tokens: number }[]; sources: { source: string; key_id: number | null; requests: number; input_tokens: number; output_tokens: number }[] } @@ -36,6 +36,10 @@ async function api(path: string, options: RequestInit = {}, csrf?: string): P return data as T } +function loadAvailableModels(): Promise<{ models: Model[] }> { + return api("/internal/models") +} + function useSession() { const [session, setSession] = useState(null) const [ready, setReady] = useState(false) @@ -116,7 +120,7 @@ function App() {
{title}
LAYA SERVER
-
{page === "home" ? : page === "keys" ? : page === "usage" ? : page === "playground" ? api("/internal/playground", { method: "POST", body }, session.csrf_token)} /> : }
+
{page === "home" ? : page === "keys" ? : page === "usage" ? : page === "playground" ? api("/internal/playground", { method: "POST", body }, session.csrf_token)} loadModels={loadAvailableModels} /> : }
{mobileNav ? -

{t("playgroundModelHint")}

+

{t("playgroundModelHint")}

}
{error ?

{error}

: null}
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 206fbfd..325c745 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -110,7 +110,7 @@ const zhCN = { playgroundRemoveOption: "删除这一项", playgroundRemoveOptionNamed: "删除第 {number} 项", playgroundModel: "模型", - playgroundModelHint: "auto 会按内容自动选择英文或多语言模型。", + playgroundModelHint: "auto 使用当前部署可用的模型。", playgroundFullResponse: "查看完整响应 JSON", playgroundInvalidJson: "请求 JSON 无效,请检查格式。", playgroundFormUnsupported: "当前 JSON 含表单无法编辑的字段,请继续在 JSON 模式修改。", @@ -134,6 +134,7 @@ const zhCN = { errorCsrf: "请求校验失败,请刷新页面后重试。", errorInvalidKey: "API Key 无效或已撤销。", errorModelUnavailable: "模型暂时不可用,请稍后重试。", + errorModelNotAvailable: "当前部署未包含所选模型。", errorUsageUnavailable: "用量记录失败,请稍后重试。", errorLoginRateLimited: "登录尝试过多,请稍后再试。", errorInvalidCredentials: "用户名或密码错误。", @@ -250,7 +251,7 @@ const en: Record = { playgroundRemoveOption: "Remove this item", playgroundRemoveOptionNamed: "Remove item {number}", playgroundModel: "Model", - playgroundModelHint: "auto selects the English or multilingual model based on the state.", + playgroundModelHint: "auto uses the model available in this deployment.", playgroundFullResponse: "View full response JSON", playgroundInvalidJson: "Request JSON is invalid. Check its syntax.", playgroundFormUnsupported: "This JSON contains fields the form cannot edit. Continue in JSON mode.", @@ -274,6 +275,7 @@ const en: Record = { errorCsrf: "Request verification failed. Refresh the page and try again.", errorInvalidKey: "The API key is invalid or revoked.", errorModelUnavailable: "The model is temporarily unavailable. Try again later.", + errorModelNotAvailable: "The selected model is not included in this deployment.", errorUsageUnavailable: "Usage could not be recorded. Try again later.", errorLoginRateLimited: "Too many sign-in attempts. Try again later.", errorInvalidCredentials: "Invalid username or password.", @@ -388,7 +390,7 @@ const zhTW: Record = { playgroundRemoveOption: "刪除這一項", playgroundRemoveOptionNamed: "刪除第 {number} 項", playgroundModel: "模型", - playgroundModelHint: "auto 會依內容自動選擇英文或多語言模型。", + playgroundModelHint: "auto 使用目前部署可用的模型。", playgroundFullResponse: "檢視完整回應 JSON", playgroundInvalidJson: "請求 JSON 無效,請檢查格式。", playgroundFormUnsupported: "目前的 JSON 包含表單無法編輯的欄位,請繼續在 JSON 模式修改。", @@ -412,6 +414,7 @@ const zhTW: Record = { errorCsrf: "請求驗證失敗,請重新整理頁面後再試。", errorInvalidKey: "API Key 無效或已撤銷。", errorModelUnavailable: "模型暫時無法使用,請稍後再試。", + errorModelNotAvailable: "目前部署未包含所選模型。", errorUsageUnavailable: "無法記錄用量,請稍後再試。", errorLoginRateLimited: "登入嘗試次數過多,請稍後再試。", errorInvalidCredentials: "使用者名稱或密碼錯誤。", @@ -466,6 +469,7 @@ const errorMessages: Record = { CSRF_INVALID: "errorCsrf", INVALID_API_KEY: "errorInvalidKey", MODEL_UNAVAILABLE: "errorModelUnavailable", + MODEL_NOT_AVAILABLE: "errorModelNotAvailable", USAGE_UNAVAILABLE: "errorUsageUnavailable", LOGIN_RATE_LIMITED: "errorLoginRateLimited", INVALID_CREDENTIALS: "errorInvalidCredentials", diff --git a/scripts/download-models.py b/scripts/download-models.py index 8e68db3..1b6408e 100644 --- a/scripts/download-models.py +++ b/scripts/download-models.py @@ -1,4 +1,4 @@ -"""Populate the persistent model volume from one pinned Hugging Face snapshot.""" +"""Download selected model files from one pinned Hugging Face snapshot.""" import argparse import os diff --git a/scripts/smoke-real-model.py b/scripts/smoke-real-model.py index 4fc0632..21339c4 100644 --- a/scripts/smoke-real-model.py +++ b/scripts/smoke-real-model.py @@ -17,7 +17,7 @@ os.environ["LAYA_ALLOW_INSECURE_LOCAL"] = "1" os.environ["LAYA_DATABASE_PATH"] = str(Path(temporary) / "smoke.sqlite3") os.environ["LAYA_MODEL_DIR"] = os.environ.get("LAYA_MODEL_DIR", "models") - os.environ["HF_HOME"] = str(Path(os.environ["LAYA_MODEL_DIR"]) / ".cache") + os.environ.setdefault("HF_HOME", str(Path(os.environ["LAYA_MODEL_DIR"]) / ".cache")) os.environ["LAYA_DEVICE"] = "cpu" os.environ["LAYA_FRONTEND_DIR"] = str(Path(temporary) / "no-frontend") from server.main import app @@ -47,13 +47,23 @@ "state": {"message": "我的账户被重复扣费了,请尽快退款。"}, "model": "multilingual", } + profile = os.environ.get("LAYA_MODEL_PROFILE", "all") + if profile == "multilingual": + cases = ( + (chinese_payload, "multilingual"), + ({**english_payload, "model": "auto"}, "multilingual"), + ) + assert client.get("/internal/models").json() == {"models": ["auto", "multilingual"]} + assert client.post("/v1/systemone", json=english_payload, headers=headers).status_code == 422 + else: + cases = ( + (english_payload, "english"), + (chinese_payload, "multilingual"), + ({**chinese_payload, "model": "auto"}, "multilingual"), + ({**english_payload, "model": "typed-decisions"}, "typed-decisions"), + ) results = [] - for payload, expected_route in ( - (english_payload, "english"), - (chinese_payload, "multilingual"), - ({**chinese_payload, "model": "auto"}, "multilingual"), - ({**english_payload, "model": "typed-decisions"}, "typed-decisions"), - ): + for payload, expected_route in cases: prediction = client.post("/v1/systemone", json=payload, headers=headers) assert prediction.status_code == 200, prediction.text data = prediction.json() @@ -63,7 +73,7 @@ assert data["routing"]["model"] == expected_route, data["routing"] results.append(data) usage = client.get("/internal/usage").json()["totals"] - assert usage["requests"] == 4 + assert usage["requests"] == len(cases) assert usage["input_tokens"] == sum(item["usage"]["input_tokens"] for item in results) assert client.post(f"/internal/api-keys/{created.json()['id']}/revoke", headers=write_headers).status_code == 200 assert client.post("/v1/systemone", json=payload, headers=headers).status_code == 401 From f40eaf72fb5217ec67b4b979fb1e25c879bb585d Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Wed, 23 Sep 2026 17:36:53 +0800 Subject: [PATCH 2/7] Update README with request and error handling details (#44) * Update README with request and error handling details Clarified the request structure and error handling in the documentation. * Update README with pnpm install instruction Add pnpm install command for frontend setup --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c7967c..5a8d718 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ curl -X POST https://console.example.com/v1/systemone \ -d '{"state":{"message":"I was charged twice"},"questions":{"refund":{"type":"noul","instructions":"Does the customer ask for a refund?"}}}' ``` -请求中的 `state` 可为字符串、JSON 对象或数组;`questions` 是非空问题 ID 映射,支持 `noul`、`choice` 和 `score`;`model` 可选 `auto`(默认)、`english`、`multilingual` 或 `typed-decisions`。返回保留上游 `answers`、`model` 和 `usage`。错误使用 `detail.code` 和 `detail.message`。无效密钥为 401,校验失败为 422,模型不可用为 503。控制台 Playground 使用管理员会话,记录为单独用量来源。 +请求中的 `state` 可为字符串、JSON 对象或数组;`questions` 是非空问题 ID 映射,支持 `noul`、`choice` 和 `score`;`model` 可选 `auto`(默认)、`english`、`multilingual` 或 `typed-decisions`。返回结果保留上游 `answers`、`model` 和 `usage`。错误使用 `detail.code` 和 `detail.message`。无效密钥为 401,校验失败为 422,模型不可用为 503。控制台 Playground 使用管理员会话,记录为单独用量来源。 ## 数据与维护 From 72748eb14ae995957d9fb385573665664e809fc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:40:45 +0000 Subject: [PATCH 3/7] fix frontend session handling after merge Co-authored-by: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com> --- frontend/src/App.tsx | 4 +++- frontend/src/api.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a26c10e..3b09d9e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,7 +33,9 @@ function useSession() { const controller = new AbortController() api("/internal/auth/session", { signal: controller.signal }) .then(value => { if (!controller.signal.aborted) setSession(value) }) - .catch(() => { if (!controller.signal.aborted) setSession(null) }) + .catch(cause => { + if (!controller.signal.aborted && cause instanceof ApiError && cause.status === 401) setSession(null) + }) .finally(() => { if (!controller.signal.aborted) setReady(true) }) return () => controller.abort() }, []) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5b927b5..b15f677 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -40,5 +40,6 @@ export async function api(path: string, options: RequestInit = {}, csrf?: str const data = await response.json().catch(() => null) throw new ApiError(response.status, data?.detail?.code || (unauthenticated ? "UNAUTHENTICATED" : undefined)) } - return response.json() as Promise + const body = await response.text() + return (body ? JSON.parse(body) : undefined) as T } From 87b28656ea60e2791ec6c98145234994a2a029d6 Mon Sep 17 00:00:00 2001 From: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:22:10 +0800 Subject: [PATCH 4/7] feat: chaneg docker action --- .dockerignore | 14 +++-- .github/workflows/build-and-push.yml | 93 ++++++++++++++++++++++++++++ Dockerfile | 18 ++++-- README.md | 32 +++++++--- backend/server/config.py | 5 ++ backend/server/laya_adapter.py | 7 ++- backend/server/main.py | 15 ++++- backend/tests/test_flow.py | 35 +++++++++++ compose.yaml | 6 +- frontend/package.json | 2 +- frontend/src/App.tsx | 8 ++- frontend/src/Playground.tsx | 23 +++++-- frontend/src/i18n.tsx | 10 ++- scripts/download-models.py | 2 +- scripts/smoke-image-model.py | 21 +++++++ scripts/smoke-real-model.py | 26 +++++--- 16 files changed, 273 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/build-and-push.yml create mode 100644 scripts/smoke-image-model.py diff --git a/.dockerignore b/.dockerignore index a993893..ed9a881 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,18 @@ /.git +!/laya/.git +!/laya/.git/** /.env /.env.* -/.venv -/.pnpm-store +/.github /.agents +/AGENTS.md +/openspec /.codex +/.claude +/CLAUDE.md +/GEMINI.md +/.venv +/.pnpm-store /.pytest_cache **/.pytest_cache **/*.egg-info @@ -16,8 +24,6 @@ /frontend/*.tsbuildinfo /data /models -.idea -.env **/__pycache__ **/*.pyc **/*.sqlite3 diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..9a2cb2e --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,93 @@ +name: Build and push LAYA SERVER + +on: + workflow_dispatch: + inputs: + dockerImageTag: + description: "Docker Hub tag (for example v0.1.0 or dev)" + required: true + default: dev + type: string + dockerImageTagWithLatest: + description: "Also publish latest" + required: true + default: false + type: boolean + runner: + description: "GitHub runner" + required: true + default: ubuntu-latest + type: choice + options: + - ubuntu-latest + - self-hosted + +permissions: + contents: read + +concurrency: + group: laya-server-build-and-push + cancel-in-progress: false + +jobs: + build-and-push-to-dockerhub: + runs-on: ${{ inputs.runner }} + steps: + - name: Check out LAYA SERVER + uses: actions/checkout@v6 + + - name: Validate image tag + id: image + shell: bash + env: + IMAGE_TAG: ${{ inputs.dockerImageTag }} + WITH_LATEST: ${{ inputs.dockerImageTagWithLatest }} + run: | + if [[ ! "$IMAGE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo 'Invalid Docker image tag' >&2 + exit 1 + fi + echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" + echo "with_latest=$WITH_LATEST" >> "$GITHUB_OUTPUT" + + - name: Check out pinned upstream Laya + shell: bash + run: | + git clone --depth 1 --branch v0.3.7 https://github.com/NandhaKishorM/laya.git laya + git -C laya checkout --detach 010bacef009c855ccba814b51f7c8e1d38ab5e3f + sh scripts/check-upstream.sh + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Test API contracts + run: | + python -m pip install -e 'backend[test]' + python -m pytest backend/tests -q + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push 1panel/laya-server + shell: bash + env: + IMAGE_TAG: ${{ steps.image.outputs.tag }} + WITH_LATEST: ${{ steps.image.outputs.with_latest }} + run: | + tags=(--tag "1panel/laya-server:$IMAGE_TAG") + if [[ "$WITH_LATEST" == 'true' && "$IMAGE_TAG" != 'latest' ]]; then + tags+=(--tag '1panel/laya-server:latest') + fi + docker buildx build \ + --platform linux/amd64 \ + --output type=image,push=true \ + "${tags[@]}" \ + . diff --git a/Dockerfile b/Dockerfile index 8dab77f..ac5c2d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,10 +15,19 @@ RUN test "$(git -C /upstream rev-parse HEAD)" = "$LAYA_UPSTREAM_SHA" && \ test -z "$(git -C /upstream status --porcelain)" || \ (echo 'Laya checkout must match the pinned SHA and be clean' >&2; exit 1) +FROM python:3.12-slim AS model-download +ENV LAYA_MODEL_DIR=/opt/model-download HF_HOME=/opt/hf-cache +WORKDIR /app +RUN pip install --no-cache-dir huggingface-hub==0.29.3 +COPY scripts/download-models.py /app/scripts/download-models.py +RUN python /app/scripts/download-models.py --model multilingual && \ + test -s /opt/model-download/multilingual/model.safetensors + FROM python:3.12-slim AS app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ - LAYA_DATABASE_PATH=/data/laya.sqlite3 LAYA_MODEL_DIR=/models \ - LAYA_FRONTEND_DIR=/app/frontend/dist HF_HOME=/models/.cache + LAYA_DATABASE_PATH=/data/laya.sqlite3 LAYA_MODEL_DIR=/opt/models \ + LAYA_MODEL_PROFILE=multilingual LAYA_FRONTEND_DIR=/app/frontend/dist \ + HF_HOME=/data/hf-cache HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 WORKDIR /app RUN pip install --no-cache-dir torch==2.5.1 --index-url https://download.pytorch.org/whl/cpu RUN pip install --no-cache-dir transformers==4.48.3 safetensors==0.5.3 huggingface-hub==0.29.3 numpy==1.26.4 @@ -27,9 +36,10 @@ COPY --from=upstream-check /upstream/laya/ /opt/laya/laya/ RUN pip install --no-cache-dir --no-deps /opt/laya COPY backend/ /app/backend/ RUN pip install --no-cache-dir /app/backend -COPY scripts/download-models.py /app/scripts/download-models.py +COPY --from=model-download /opt/model-download/multilingual/ /opt/models/multilingual/ +COPY scripts/smoke-image-model.py /app/scripts/smoke-image-model.py COPY --from=frontend-build /app/frontend/dist/ /app/frontend/dist/ -RUN mkdir -p /data /models +RUN mkdir -p /data && python /app/scripts/smoke-image-model.py EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)" || exit 1 CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", "--proxy-headers"] diff --git a/README.md b/README.md index 5a8d718..340028d 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ python3.12 -m venv .venv ## 模型文件 -模型权重不随仓库和镜像分发。`scripts/download-models.py` 将 Hugging Face 仓库固定在提交 `1c5edc17a7acd8701df6fc341c0d179f1c62c982`,把三个 checkpoint 放到持久化模型卷的 `/models/english`、`/models/multilingual`、`/models/typed-decisions`。每个目录至少要有上游模型包里的 `rl_agent_config.json`、`model.safetensors`、`tokenizer/` 和 `encoder/`。文件存在性可通过 `GET /health/ready` 检查;缺失时推理返回 `503 MODEL_UNAVAILABLE`。实际模型是否兼容仍需做推理冒烟测试。 +仓库不跟踪模型权重。Dockerfile 在构建阶段从 Hugging Face 固定提交 `1c5edc17a7acd8701df6fc341c0d179f1c62c982` 下载 **multilingual** checkpoint,只把该模型文件复制到最终镜像的 `/opt/models/multilingual`。构建时在离线模式下分别执行英文和中文推理,失败则不会发布镜像。运行容器无需下载模型,也无需挂载模型卷。`GET /health/ready` 检查镜像中的模型文件。 -本项目的模型推理不在应用启动时自动下载;请在启动前准备模型卷。一个应用进程只加载所需模型,默认最多驻留一个,可用 `LAYA_MAX_LOADED_MODELS` 调整;该值控制模型缓存数量,不限制同时处理的请求数。服务不设置额外的推理并发槽位;实际并发能力取决于运行时线程池、模型和机器资源。CPU 推理镜像使用 PyTorch CPU wheel;若部署 GPU,需按设备改用相应 PyTorch 基础环境并验收。 +发布镜像的 `LAYA_MODEL_PROFILE=multilingual`:`model=auto` 和 `model=multilingual` 都使用此模型;显式请求 `english` 或 `typed-decisions` 返回 `422 MODEL_NOT_AVAILABLE`。Playground 只列出镜像支持的模型。一个应用进程默认最多驻留一个模型,可用 `LAYA_MAX_LOADED_MODELS` 调整;该值控制模型缓存数量,不限制同时处理的请求数。实际并发能力取决于运行时线程池、模型和机器资源。发布镜像使用 PyTorch CPU wheel,当前 Action 构建 `linux/amd64`。 本地先安装上游运行依赖与被忽略的 Laya 检出,再下载三个固定版本模型,运行包含英文、中文显式选型、中文自动路由和 typed-decisions 的真实请求冒烟测试: @@ -46,15 +46,33 @@ LAYA_MODEL_DIR=models .venv/bin/python scripts/smoke-real-model.py ## 启动 -首次启动前,先构建镜像并将固定版本模型下载到模型卷,然后启动应用: +在 GitHub 仓库的 **Settings → Secrets and variables → Actions** 配置 `DOCKERHUB_USERNAME` 和 `DOCKERHUB_TOKEN`(需要有 `1panel/laya-server` 的推送权限)。在 **Actions → Build and push LAYA SERVER → Run workflow** 输入版本标签。正式发布时可同时勾选 `latest`;测试标签保持关闭。工作流会检出被忽略的上游 v0.3.7 源码并校验 SHA,运行后端测试,再构建及推送镜像。 + +拉取已发布镜像并启动: + +```sh +cp .env.example .env +# 编辑 .env,配置管理员账号、密码和 LAYA_PUBLIC_ORIGIN +LAYA_IMAGE_TAG=dev docker compose pull +LAYA_IMAGE_TAG=dev docker compose up -d +``` + +也可用本地已检出的上游源码构建: + +```sh +sh scripts/check-upstream.sh +docker build -t 1panel/laya-server:dev . +LAYA_IMAGE_TAG=dev docker compose up -d +``` + +如使用 `latest`,直接执行: ```sh -docker compose build -docker compose run --rm app python /app/scripts/download-models.py +docker compose pull docker compose up -d ``` -Compose 只启动一个应用服务并将 `127.0.0.1:8080` 暴露给宿主机。公网入口需由外部反向代理提供 HTTPS,并将请求转发到该端口。SQLite 数据在 `laya-data` 卷,模型在 `laya-models` 卷。构建机器需要能访问 PyPI、PyTorch CPU 包索引和 npm registry;已构建镜像启动时无需拉取源码或依赖。 +Compose 只启动一个应用服务并将 `127.0.0.1:8080` 暴露给宿主机。公网入口需由外部反向代理提供 HTTPS,并将请求转发到该端口。SQLite 数据在 `laya-data` 卷;更新容器不会丢失数据库。构建机器需要能访问 PyPI、PyTorch CPU 包索引、npm registry 和 Hugging Face;已构建镜像启动时无需拉取源码、依赖或模型。 如果由现有的 1Panel 反向代理提供公网 HTTPS,将域名请求转发到宿主机的 `127.0.0.1:8080`,并确保 `LAYA_PUBLIC_ORIGIN` 与实际 HTTPS 域名一致。反向代理不属于本项目的应用容器。 @@ -92,7 +110,7 @@ curl -X POST https://console.example.com/v1/systemone \ -d '{"state":{"message":"I was charged twice"},"questions":{"refund":{"type":"noul","instructions":"Does the customer ask for a refund?"}}}' ``` -请求中的 `state` 可为字符串、JSON 对象或数组;`questions` 是非空问题 ID 映射,支持 `noul`、`choice` 和 `score`;`model` 可选 `auto`(默认)、`english`、`multilingual` 或 `typed-decisions`。返回结果保留上游 `answers`、`model` 和 `usage`。错误使用 `detail.code` 和 `detail.message`。无效密钥为 401,校验失败为 422,模型不可用为 503。控制台 Playground 使用管理员会话,记录为单独用量来源。 +请求中的 `state` 可为字符串、JSON 对象或数组;`questions` 是非空问题 ID 映射,支持 `noul`、`choice` 和 `score`。发布镜像只内置 multilingual,`model` 可选 `auto`(默认)或 `multilingual`;显式请求 `english` 或 `typed-decisions` 返回 `422 MODEL_NOT_AVAILABLE`。本地源码开发默认仍可使用全部三个模型,前提是已下载对应权重。返回结果保留上游 `answers`、`model` 和 `usage`。错误使用 `detail.code` 和 `detail.message`;无效密钥为 401,请求校验失败为 422,模型不可用为 503。控制台 Playground 使用管理员会话,记录为单独用量来源。 ## 数据与维护 diff --git a/backend/server/config.py b/backend/server/config.py index f3c1571..ec102ad 100644 --- a/backend/server/config.py +++ b/backend/server/config.py @@ -17,6 +17,7 @@ class Settings: device: str | None max_loaded_models: int frontend_dir: Path + model_profile: str = "all" @classmethod def from_env(cls) -> "Settings": @@ -38,6 +39,9 @@ def from_env(cls) -> "Settings": raise RuntimeError("HTTP origin requires LAYA_ALLOW_INSECURE_LOCAL=1") if any(char in origin.split("://", 1)[1] for char in "/?#"): raise RuntimeError("LAYA_PUBLIC_ORIGIN must not contain a path, query, or fragment") + model_profile = os.environ.get("LAYA_MODEL_PROFILE", "all") + if model_profile not in ("all", "multilingual"): + raise RuntimeError("LAYA_MODEL_PROFILE must be all or multilingual") return cls( username, password_hash, Path(os.environ.get("LAYA_DATABASE_PATH", "/data/laya.sqlite3")), @@ -47,4 +51,5 @@ def from_env(cls) -> "Settings": os.environ.get("LAYA_DEVICE") or None, int(os.environ.get("LAYA_MAX_LOADED_MODELS", "1")), Path(os.environ.get("LAYA_FRONTEND_DIR", "/app/frontend/dist")), + model_profile, ) diff --git a/backend/server/laya_adapter.py b/backend/server/laya_adapter.py index b931821..93f62c1 100644 --- a/backend/server/laya_adapter.py +++ b/backend/server/laya_adapter.py @@ -8,7 +8,7 @@ def predict(self, state: Any, questions: dict[str, Any], model: str | None = Non class LayaAdapter: - def __init__(self, model_dir: Path, device: str | None, max_loaded: int): + def __init__(self, model_dir: Path, device: str | None, max_loaded: int, model_profile: str = "all"): checkout = Path(__file__).resolve().parents[2] / "laya" if (checkout / "laya" / "__init__.py").is_file(): # The repository's ignored checkout shadows the installed package when @@ -22,6 +22,11 @@ def __init__(self, model_dir: Path, device: str | None, max_loaded: int): models = {name: str(model_dir / name) for name in ("english", "multilingual", "typed-decisions")} self.router = Router(models=models, device=device, max_loaded=max_loaded) + self.model_profile = model_profile def predict(self, state: Any, questions: dict[str, Any], model: str | None = None) -> dict[str, Any]: + if self.model_profile == "multilingual": + if model not in (None, "multilingual"): + raise ValueError("Model is not available in this image") + model = "multilingual" return self.router.predict(state, questions, model=model) diff --git a/backend/server/main.py b/backend/server/main.py index 98856ff..af2f350 100644 --- a/backend/server/main.py +++ b/backend/server/main.py @@ -100,11 +100,13 @@ def api_key_id(authorization: Annotated[str | None, Header()] = None) -> int: ApiKeyId = Annotated[int, Depends(api_key_id)] def infer(payload: InferenceRequest, source: str, key_id: int | None) -> dict[str, Any]: + if settings.model_profile == "multilingual" and payload.model not in ("auto", "multilingual"): + raise error(422, "MODEL_NOT_AVAILABLE", "Only the multilingual model is installed") with router_lock: if "router" not in router_holder: try: router_holder["router"] = predictor or LayaAdapter( - settings.model_dir, settings.device, settings.max_loaded_models + settings.model_dir, settings.device, settings.max_loaded_models, settings.model_profile ) except Exception: logger.exception("Failed to initialize model router") @@ -114,7 +116,8 @@ def infer(payload: InferenceRequest, source: str, key_id: int | None) -> dict[st result = router_holder["router"].predict( payload.state, {qid: q.model_dump(exclude_none=True) for qid, q in payload.questions.items()}, - model=None if payload.model == "auto" else payload.model, + model=("multilingual" if settings.model_profile == "multilingual" else None) + if payload.model == "auto" else payload.model, ) if not isinstance(result, dict) or not isinstance(result.get("answers"), dict): raise ValueError("Laya returned invalid answers") @@ -148,7 +151,8 @@ def live() -> dict[str, str]: @app.get("/health/ready") def ready() -> dict[str, str]: - for name in ("english", "multilingual", "typed-decisions"): + names = ("multilingual",) if settings.model_profile == "multilingual" else ("english", "multilingual", "typed-decisions") + for name in names: directory = settings.model_dir / name if (not (directory / "rl_agent_config.json").is_file() or not (directory / "model.safetensors").is_file() @@ -209,6 +213,11 @@ def list_keys(session: Session) -> list[dict[str, Any]]: ).fetchall() return [dict(row) for row in rows] + @app.get("/internal/models") + def available_models(session: Session) -> dict[str, list[str]]: + names = ["auto", "multilingual"] if settings.model_profile == "multilingual" else ["auto", "english", "multilingual", "typed-decisions"] + return {"models": names} + @app.post("/internal/api-keys", status_code=201) def create_key(payload: CreateKeyRequest, session: Session, _: WriteSession) -> dict[str, Any]: token = "laya_" + secrets.token_urlsafe(32) diff --git a/backend/tests/test_flow.py b/backend/tests/test_flow.py index a136905..0669f45 100644 --- a/backend/tests/test_flow.py +++ b/backend/tests/test_flow.py @@ -20,9 +20,11 @@ class FakePredictor: def __init__(self): self.calls = 0 + self.models = [] def predict(self, state, questions, model=None): self.calls += 1 + self.models.append(model) assert set(questions) == {"flag", "intent", "score"} return { "model": "laya-rl-agent", @@ -41,6 +43,33 @@ def predict(self, state, questions, model=None): } +def test_multilingual_image_routes_auto_and_rejects_unbundled_models(tmp_path): + model = tmp_path / "models" / "multilingual" + for filename in ("rl_agent_config.json", "model.safetensors", "tokenizer/tokenizer.json", "encoder/config.json"): + path = model / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + fake = FakePredictor() + settings = Settings("admin", PasswordHasher().hash("correct horse battery staple"), tmp_path / "db.sqlite3", + "http://testserver", False, 1, tmp_path / "models", None, 1, tmp_path / "missing-dist", "multilingual") + client = TestClient(create_app(settings, fake)) + assert client.get("/health/ready").status_code == 200 + assert client.get("/internal/models").status_code == 401 + origin = {"Origin": "http://testserver"} + assert client.post("/internal/auth/login", json={"username": "admin", "password": "correct horse battery staple"}, headers=origin).status_code == 200 + assert client.get("/internal/models").json() == {"models": ["auto", "multilingual"]} + csrf = client.get("/internal/auth/session").json()["csrf_token"] + key = client.post("/internal/api-keys", json={"name": "smoke"}, headers={**origin, "X-CSRF-Token": csrf}).json()["key"] + headers = {"Authorization": f"Bearer {key}"} + assert client.post("/v1/systemone", json={**PAYLOAD, "model": "auto"}, headers=headers).status_code == 200 + assert client.post("/v1/systemone", json={**PAYLOAD, "model": "multilingual"}, headers=headers).status_code == 200 + for unsupported in ("english", "typed-decisions"): + response = client.post("/v1/systemone", json={**PAYLOAD, "model": unsupported}, headers=headers) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "MODEL_NOT_AVAILABLE" + assert fake.models == ["multilingual", "multilingual"] + + def test_admin_password_from_environment(tmp_path, monkeypatch): password = "0123456789" monkeypatch.setenv("LAYA_ADMIN_USERNAME", "configured-admin") @@ -50,6 +79,12 @@ def test_admin_password_from_environment(tmp_path, monkeypatch): monkeypatch.setenv("LAYA_ALLOW_INSECURE_LOCAL", "1") monkeypatch.setenv("LAYA_DATABASE_PATH", str(tmp_path / "db.sqlite3")) settings = Settings.from_env() + monkeypatch.setenv("LAYA_MODEL_PROFILE", "multilingual") + assert Settings.from_env().model_profile == "multilingual" + monkeypatch.setenv("LAYA_MODEL_PROFILE", "unknown") + with pytest.raises(RuntimeError, match="LAYA_MODEL_PROFILE"): + Settings.from_env() + monkeypatch.delenv("LAYA_MODEL_PROFILE") assert settings.admin_password_hash != password assert settings.admin_password_hash.startswith("$argon2id$") client = TestClient(create_app(settings, FakePredictor())) diff --git a/compose.yaml b/compose.yaml index ab4d16b..01e56e3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,17 +1,13 @@ services: app: - build: - context: . - dockerfile: Dockerfile + image: 1panel/laya-server:${LAYA_IMAGE_TAG:-latest} env_file: .env ports: - "127.0.0.1:8080:8080" volumes: - laya-data:/data - - laya-models:/models restart: unless-stopped init: true volumes: laya-data: - laya-models: diff --git a/frontend/package.json b/frontend/package.json index f16ea27..ff19965 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc -b && vite build", - "lint": "eslint .", + "lint": "eslint ." }, "dependencies": { "@tailwindcss/vite": "^4.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab8b97d..a26c10e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { Activity, BookOpen, ChartNoAxesColumn, CircleHelp, Copy, KeyRound, Lang import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { apiErrorMessage, useI18n, type Locale, type MessageKey, type Translate } from "./i18n" -import { Playground, sampleRequest } from "./Playground" +import { Playground, sampleRequest, type Model } from "./Playground" import { api, ApiError, getSession, setSession, subscribeSession, type Session } from "./api" type ApiKey = { id: number; name: string; mask: string; created_at: string; revoked_at: string | null; last_used_at: string | null } @@ -22,6 +22,10 @@ function displayError(error: unknown, t: Translate) { return apiErrorMessage(error instanceof ApiError ? error.code : undefined, t) } +function loadAvailableModels(): Promise<{ models: Model[] }> { + return api("/internal/models") +} + function useSession() { const session = useSyncExternalStore(subscribeSession, getSession) const [ready, setReady] = useState(false) @@ -108,7 +112,7 @@ function App() {
{title}
LAYA SERVER
-
{page === "home" ? : page === "keys" ? : page === "usage" ? : page === "playground" ? api("/internal/playground", { method: "POST", body }, session.csrf_token)} /> : }
+
{page === "home" ? : page === "keys" ? : page === "usage" ? : page === "playground" ? api("/internal/playground", { method: "POST", body }, session.csrf_token)} loadModels={loadAvailableModels} /> : }
{mobileNav ? -

{t("playgroundModelHint")}

+

{t("playgroundModelHint")}

}
{error ?

{error}

: null}
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 720693f..681afe1 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -110,7 +110,7 @@ const zhCN = { playgroundRemoveOption: "删除这一项", playgroundRemoveOptionNamed: "删除第 {number} 项", playgroundModel: "模型", - playgroundModelHint: "auto 会按内容自动选择英文或多语言模型。", + playgroundModelHint: "auto 使用当前部署可用的模型。", playgroundFullResponse: "查看完整响应 JSON", playgroundInvalidJson: "请求 JSON 无效,请检查格式。", playgroundFormUnsupported: "当前 JSON 含表单无法编辑的字段,请继续在 JSON 模式修改。", @@ -134,6 +134,7 @@ const zhCN = { errorCsrf: "请求校验失败,请刷新页面后重试。", errorInvalidKey: "API Key 无效或已撤销。", errorModelUnavailable: "模型暂时不可用,请稍后重试。", + errorModelNotAvailable: "当前部署未包含所选模型。", errorUsageUnavailable: "用量记录失败,请稍后重试。", errorLoginRateLimited: "登录尝试过多,请稍后再试。", errorInvalidCredentials: "用户名或密码错误。", @@ -250,7 +251,7 @@ const en: Record = { playgroundRemoveOption: "Remove this item", playgroundRemoveOptionNamed: "Remove item {number}", playgroundModel: "Model", - playgroundModelHint: "auto selects the English or multilingual model based on the state.", + playgroundModelHint: "auto uses the model available in this deployment.", playgroundFullResponse: "View full response JSON", playgroundInvalidJson: "Request JSON is invalid. Check its syntax.", playgroundFormUnsupported: "This JSON contains fields the form cannot edit. Continue in JSON mode.", @@ -274,6 +275,7 @@ const en: Record = { errorCsrf: "Request verification failed. Refresh the page and try again.", errorInvalidKey: "The API key is invalid or revoked.", errorModelUnavailable: "The model is temporarily unavailable. Try again later.", + errorModelNotAvailable: "The selected model is not included in this deployment.", errorUsageUnavailable: "Usage could not be recorded. Try again later.", errorLoginRateLimited: "Too many sign-in attempts. Try again later.", errorInvalidCredentials: "Invalid username or password.", @@ -388,7 +390,7 @@ const zhTW: Record = { playgroundRemoveOption: "刪除這一項", playgroundRemoveOptionNamed: "刪除第 {number} 項", playgroundModel: "模型", - playgroundModelHint: "auto 會依內容自動選擇英文或多語言模型。", + playgroundModelHint: "auto 使用目前部署可用的模型。", playgroundFullResponse: "檢視完整回應 JSON", playgroundInvalidJson: "請求 JSON 無效,請檢查格式。", playgroundFormUnsupported: "目前的 JSON 包含表單無法編輯的欄位,請繼續在 JSON 模式修改。", @@ -412,6 +414,7 @@ const zhTW: Record = { errorCsrf: "請求驗證失敗,請重新整理頁面後再試。", errorInvalidKey: "API Key 無效或已撤銷。", errorModelUnavailable: "模型暫時無法使用,請稍後再試。", + errorModelNotAvailable: "目前部署未包含所選模型。", errorUsageUnavailable: "無法記錄用量,請稍後再試。", errorLoginRateLimited: "登入嘗試次數過多,請稍後再試。", errorInvalidCredentials: "使用者名稱或密碼錯誤。", @@ -475,6 +478,7 @@ const errorMessages: Record = { CSRF_INVALID: "errorCsrf", INVALID_API_KEY: "errorInvalidKey", MODEL_UNAVAILABLE: "errorModelUnavailable", + MODEL_NOT_AVAILABLE: "errorModelNotAvailable", USAGE_UNAVAILABLE: "errorUsageUnavailable", LOGIN_RATE_LIMITED: "errorLoginRateLimited", INVALID_CREDENTIALS: "errorInvalidCredentials", diff --git a/scripts/download-models.py b/scripts/download-models.py index 8e68db3..1b6408e 100644 --- a/scripts/download-models.py +++ b/scripts/download-models.py @@ -1,4 +1,4 @@ -"""Populate the persistent model volume from one pinned Hugging Face snapshot.""" +"""Download selected model files from one pinned Hugging Face snapshot.""" import argparse import os diff --git a/scripts/smoke-image-model.py b/scripts/smoke-image-model.py new file mode 100644 index 0000000..01a45a7 --- /dev/null +++ b/scripts/smoke-image-model.py @@ -0,0 +1,21 @@ +"""Fail the image build if the bundled multilingual model cannot infer offline.""" + +import os +from pathlib import Path + +from server.laya_adapter import LayaAdapter + + +model_dir = Path(os.environ["LAYA_MODEL_DIR"]) +assert os.environ["LAYA_MODEL_PROFILE"] == "multilingual" +assert (model_dir / "multilingual" / "model.safetensors").is_file() +for unavailable in ("english", "typed-decisions"): + assert not (model_dir / unavailable).exists() + +predictor = LayaAdapter(model_dir, "cpu", 1, "multilingual") +question = {"flag": {"type": "noul", "instructions": "Does this customer request a refund?"}} +for state in ("Please refund the duplicate charge.", "请退还重复扣除的费用。"): + result = predictor.predict(state, question) + assert result["routing"]["model"] == "multilingual", result + assert "flag" in result["answers"], result +print("Bundled multilingual model passed offline inference in both languages") diff --git a/scripts/smoke-real-model.py b/scripts/smoke-real-model.py index b7f811e..166b49c 100644 --- a/scripts/smoke-real-model.py +++ b/scripts/smoke-real-model.py @@ -18,7 +18,7 @@ os.environ["LAYA_ALLOW_INSECURE_LOCAL"] = "1" os.environ["LAYA_DATABASE_PATH"] = str(Path(temporary) / "smoke.sqlite3") os.environ["LAYA_MODEL_DIR"] = os.environ.get("LAYA_MODEL_DIR", "models") - os.environ["HF_HOME"] = str(Path(os.environ["LAYA_MODEL_DIR"]) / ".cache") + os.environ.setdefault("HF_HOME", str(Path(os.environ["LAYA_MODEL_DIR"]) / ".cache")) os.environ["LAYA_DEVICE"] = "cpu" os.environ["LAYA_FRONTEND_DIR"] = str(Path(temporary) / "no-frontend") from server.main import app @@ -48,13 +48,23 @@ "state": {"message": "我的账户被重复扣费了,请尽快退款。"}, "model": "multilingual", } + profile = os.environ.get("LAYA_MODEL_PROFILE", "all") + if profile == "multilingual": + cases = ( + (chinese_payload, "multilingual"), + ({**english_payload, "model": "auto"}, "multilingual"), + ) + assert client.get("/internal/models").json() == {"models": ["auto", "multilingual"]} + assert client.post("/v1/systemone", json=english_payload, headers=headers).status_code == 422 + else: + cases = ( + (english_payload, "english"), + (chinese_payload, "multilingual"), + ({**chinese_payload, "model": "auto"}, "multilingual"), + ({**english_payload, "model": "typed-decisions"}, "typed-decisions"), + ) results = [] - for payload, expected_route in ( - (english_payload, "english"), - (chinese_payload, "multilingual"), - ({**chinese_payload, "model": "auto"}, "multilingual"), - ({**english_payload, "model": "typed-decisions"}, "typed-decisions"), - ): + for payload, expected_route in cases: prediction = client.post("/v1/systemone", json=payload, headers=headers) assert prediction.status_code == 200, prediction.text data = prediction.json() @@ -64,7 +74,7 @@ assert data["routing"]["model"] == expected_route, data["routing"] results.append(data) usage = client.get("/internal/usage").json()["totals"] - assert usage["requests"] == 4 + assert usage["requests"] == len(cases) assert usage["input_tokens"] == sum(item["usage"]["input_tokens"] for item in results) assert client.post(f"/internal/api-keys/{created.json()['id']}/revoke", headers=write_headers).status_code == 200 assert client.post("/v1/systemone", json=payload, headers=headers).status_code == 401 From c723c9fcf6be01fbddd1ba9fe1106c5b148d30d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:41:38 +0000 Subject: [PATCH 5/7] fix api method normalization Co-authored-by: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com> --- frontend/src/api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b15f677..813c85b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -31,9 +31,10 @@ export class ApiError extends Error { export async function api(path: string, options: RequestInit = {}, csrf?: string): Promise { const requestSessionVersion = sessionVersion const headers = new Headers(options.headers) + const method = options.method?.toUpperCase() if (options.body) headers.set("Content-Type", "application/json") - if (options.method && options.method !== "GET") headers.set("X-CSRF-Token", csrf || "") - const response = await fetch(path, { credentials: "same-origin", ...options, headers }) + if (method && !["GET", "HEAD"].includes(method)) headers.set("X-CSRF-Token", csrf || "") + const response = await fetch(path, { credentials: "same-origin", ...options, method, headers }) if (!response.ok) { const unauthenticated = response.status === 401 && path.startsWith("/internal/") && path !== "/internal/auth/login" if (unauthenticated && requestSessionVersion === sessionVersion) setSession(null) From f72a354d3fb33f2129dfa66ba29fb6bca9933c58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:42:24 +0000 Subject: [PATCH 6/7] fix api error naming Co-authored-by: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com> --- frontend/src/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 813c85b..f1518cf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -23,6 +23,7 @@ export class ApiError extends Error { constructor(status: number, code?: string) { super(code || "UNKNOWN_ERROR") + this.name = "ApiError" this.status = status this.code = code } From 58679a3646aa0124375b2987d35d3fd1b0585f46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:43:14 +0000 Subject: [PATCH 7/7] fix api json content type detection Co-authored-by: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com> --- frontend/src/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f1518cf..4c543d0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -32,8 +32,12 @@ export class ApiError extends Error { export async function api(path: string, options: RequestInit = {}, csrf?: string): Promise { const requestSessionVersion = sessionVersion const headers = new Headers(options.headers) + const body = options.body const method = options.method?.toUpperCase() - if (options.body) headers.set("Content-Type", "application/json") + if (typeof body === "string" && !headers.has("Content-Type")) { + const payload = body.trim() + if (payload.startsWith("{") || payload.startsWith("[")) headers.set("Content-Type", "application/json") + } if (method && !["GET", "HEAD"].includes(method)) headers.set("X-CSRF-Token", csrf || "") const response = await fetch(path, { credentials: "same-origin", ...options, method, headers }) if (!response.ok) {