Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,8 +24,6 @@
/frontend/*.tsbuildinfo
/data
/models
.idea
.env
**/__pycache__
**/*.pyc
**/*.sqlite3
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
@@ -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[@]}" \
.
18 changes: 14 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的真实请求冒烟测试:

Expand All @@ -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 域名一致。反向代理不属于本项目的应用容器。

Expand Down Expand Up @@ -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 使用管理员会话,记录为单独用量来源。

## 数据与维护

Expand Down
5 changes: 5 additions & 0 deletions backend/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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")),
Expand All @@ -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,
)
7 changes: 6 additions & 1 deletion backend/server/laya_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
15 changes: 12 additions & 3 deletions backend/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/test_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
Expand All @@ -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()))
Expand Down
6 changes: 1 addition & 5 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading