Merge pull request #3671 from modelscope/codex/kubernetes-docs-contracts-20260908

docs: make Kubernetes recipes reliable and connect Gradio guides
This commit is contained in:
zhifu gao
2026-09-08 05:32:34 +08:00
committed by GitHub
6 changed files with 287 additions and 36 deletions
+46 -18
View File
@@ -1,6 +1,10 @@
# Kubernetes Deployment for the FunASR OpenAI-Compatible API
This folder provides a CPU-first Kubernetes template for the `examples/openai_api` server. Use it when you want an internal OpenAI-compatible speech endpoint reachable by agents, web backends, workflow engines, or batch workers inside a cluster.
Deploy an internal speech endpoint for agents, web backends, workflow engines, or batch workers. Start with the SenseVoice CPU template; the separate MOSS GPU recipe below adds transcription with speaker diarization.
Run **every command from the FunASR repository root**, including commands in additional terminals. You need Docker, a writable image registry, `kubectl` configured for the intended cluster, permission to create resources in `speech`, and a default StorageClass for the cache PVCs. Replace the example registry before building. The local smoke client requires Python 3.10 or newer, with no third-party Python packages.
**ClusterIP is not authentication.** Neither manifest supplies a NetworkPolicy, authentication gateway, TLS, or request limits. Review the [service security guide](../SECURITY.md) before allowing untrusted clients. The recipes below use a local port-forward, not a public ingress.
The manifest is intentionally conservative:
@@ -12,59 +16,83 @@ The manifest is intentionally conservative:
## 1. Build and push the image
From `examples/openai_api`:
Use the API directory as the CPU build context, while keeping your shell at the repository root:
```bash
docker build -t registry.example.com/speech/funasr-api:cpu-latest .
docker build -f examples/openai_api/Dockerfile -t registry.example.com/speech/funasr-api:cpu-latest examples/openai_api
docker push registry.example.com/speech/funasr-api:cpu-latest
```
Edit `kustomization.yaml` if you use a different registry, repository, or tag.
Set `images` in `examples/openai_api/kubernetes/kustomization.yaml` to the pushed image. For reproducible deployments, replace the example mutable tag with your registry's immutable digest. Record the image digest and manifest before each rollout so you can restore them. This CPU Dockerfile copies the example `server.py` but installs FunASR from PyPI; it does not install the checkout's FunASR package. Its dependencies are not a locked environment.
## 2. Deploy
```bash
kubectl create namespace speech --dry-run=client -o yaml | kubectl apply -f -
kubectl -n speech apply -k .
kubectl -n speech apply -k examples/openai_api/kubernetes
kubectl -n speech rollout status deploy/funasr-api --timeout=15m
```
Model download and first load can take several minutes. The `startupProbe` allows up to 10 minutes before Kubernetes restarts the container.
The CPU server preloads its configured model before starting HTTP. Model download and first load can take several minutes. The startup probe has an approximately 10-minute failure budget (`10s` period, `60` failures); this does not include image pulls, scheduling, or PVC binding. A successful `/health` response is not an inference check: complete the audio request below before admitting traffic.
## 3. Smoke test
Keep the service private and verify it through `port-forward` first:
```bash
kubectl -n speech port-forward svc/funasr-api 8000:8000
kubectl -n speech port-forward --address 127.0.0.1 svc/funasr-api 8000:8000
```
From another terminal in `examples/openai_api`:
Leave port-forward running. In another terminal at the same repository root:
```bash
python smoke_test.py --base-url http://localhost:8000
python3 examples/openai_api/smoke_test.py --base-url http://127.0.0.1:8000 --model sensevoice --response-format verbose_json
```
For in-cluster clients, use `http://funasr-api.speech.svc.cluster.local:8000` as the direct HTTP base URL and `http://funasr-api.speech.svc.cluster.local:8000/v1` as the OpenAI SDK base URL.
The smoke client downloads a public Chinese sample only if `sample.wav` is missing in the current directory; an existing file is reused. It prints health, model metadata, and the full transcription JSON. Inspect the text and timestamps yourself: exit status zero does not validate recognition accuracy, speaker labels, memory capacity, or concurrency. Avoid retaining sensitive audio or unredacted output. The client does not send Authorization; the security guide's transcription-only gateway intentionally denies metadata routes, so use this smoke recipe through the local port-forward, not that gateway.
## 4. Tune for your cluster
| Setting | Default | When to change it |
|---|---|---|
| `FUNASR_MODEL` | `sensevoice` | Use another alias after checking `/v1/models`. |
| `FUNASR_MODEL` | `sensevoice` | Check the model's dependencies and hardware requirements first; `/v1/models` lists aliases, not proof that each model is ready. |
| `FUNASR_DEVICE` | `cpu` | Set to `cuda` only after building a CUDA-capable image and configuring GPU scheduling. |
| PVC size | `20Gi` | Increase when caching multiple models or large model revisions. |
| Memory request | `8Gi` | Tune after observing startup and real audio workloads. |
| Startup probe | 10 minutes | Increase if your registry, model hub, or storage backend is slow. |
| Startup probe | About 10 minutes | Tune for model initialization; diagnose image pulls, scheduling, and PVC binding separately. |
The `moss-transcribe-diarize` alias uses a separate GPU image and resource profile. Build
`examples/openai_api/Dockerfile.moss`, then apply `funasr-moss-api.yaml` after
replacing `funasr-moss-api:local` with your registry's immutable image digest.
The complete runtime matrix is in the [MOSS deployment guide](../../../docs/moss_transcribe_diarize.md).
### MOSS GPU alternative
MOSS-Transcribe-Diarize is an OpenMOSS-Team model integrated into FunASR. This manifest runs the packaged FunASR HTTP adapter with `verbose_json`, not native vLLM or its `diarized_json` contract. Diarization labels are not verified speaker identity. For model requirements, outputs, no-external-VAD behavior, and other serving backends, use the [MOSS deployment guide](../../../docs/moss_transcribe_diarize.md).
The MOSS manifest is not included in `kustomization.yaml`. It requests one NVIDIA GPU, 24Gi memory, a 40Gi cache PVC, and an 8Gi memory-backed `/dev/shm`; these are template settings, not measured capacity guarantees. Configure the cluster's GPU device plugin and scheduling first. Unlike the CPU image, `Dockerfile.moss` copies and installs the whole checkout, so its build context must be the repository root. Use a clean checkout without credentials or private data in the build context.
```bash
docker build -f examples/openai_api/Dockerfile.moss -t registry.example.com/speech/funasr-api:moss-local .
docker push registry.example.com/speech/funasr-api:moss-local
```
Before applying, replace `funasr-moss-api:local` in `examples/openai_api/kubernetes/funasr-moss-api.yaml` with the pushed image's immutable digest. Create the `speech` namespace as in step 2 if you skipped the CPU deployment. Save the image digest and edited manifest as your rollback record.
```bash
kubectl -n speech apply -f examples/openai_api/kubernetes/funasr-moss-api.yaml
kubectl -n speech rollout status deploy/funasr-moss-api --timeout=15m
kubectl -n speech port-forward --address 127.0.0.1 svc/funasr-moss-api 8001:8000
```
In another terminal at the repository root, use local port 8001 to keep this endpoint separate from the CPU example:
```bash
python3 examples/openai_api/smoke_test.py --base-url http://127.0.0.1:8001 --model moss-transcribe-diarize --response-format verbose_json
```
The MOSS template has startup and readiness probes, but no liveness probe. Its `/health` response does not test transcription. Review the returned text and diarization against your audio; this recipe does not certify a particular GPU, real-time performance, or production load.
## GPU notes
The example Dockerfile is CPU-first. For GPU clusters you need to adapt the image to CUDA-capable PyTorch/FunASR dependencies, then add your cluster's GPU scheduling fields, for example:
The ordinary Dockerfile is CPU-first. Setting `FUNASR_DEVICE=cuda` alone does not make it a supported GPU image. For other GPU models, adapt the dependencies and scheduling. In the following field sketch, `resources` belongs to the container while `nodeSelector` belongs to the Pod spec; they are not a complete same-level manifest:
```yaml
resources:
@@ -78,8 +106,8 @@ Exact GPU labels, runtime classes, and device plugin configuration vary by Kuber
## Operational checks
- Use `/health` for readiness and `/v1/models` to confirm model aliases.
- Check PVC binding, image pulls, Pod events, and model-loading logs before changing probe budgets. Then inspect `/health`, `/v1/models`, and a real audio response.
- Log model alias, device, audio duration, response format, latency, and error text.
- Start with one replica because the cache PVC is `ReadWriteOnce`; scale horizontally with a registry image, per-pod cache, or a shared read-only model cache after measuring memory and startup time.
- Put authentication and network policy in front of the service before exposing it outside a trusted namespace.
- Enforce authentication and NetworkPolicy for the intended clients; a namespace alone is not a network isolation boundary.
- For Dify, n8n, or web backends inside the same cluster, point them at the Kubernetes service name instead of `localhost`.
+46 -18
View File
@@ -1,6 +1,10 @@
# FunASR OpenAI 兼容 API Kubernetes 部署
这个目录提供 `examples/openai_api` 服务的 CPU-first Kubernetes 模板。适合在集群内部为 Agent、Web 后端、工作流引擎或批处理 worker 提供 OpenAI 兼容语音转写接口。
为集群内部的 Agent、Web 后端、工作流引擎或批处理 worker 部署语音接口。先使用 SenseVoice CPU 模板;下方独立的 MOSS GPU 配方提供转写与说话人分离。
**所有命令都从 FunASR 仓库根目录执行**,包括新开终端中的命令。需要 Docker、可推送的镜像仓库、已指向目标集群的 `kubectl`、在 `speech` 中创建资源的权限,以及供缓存 PVC 使用的默认 StorageClass。构建前替换示例镜像仓库地址。本地 smoke 客户端需要 Python 3.10 或更高版本,不需要第三方 Python 包。
**ClusterIP 不是鉴权。** 两套清单都没有提供 NetworkPolicy、鉴权网关、TLS 或请求限制。允许不可信客户端访问前,请阅读[服务安全指南](../SECURITY_zh.md)。以下配方使用本地 port-forward,不是公网入口。
模板默认比较保守:
@@ -12,59 +16,83 @@
## 1. 构建并推送镜像
在 `examples/openai_api` 目录中执行:
保持 shell 位于仓库根目录,CPU 镜像使用 API 目录作为构建上下文:
```bash
docker build -t registry.example.com/speech/funasr-api:cpu-latest .
docker build -f examples/openai_api/Dockerfile -t registry.example.com/speech/funasr-api:cpu-latest examples/openai_api
docker push registry.example.com/speech/funasr-api:cpu-latest
```
如果使用不同的 registry、repo 或 tag,请修改 `kustomization.yaml`。
将 `examples/openai_api/kubernetes/kustomization.yaml` 的 `images` 配置改为已推送的镜像。需要可复现部署时,把示例可变 tag 换成镜像仓库的不可变 digest。每次 rollout 前记录镜像 digest 与清单,方便恢复。CPU Dockerfile 复制 example `server.py`,但从 PyPI 安装 FunASR,不安装当前 checkout 的 FunASR 包;依赖环境也未锁定。
## 2. 部署
```bash
kubectl create namespace speech --dry-run=client -o yaml | kubectl apply -f -
kubectl -n speech apply -k .
kubectl -n speech apply -k examples/openai_api/kubernetes
kubectl -n speech rollout status deploy/funasr-api --timeout=15m
```
模型下载和首次加载可能需要几分钟。`startupProbe` 默认允许最多 10 分钟,超过后 Kubernetes 才会重启容器。
CPU 服务在启动 HTTP 前预加载配置的模型,下载和首次加载可能需要几分钟。startup probe 的失败预算约为 10 分钟(周期 `10s`、失败阈值 `60`),不包括拉取镜像、调度或 PVC 绑定时间。`/health` 成功不代表推理验收:接入流量前,还要完成下面的音频请求。
## 3. Smoke test
建议先保持服务内网私有,通过 `port-forward` 验证:
```bash
kubectl -n speech port-forward svc/funasr-api 8000:8000
kubectl -n speech port-forward --address 127.0.0.1 svc/funasr-api 8000:8000
```
在另一个终端进入 `examples/openai_api` 后运行:
保持 port-forward 运行。在另一个终端的同一仓库根目录执行:
```bash
python smoke_test.py --base-url http://localhost:8000
python3 examples/openai_api/smoke_test.py --base-url http://127.0.0.1:8000 --model sensevoice --response-format verbose_json
```
集群内客户端可以使用 `http://funasr-api.speech.svc.cluster.local:8000` 作为直接 HTTP base URL,使用 `http://funasr-api.speech.svc.cluster.local:8000/v1` 作为 OpenAI SDK base URL。
smoke 客户端仅在当前目录不存在 `sample.wav` 时下载公开中文样本,已有文件会被复用。它打印 health、模型 metadata 和完整转写 JSON;请自行核对文本与时间戳,退出码为零并不验证识别准确率、说话人标签、内存容量或并发能力。避免保留敏感音频或未脱敏输出。客户端不发送 Authorization;安全指南的仅转写网关会主动拒绝 metadata 路由,因此这套 smoke 应通过本地 port-forward 运行,不要直接指向该网关。
## 4. 根据集群调整配置
| 配置 | 默认值 | 什么时候调整 |
|---|---|---|
| `FUNASR_MODEL` | `sensevoice` | 调用 `/v1/models` 后按需要切换模型别名。 |
| `FUNASR_MODEL` | `sensevoice` | 先检查目标模型的依赖与硬件要求;`/v1/models` 列出别名,不证明每个模型都已就绪。 |
| `FUNASR_DEVICE` | `cpu` | 只有在镜像已适配 CUDA 且集群 GPU 调度已配置后才改成 `cuda`。 |
| PVC 大小 | `20Gi` | 缓存多个模型或较大模型版本时增大。 |
| 内存 request | `8Gi` | 根据启动过程和真实音频负载观测结果调整。 |
| Startup probe | 10 分钟 | registry、模型下载或存储后端较慢时增大。 |
| Startup probe | 约 10 分钟 | 按模型初始化情况调整;镜像拉取、调度和 PVC 绑定需分别排查。 |
`moss-transcribe-diarize` 使用独立的 GPU 镜像与资源配置。先构建
`examples/openai_api/Dockerfile.moss`,将 `funasr-moss-api.yaml` 中的
`funasr-moss-api:local` 替换为内部镜像仓库的不可变 digest 后再应用。完整运行矩阵
见 [MOSS 部署指南](../../../docs/moss_transcribe_diarize_zh.md)。
### MOSS GPU 替代方案
MOSS-Transcribe-Diarize 是 OpenMOSS-Team 的模型,由 FunASR 集成。这套清单运行 packaged FunASR HTTP 适配器,使用 `verbose_json`,不是原生 vLLM 或其 `diarized_json` 接口。分离标签不是经过验证的说话人身份。模型要求、输出、不依赖外部 VAD 的行为和其他服务后端,见 [MOSS 部署指南](../../../docs/moss_transcribe_diarize_zh.md)。
`kustomization.yaml` 不包含 MOSS 清单。MOSS 模板请求一张 NVIDIA GPU、24Gi 内存、40Gi 缓存 PVC,并配置 8Gi 内存型 `/dev/shm`;这些是模板设置,不是实测容量保证。先配置集群的 GPU device plugin 和调度。与 CPU 镜像不同,`Dockerfile.moss` 复制并安装整个 checkout,因此构建上下文必须是仓库根目录;请使用干净的 checkout,不要把凭据或私有数据放入构建上下文。
```bash
docker build -f examples/openai_api/Dockerfile.moss -t registry.example.com/speech/funasr-api:moss-local .
docker push registry.example.com/speech/funasr-api:moss-local
```
应用前,将 `examples/openai_api/kubernetes/funasr-moss-api.yaml` 中的 `funasr-moss-api:local` 替换成已推送镜像的不可变 digest。如果跳过了 CPU 部署,先按第 2 步创建 `speech` namespace。保存镜像 digest 和修改后的清单作为回滚记录。
```bash
kubectl -n speech apply -f examples/openai_api/kubernetes/funasr-moss-api.yaml
kubectl -n speech rollout status deploy/funasr-moss-api --timeout=15m
kubectl -n speech port-forward --address 127.0.0.1 svc/funasr-moss-api 8001:8000
```
在另一个终端的仓库根目录中使用本地端口 8001,与 CPU 示例端口区分:
```bash
python3 examples/openai_api/smoke_test.py --base-url http://127.0.0.1:8001 --model moss-transcribe-diarize --response-format verbose_json
```
MOSS 模板有 startup 和 readiness probe,没有 liveness probe;其 `/health` 不测试转写。请对照音频检查返回的文本与分离结果;这套配方不认证具体 GPU、实时性能或生产负载。
## GPU 说明
示例 Dockerfile 默认面向 CPU。GPU 集群需要先把镜像改成 CUDA-capable PyTorch/FunASR 依赖,再根据集群增加 GPU 调度字段,例如:
普通 Dockerfile 默认面向 CPU,单独设置 `FUNASR_DEVICE=cuda` 不会使它成为受支持的 GPU 镜像。其他 GPU 模型需要适配依赖与调度。下面只是字段示意:`resources` 属于 container,`nodeSelector` 属于 Pod spec,不是可以整体粘贴的同层完整清单:
```yaml
resources:
@@ -78,8 +106,8 @@ nodeSelector:
## 运维检查
- 使用 `/health` 做就绪检查,使用 `/v1/models` 确认模型别名。
- 修改探针预算前,先检查 PVC 绑定、镜像拉取、Pod events 和模型加载日志,再检查 `/health`、`/v1/models` 和真实音频响应。
- 记录模型别名、设备、音频时长、响应格式、延迟和错误文本。
- 由于缓存 PVC 是 `ReadWriteOnce`,建议先从 1 个副本开始;横向扩容前先评估镜像、每 Pod 缓存或共享只读模型缓存方案。
- 服务暴露到可信 namespace 之外前,先加鉴权和网络策略。
- 为预期客户端实施鉴权和 NetworkPolicy;namespace 本身不是网络隔离边界。
- Dify、n8n 或 Web 后端在同一集群内访问时,应使用 Kubernetes service name,不要使用 `localhost`。
+107
View File
@@ -990,5 +990,112 @@ class GradioDocsContract(unittest.TestCase):
self.assertIn("Python 3.12", section)
KUBERNETES_DOCS = [EXAMPLE / "kubernetes" / name for name in ("README.md", "README_zh.md")]
class KubernetesDocsContract(unittest.TestCase):
def test_bilingual_commands_match_and_parse(self):
self.assertEqual(*(shell_commands(path.read_text(encoding="utf-8")) for path in KUBERNETES_DOCS))
for path in KUBERNETES_DOCS:
for code in blocks(path.read_text(encoding="utf-8"), "bash"):
result = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_builds_use_explicit_source_backed_contexts(self):
expected = {
"examples/openai_api/Dockerfile": ("examples/openai_api", "server.py", "cpu-latest"),
"examples/openai_api/Dockerfile.moss": (".", "pyproject.toml", "moss-local"),
}
for path in KUBERNETES_DOCS:
commands = shell_commands(path.read_text(encoding="utf-8"))
self.assertFalse(any(args[0] == "cd" for args in commands))
builds = [args[2:] for args in commands if args[:2] == ["docker", "build"]]
self.assertEqual(len(builds), 2)
seen = set()
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("-f", "--file", required=True)
parser.add_argument("-t", "--tag", required=True)
parser.add_argument("context")
for args in builds:
options = parser.parse_args(args)
context, source, tag = expected[options.file]
seen.add(options.file)
self.assertEqual(options.context, context)
self.assertTrue((ROOT / context / source).is_file())
self.assertTrue((ROOT / options.file).is_file())
dockerfile = (ROOT / options.file).read_text(encoding="utf-8")
copy_source = "." if source == "pyproject.toml" else source
self.assertRegex(dockerfile, rf"(?m)^COPY {re.escape(copy_source)} ")
self.assertTrue(options.tag.endswith(":" + tag))
self.assertIn(["docker", "push", options.tag], commands)
self.assertEqual(seen, set(expected))
def test_apply_paths_resolve_from_checkout_root(self):
for path in KUBERNETES_DOCS:
commands = shell_commands(path.read_text(encoding="utf-8"))
applies = [args for args in commands if args[:4] == ["kubectl", "-n", "speech", "apply"]]
self.assertEqual(applies, [
["kubectl", "-n", "speech", "apply", "-k", "examples/openai_api/kubernetes"],
["kubectl", "-n", "speech", "apply", "-f", "examples/openai_api/kubernetes/funasr-moss-api.yaml"],
])
self.assertTrue((ROOT / applies[0][-1] / "kustomization.yaml").is_file())
self.assertTrue((ROOT / applies[1][-1]).is_file())
for deployment in ("funasr-api", "funasr-moss-api"):
self.assertIn(["kubectl", "-n", "speech", "rollout", "status", "deploy/" + deployment,
"--timeout=15m"], commands)
def test_port_forward_and_smoke_match_each_service(self):
smoke_path = "examples/openai_api/smoke_test.py"
options = {ast.literal_eval(arg) for node in ast.walk(tree(ROOT / smoke_path))
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "add_argument" for arg in node.args
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
for path in KUBERNETES_DOCS:
commands = shell_commands(path.read_text(encoding="utf-8"))
smokes = [args for args in commands if args[0] == "python3"]
self.assertEqual(len(smokes), 2)
for args, service, model, port in zip(smokes, ("funasr-api", "funasr-moss-api"),
("sensevoice", "moss-transcribe-diarize"), (8000, 8001)):
self.assertEqual(args[1], smoke_path)
self.assertLessEqual({arg for arg in args[2:] if arg.startswith("--")}, options)
self.assertEqual(args[args.index("--model") + 1], model)
self.assertEqual(args[args.index("--response-format") + 1], "verbose_json")
self.assertEqual(args[args.index("--base-url") + 1], f"http://127.0.0.1:{port}")
self.assertIn(["kubectl", "-n", "speech", "port-forward", "--address", "127.0.0.1",
"svc/" + service, f"{port}:8000"], commands)
def test_guides_link_owned_security_and_model_contracts(self):
for path in KUBERNETES_DOCS:
text = path.read_text(encoding="utf-8")
suffix = "_zh" if path.stem.endswith("_zh") else ""
for link in (f"../SECURITY{suffix}.md", f"../../../docs/moss_transcribe_diarize{suffix}.md"):
self.assertIn(f"]({link})", text)
self.assertIn(f"](../SECURITY{suffix}.md)", text.split("```bash", 1)[0])
for link in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
parts = urlsplit(link)
if parts.scheme or parts.netloc:
continue
target = (path.parent / unquote(parts.path)).resolve()
self.assertIn(ROOT.resolve(), target.parents)
self.assertNotIn(".mcp-tasks", target.parts)
self.assertTrue(target.is_file(), link)
if parts.fragment:
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")))
def test_operational_limits_are_explicit(self):
expected = {
"README.md": ("repository root", "ClusterIP is not authentication", "PyPI",
"not an inference check", "does not send", "Python 3.10", "immutable digest",
"not native vLLM", "speaker identity", "not included in", "kustomization.yaml"),
"README_zh.md": ("仓库根目录", "ClusterIP 不是鉴权", "PyPI", "不代表推理验收",
"不发送", "Python 3.10", "不可变 digest", "不是原生 vLLM", "说话人身份",
"不包含", "kustomization.yaml"),
}
for path in KUBERNETES_DOCS:
text = path.read_text(encoding="utf-8")
for marker in expected[path.name]:
self.assertIn(marker, text, path.name)
if __name__ == "__main__":
unittest.main()
@@ -46,6 +46,7 @@
{"slug": "community", "group": "ecosystem", "zh": "社区集成", "en": "Community integrations", "source_zh": "docs/community_projects_zh.md", "source_en": "docs/community_projects.md"},
{"slug": "repositories", "group": "ecosystem", "zh": "四仓职责与贡献入口", "en": "Repositories & contribution paths", "source_zh": "docs/repository_roles_zh.md", "source_en": "docs/repository_roles.md"},
{"slug": "javascript", "group": "ecosystem", "zh": "JavaScript 客户端", "en": "JavaScript clients", "source_zh": "examples/openai_api/JAVASCRIPT_zh.md", "source_en": "examples/openai_api/JAVASCRIPT.md"},
{"slug": "gradio", "group": "ecosystem", "zh": "Gradio 浏览器客户端", "en": "Gradio browser client", "source_zh": "examples/openai_api/GRADIO_zh.md", "source_en": "examples/openai_api/GRADIO.md"},
{"slug": "workflows", "group": "ecosystem", "zh": "工作流集成", "en": "Workflow integrations", "source_zh": "examples/openai_api/WORKFLOWS_zh.md", "source_en": "examples/openai_api/WORKFLOWS.md"},
{"slug": "service-api", "group": "reference", "zh": "HTTP API 契约", "en": "HTTP API contract", "source_zh": "examples/openai_api/OPENAPI_zh.md", "source_en": "examples/openai_api/OPENAPI.md"},
{"slug": "websocket-protocol", "group": "reference", "zh": "C++ WebSocket 协议", "en": "C++ WebSocket protocol", "source_zh": "runtime/docs/websocket_protocol_zh.md", "source_en": "runtime/docs/websocket_protocol.md"},
@@ -1,5 +1,52 @@
import { expect, test } from '@playwright/test';
for (const prefix of ['', '/en']) {
for (const width of [320, 390, 1440]) {
for (const slug of ['gradio', 'kubernetes']) {
test(`Service guide ${slug} ${prefix || '/zh'} ${width}`, async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.setViewportSize({ width, height: 900 });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`${prefix}/docs/`);
await page.locator('[data-doc-search] input').fill(slug === 'gradio' ? 'Gradio' : 'Kubernetes');
const route = `${prefix}/docs/${slug}.html`;
await page.locator(`.search-results a[href="${route}"]`).click();
await expect(page).toHaveURL(new RegExp(`${route}$`));
await expect(page.locator('.docs-article')).toBeVisible();
expect((await page.locator('.docs-title h1').boundingBox())!.y).toBeLessThan(450);
await page.screenshot({ path: `/tmp/funasr-kubernetes-docs-evidence-20260908/${slug}-${prefix ? 'en' : 'zh'}-${width}-top.png` });
const block = page.locator('.docs-article pre').first();
const expected = await block.locator('code').innerText();
await block.locator('button').click();
expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(expected.trim());
for (const heading of await page.locator('.docs-article h2, .docs-article h3').all()) {
await heading.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
}
if (slug === 'gradio') {
await expect(page.locator('.docs-article h2')).toHaveCount(5);
await page.goto(`${prefix}/docs/http-server.html`);
await page.locator(`.docs-article a[href="${route}"]`).first().click();
await expect(page).toHaveURL(new RegExp(`${route}$`));
} else {
const moss = page.locator('.docs-article h3').filter({ hasText: 'MOSS GPU' });
await moss.scrollIntoViewIfNeeded();
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
await page.screenshot({ path: `/tmp/funasr-kubernetes-docs-evidence-20260908/kubernetes-${prefix ? 'en' : 'zh'}-${width}-moss.png` });
}
await page.locator(`.docs-article a[href="${prefix}/docs/security.html"]`).click();
await expect(page).toHaveURL(new RegExp(`${prefix}/docs/security.html$`));
await page.goto(route);
const peer = `${prefix ? '' : '/en'}/docs/${slug}.html`;
await page.locator(`a[href="${peer}"]`).first().click();
await expect(page).toHaveURL(new RegExp(`${peer}$`));
expect(errors).toEqual([]);
});
}
}
}
for (const language of ['ja', 'ko']) {
for (const filename of ['agent', 'benchmark']) {
for (const width of [390, 1440]) {
@@ -52,6 +52,46 @@ def test_official_native_record_has_local_language_links_and_search(output):
assert 'allendou/' not in commands
@pytest.mark.parametrize('language, prefix, suffix, peer', [
('zh', '', '_zh', '/en/docs/gradio.html'),
('en', 'en/', '', '/docs/gradio.html'),
])
def test_gradio_uses_owned_sources_and_internal_discovery(output, language, prefix, suffix, peer):
route = '/' + prefix + 'docs/gradio.html'
path = output / route.lstrip('/')
assert path.is_file(), route
page = BeautifulSoup(path.read_text(), 'html.parser')
article = page.select_one('.docs-article')
assert len(article.select('h2')) == 5
assert page.select_one('[data-source-link]')['href'].endswith(f'/examples/openai_api/GRADIO{suffix}.md')
assert page.select_one(f'a[href="{peer}"]')
assert page.select_one(f'.docs-sidebar a[href="{route}"]')
for slug in ('http-server', 'security', 'moss-transcribe-diarize', 'model-selection'):
links = [a['href'] for a in article.select('a[href]')]
assert any(urlsplit(link).path == '/' + prefix + f'docs/{slug}.html' for link in links)
http = BeautifulSoup((output / prefix / 'docs/http-server.html').read_text(), 'html.parser')
assert http.select_one(f'.docs-article a[href="{route}"]')
index = BeautifulSoup((output / prefix / 'docs/index.html').read_text(), 'html.parser')
assert index.select_one(f'a[href="{route}"]')
search = json.loads((output / f'search-{language}.json').read_text())
assert any(row['url'] == route and 'Gradio' in row['title'] for row in search)
for marker in ('127.0.0.1', 'Python 3.12', 'gradio==6.26.0', 'moss-transcribe-diarize',
'sglang-omni', 'verbose_json', 'diarized_json', 'Authorization', 'allowlist'):
assert marker in article.get_text(), (language, marker)
@pytest.mark.parametrize('prefix', ['', 'en/'])
def test_kubernetes_generated_commands_preserve_root_context_and_internal_security(output, prefix):
page = BeautifulSoup((output / prefix / 'docs/kubernetes.html').read_text(), 'html.parser')
article = page.select_one('.docs-article')
commands = '\n'.join(node.get_text() for node in article.select('pre code'))
assert 'apply -k examples/openai_api/kubernetes' in commands
assert 'apply -k .' not in commands
assert '-f examples/openai_api/Dockerfile.moss' in commands
assert '--model moss-transcribe-diarize --response-format verbose_json' in commands
assert article.select_one(f'a[href="/{prefix}docs/security.html"]')
def test_deployment_limitations_use_short_heading_and_preserve_full_body(output):
entries = json.loads((SITE_ROOT / 'data/deployments.json').read_text())['deployments']
for language in ('zh', 'en'):