From 438844b01a4c370eaeadf2b4e96bf7ee77e147c6 Mon Sep 17 00:00:00 2001 From: AdamWen95 <58934607+AdamWen95@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:38:58 +0800 Subject: [PATCH] feat: initialize XAS test console with native execution --- .dockerignore | 5 + .gitattributes | 2 + .gitignore | 13 + README.md | 33 + catalog/external-sources.json | 10 + catalog/native-profiles.json | 2808 +++++++++++++++++ catalog/test-definitions.json | 574 ++++ console/__init__.py | 1 + console/app.py | 297 ++ console/artifacts.py | 144 + console/catalog.py | 193 ++ console/job_main.py | 115 + console/native_executor.py | 160 + console/native_files.py | 75 + console/native_io.py | 122 + console/native_job.py | 176 ++ console/native_plans.py | 193 ++ console/native_rpc.py | 95 + console/requirements-test.txt | 4 + console/requirements.txt | 16 + console/settings.py | 62 + console/static/app.js | 61 + console/static/core.js | 29 + console/static/index.html | 37 + console/static/native-ui.js | 187 ++ console/static/related-files.js | 63 + console/static/runs.js | 184 ++ console/static/scripts.js | 275 ++ console/static/styles.css | 176 ++ console/static/ui.js | 125 + console/store.py | 216 ++ console/test_catalog.py | 72 + console/worker.py | 270 ++ console/workspace.py | 35 + deploy/Dockerfile | 8 + deploy/console-worker.service | 21 + deploy/console.Dockerfile | 10 + deploy/console.Dockerfile.dockerignore | 10 + deploy/cutover_console.py | 90 + deploy/dedicated.py | 102 + deploy/jaas-loginmodule.conf.template | 5 + deploy/jobs.yaml | 31 + deploy/lan-settings.json | 5 + deploy/lan_config.py | 89 + deploy/migrate_lan.py | 81 + deploy/native_profiles.py | 83 + deploy/operator.aclpolicy | 30 + deploy/realm.disabled.properties | 1 + deploy/start.py | 111 + docs/acceptance-2026-10-13.md | 81 + .../acceptance-behavior-catalog-2026-10-13.md | 35 + docs/acceptance-dedicated-2026-10-13.md | 64 + docs/acceptance-lan-2026-10-13.md | 51 + docs/acceptance-native-2026-10-13.md | 68 + docs/adr/0001-intranet-access.md | 13 + docs/adr/0002-dedicated-console.md | 21 + docs/adr/0003-test-behavior-catalog.md | 29 + docs/adr/0004-native-execution.md | 25 + docs/console-operations.md | 67 + docs/console-plan.md | 52 + docs/design.md | 31 + docs/images/job.png | Bin 0 -> 72945 bytes docs/images/lan-jobs.png | Bin 0 -> 128056 bytes docs/images/lan-logs.png | Bin 0 -> 64450 bytes docs/images/logs.png | Bin 0 -> 62337 bytes docs/implementation-plan.md | 45 + docs/legacy-smoke-plan.md | 35 + docs/native-execution-plan.md | 45 + legacy/dsv41-smoke/SOURCE.md | 14 + legacy/dsv41-smoke/smoke.sh | 17 + scripts/console_example.py | 23 + scripts/run_smoke.py | 175 + scripts/smoke_task.py | 38 + tests/test_console.py | 157 + tests/test_console_outputs.py | 126 + tests/test_console_worker.py | 48 + tests/test_deployment.py | 82 + tests/test_lan_config.py | 36 + tests/test_native_lifecycle.py | 89 + tests/test_native_plans.py | 89 + tests/test_native_rpc.py | 68 + tests/test_runner.py | 102 + tests/test_test_catalog.py | 103 + 83 files changed, 9334 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 catalog/external-sources.json create mode 100644 catalog/native-profiles.json create mode 100644 catalog/test-definitions.json create mode 100644 console/__init__.py create mode 100644 console/app.py create mode 100644 console/artifacts.py create mode 100644 console/catalog.py create mode 100644 console/job_main.py create mode 100644 console/native_executor.py create mode 100644 console/native_files.py create mode 100644 console/native_io.py create mode 100644 console/native_job.py create mode 100644 console/native_plans.py create mode 100644 console/native_rpc.py create mode 100644 console/requirements-test.txt create mode 100644 console/requirements.txt create mode 100644 console/settings.py create mode 100644 console/static/app.js create mode 100644 console/static/core.js create mode 100644 console/static/index.html create mode 100644 console/static/native-ui.js create mode 100644 console/static/related-files.js create mode 100644 console/static/runs.js create mode 100644 console/static/scripts.js create mode 100644 console/static/styles.css create mode 100644 console/static/ui.js create mode 100644 console/store.py create mode 100644 console/test_catalog.py create mode 100644 console/worker.py create mode 100644 console/workspace.py create mode 100644 deploy/Dockerfile create mode 100644 deploy/console-worker.service create mode 100644 deploy/console.Dockerfile create mode 100644 deploy/console.Dockerfile.dockerignore create mode 100644 deploy/cutover_console.py create mode 100644 deploy/dedicated.py create mode 100644 deploy/jaas-loginmodule.conf.template create mode 100644 deploy/jobs.yaml create mode 100644 deploy/lan-settings.json create mode 100644 deploy/lan_config.py create mode 100644 deploy/migrate_lan.py create mode 100644 deploy/native_profiles.py create mode 100644 deploy/operator.aclpolicy create mode 100644 deploy/realm.disabled.properties create mode 100644 deploy/start.py create mode 100644 docs/acceptance-2026-10-13.md create mode 100644 docs/acceptance-behavior-catalog-2026-10-13.md create mode 100644 docs/acceptance-dedicated-2026-10-13.md create mode 100644 docs/acceptance-lan-2026-10-13.md create mode 100644 docs/acceptance-native-2026-10-13.md create mode 100644 docs/adr/0001-intranet-access.md create mode 100644 docs/adr/0002-dedicated-console.md create mode 100644 docs/adr/0003-test-behavior-catalog.md create mode 100644 docs/adr/0004-native-execution.md create mode 100644 docs/console-operations.md create mode 100644 docs/console-plan.md create mode 100644 docs/design.md create mode 100644 docs/images/job.png create mode 100644 docs/images/lan-jobs.png create mode 100644 docs/images/lan-logs.png create mode 100644 docs/images/logs.png create mode 100644 docs/implementation-plan.md create mode 100644 docs/legacy-smoke-plan.md create mode 100644 docs/native-execution-plan.md create mode 100644 legacy/dsv41-smoke/SOURCE.md create mode 100644 legacy/dsv41-smoke/smoke.sh create mode 100644 scripts/console_example.py create mode 100644 scripts/run_smoke.py create mode 100644 scripts/smoke_task.py create mode 100644 tests/test_console.py create mode 100644 tests/test_console_outputs.py create mode 100644 tests/test_console_worker.py create mode 100644 tests/test_deployment.py create mode 100644 tests/test_lan_config.py create mode 100644 tests/test_native_lifecycle.py create mode 100644 tests/test_native_plans.py create mode 100644 tests/test_native_rpc.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_test_catalog.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76c58c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!scripts/ +!scripts/*.py +!deploy/ +!deploy/Dockerfile diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ba2e08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.local/ +runtime/ +results/ +__pycache__/ +*.pyc +.env +.env.* +!.env.example +*.pem +*.key +realm.properties +tokens.properties +deploy/console-worker.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..46fd773 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# XAS 测试脚本控制台 + +核对日期:2026-10-13。当前采用专用网页控制台,替代前期 Rundeck 作为日常入口。 + +访问 [公司内网测试控制台](http://10.88.118.188:14440/),无需账号密码。 + +- 测试项目:按实际测试行为展示,一套测试为一项,内部脚本放到详情的“相关文件”。 +- 上传:新增 Python/Shell 脚本或上传不可变的新版本,不在线编辑代码。 +- 运行:配置参数后排队执行,查看实时标准输出/错误及登记的 .log 内容。 +- 记录:查看状态、版本、配置快照、历史日志和归档结果,预览或下载报告。 + +当前整理为 23 项业务测试(8 项套件、15 项单项测试),关联原先登记的 307 个源文件。22 项原位置入口已接入原环境执行,保留原目录、解释器、配置和输出行为;普通测试预检通过后提交,资源占用/服务启停测试每次显示影响并确认。两个控制台验收样例默认隐藏,可在“项目用途”中选择查看。原文件、GPU/端口设置和历史记录均保留。 + +2026-10-13 23:27 只读预检:16 个原环境方案满足登记的启动条件,6 个因所需模型端口未监听而被拦截。预检通过不等于业务测试通过,也不预留 GPU。GPU 综合套件支持菜单数字、y/n/q 和回车;需要自由文本的维护功能仍从原维护入口使用。本轮仅用独立短样例验证执行链路,没有运行原 GPU/长稳测试。 + +上传版本保存在服务器 `/adamwen-work-space/xas-test-library/runtime/library/`,日志和结果在 `runtime/runs/`,SQLite 在 `runtime/console-data/`,仅记录元数据、状态与文件引用。外部结果原文件保留,同时可归档本次变更;Git 不参与每次运行时拉取。 + +## 维护入口 + +- [使用、存储、配额及恢复说明](docs/console-operations.md) +- [已确认方案与 API 契约](docs/console-plan.md) +- [本轮验收记录](docs/acceptance-dedicated-2026-10-13.md) +- [按测试行为整理后的验收](docs/acceptance-behavior-catalog-2026-10-13.md) +- [原环境接入与本轮验收](docs/acceptance-native-2026-10-13.md) +- [原环境执行架构决策](docs/adr/0004-native-execution.md) +- [专用控制台架构决策](docs/adr/0002-dedicated-console.md) +- [按测试行为组织目录的规则](docs/adr/0003-test-behavior-catalog.md) + +源码位于 `console/`,部署入口 `deploy/dedicated.py`,原入口切换工具 `deploy/cutover_console.py`。旧 Rundeck 容器与运行数据保留;早期 design.md、implementation-plan.md、legacy-smoke-plan.md 均为历史阶段,不再是当前维护入口。 + +公司仓库:[wenhuadian/xas-test-library](https://code.it.aisuan.cn/wenhuadian/xas-test-library),主分支为 `main`。Git 管理控制台源码、可信执行方案、部署脚本、测试和维护文档;数据库、结果、上传文件及凭据不纳入 Git。原服务器其他工作区的脚本通过路径引用,不会随本仓库自动上传。 + +提交与推送按用户明确授权执行,服务器部署单独进行,未配置自动发布。首次入库检查日期为 2026-10-14;本地回归 55 项中通过 48 项、因 Windows 平台限制跳过 7 项。此前 Linux 和原环境运行验收保留在各自日期的验收文档中。 diff --git a/catalog/external-sources.json b/catalog/external-sources.json new file mode 100644 index 0000000..eadaf14 --- /dev/null +++ b/catalog/external-sources.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "observed_at": "2026-10-13T17:19:29+08:00", + "evidence": "Dedicated console inventories source files read-only; execution readiness is managed per script in SQLite", + "sources": [ + {"id": "company-gpu-suite", "path": "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0"}, + {"id": "dsv41-trial-20261010", "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts"}, + {"id": "model-trials-20261011", "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts"} + ] +} diff --git a/catalog/native-profiles.json b/catalog/native-profiles.json new file mode 100644 index 0000000..c3c5e99 --- /dev/null +++ b/catalog/native-profiles.json @@ -0,0 +1,2808 @@ +{ + "schema_version": 1, + "targets": [ + { + "id": "deepseek-a", + "label": "DeepSeek A(原服务,18100)", + "url": "http://127.0.0.1:18100", + "model": "deepseek-v4.1-flash", + "environment": { + "CT_OFF": "{\"thinking\": false}", + "CT_ON": "{\"thinking\": true}", + "MAX_LEN": "1048576" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" + ] + }, + { + "id": "dsv41cap", + "label": "dsv41cap(配置端口 18110)", + "config_name": "dsv41cap", + "url": "http://127.0.0.1:18110", + "model": "deepseek-v4.1-flash", + "environment": { + "CT_OFF": "{\"thinking\": false}", + "CT_ON": "{\"thinking\": true}", + "MAX_LEN": "1048576" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/dsv41cap.conf" + }, + { + "id": "glm53", + "label": "glm53(配置端口 18102)", + "config_name": "glm53", + "url": "http://127.0.0.1:18102", + "model": "glm-5.3", + "environment": { + "CT_OFF": "{\"reasoning_effort\": \"low\"}", + "CT_ON": "{}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/glm53.conf" + }, + { + "id": "mimo", + "label": "mimo(配置端口 18104)", + "config_name": "mimo", + "url": "http://127.0.0.1:18104", + "model": "mimo-v2.6-pro", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/mimo.conf" + }, + { + "id": "qwen27b", + "label": "qwen27b(配置端口 18103)", + "config_name": "qwen27b", + "url": "http://127.0.0.1:18103", + "model": "qwen3.5-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen27b.conf" + }, + { + "id": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)", + "config_name": "qwen27btp8", + "url": "http://127.0.0.1:18103", + "model": "qwen3.5-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen27btp8.conf" + }, + { + "id": "qwen38cap", + "label": "qwen38cap(配置端口 18107)", + "config_name": "qwen38cap", + "url": "http://127.0.0.1:18107", + "model": "qwen3.8-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38cap.conf" + }, + { + "id": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)", + "config_name": "qwen38fp8", + "url": "http://127.0.0.1:18105", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8.conf" + }, + { + "id": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)", + "config_name": "qwen38fp8cap", + "url": "http://127.0.0.1:18108", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8cap.conf" + }, + { + "id": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)", + "config_name": "qwen38fp8mtpcap", + "url": "http://127.0.0.1:18109", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8mtpcap.conf" + }, + { + "id": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)", + "config_name": "qwen38fp8tp8", + "url": "http://127.0.0.1:18110", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8tp8.conf" + }, + { + "id": "sglang38", + "label": "sglang38(配置端口 18106)", + "config_name": "sglang38", + "url": "http://127.0.0.1:18106", + "model": "qwen3.8-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/sglang38.conf" + }, + { + "id": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)", + "config_name": "sglang38fp8", + "url": "http://127.0.0.1:18106", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/sglang38fp8.conf" + } + ], + "profiles": [ + { + "id": "gpu-suite", + "source_group": "company-gpu-suite", + "entry": "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/xas-gpu-test.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0", + "arguments": [], + "parameters": [], + "environment": {}, + "credential_files": [], + "impact": { + "level": "service", + "title": "进入 GPU 验收原菜单,可能影响整机与模型服务", + "details": [ + "菜单包含 GPU 压力、驱动/环境安装、模型启停、固件切换和重启相关操作;按所选菜单继续确认影响。", + "原入口会初始化既有数据目录,并可能通过 tmux 创建持久会话;取消控制台不证明菜单产生的容器或 tmux 会话已结束。" + ], + "requires_confirmation": true + }, + "interactive": true, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/nvidia-smi", + "/usr/bin/docker", + "/usr/bin/tmux" + ], + "required_files": [ + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/xas-gpu-test.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/report.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/stress_report.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/detail_export.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/stress_metrics.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/db.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/html_to_pdf.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_bench.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_catalog.json", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/report_templates.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/report_template_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/gpu_compat.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_runtime.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_environment.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_environment.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_manager.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_discovery.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_downloads.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_planner.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_batch.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_model_environment.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_report.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/token_economics.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/token_prices.json", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/bandwidth_audit.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/bandwidth_ab.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_switch.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_tool.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_probe.py", + "/etc/xas-gpu-test/config.env" + ], + "check_ports": [], + "output_paths": [ + "/XAS/GPUTest/runs", + "/XAS/GPUTest/reports" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原 config.env 的 DATA_ROOT=/XAS/GPUTest。只打开原菜单,不自动输入或选择任何测试项目。", + "原工具创建的 tmux 持久任务可能脱离本次控制台 cgroup;平台取消后必须核查原工具会话和模型容器,不能承诺全部停止。", + "runs/reports 使用原工具的时间戳产物;只收集本次运行新增或发生变化的文件,不预拷整个历史 runs/reports。" + ], + "available": true + }, + { + "id": "deepseek-suite", + "source_group": "dsv41-trial-20261010", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/run-b-suite.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial", + "arguments": [ + "{run_label}", + "{mode}" + ], + "parameters": [ + { + "key": "mode", + "label": "原测试模式", + "type": "select", + "default": "full", + "options": [ + { + "value": "full", + "label": "完整(原默认)" + }, + { + "value": "quick", + "label": "快速" + } + ], + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/run-b-suite.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/multiturn_cache.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/toolcall_stress.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/curves.py" + ], + "check_ports": [ + 18101 + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/test-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原入口硬编码 B 服务 18101,不会切到 A 的 18100;B 未运行时预检应阻止启动。" + ], + "available": true + }, + { + "id": "model-suite", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-suite.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}", + "{mode}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "mode", + "label": "原测试模式", + "type": "select", + "default": "full", + "options": [ + { + "value": "full", + "label": "完整(原默认)" + }, + { + "value": "quick", + "label": "快速" + }, + { + "value": "load", + "label": "仅并发负载" + } + ], + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-suite.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/multiturn_cache.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/toolcall_stress.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/functional_check.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/needle.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。" + ], + "available": true + }, + { + "id": "functional", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/functional_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/functional_check.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。" + ], + "available": true + }, + { + "id": "curves", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{mode}", + "{output_file}", + "{lengths}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "mode", + "label": "曲线类型", + "type": "select", + "default": "context", + "options": [ + { + "value": "context", + "label": "输入长度曲线" + }, + { + "value": "output", + "label": "输出长度曲线" + } + ], + "required": true + }, + { + "key": "lengths", + "label": "输入长度档位", + "type": "select", + "default": "1000,8000,32000,128000,256000", + "options": [ + { + "value": "1000,8000,32000,128000,256000", + "label": "原默认 1千 / 8千 / 3.2万 / 12.8万 / 25.6万" + }, + { + "value": "1000,8000,32000", + "label": "1千 / 8千 / 3.2万" + }, + { + "value": "32000", + "label": "3.2万" + } + ], + "required": true, + "help": "输出曲线固定生成 8192 token;该模式忽略输入长度档位。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "context 长度默认与原脚本一致;超过服务上下文上限会按原脚本返回错误。" + ], + "available": true + }, + { + "id": "language", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8tp8-01-prompts.json", + "{concurrency}", + "{requests}", + "{output_file}", + "--max-tokens", + "{max_tokens}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 1, + "min": 1, + "max": 512, + "required": true + }, + { + "key": "requests", + "label": "正式请求数", + "type": "integer", + "default": 32, + "min": 1, + "max": 100000, + "required": true, + "help": "另外有原脚本固定的 4 次预热请求。" + }, + { + "key": "max_tokens", + "label": "每次最大生成 token", + "type": "integer", + "default": 768, + "min": 1, + "max": 32768, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8tp8-01-prompts.json" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "复用原 logs/qwen38fp8tp8-01-prompts.json;本轮仅核对路径及文件大小,未读取题目内容。", + "保留原温度 0.6、关闭思考、正常采样流程;原 --greedy / --thinking / --logprobs 未在本方案开放。" + ], + "available": true + }, + { + "id": "conversation-cache", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/multiturn_cache.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/multiturn_cache.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。" + ], + "available": true + }, + { + "id": "needle", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/needle.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{input_tokens}", + "{depth}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "input_tokens", + "label": "目标上下文 token", + "type": "integer", + "default": 32000, + "min": 1000, + "max": 1000000, + "required": true + }, + { + "key": "depth", + "label": "目标信息位置比例", + "type": "number", + "default": 0.5, + "min": 0, + "max": 1, + "required": true + }, + { + "key": "max_tokens", + "label": "最大生成 token", + "type": "integer", + "default": 4096, + "min": 1, + "max": 32768, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}", + "NEEDLE_MAX_TOKENS": "{max_tokens}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/needle.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原入口只向标准输出打印 JSON,不接受结果路径;结果保存在本次控制台运行日志。" + ], + "available": true + }, + { + "id": "concurrent-input", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/concurrent_prefill.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{concurrency}", + "{input_tokens}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 4, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "input_tokens", + "label": "每请求输入 token", + "type": "integer", + "default": 32000, + "min": 1000, + "max": 1000000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/concurrent_prefill.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "cache-revisit", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/kv_revisit.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{sessions}", + "{input_tokens}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "sessions", + "label": "会话数", + "type": "integer", + "default": 8, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "input_tokens", + "label": "每会话输入 token", + "type": "integer", + "default": 32000, + "min": 1000, + "max": 1000000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/kv_revisit.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "interference", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/prefill_interference.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{input_tokens}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "input_tokens", + "label": "长输入 token", + "type": "integer", + "default": 128000, + "min": 1000, + "max": 1000000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/prefill_interference.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "tool-calls", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/toolcall_stress.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{concurrency}", + "{repeats}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 16, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "repeats", + "label": "每题重复次数", + "type": "integer", + "default": 4, + "min": 1, + "max": 1000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/toolcall_stress.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "throughput", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}-throughput.json", + "--dataset-name", + "random", + "--random-input-len", + "{input_tokens}", + "--random-output-len", + "{output_tokens}", + "--random-range-ratio", + "{range_ratio}", + "--num-prompts", + "{requests}", + "--max-concurrency", + "{concurrency}", + "--seed", + "{seed}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "input_tokens", + "label": "平均输入 token", + "type": "integer", + "default": 1024, + "min": 1, + "max": 1000000, + "required": true + }, + { + "key": "output_tokens", + "label": "平均生成 token", + "type": "integer", + "default": 512, + "min": 1, + "max": 32768, + "required": true + }, + { + "key": "range_ratio", + "label": "随机长度浮动比例", + "type": "number", + "default": 0, + "min": 0, + "max": 1, + "required": true + }, + { + "key": "requests", + "label": "请求数", + "type": "integer", + "default": 32, + "min": 1, + "max": 1000000, + "required": true + }, + { + "key": "concurrency", + "label": "最大并发数", + "type": "integer", + "default": 4, + "min": 1, + "max": 2048, + "required": true + }, + { + "key": "seed", + "label": "随机种子", + "type": "integer", + "default": 20261013, + "min": 0, + "max": 2147483647, + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{run_label}-throughput.json" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "原 Shell 透传的 vllm bench serve 参数限定为上述登记字段;不接受任意额外参数。表单初值是控制台建议值。" + ], + "available": true + }, + { + "id": "math", + "source_group": "dsv41-trial-20261010", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/math_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial", + "arguments": [ + "{concurrency}", + "{samples}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 4, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "samples", + "label": "每题采样次数", + "type": "integer", + "default": 1, + "min": 1, + "max": 1000, + "required": true, + "help": "原脚本固定生成 32 道题,总请求数为 32 × 采样次数。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/math_check.py" + ], + "check_ports": [ + 18100 + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。", + "原脚本硬编码 DeepSeek 模型和 18100 地址;本方案固定目标 A。" + ], + "available": true + }, + { + "id": "vision", + "source_group": "dsv41-trial-20261010", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/vision_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial", + "arguments": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/test-34-vision-input.png", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/vision_check.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/test-34-vision-input.png" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "复用原 run-d7v-d8.sh 引用的 test-34-vision-input.png;本轮仅核对文件名及大小,未读取或复制图像内容。", + "原入口分别发送关闭/开启 thinking 的两次看图请求;目标服务需已启用视觉。未自动修改或重启模型。" + ], + "available": true + }, + { + "id": "robust", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/robust_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "storm_n", + "label": "断连并发数", + "type": "integer", + "default": 64, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "storm_rounds", + "label": "断连轮数", + "type": "integer", + "default": 5, + "min": 1, + "max": 100, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}", + "STORM_N": "{storm_n}", + "STORM_ROUNDS": "{storm_rounds}" + }, + "credential_files": [], + "impact": { + "level": "resource", + "title": "执行断连风暴及超长异常请求", + "details": [ + "原默认是 64 路并发、5 轮断连,并发送超过目标上下文上限的输入/输出等异常请求,可能短时影响目标服务。", + "MAX_LEN 来自登记目标的原启动配置,运行前需核对服务实际配置是否仍一致。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/robust_check.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。" + ], + "available": true + }, + { + "id": "agent", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/agent_sim.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_url}", + "{target_model}", + "{sessions}", + "{turns}", + "{context_tokens}", + "{new_tokens}", + "{output_tokens}", + "{seed}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "sessions", + "label": "每地址会话数", + "type": "integer", + "default": 4, + "min": 1, + "max": 512, + "required": true + }, + { + "key": "turns", + "label": "每会话轮数", + "type": "integer", + "default": 4, + "min": 1, + "max": 1000, + "required": true + }, + { + "key": "context_tokens", + "label": "固定上下文 token", + "type": "integer", + "default": 8192, + "min": 0, + "max": 1000000, + "required": true + }, + { + "key": "new_tokens", + "label": "每轮新增 token", + "type": "integer", + "default": 1024, + "min": 0, + "max": 1000000, + "required": true + }, + { + "key": "output_tokens", + "label": "每轮生成 token", + "type": "integer", + "default": 256, + "min": 1, + "max": 32768, + "required": true + }, + { + "key": "seed", + "label": "随机种子", + "type": "integer", + "default": 20261013, + "min": 0, + "max": 2147483647, + "required": true + }, + { + "key": "agent_mode", + "label": "上下文增长方式", + "type": "select", + "default": "replace", + "options": [ + { + "value": "replace", + "label": "替换新增段(原默认)" + }, + { + "value": "append", + "label": "逐轮追加" + } + ], + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}", + "AGENT_MODE": "{agent_mode}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/agent_sim.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 数字参数全部必填;表单初值是控制台建议值。", + "输入使用原脚本生成的随机 token 编号;append 模式每轮增长,超过目标上下文限制会失败。" + ], + "available": true + }, + { + "id": "replicas", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-replicas.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}", + "8" + ], + "parameters": [ + { + "key": "target", + "label": "原实例配置", + "type": "select", + "default": "qwen38fp8cap", + "options": [ + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + } + ], + "required": true, + "help": "请选择当前 18110–18117 上已有 8 份服务对应的原配置。" + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout", + "/usr/bin/vmstat" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-replicas.sh" + ], + "check_ports": [ + 18110, + 18111, + 18112, + 18113, + 18114, + 18115, + 18116, + 18117 + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "保留原默认 8 份、GPU_LIST=8 9 10 11 12 13 14 15、端口 18110–18117,以及原 NAT_LEVELS/RAND_LEVELS;控制台本方案固定此组合。", + "只检测并使用已启动的 8 个实例,不调用 serve-replicas.sh。所选配置自身 PORT 不代表此入口的实例端口。" + ], + "available": true + }, + { + "id": "engine-comparison", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-compare.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-compare.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "保留原 NAT_LEVELS、THINK_N=16、RAND_LEVELS、LONG=32000、SEED_BASE=5000;本入口测试所选已运行配置,并不自行切换引擎。" + ], + "available": true + }, + { + "id": "soak", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/soak.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}", + "{concurrency}", + "{requests}", + "{long_inputs}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 32, + "min": 1, + "max": 2048, + "required": true + }, + { + "key": "requests", + "label": "主负载请求数", + "type": "integer", + "default": 3200, + "min": 1, + "max": 10000000, + "required": true + }, + { + "key": "long_inputs", + "label": "长输入次数上限", + "type": "integer", + "default": 12, + "min": 0, + "max": 864, + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/soak.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/concurrent_prefill.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "32 / 3200 / 12 为原 CLI 默认;按请求数决定时长。原遥测 timeout 固定 14400 秒,延长控制台超时不会延长原遥测覆盖。" + ], + "available": true + }, + { + "id": "capacity", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/cap-8x-chain.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [], + "parameters": [], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" + ], + "impact": { + "level": "service", + "title": "停止 DeepSeek A,并进行 16 卡整机满载测试", + "details": [ + "原链先使用 GPU 8–15,随后停止 DeepSeek A,使用 GPU 0–15,启停多套模型及 16 个 Qwen 实例。", + "原 EXIT trap 会尝试按 start-rec.sh 重启 A;失败、超时或取消不能保证恢复原状态。", + "原日志和结果名称固定,可能覆盖已有 dsv41cap、glm53、mimo、qwen38fp8tp8、qwen38fp8cap 结果;需逐次确认并保全历史文件。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout", + "/usr/bin/pgrep", + "/usr/bin/setsid", + "/usr/bin/vmstat" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/cap-8x-chain.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-sets.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/stop-model.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-multi.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-compare.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/dsv41cap.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/glm53.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/mimo.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8tp8.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8cap.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/phaseb-levels.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/start-rec.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/serve.sh" + ], + "check_ports": [], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/dsv41cap-01-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/dsv41cap-02-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/glm53-11-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/glm53-12-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/mimo-11-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/mimo-12-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8tp8-01-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8cap-02-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-dsv41cap-x01-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-dsv41cap-x02-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-glm53-x11-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-glm53-x12-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-mimo-x11-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-mimo-x12-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-qwen38fp8tp8-a1-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-qwen38fp8cap-x16-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/serve-r7-rec-pin.log" + ], + "preserve_existing_outputs": true, + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原入口无位置参数;不伪造可配置的编号或输出路径。原链使用固定历史文件名,再次运行可能覆盖同名结果,应在确认前检查并保全相关历史文件。" + ], + "available": true + }, + { + "id": "cost", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/value-chain.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [], + "parameters": [], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "service", + "title": "启停 GPU 8–15 上的性能与成本测试模型", + "details": [ + "原链等待 cap-8x-chain.sh 退出,再检查 GPU 8–15 显存并启动/停止 DeepSeek 与 8 份 Qwen 模型。", + "原结果使用固定 dsv41v-*、qwen38v-* 名称,可能覆盖历史结果。", + "该入口产生本机性能证据,成本折算和报告仍按原配套脚本处理;不代表本轮已验证价格口径。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout", + "/usr/bin/pgrep", + "/usr/bin/setsid", + "/usr/bin/vmstat" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/value-chain.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-sets.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/stop-model.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-multi.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-one.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/dsv41cap.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8cap.conf" + ], + "check_ports": [], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/dsv41v-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38v-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-dsv41cap-xv-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-qwen38fp8cap-xv8-g*.log" + ], + "preserve_existing_outputs": true, + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原入口无位置参数;不伪造可配置的编号或输出路径。原链使用固定历史文件名,再次运行可能覆盖同名结果,应在确认前检查并保全相关历史文件。" + ], + "available": true + } + ] +} diff --git a/catalog/test-definitions.json b/catalog/test-definitions.json new file mode 100644 index 0000000..606020e --- /dev/null +++ b/catalog/test-definitions.json @@ -0,0 +1,574 @@ +{ + "schema_version": 1, + "reviewed_at": "2026-10-13", + "basis": "Reviewed README entry points, script purpose comments and call references; registration does not authorize service/GPU actions.", + "example_script_ids": [ + "499b615a7dc7435eb30789b7a7dc090e", + "fe5a2b28c8b4449591ef55bd88299a6d" + ], + "tests": [ + { + "key": "gpu-suite", + "source_group": "company-gpu-suite", + "entry": "xas-gpu-test.sh", + "name": "GPU 综合验收套件", + "description": "公司 XAS GPU Test v3.5.0 的统一入口,包含硬件压力、多卡通信、模型性能及报告。内部模块和套件自身测试统一收在详情中。", + "kind": "suite", + "members": [ + { + "source_group": "company-gpu-suite", + "pattern": "**/*" + } + ] + }, + { + "key": "deepseek-suite", + "source_group": "dsv41-trial-20261010", + "entry": "run-b-suite.sh", + "name": "DeepSeek 综合性能测试", + "description": "在第二套 DeepSeek 服务上完成单路曲线、并发吞吐、自然语言生成、多轮缓存和工具调用的一整轮测试。", + "kind": "suite", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "bench-serve.sh" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "curves.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "multiturn_cache.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "spec_bench.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "spec_prompts.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "toolcall_stress.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-report-phase*.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d2.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d3.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d456.sh", + "role": "variant" + } + ] + }, + { + "key": "model-suite", + "source_group": "model-trials-20261011", + "entry": "run-suite.sh", + "name": "模型综合测试套件", + "description": "面向被测模型的一整轮速度、长上下文、功能与稳定性测试。不同模型的历史调度脚本收在相关文件中。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-serve.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "curves.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "functional_check.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "multiturn_cache.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "needle.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_bench.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "toolcall_stress.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-glm-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-mimo-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-qwen-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-model.sh", + "role": "variant" + } + ] + }, + { + "key": "functional", + "source_group": "model-trials-20261011", + "entry": "functional_check.py", + "name": "模型功能检查", + "description": "检查接口功能、结构化输出、多轮交互等可用性,不等同于答案准确率评测。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "functional_check.py", + "role": "variant" + } + ] + }, + { + "key": "curves", + "source_group": "model-trials-20261011", + "entry": "curves.py", + "name": "单路性能曲线", + "description": "单请求逐项测量生成长度和上下文长度变化时的性能曲线。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "curves.py", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-curves.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "context_probe.py", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-mimo-recheck.sh", + "role": "variant" + } + ] + }, + { + "key": "language", + "source_group": "model-trials-20261011", + "entry": "spec_bench.py", + "name": "自然语言生成速度", + "description": "用固定自然语言题库测量生成速度、并发和不同解码设置的表现。", + "kind": "test", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "spec_bench.py", + "role": "variant" + } + ] + }, + { + "key": "conversation-cache", + "source_group": "model-trials-20261011", + "entry": "multiturn_cache.py", + "name": "多轮对话缓存效果", + "description": "模拟多轮对话,观察前缀缓存对每轮首字时间的影响。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "multiturn_cache.py", + "role": "variant" + } + ] + }, + { + "key": "needle", + "source_group": "model-trials-20261011", + "entry": "needle.py", + "name": "长上下文检索", + "description": "在指定上下文长度和位置中检索目标信息,检查长上下文能力。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "needle.py", + "role": "variant" + } + ] + }, + { + "key": "concurrent-input", + "source_group": "model-trials-20261011", + "entry": "concurrent_prefill.py", + "name": "并发长输入读入", + "description": "检查多个长输入同时到达时的读入表现。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "concurrent_prefill.py", + "role": "variant" + } + ] + }, + { + "key": "cache-revisit", + "source_group": "model-trials-20261011", + "entry": "kv_revisit.py", + "name": "长会话缓存回访", + "description": "检查长会话缓存被挤出后再次访问时的等待时间。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "kv_revisit.py", + "role": "variant" + } + ] + }, + { + "key": "interference", + "source_group": "model-trials-20261011", + "entry": "prefill_interference.py", + "name": "长输入对聊天延迟的影响", + "description": "测量超长输入进入时,对其他交互请求延迟造成的影响。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "prefill_interference.py", + "role": "variant" + } + ] + }, + { + "key": "tool-calls", + "source_group": "model-trials-20261011", + "entry": "toolcall_stress.py", + "name": "工具调用并发稳定性", + "description": "检查并发工具调用、参数 JSON 和必填字段的稳定性。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "toolcall_stress.py", + "role": "variant" + } + ] + }, + { + "key": "throughput", + "source_group": "model-trials-20261011", + "entry": "bench-serve.sh", + "name": "单服务并发吞吐", + "description": "对一个已部署的模型服务测量不同并发下的吞吐和延迟。", + "kind": "test", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-one.sh" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "bench-serve.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "bench.py", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-fresh-seed-dsv41.sh", + "role": "variant" + } + ] + }, + { + "key": "math", + "source_group": "dsv41-trial-20261010", + "entry": "math_check.py", + "name": "数学应用题回归", + "description": "通过可自动判对错的算术应用题检查解码配置变更后是否出现明显退化。", + "kind": "test", + "members": [] + }, + { + "key": "vision", + "source_group": "dsv41-trial-20261010", + "entry": "vision_check.py", + "name": "图片理解功能检查", + "description": "检查模型图片输入功能是否可用,需要准备图片素材。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d7v-d8.sh", + "role": "variant" + } + ] + }, + { + "key": "robust", + "source_group": "model-trials-20261011", + "entry": "robust_check.py", + "name": "异常输入与断连检查", + "description": "检查异常输入、请求中断等情况下的模型服务稳定性。", + "kind": "test", + "members": [] + }, + { + "key": "agent", + "source_group": "model-trials-20261011", + "entry": "agent_sim.py", + "name": "Agent 多轮场景测试", + "description": "用多轮 Agent 场景请求测量运行表现和 token 消耗,汇总脚本和历史调度链作为配套文件。", + "kind": "test", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "agent_summary.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "agent-chain.sh" + } + ] + }, + { + "key": "replicas", + "source_group": "model-trials-20261011", + "entry": "run-replicas.sh", + "name": "多实例并行吞吐", + "description": "测量多个模型实例同时运行时的合计能力;实例数、GPU 和端口仍由原配置确定。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-replicas.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "bench-multi.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_bench.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-replicas.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-sets.sh" + } + ] + }, + { + "key": "engine-comparison", + "source_group": "model-trials-20261011", + "entry": "run-compare.sh", + "name": "推理引擎与解码配置对比", + "description": "比较推理引擎、权重精度和解码配置的速度表现;同类历史对照脚本不再单独平铺。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-serve.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "curves.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_bench.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-compare-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-fast-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-sglang-track.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-glm-mtp-retry.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-qwen-pin-check.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-qwen-shm-check.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-spec-compare.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-dspark-tests.sh", + "role": "variant" + } + ] + }, + { + "key": "soak", + "source_group": "model-trials-20261011", + "entry": "soak.sh", + "name": "长时间稳定性测试", + "description": "持续施加并发和长输入负载,观察失败、吞吐和资源变化。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-serve.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "concurrent_prefill.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "soak24-chain.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d8.sh", + "role": "variant" + } + ] + }, + { + "key": "capacity", + "source_group": "model-trials-20261011", + "entry": "cap-8x-chain.sh", + "name": "单卡与整机并发上限", + "description": "测量单卡或单套服务的并发上限,以及多实例整机满载表现。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-multi.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-replicas.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-sets.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-cap-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "x16-chain.sh", + "role": "variant" + } + ] + }, + { + "key": "cost", + "source_group": "model-trials-20261011", + "entry": "value-chain.sh", + "name": "性能与成本评估", + "description": "按聊天、检索和代码生成场景测量性能,结合既定价格口径汇总成本。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-multi.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "bench-one.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "value_ladder.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "value2-chain.sh", + "role": "variant" + } + ] + }, + { + "key": "light-smoke", + "managed_profile": "legacy_smoke", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "smoke.sh", + "role": "variant" + } + ] + } + ] +} diff --git a/console/__init__.py b/console/__init__.py new file mode 100644 index 0000000..5ec2b6a --- /dev/null +++ b/console/__init__.py @@ -0,0 +1 @@ +"""XAS dedicated script console.""" diff --git a/console/app.py b/console/app.py new file mode 100644 index 0000000..2a3ed69 --- /dev/null +++ b/console/app.py @@ -0,0 +1,297 @@ +"""LAN-only HTTP interface; does not spawn processes or access Docker.""" +import hashlib +import ipaddress +from pathlib import Path +from typing import Annotated +from urllib.parse import urlsplit +import uuid +from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict, Field +from . import catalog +from . import test_catalog +from . import native_plans, native_io +from .settings import Settings +from .store import Store, TERMINAL, now + + +class ConfigPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + args: list[str] | None = None + cwd: str | None = None + output_paths: list[str] | None = None + timeout_seconds: Annotated[int, Field(strict=True)] | None = None + native_options: dict[str, str | int | float | bool] | None = None + + +class ScriptPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=4000) + enabled: bool | None = None + config: ConfigPatch | None = None + + +class RunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + request_id: uuid.UUID + plan_token: str | None = Field(default=None, max_length=64) + impact_acknowledged: bool = False + + +class NativeInput(BaseModel): + model_config = ConfigDict(extra="forbid") + text: str = Field(max_length=1024) + + +def create_app(settings=None): + settings = settings or Settings.from_env() + store = Store(settings) + app = FastAPI(title="XAS 测试控制台", docs_url=None, redoc_url=None, openapi_url=None) + app.state.store = store + static = Path(__file__).parent / "static" + + @app.middleware("http") + async def boundaries(request, call_next): + host = request.headers.get("host", "") + try: + address = ipaddress.ip_address(request.client.host) + allowed = any(address in ipaddress.ip_network(n) for n in settings.allowed_networks) + except ValueError: + allowed = False + if host not in settings.allowed_hosts or not allowed: + return JSONResponse({"detail": "仅允许公司内网访问"}, status_code=403) + if request.method not in {"GET", "HEAD", "OPTIONS"}: + origin = request.headers.get("origin") + valid_origin = not origin or origin == f"{request.url.scheme}://{host}" + if (not valid_origin or request.headers.get("x-xas-console") != "1" + or request.headers.get("sec-fetch-site") == "cross-site"): + return JSONResponse({"detail": "请求来源校验失败,请从控制台页面操作"}, status_code=403) + try: + size = int(request.headers.get("content-length", "0")) + except ValueError: + size = -1 + if size < 0 or size > settings.max_upload + 65536 or "transfer-encoding" in request.headers: + return JSONResponse({"detail": "请求超过大小限制"}, status_code=413) + response = await call_next(request) + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "same-origin" + response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + return response + + @app.exception_handler(ValueError) + async def value_error(request, exc): + return JSONResponse({"detail": str(exc)}, status_code=400) + + @app.exception_handler(KeyError) + async def missing(request, exc): + return JSONResponse({"detail": str(exc.args[0])}, status_code=404) + + @app.get("/api/summary") + def summary(): + inventory, runs = store.scripts(), store.runs(limit=10000) + scripts = test_catalog.tests(settings, inventory) + # No directory recursion on every page refresh; size derives from recorded artifacts. + size = sum(a["size"] for r in runs for a in r.get("artifacts", [])) + return {"scripts": len(scripts), "source_files": len(inventory), + "examples": len(test_catalog.tests(settings, inventory, True)) - len(scripts), + "ready": sum(s["enabled"] for s in scripts), "runs": len(runs), + "active": sum(r["state"] in {"queued", "running"} for r in runs), "worker": store.health(), + "storage": {"run_bytes": size}, "limits": {"upload_bytes": settings.max_upload, + "log_bytes": settings.max_log, "concurrency": 1, "timeout_seconds": 7200}} + + @app.get("/api/scripts") + def scripts(include_examples: bool = False): + return {"items": test_catalog.tests(settings, store.scripts(), include_examples)} + + @app.post("/api/scripts/upload") + async def new_script(file: UploadFile, name: Annotated[str, Form()] = "", description: Annotated[str, Form()] = ""): + try: + item = catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, name, description) + return store.script(item["id"], versions=True) + finally: + await file.close() + + @app.get("/api/scripts/{identity}") + def script(identity: str): + return test_catalog.describe(settings, store.script(identity, versions=True), store.scripts()) + + @app.get("/api/scripts/{identity}/files") + def script_files(identity: str): + return {"items": test_catalog.members(settings, store.script(identity), store.scripts())} + + @app.post("/api/scripts/{identity}/versions") + async def new_version(identity: str, file: UploadFile): + try: + catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, identity=identity) + return store.script(identity, versions=True) + finally: + await file.close() + + def source_version(identity, version): + item = store.script(identity) + if item["kind"] == "managed" and version: + v = store.version(identity, version) + item.update(source_path=v["path"], sha256=v["sha256"]) + elif version and version != item["version"]: + raise ValueError("原位置脚本仅展示当前文件,历史运行请查看运行归档") + return item + + @app.get("/api/scripts/{identity}/code") + def code(identity: str, version: int | None = None): + return catalog.read_code(settings, source_version(identity, version)) + + @app.get("/api/scripts/{identity}/download") + def download_source(identity: str, version: int | None = None): + item = source_version(identity, version) + source = catalog.read_code(settings, item) + if source["redacted"]: + raise ValueError("包含疑似凭据的原文件不能下载") + return FileResponse(catalog.script_path(settings, item), filename=source["filename"], media_type="text/plain") + + @app.patch("/api/scripts/{identity}") + def update_script(identity: str, patch: ScriptPatch): + # Serialize with version uploads to avoid restoring an obsolete current-version pointer. + import json + with store.connect(write=True) as db: + row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone() + if not row: + raise KeyError("脚本不存在") + item = json.loads(row[0]) + if not test_catalog.is_entry(settings, item): + raise ValueError("此文件属于内部源码,请从测试项目入口配置和运行") + values = patch.model_dump(exclude_none=True) + if "config" in values: + config_patch = values.pop("config") + if native_plans.profile_for(settings, item) and set(config_patch) - {"native_options", "timeout_seconds"}: + raise ValueError("原环境入口、目录和输出规则由已登记方案管理") + item["config"] = catalog.validate_config(settings, item, {**item["config"], **config_patch}) + if values.get("enabled"): + native = native_plans.profile_for(settings, item) + blocked = not native.get("available", True) if native else item.get("blocked") + if blocked or item["status"] == "missing": + raise ValueError(item["reason"] or "此脚本暂不能启用") + catalog.script_path(settings, item) + item["config"] = catalog.validate_config(settings, item, item["config"]) + item.update(values, updated_at=now()) + item["status"] = "ready" if item["enabled"] else "needs_configuration" + item["reason"] = "" if item["enabled"] else (item.get("reason") or "已停用") + db.execute("UPDATE scripts SET data=? WHERE id=?", (json.dumps(item), identity)) + return store.script(identity, versions=True) + + @app.post("/api/discover") + def discover(): + result = catalog.discover(store) + result["tests"] = len(test_catalog.tests(settings, store.scripts())) + return result + + @app.post("/api/scripts/{identity}/runs") + def run_script(identity: str, body: RunRequest, request: Request): + script = test_catalog.describe(settings, store.script(identity), store.scripts()) + if not script["is_test"]: + raise ValueError("此文件属于内部源码,不能作为独立测试运行") + if not script["enabled"] or script["status"] != "ready": + raise ValueError(script["reason"] or "请先配置并启用脚本") + if native_plans.profile_for(settings, script): + return native_io.request(settings, {"op": "queue", "script_id": identity, + "request_id": str(body.request_id), "plan_token": body.plan_token, + "impact_acknowledged": body.impact_acknowledged, "source_ip": request.client.host}) + source = catalog.script_path(settings, script).read_bytes() + if hashlib.sha256(source).hexdigest() != script["sha256"]: + raise ValueError("原脚本已变动,请刷新目录并重新确认配置") + if catalog.SECRET.search(catalog.clean_source(source)): + raise ValueError("源文件包含疑似凭据,暂不执行") + return store.queue(script, str(body.request_id), request.client.host) + + @app.post("/api/scripts/{identity}/preflight") + def preflight(identity: str): + item = store.script(identity) + if not native_plans.profile_for(settings, item): + raise ValueError("此项目使用隔离容器,无原环境启动方案") + return native_io.request(settings, {"op": "preflight", "script_id": identity}) + + @app.post("/api/runs/{identity}/input") + def native_input(identity: str, body: NativeInput): + return native_io.request(settings, {"op": "input", "run_id": identity, "text": body.text}) + + @app.get("/api/runs") + def runs(script_id: str | None = None, limit: int = Query(default=100, ge=1, le=500)): + return {"items": store.runs(script_id, limit)} + + @app.get("/api/runs/{identity}") + def run(identity: str): + return store.run(identity) + + @app.post("/api/runs/{identity}/cancel") + def cancel(identity: str): + return store.cancel(identity) + + def run_path(identity, filename): + store.run(identity) + root = catalog.safe_path(settings.runs / identity, settings.runs, must_exist=False) + return catalog.safe_path(root / filename, root, must_exist=False) + + @app.get("/api/runs/{identity}/log") + def log(identity: str, offset: int = Query(default=0, ge=0)): + item = store.run(identity) + path = run_path(identity, "console.log") + data = b"" + at_eof = True + if path.exists(): + with path.open("rb") as handle: + handle.seek(min(offset, path.stat().st_size)) + offset = handle.tell() + data = handle.read(65536) + at_eof = offset + len(data) >= path.stat().st_size + # Keep any incomplete UTF-8 tail for the next poll. + import codecs + decoder = codecs.getincrementaldecoder("utf-8")("replace") + text = decoder.decode(data, final=item["state"] in TERMINAL and at_eof) + pending = len(decoder.getstate()[0]) + else: + text, pending = "", 0 + return {"text": text, "next_offset": offset + len(data) - pending, + "done": item["state"] in TERMINAL and len(data) < 65536, + "truncated": path.exists() and path.stat().st_size >= settings.max_log} + + @app.get("/api/runs/{identity}/log/download") + def log_download(identity: str): + path = run_path(identity, "console.log") + if not path.is_file(): + raise KeyError("日志尚未生成") + return FileResponse(path, filename=f"{identity}.log", media_type="text/plain") + + def artifact_path(identity, artifact_id): + item = store.run(identity) + artifact = next((a for a in item.get("artifacts", []) if a["id"] == artifact_id), None) + if not artifact: + raise KeyError("结果文件不存在") + return run_path(identity, "artifacts/" + artifact["id"]), artifact + + @app.get("/api/runs/{identity}/artifacts/{artifact_id}/preview") + def artifact_preview(identity: str, artifact_id: str): + path, artifact = artifact_path(identity, artifact_id) + if artifact["kind"] not in {"text", "json"}: + raise ValueError("此文件只支持下载") + with path.open("rb") as handle: + data = handle.read(262145) + return {"text": data[:262144].decode("utf-8", errors="replace"), "truncated": len(data) > 262144} + + @app.get("/api/runs/{identity}/artifacts/{artifact_id}") + def artifact_download(identity: str, artifact_id: str): + path, artifact = artifact_path(identity, artifact_id) + return FileResponse(path, filename=artifact["name"], media_type="application/octet-stream") + + @app.get("/") + def index(): + return FileResponse(static / "index.html") + + @app.get("/project/xas-test-library/{legacy_path:path}") + def old_bookmark(legacy_path: str): + return RedirectResponse("/#/scripts", status_code=307) + + if static.exists(): + app.mount("/static", StaticFiles(directory=static), name="static") + return app diff --git a/console/artifacts.py b/console/artifacts.py new file mode 100644 index 0000000..74c8091 --- /dev/null +++ b/console/artifacts.py @@ -0,0 +1,144 @@ +"""Bounded log following and result snapshots; never modify original outputs.""" +import hashlib +import codecs +import os +from pathlib import Path +import time +from .catalog import safe_path, SECRET + + +def files_under(paths, limit=256): + items = [] + deadline = time.monotonic() + 2 + for root in map(Path, paths): + if root.is_symlink(): + continue + if root.is_file(): + items.append(root) + elif root.is_dir(): + for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = sorted(d for d in dirs if not (Path(directory) / d).is_symlink()) + for name in sorted(files): + path = Path(directory) / name + if not path.is_symlink() and path.is_file(): + items.append(path) + if len(items) >= limit or time.monotonic() > deadline: + return items[:limit] + return items[:limit] + + +def signature(path): + try: + stat = path.stat() + return (stat.st_ino, stat.st_size, stat.st_mtime_ns) + except OSError: + return None + + +class Collector: + def __init__(self, settings, directory, output_paths, redactor=None, stream_filter_factory=None, file_limit=256): + self.settings, self.directory = settings, directory + self.file_limit = file_limit + self.output_provider = output_paths if callable(output_paths) else lambda: [Path(p) for p in output_paths] + self.redactor = redactor + self.stream_filter_factory = stream_filter_factory + self.log_filters = {} + self.before = {str(p): signature(p) for p in files_under(self.outputs, self.file_limit)} + self.positions = {str(p): p.stat().st_size for p in files_under(self.outputs, self.file_limit) if p.suffix == ".log"} + self.notices = [] + self.decoders = {} + self.last_file = None + + @property + def outputs(self): + return self.output_provider() + + def follow(self, emit): + total = 0 + for path in files_under([*self.outputs, self.directory / "work"], self.file_limit): + if path.suffix != ".log": + continue + try: + start = self.positions.get(str(path), 0) + if path.stat().st_size < start: + start = 0 + self.decoders.pop(str(path), None) + with path.open("rb") as handle: + handle.seek(start) + data = handle.read(32768) + self.positions[str(path)] = handle.tell() + if data: + total += len(data) + if self.stream_filter_factory: + log_filter = self.log_filters.setdefault(str(path), self.stream_filter_factory()) + data = log_filter.feed(data) + decoder = self.decoders.setdefault(str(path), codecs.getincrementaldecoder("utf-8")("replace")) + text = decoder.decode(data) + prefix = f"\n[文件日志 {path.name}]\n" if self.last_file != str(path) else "" + emit((prefix + text).encode("utf-8")) + self.last_file = str(path) + except OSError: + self.notices.append(f"日志读取失败:{path.name}") + return total + + def finish_follow(self, emit): + total = 0 + while total < self.settings.max_log: + count = self.follow(emit) + if count == 0: + break + total += count + for name, decoder in self.decoders.items(): + extra = self.log_filters[name].finish() if name in self.log_filters else b"" + tail = decoder.decode(extra, final=True) + if tail: + emit(tail.encode("utf-8")) + self.decoders.clear() + self.log_filters.clear() + + def external_growth(self): + growth = 0 + for path in files_under(self.outputs, limit=1025): + stat = signature(path) + before = self.before.get(str(path)) + if stat: + growth += max(0, stat[1] - (before[1] if before and before[0] == stat[0] else 0)) + return growth + + def collect(self): + destination = self.directory / "artifacts" + destination.mkdir(exist_ok=True) + local = files_under([self.directory / "work"]) + changed = [p for p in files_under(self.outputs, self.file_limit) if signature(p) != self.before.get(str(p))] + sources = [self.directory / "source" / "script.txt", *local, *changed] + items, total = [], 0 + for path in sources[:self.settings.max_run_files - 1]: + try: + if not path.is_file() or path.is_symlink(): + continue + if path.stat().st_size > self.settings.max_artifact: + retention = "临时文件将在收尾时释放" if path in local else "原位置文件保留" + self.notices.append(f"未归档 {path.name}:超过单文件 32 MiB 上限,{retention}") + continue + data = path.read_bytes() + if len(data) > self.settings.max_artifact or total + len(data) > 64 * 1024 * 1024: + self.notices.append("结果超过归档容量限制:原目录文件保留,未归档的临时结果将在收尾时释放") + continue + suffix = path.suffix.lower() + kind = "json" if suffix == ".json" else ("text" if suffix in {".log", ".txt", ".py", ".sh", ".csv", ".md", ".jsonl"} else "file") + if kind != "file": + text = data.decode("utf-8", errors="replace") + data = SECRET.sub("[已隐藏疑似凭据]", text).encode("utf-8") + if self.redactor: + data = self.redactor(data) + identity = hashlib.sha256(str(path).encode()).hexdigest()[:24] + target = destination / identity + with target.open("xb") as handle: + handle.write(data) + items.append({"id": identity, "name": path.name, "size": len(data), "kind": kind}) + total += len(data) + except OSError: + self.notices.append(f"未能归档:{path.name}") + if len(sources) > self.settings.max_run_files - 1: + self.notices.append("结果超过文件数上限:保留 63 个文件及运行摘要,未归档临时结果将释放") + return items diff --git a/console/catalog.py b/console/catalog.py new file mode 100644 index 0000000..09dcb11 --- /dev/null +++ b/console/catalog.py @@ -0,0 +1,193 @@ +"""Source registration and filesystem boundaries shared by API and worker.""" +import hashlib +import os +from pathlib import Path +import re +import uuid +from .store import now + +SECRET = re.compile(r"(?im)(?:[\w-]*(?:api[_-]?key|password|token|secret)[\w-]*)\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]|-----BEGIN [A-Z ]*PRIVATE KEY-----|Bearer\s+[A-Za-z0-9._-]{16,}") +SERVICE = re.compile(r"(?im)\b(?:docker|podman|systemctl|sudo|pkill|killall|shutdown|reboot|nvidia-smi)\b|\brm\s+-[\w]*r|\bkill\s+-") +SMOKE_HASH = "be858e914d87454240791da773578cb3f80f380e740f919546db347b7715a13d" + + +def clean_source(content): + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ValueError("脚本必须使用 UTF-8 编码") from exc + if "\x00" in text: + raise ValueError("不支持二进制脚本") + return text + + +def safe_path(path, root, must_exist=True): + candidate, base = Path(path), Path(root) + if not candidate.is_absolute(): + raise ValueError("路径必须为绝对路径") + # Never follow a symlink, including symlink ancestors under the allowed root. + resolved_base = base.resolve() + resolved = candidate.resolve() + if not resolved.is_relative_to(resolved_base): + raise ValueError("路径超出允许目录") + for component in [candidate, *candidate.parents]: + if component.is_symlink(): + raise ValueError("不允许符号链接") + if component == base: + break + if must_exist and not candidate.exists(): + raise ValueError("文件或目录不存在") + return candidate + + +def source_for(settings, group): + for source in settings.sources: + if source["id"] == group: + return Path(source["path"]) + raise ValueError("未知来源目录") + + +def validate_config(settings, item, config): + from . import native_plans + if native_plans.profile_for(settings, item): + return native_plans.validate_config(settings, item, config) + if config.get("native_options"): + raise ValueError("此项目不支持原环境参数") + defaults = {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"} + config = {**defaults, **config} + if config["profile"] not in {"standard", "legacy_smoke"}: + raise ValueError("未知执行环境") + if not isinstance(config["args"], list) or len(config["args"]) > 40 or any( + not isinstance(a, str) or len(a) > 2048 or "\x00" in a for a in config["args"]): + raise ValueError("参数需为最多 40 项的字符串数组") + if SECRET.search("\n".join(config["args"])): + raise ValueError("参数不能包含明文凭据") + if config["profile"] == "legacy_smoke" and config["args"]: + raise ValueError("轻量模型兼容环境使用已核对的固定参数,请新建脚本以配置其他测试") + if type(config["timeout_seconds"]) is not int or not 5 <= config["timeout_seconds"] <= 7200: + raise ValueError("运行时限须为 5 至 7200 秒") + if not isinstance(config["output_paths"], list) or len(config["output_paths"]) > 8: + raise ValueError("最多登记 8 个日志或结果路径") + if not isinstance(config["cwd"], str): + raise ValueError("工作目录格式错误") + if item["kind"] == "managed": + if config["cwd"] or config["output_paths"]: + raise ValueError("上传脚本使用本次独立运行目录;通过 XAS_RUN_DIR 写入结果") + else: + source = source_for(settings, item["source_group"]) + base = source.parent if source.name == "scripts" else source + cwd = safe_path(config["cwd"] or str(base), base) + if not cwd.is_dir(): + raise ValueError("工作目录不存在") + config["cwd"] = str(cwd) + for raw in config["output_paths"]: + if not isinstance(raw, str): + raise ValueError("输出路径格式错误") + # Only logs/results directories in that script family may become writable. + if not any(Path(raw).resolve().is_relative_to((base / name).resolve()) for name in ("logs", "results")): + raise ValueError("原目录输出只允许该来源的 logs/ 或 results/ 下路径") + safe_path(raw, base) + return config + + +def script_path(settings, item): + root = settings.library if item["kind"] == "managed" else source_for(settings, item["source_group"]) + path = safe_path(item["source_path"], root) + if not path.is_file() or path.suffix not in {".py", ".sh"} or path.stat().st_size > settings.max_upload: + raise ValueError("脚本缺失、类型不支持或大于 1 MiB") + return path + + +def read_code(settings, item): + data = script_path(settings, item).read_bytes() + text = clean_source(data) + redacted = bool(SECRET.search(text)) + if redacted: + text = SECRET.sub("[已隐藏疑似凭据]", text) + return {"code": text, "filename": Path(item["source_path"]).name, + "sha256": hashlib.sha256(data).hexdigest(), "redacted": redacted, "truncated": False} + + +def upload(store, data, filename, name="", description="", identity=None): + if not filename or Path(filename).name != filename or "/" in filename or "\\" in filename or not re.fullmatch(r"[\w.\- ]{1,120}\.(py|sh)", filename): + raise ValueError("只支持文件名安全的 .py 或 .sh 脚本") + if len(data) > store.settings.max_upload: + raise ValueError("脚本不能超过 1 MiB") + if not data: + raise ValueError("脚本不能为空") + text = clean_source(data) + if SECRET.search(text): + raise ValueError("脚本包含疑似明文凭据,请移除后上传") + if identity: + item = store.script(identity) + if item["kind"] != "managed": + raise ValueError("原位置脚本不支持上传覆盖,请新建脚本") + if item["config"]["profile"] == "legacy_smoke" and hashlib.sha256(data).hexdigest() != SMOKE_HASH: + raise ValueError("此兼容环境绑定已核对的原脚本;修改代码请新建脚本并另行配置认证") + if (Path(filename).suffix == ".py") != (item["language"] == "python"): + raise ValueError("新版本的语言必须与原脚本一致") + else: + item = {"id": uuid.uuid4().hex, "name": (name or filename)[:120], "description": description[:4000], + "kind": "managed", "language": "python" if filename.endswith(".py") else "shell", + "source_group": "uploaded", "source_path": "", "status": "ready", "enabled": True, + "reason": "", "version": 0, "sha256": "", "updated_at": now(), "synthetic": False, + "config": {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"}} + return store.upload_version(item, data, filename) + + +def discover(store): + existing = {s["id"]: s for s in store.scripts() if s["kind"] == "external"} + found, registered = set(), 0 + for source in store.settings.sources: + root = Path(source["path"]) + if not root.is_dir(): + continue + count = 0 + for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".venv", "node_modules", "logs", "results"} + and not (Path(directory) / d).is_symlink()] + for filename in sorted(files): + path = Path(directory) / filename + if path.suffix not in {".py", ".sh"} or path.is_symlink(): + continue + count += 1 + if count > 2000: + raise ValueError("来源脚本超过扫描上限 2000,请收窄来源目录") + identity = hashlib.sha256(str(path).encode()).hexdigest()[:32] + found.add(identity) + before = existing.get(identity) + try: + if path.stat().st_size > store.settings.max_upload: + raise ValueError("大于 1 MiB,仅登记") + raw = path.read_bytes() + code = clean_source(raw) + digest = hashlib.sha256(raw).hexdigest() + blocked = bool(SERVICE.search(code) or SECRET.search(code)) + reason = "涉及服务、设备管理或疑似凭据,需管理员审查后接入" if blocked else "请确认参数、依赖和输出路径后启用" + except (OSError, ValueError): + digest, blocked, reason = "", True, "文件不可读取或格式不支持" + item = before or {"id": identity, "name": filename, "description": "", + "kind": "external", "language": "python" if path.suffix == ".py" else "shell", + "source_group": source["id"], "source_path": str(path), "version": 1, "enabled": False, + "config": {"args": [], "cwd": str(root.parent if root.name == "scripts" else root), + "output_paths": [], "timeout_seconds": 300, "profile": "standard"}} + from .native_plans import profile_for + native = profile_for(store.settings, item) + if native: + blocked = not native.get("available", True) + reason = native.get("unavailable_reason", "原环境方案已登记,请确认参数后启用") + if before and before["sha256"] != digest: + item["version"] += 1 + item["enabled"] = False + reason = "源文件已变动,请重新确认配置并启用" + if blocked: + item["enabled"] = False + item.update(sha256=digest, blocked=blocked, reason="" if item["enabled"] else reason, + status="ready" if item["enabled"] else "needs_configuration", updated_at=now()) + store.save_script(item) + registered += int(before is None) + for identity, item in existing.items(): + if identity not in found: + item.update(status="missing", enabled=False, reason="原脚本当前不存在或来源不可访问", updated_at=now()) + store.save_script(item) + return {"registered": registered, "missing": len(set(existing) - found), "total": len(found)} diff --git a/console/job_main.py b/console/job_main.py new file mode 100644 index 0000000..73056bf --- /dev/null +++ b/console/job_main.py @@ -0,0 +1,115 @@ +"""In-container bootstrap. Input contains optional credentials; never persist it.""" +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + return None + + +class BudgetRelay(HTTPServer): + def __init__(self, address, upstream, key): + super().__init__(address, RelayHandler) + self.upstream, self.key = upstream, key + self.gets, self.posts = 0, 0 + self.records = [] + + +class RelayHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path != "/v1/models" or self.server.gets >= 1: + self.send_error(403, "Request outside run budget") + return + self.server.gets += 1 + self.forward(None, 10) + + def do_POST(self): + if self.path != "/v1/chat/completions" or self.server.posts >= 3: + self.send_error(403, "Request outside run budget") + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 8192: + raise ValueError() + data = self.rfile.read(length) + body = json.loads(data) + if body.get("model") != "deepseek-v4.1-flash" or body.get("max_tokens") != 512 or body.get("stream"): + raise ValueError() + except (ValueError, json.JSONDecodeError): + self.send_error(400, "Unexpected smoke request") + return + self.server.posts += 1 # Count before I/O: timeouts never replenish the budget. + self.forward(data, 600) + + def forward(self, data, timeout): + started = time.monotonic() + record = {"path": self.path, "status": None} + try: + req = urllib.request.Request(self.server.upstream + self.path, data=data, headers={ + "Content-Type": "application/json", "Authorization": "Bearer " + self.server.key}) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(req, timeout=timeout) as response: + status, payload = response.status, response.read(2 * 1024 * 1024 + 1) + if len(payload) > 2 * 1024 * 1024: + raise ValueError("Response exceeded limit") + record["status"] = status + decoded = json.loads(payload) + if self.path.endswith("completions"): + record["response"] = decoded + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + except urllib.error.HTTPError as exc: + record["status"] = exc.code + self.send_error(exc.code, "Upstream request failed") + except (OSError, ValueError) as exc: + record["error"] = type(exc).__name__ + try: + self.send_error(502, "Upstream outcome uncertain; no retry") + except OSError: + pass + finally: + record["seconds"] = round(time.monotonic() - started, 3) + self.server.records.append(record) + print(json.dumps({"event": "model_request", **{k: v for k, v in record.items() if k != "response"}}), flush=True) + + +def main(): + payload = json.loads(sys.stdin.buffer.readline(16384)) + relay = None + if payload.get("profile") == "legacy_smoke": + relay = BudgetRelay(("127.0.0.1", 18100), payload.pop("upstream"), payload.pop("key")) + threading.Thread(target=relay.serve_forever, daemon=True).start() + command = (["python3", "-u"] if payload["language"] == "python" else ["/bin/bash"]) + [payload["script"]] + payload["args"] + print("[控制台] 测试进程已启动,输出将持续显示。", flush=True) + status = subprocess.call(command, stdin=subprocess.DEVNULL) + if relay: + report = {"synthetic": False, "scope": "接口冒烟检查,非模型质量评测", "get_attempts": relay.gets, + "completion_attempts": relay.posts, "records": relay.records, "script_exit_code": status} + valid = len(relay.records) == 4 and all(r.get("status") == 200 for r in relay.records) + report["requests_completed"] = valid + report["message"] = "请求均返回成功;请结合实际回答与 finish_reason 判断内容" if valid else "存在失败或未完成请求;未自动重试" + Path(os.environ["XAS_RUN_DIR"], "smoke-response.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + if not valid: + status = status or 1 + relay.shutdown() + print(f"[控制台] 测试进程结束,退出码 {status}。", flush=True) + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/console/native_executor.py b/console/native_executor.py new file mode 100644 index 0000000..068001f --- /dev/null +++ b/console/native_executor.py @@ -0,0 +1,160 @@ +"""Manage only this console's transient units; reattach after worker restarts.""" +import hashlib +import json +from pathlib import Path +import re +import subprocess +import time +from .native_plans import build_plan, public_plan +from .store import now + + +def unit_name(identity): + if not re.fullmatch(r"[a-f0-9]{32}", identity):raise ValueError("运行编号不合法") + return "adamwen-native-" + identity + ".service" + + +def inspect(identity): + unit = unit_name(identity) + result = subprocess.run(["/usr/bin/systemctl", "show", unit, "--property=Id,LoadState,ActiveState,SubState,Description,Result,ExecMainStatus,MainPID,TasksCurrent"], + capture_output=True, timeout=8, text=True) + values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + if values.get("LoadState") == "not-found":return None + if result.returncode or not values.get("LoadState"): + raise OSError("systemd 状态查询失败,任务状态未知") + if values.get("Id") != unit or values.get("Description") != "XAS native " + identity: + raise ValueError("原环境服务归属核验失败") + return values + + +def stop_unit(identity): + if inspect(identity): + subprocess.run(["/usr/bin/systemctl", "--no-block", "stop", unit_name(identity)], check=True, capture_output=True, timeout=8) + + +class NativeExecutor: + def __init__(self, worker): + self.worker, self.store, self.settings = worker, worker.store, worker.settings + + def execute(self, run): + if self.store.run(run["id"])["cancel_requested"]: + self.store.finish(run["id"], "cancelled", message="启动前已取消,原脚本未执行") + return + directory = self.settings.runs / run["id"] + directory.mkdir(mode=0o750, exist_ok=False) + try: + script = {**run, "id": run["script_id"]} + plan = build_plan(self.settings, script, run_id=run["id"]) + if plan["plan_token"] != run["execution"]["plan_token"] or not plan["ready"]: + raise ValueError("执行前原入口或环境已变化,请重新预检;未启动原测试") + if plan["impact"]["requires_confirmation"] and not run["execution"].get("impact_acknowledged"): + raise ValueError("缺少本次运行影响确认") + if inspect(run["id"]):raise ValueError("本次原环境服务已存在,拒绝重复启动") + run = self.store.update_execution(run["id"], {**public_plan(plan), "unit": unit_name(run["id"])}) + project = Path(self.worker.config["project"]) + log = directory / "console.log" + log.touch(mode=0o600) + with (directory / "native-permit").open("x") as permit: + permit.write(run["execution"]["plan_token"]) + command = ["/usr/bin/systemd-run", "--unit=" + unit_name(run["id"]), "--description=XAS native " + run["id"], + "--service-type=exec", "--property=RemainAfterExit=yes", "--property=KillMode=control-group", + "--property=TimeoutStopSec=120", "--property=RuntimeMaxSec=" + str(plan["timeout_seconds"]), + "--property=WorkingDirectory=" + str(project), "--property=User=root", "--property=UMask=0022", + "--property=StandardOutput=append:" + str(log), "--property=StandardError=append:" + str(log), + "--setenv=XAS_CONSOLE_ROOT=" + str(self.settings.root), "--setenv=PYTHONDONTWRITEBYTECODE=1", + "--", "/usr/bin/python3", "-m", "console.native_job", run["id"]] + if self.store.run(run["id"])["cancel_requested"]: + (directory / "native-permit").unlink(missing_ok=True) + self.store.finish(run["id"], "cancelled", message="启动前已取消,原脚本未执行") + return + result = subprocess.run(command, capture_output=True, timeout=15, text=True) + if result.returncode: + raise ValueError("systemd 未接受原环境任务,原脚本未启动") + except Exception as exc: + message = str(exc) if isinstance(exc, ValueError) else "原环境启动失败,未自动重试" + # A timeout can leave a submitted unit; inspect it instead of submitting again. + if inspect(run["id"]): + return self.monitor(run) + (directory / "native-permit").unlink(missing_ok=True) + if (directory / "native-started").exists(): + self.worker.stopping = True + self.store.finish(run["id"], "interrupted", message="原入口可能已启动但服务状态未知,执行器停止接单;请核对后再操作") + return + (directory / "console.log").write_text(message + "\n", encoding="utf-8") + self.store.finish(run["id"], "failed", message=message) + return + self.monitor(run) + + def monitor(self, run): + requested = False + failures = 0 + while not self.worker.stopping: + self.store.heartbeat() + try: + state = inspect(run["id"]) + failures = 0 + except (OSError, ValueError, subprocess.TimeoutExpired): + failures += 1 + self.store.running_message(run["id"], "无法确认原环境服务状态,保留运行占位,不启动下一项") + if failures >= 10:self.worker.stopping = True + time.sleep(1) + continue + if state is None or state.get("SubState") in {"exited", "dead", "failed"}: + return self.finish(run, state) + if self.store.run(run["id"])["cancel_requested"] and not requested: + stop_unit(run["id"]) + requested = True + time.sleep(.5) + # The independent unit keeps running. The next worker attaches without replaying it. + + def finish(self, run, unit): + directory = self.settings.runs / run["id"] + directory.mkdir(mode=0o750, exist_ok=True) + outcome_file = directory / "unit-outcome.json" + outcome = unit + if outcome_file.exists():outcome = json.loads(outcome_file.read_text()) + elif unit:outcome_file.write_text(json.dumps(unit)) + result_file = directory / "native-result.json" + result = json.loads(result_file.read_text()) if result_file.is_file() else {} + current = self.store.run(run["id"]) + code = result.get("exit_code") + if current["cancel_requested"]:state, message = "cancelled", "已取消本次宿主入口;请核对原脚本启动的 Docker/tmux 任务和模型恢复情况" + elif outcome and outcome.get("Result") == "timeout":state, message = "timed_out", "原环境运行超过时限;原服务是否恢复需结合收尾记录核对" + elif not result:state, message = "interrupted", "原环境任务结束但完整结果未取得;未自动重跑,请查看日志和原服务状态" + elif result.get("stopped"):state, message = "interrupted", "原环境入口收到外部停止信号,服务恢复情况未确认" + elif code == 0:state, message = "succeeded", "原入口正常退出;业务结论和共享资源状态请查看报告" + else:state, message = "failed", f"原入口退出码 {code},请查看日志" + sessions = result.get("runtime_changes", {}).get("new_tmux_sessions", []) + if sessions and state == "succeeded": + state, message = "interrupted", "原入口已退出,但检测到新增 tmux 会话;后台任务完成状态未确认,请核对原工具" + artifacts = result.get("artifacts", []) + summary = {"run_id": run["id"], "state": state, "exit_code": code, "message": message, + "finished_at": now(), "source_sha256": run["sha256"], "execution": run["execution"], + "runtime_changes": result.get("runtime_changes"), "archive_notices": result.get("archive_notices", []), + "log_truncated": result.get("log_truncated", False)} + directory.mkdir(mode=0o750, exist_ok=True) + (directory / "artifacts").mkdir(exist_ok=True) + data = json.dumps(summary, ensure_ascii=False, indent=2).encode() + identity = hashlib.sha256((str(directory) + "/summary.json").encode()).hexdigest()[:24] + (directory / "artifacts" / identity).write_bytes(data) + artifacts.append({"id": identity, "name": "summary.json", "kind": "json", "size": len(data)}) + if unit: + if not self.wait_stopped(run):return + subprocess.run(["/usr/bin/systemctl", "reset-failed", unit_name(run["id"])], capture_output=True, timeout=8) + self.store.finish(run["id"], state, message=message, exit_code=code, artifacts=artifacts) + + def wait_stopped(self, run): + try: + stop_unit(run["id"]) + deadline = time.monotonic() + 130 + while time.monotonic() < deadline and not self.worker.stopping: + self.store.heartbeat() + current = inspect(run["id"]) + if current is None:return True + if current.get("ActiveState") in {"inactive", "failed"} and current.get("MainPID", "0") == "0" and current.get("TasksCurrent", "0") in {"0", "[not set]"}: + return True + time.sleep(.5) + except (OSError, ValueError, subprocess.TimeoutExpired, subprocess.CalledProcessError):pass + self.store.running_message(run["id"], "本次进程收尾未确认,保留运行占位,执行器停止接单") + self.worker.stopping = True + return False diff --git a/console/native_files.py b/console/native_files.py new file mode 100644 index 0000000..6d9600c --- /dev/null +++ b/console/native_files.py @@ -0,0 +1,75 @@ +"""Read-only output discovery, history preservation and small runtime snapshots.""" +import glob +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +from .artifacts import files_under + + +def outputs(patterns): + paths = [] + for pattern in patterns: + expanded = glob.iglob(pattern) if glob.has_magic(pattern) else [pattern] + for name in expanded: + path = Path(name) + if path.is_symlink(): + continue + if path.is_file():paths.append(path) + elif path.is_dir():paths.extend(files_under([path], limit=4097)) + if len(paths) > 4096: + raise ValueError("原输出文件超过 4096 项,请收窄登记输出范围") + return sorted(set(paths)) + + +def preserve(paths, directory, maximum=64 * 1024**3): + sizes = [path.stat().st_size for path in paths] + total = sum(sizes) + if total > maximum or shutil.disk_usage(directory).free < total + 10 * 1024**3: + raise ValueError("保护原有同名结果所需空间不足或超过 64 GiB,本次未启动") + destination = directory / "previous-outputs" + destination.mkdir(mode=0o700) + manifest = [] + for path in paths: + identity = hashlib.sha256(str(path).encode()).hexdigest() + target = destination / identity + before = path.stat() + with path.open("rb") as source, target.open("xb") as output: + shutil.copyfileobj(source, output, length=1024 * 1024) + after = path.stat() + if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns): + raise ValueError("原输出仍被其他任务写入,请等待后再试") + manifest.append({"path": str(path), "backup": identity, "size": before.st_size}) + (destination / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return len(manifest) + + +def runtime_snapshot(): + result = {"containers": [], "tmux_sessions": [], "errors": []} + try: + raw = subprocess.run(["/usr/bin/docker", "ps", "-a", "--format", "{{json .}}"], capture_output=True, timeout=10, text=True) + if raw.returncode == 0: + for line in raw.stdout.splitlines(): + value = json.loads(line) + result["containers"].append({key: value.get(key) for key in ("ID", "Names", "Image", "State", "Status")}) + else:result["errors"].append("容器状态读取失败") + except (OSError, ValueError, subprocess.TimeoutExpired):result["errors"].append("容器状态不可用") + try: + raw = subprocess.run(["/usr/bin/tmux", "list-sessions", "-F", "#{session_id}\t#{session_name}"], capture_output=True, timeout=5, text=True) + if raw.returncode == 0: + result["tmux_sessions"] = [dict(zip(("id", "name"), line.split("\t", 1))) for line in raw.stdout.splitlines() if "\t" in line] + except (OSError, subprocess.TimeoutExpired):result["errors"].append("tmux 状态不可用") + return result + + +def differences(before, after): + old = {v["ID"]: v for v in before["containers"]} + new = {v["ID"]: v for v in after["containers"]} + sessions = {v["id"] for v in before["tmux_sessions"]} + return {"created_containers": [v for k, v in new.items() if k not in old], + "removed_containers": [v for k, v in old.items() if k not in new], + "changed_containers": [v for k, v in new.items() if k in old and v["State"] != old[k]["State"]], + "new_tmux_sessions": [v for v in after["tmux_sessions"] if v["id"] not in sessions], + "note": "共享宿主前后快照差异不等于资源归属证明;不会自动删除这些容器或会话。"} diff --git a/console/native_io.py b/console/native_io.py new file mode 100644 index 0000000..46675b2 --- /dev/null +++ b/console/native_io.py @@ -0,0 +1,122 @@ +"""Bounded IPC and streaming redaction for original-environment runs.""" +import json +import os +from pathlib import Path +import re +import shlex +import socket +import subprocess + +ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2A-Z]|[@-_])") +SENSITIVE_NAME = re.compile(r"(?i)(?:api_?key|password|passwd|secret|access_token|auth_token|credential)") + + +def strip_terminal(data, state="text"): + output = bytearray() + for value in data: + if state == "text": + if value == 27:state = "escape" + else:output.append(value) + elif state == "escape": + state = "csi" if value == 91 else "osc" if value == 93 else "charset" if value in (40, 41) else "text" + elif state == "csi": + if 64 <= value <= 126:state = "text" + elif value == 27:state = "escape" + elif state == "osc": + if value == 7:state = "text" + elif value == 27:state = "osc_escape" + elif state == "osc_escape": + state = "text" if value in (92, 7) else "osc" + else:state = "text" + return bytes(output), state + + +class SecretFilter: + def __init__(self, secrets): + values = set() + for value in secrets: + values.update(v.encode() for v in [value, *value.splitlines()] if len(v) >= 4) + self.secrets = sorted(values, key=len, reverse=True) + self.keep = max([256, *[len(v) for v in self.secrets]]) + self.pending = b"" + self.terminal_state = "text" + + def clean(self, data): + data, _ = strip_terminal(data) + for secret in self.secrets: + data = data.replace(secret, b"[REDACTED]") + return data + + def feed(self, data): + plain, self.terminal_state = strip_terminal(data, self.terminal_state) + self.pending += plain + for secret in self.secrets:self.pending = self.pending.replace(secret, b"[REDACTED]") + # Emit prompts without requiring a newline, retaining only a possible secret prefix. + hold = 0 + for secret in self.secrets: + for size in range(min(len(secret) - 1, len(self.pending)), hold, -1): + if self.pending.endswith(secret[:size]): + hold = size + break + cut = len(self.pending) - hold + result, self.pending = self.pending[:cut], self.pending[cut:] + return result + + def finish(self): + partial = len(self.pending) >= 4 and any(s.startswith(self.pending) for s in self.secrets) + result = b"[REDACTED]" if partial else self.clean(self.pending) + self.pending = b"" + self.terminal_state = "text" + return result + + +def load_environment(plan): + import pwd + account = pwd.getpwnam(plan["user"]) + env = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": account.pw_dir, + "USER": account.pw_name, "LOGNAME": account.pw_name, "LANG": "C.UTF-8", "TERM": "xterm", + "PYTHONUNBUFFERED": "1", "PYTHONDONTWRITEBYTECODE": "1"} + secrets = [] + for path in plan.get("env_setup_files", []): + # Only server-owned recipe paths reach this command; output stays in private process memory. + command = ["/bin/bash", "-c", 'set -a; source "$1" >/dev/null; /usr/bin/env -0', "xas-env", path] + result = subprocess.run(command, env=env, cwd=plan["cwd"], capture_output=True, timeout=10) + if result.returncode: + raise ValueError("原环境配置加载失败") + env.update(dict(v.decode().split("=", 1) for v in result.stdout.split(b"\0") if b"=" in v)) + secrets.extend(v for k, v in env.items() if SENSITIVE_NAME.search(k) and v) + for path in plan.get("credential_files", []): + for line in Path(path).read_text().splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$", line.strip()) + if not match: + raise ValueError("原认证配置格式不支持") + parts = shlex.split(match[2], comments=True) + if len(parts) > 1: + raise ValueError("原认证配置格式不支持") + env[match[1]] = parts[0] if parts else "" + if SENSITIVE_NAME.search(match[1]) and env[match[1]]:secrets.append(env[match[1]]) + env.update(plan.get("environment", {})) + secrets.extend(v for k, v in env.items() if SENSITIVE_NAME.search(k) and v) + return env, secrets + + +def request(settings, payload): + if not hasattr(socket, "AF_UNIX"): + raise ValueError("原环境执行器仅在 Linux 服务器可用") + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(15) + connection.connect(str(settings.native_socket)) + connection.sendall(json.dumps(payload).encode() + b"\n") + stream = connection.makefile("rb") + data = stream.readline(131073) + if len(data) > 131072: + raise ValueError("原环境执行器响应过大") + result = json.loads(data) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("原环境执行器暂不可用,请稍后重试") from exc + if "error" in result: + raise ValueError(result["error"]) + return result diff --git a/console/native_job.py b/console/native_job.py new file mode 100644 index 0000000..137ceba --- /dev/null +++ b/console/native_job.py @@ -0,0 +1,176 @@ +"""Original-environment launcher, supervised by a per-run systemd service.""" +import errno +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pty +import pwd +import selectors +import signal +import stat +import struct +import subprocess +import sys +import termios +import time +from .artifacts import Collector +from .native_files import differences, outputs, preserve, runtime_snapshot +from .native_io import SecretFilter, load_environment +from .native_plans import build_plan, profile_for +from .settings import Settings +from .store import Store, now + + +def acquire_terminal(): + # Popen has already created the session and duplicated the slave onto stdin. + # The original tmux/menu tools need a controlling terminal, not just isatty(). + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + + +def execute(settings, run): + script = {**run, "id": run["script_id"]} + plan = build_plan(settings, script, run_id=run["id"]) + if plan["plan_token"] != run["execution"]["plan_token"] or not plan["ready"]: + raise ValueError("执行前环境或参数发生变化,未启动原测试") + if plan["impact"]["requires_confirmation"] and not run["execution"].get("impact_acknowledged"): + raise ValueError("缺少本次影响确认") + directory = settings.runs / run["id"] + (directory / "work").mkdir(mode=0o700, exist_ok=True) + env, secrets = load_environment(plan) + redactor = SecretFilter(secrets) + file_redactor = SecretFilter(secrets) + limit, written, truncated = settings.max_log, 0, False + def emit(data): + nonlocal written, truncated + remaining = limit - written + if len(data) > remaining:truncated = True + if remaining > 0: + sys.stdout.buffer.write(data[:remaining]);sys.stdout.buffer.flush() + written += min(len(data), remaining) + def message(text):emit(("[原环境] " + text + "\n").encode()) + collector = Collector(settings, directory, lambda: outputs(plan["output_paths"]), redactor=file_redactor.clean, + stream_filter_factory=lambda: SecretFilter(secrets), file_limit=4096) + profile = profile_for(settings, script) + if profile.get("preserve_existing_outputs"): + message("正在备份原有同名结果,原测试尚未开始。") + count = preserve(collector.outputs, directory) + message(f"已保护 {count} 个原有结果文件。") + before = runtime_snapshot() + (directory / "runtime-before.json").write_text(json.dumps(before, ensure_ascii=False), encoding="utf-8") + stopped = False + def stop(signum, frame): + nonlocal stopped + stopped = True # systemd sends the same signal to the full cgroup and enforces the grace period. + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + fifo = settings.native_socket.parent / ("input-" + run["id"]) + master = slave = input_fd = None + selector = selectors.DefaultSelector() + process = None + code = 1 + try: + account = pwd.getpwnam(plan["user"]) + options = {"cwd": plan["cwd"], "env": env, "start_new_session": True, + "user": account.pw_uid, "group": account.pw_gid, "extra_groups": []} + if stopped or Store(settings).run(run["id"])["cancel_requested"]: + message("已在创建原入口前取消,原测试未启动。") + return 1 + if plan["interactive"]: + master, slave = pty.openpty() + attributes = termios.tcgetattr(slave) + attributes[3] &= ~(termios.ECHO | termios.ECHONL) + termios.tcsetattr(slave, termios.TCSANOW, attributes) + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 36, 120, 0, 0)) + process = subprocess.Popen(plan["argv"], stdin=slave, stdout=slave, stderr=slave, + preexec_fn=acquire_terminal, **options) + os.close(slave);slave = None + os.mkfifo(fifo, 0o600) + input_fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) + selector.register(input_fd, selectors.EVENT_READ, "input") + output_fd = master + else: + process = subprocess.Popen(plan["argv"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **options) + output_fd = process.stdout.fileno() + os.set_blocking(output_fd, False) + selector.register(output_fd, selectors.EVENT_READ, "output") + message("已在原目录启动登记入口。取消仅处理本次宿主进程,Docker/tmux 或远端推理需结合收尾记录核对。") + last_follow = time.monotonic() + while selector.get_map(): + for key, _ in selector.select(.3): + try:data = os.read(key.fd, 4096) + except OSError as exc: + if exc.errno in (errno.EIO, errno.EBADF):data = b"" + elif exc.errno == errno.EAGAIN:continue + else:raise + if key.data == "input": + if data and process.poll() is None: + os.write(master, data) + elif data:emit(redactor.feed(data)) + else:selector.unregister(key.fd) + if time.monotonic() - last_follow > 1: + collector.follow(lambda data: emit(file_redactor.clean(data))) + last_follow = time.monotonic() + if process.poll() is not None: + if input_fd is not None: + try:selector.unregister(input_fd) + except KeyError:pass + if not any(k.data == "output" for k in selector.get_map().values()):break + code = process.wait() + emit(redactor.finish()) + finally: + selector.close() + if process and process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try:process.wait(timeout=5) + except subprocess.TimeoutExpired:os.killpg(process.pid, signal.SIGKILL);process.wait(timeout=5) + for descriptor in (input_fd, master, slave): + if descriptor is not None: + try:os.close(descriptor) + except OSError:pass + if process and process.stdout:process.stdout.close() + if fifo.exists() and stat.S_ISFIFO(fifo.lstat().st_mode):fifo.unlink() + after = runtime_snapshot() + changes = differences(before, after) + if plan["interactive"] and code == 0 and changes["new_tmux_sessions"] and not stopped: + sessions = {item["id"] for item in changes["new_tmux_sessions"]} + message("原菜单已退出,观察到新增 tmux 会话;继续跟踪其结束,不把入口退出当作后台测试完成。") + while sessions and not stopped: + collector.follow(lambda data: emit(file_redactor.clean(data))) + time.sleep(2) + current = runtime_snapshot() + sessions &= {item["id"] for item in current["tmux_sessions"]} + after = runtime_snapshot() + changes = differences(before, after) + (directory / "work/runtime-changes.json").write_text(json.dumps(changes, ensure_ascii=False, indent=2), encoding="utf-8") + collector.finish_follow(lambda data: emit(file_redactor.clean(data))) + artifacts = collector.collect() + result = {"exit_code": code, "stopped": stopped, "finished_at": now(), "artifacts": artifacts, + "log_truncated": truncated, "archive_notices": list(dict.fromkeys(collector.notices))[:32], + "runtime_changes": changes, "command_preview": plan["command_preview"]} + tmp = directory / "native-result.tmp" + tmp.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, directory / "native-result.json") + message(f"原入口进程退出,退出码 {code}。共享资源前后变化已记录;不代表原模型已恢复。") + return code + + +def main(): + identity = sys.argv[1] + import re + if not re.fullmatch(r"[a-f0-9]{32}", identity):return 2 + settings = Settings.from_env() + run = Store(settings).run(identity) + if run["state"] != "running" or run.get("execution", {}).get("mode") != "native":return 2 + directory = settings.runs / identity + try:os.rename(directory / "native-permit", directory / "native-started") + except OSError:return 2 + try:return execute(settings, run) + except Exception as exc: + print(f"[原环境] 启动或收尾失败:{type(exc).__name__}。未自动重试。", flush=True) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/console/native_plans.py b/console/native_plans.py new file mode 100644 index 0000000..22fa0fe --- /dev/null +++ b/console/native_plans.py @@ -0,0 +1,193 @@ +"""Trusted original-environment recipes; user input never supplies executable code.""" +import hashlib +import json +import math +import os +from pathlib import Path +import re +import shlex +import socket +from urllib.parse import urlsplit +from .catalog import script_path, safe_path + +MAX_TIMEOUT = 72 * 3600 +PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") + + +def profile_for(settings, script): + if script["kind"] != "external": + return None + source = next((s for s in settings.sources if s["id"] == script["source_group"]), None) + if not source: + return None + expected = Path(script["source_path"]) + for profile in settings.native_catalog.get("profiles", []): + if profile["source_group"] == source["id"] and Path(source["path"]) / profile["entry"] == expected: + return profile + return None + + +def validate_options(profile, options): + if not isinstance(options, dict): + raise ValueError("原环境参数必须是对象") + fields = {f["key"]: f for f in profile.get("parameters", [])} + if set(options) - set(fields): + raise ValueError("包含启动方案未登记的参数") + result = {} + for key, field in fields.items(): + value = options.get(key, field.get("default")) + label = field.get("label", key) + if value is None or value == "": + if field.get("required", True): + raise ValueError(f"请填写{label}") + result[key] = "" + continue + kind = field["type"] + if kind in {"integer", "number"}: + valid = type(value) is int if kind == "integer" else type(value) in (int, float) + if not valid or not math.isfinite(value) or value < field.get("min", 0) or value > field.get("max", 10000000): + raise ValueError(f"{label}超出允许范围或类型不正确") + elif kind == "select": + if value not in [o["value"] for o in field["options"]]: + raise ValueError(f"{label}必须从登记选项中选择") + elif kind == "text": + if not isinstance(value, str) or len(value) > 1024 or re.search(r"[\x00-\x1f;$`|&<>\\]", value): + raise ValueError(f"{label}格式不正确") + if "pattern" in field and not re.fullmatch(field["pattern"], value): + raise ValueError(f"{label}格式不正确") + else: + raise ValueError("启动方案包含未知参数类型") + result[key] = value + return result + + +def resolve_target(target): + target = {**target, "environment": dict(target.get("environment", {}))} + path = target.get("config_file") + if not path: + return target + values = {} + for line in Path(path).read_text().splitlines(): + match = re.match(r"^(PORT|SERVED|CT_OFF|CT_ON)=(.*)$", line.strip()) + if not match:continue + parts = shlex.split(match[2], comments=True) + if len(parts) != 1 or "$" in parts[0] or "`" in parts[0]: + raise ValueError("原配置的模型/端口包含动态表达式,需管理员核对") + values[match[1]] = parts[0] + if "PORT" in values: + port = int(values["PORT"]) + if not 1 <= port <= 65535:raise ValueError("原模型端口不合法") + target["url"] = "http://127.0.0.1:" + str(port) + if "SERVED" in values:target["model"] = values["SERVED"] + for key in ("CT_OFF", "CT_ON"): + if key in values:target["environment"][key] = values[key] + return target + + +def public_execution(settings, script): + profile = profile_for(settings, script) + if profile is None: + return {"mode": "container", "interactive": False, "max_timeout_seconds": 7200} + return {"mode": "native", "profile_id": profile["id"], "interpreter": profile["interpreter"], + "user": profile["user"], "cwd": profile["cwd"], "description": "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。", + "impact": profile["impact"], "parameters": profile.get("parameters", []), + "interactive": profile.get("interactive", False), "max_timeout_seconds": MAX_TIMEOUT, + "notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮 GPU/长稳业务验收"} + + +def validate_config(settings, script, values): + profile = profile_for(settings, script) + if not profile: + raise ValueError("未登记原环境方案") + config = dict(values) + config["native_options"] = validate_options(profile, config.get("native_options", {})) + timeout = config.get("timeout_seconds", profile.get("timeout_seconds", 3600)) + if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT: + raise ValueError("原环境时限须为 5 秒至 72 小时") + config.update(timeout_seconds=timeout, cwd=profile["cwd"], args=[], output_paths=[], profile="native") + return config + + +def build_plan(settings, script, run_id=None, check_host=True): + profile = profile_for(settings, script) + if not profile: + raise ValueError("该入口未登记原环境方案") + source = script_path(settings, script) + source_hash = hashlib.sha256(source.read_bytes()).hexdigest() + if source_hash != script["sha256"]: + raise ValueError("原入口已变更,请刷新来源并重新确认后运行") + options = validate_options(profile, script["config"].get("native_options", {})) + target_id = options.get("target") + target = next((t for t in settings.native_catalog.get("targets", []) if t["id"] == target_id), {}) + if target_id and not target: + raise ValueError("目标服务未登记") + if target: + target = resolve_target(target) + if run_id and not re.fullmatch(r"[a-f0-9]{32}", run_id): + raise ValueError("运行编号格式错误") + identity = run_id or "{run_id}" + directory = settings.runs / identity + variables = {**options, "run_id": identity, "run_label": "console-" + identity, + "run_dir": str(directory), "output_file": str(Path(profile["cwd"]) / "logs" / f"console-{identity}-{profile['id']}.json"), + "target_url": target.get("url", ""), "target_model": target.get("model", ""), + "target_port": urlsplit(target.get("url", "")).port or "", "target_config": target.get("config_name", "")} + def render(value): + def replace(match): + if match[1] not in variables: + raise ValueError("原环境方案包含未定义的占位参数") + return str(variables[match[1]]) + return PLACEHOLDER.sub(replace, str(value)) + argv = [profile["interpreter"], str(source), *[render(a) for a in profile.get("arguments", [])]] + environment = {**target.get("environment", {}), **{k: render(v) for k, v in profile.get("environment", {}).items()}} + credential_files = list(dict.fromkeys(profile.get("credential_files", []) + target.get("credential_files", []))) + setup_files = [render(p) for p in profile.get("env_setup_files", [])] + required_files = [render(p) for p in profile.get("required_files", [])] + if target.get("config_file"): + required_files.append(target["config_file"]) + timeout = script["config"].get("timeout_seconds", profile.get("timeout_seconds", 3600)) + if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT: + raise ValueError("原环境时限不合法") + checks = [] + def check(name, ok, message): + checks.append({"name": name, "ok": bool(ok), "message": message}) + check("启动方案", profile.get("available", True), profile.get("unavailable_reason", "已登记原环境入口")) + if check_host: + check("原工作目录", Path(profile["cwd"]).is_dir(), profile["cwd"]) + tools = list(dict.fromkeys([profile["interpreter"], "/usr/bin/systemd-run", *profile.get("required_tools", [])])) + for tool in tools: + check("工具", Path(tool).is_file() and os.access(tool, os.X_OK), tool) + for path in required_files + credential_files + setup_files: + check("原配置或素材", Path(path).is_file(), path) + for value in profile.get("check_ports", []): + port = int(render(value)) + if not 1 <= port <= 65535: + raise ValueError("原服务端口不合法") + try: + with socket.create_connection(("127.0.0.1", port), timeout=.3):pass + available = True + except OSError:available = False + check("目标服务端口", available, f"127.0.0.1:{port}" + (" 可连接" if available else " 未监听,请先准备原服务")) + # Binds acknowledgment to saved configuration, source and recipe, not to a mutable browser flag. + dependency_hashes = {} + for name in required_files + setup_files: + path = Path(name) + if name not in credential_files and path.is_file() and path.stat().st_size <= 16 * 1024**2: + dependency_hashes[name] = hashlib.sha256(path.read_bytes()).hexdigest() + fingerprint = {"profile": profile, "target": target, "options": options, "source": source_hash, + "dependencies": dependency_hashes, + "script_id": script["id"], "timeout_seconds": timeout} + token = hashlib.sha256(json.dumps(fingerprint, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + return {"mode": "native", "profile_id": profile["id"], "plan_token": token, + "ready": all(c["ok"] for c in checks), "checks": checks, "argv": argv, "command_preview": shlex.join(argv), + "cwd": profile["cwd"], "user": profile["user"], "environment": environment, + "credential_files": credential_files, "env_setup_files": setup_files, + "impact": profile["impact"], "interactive": profile.get("interactive", False), + "timeout_seconds": timeout, "source_sha256": source_hash, "dependency_hashes": dependency_hashes, + "output_paths": [render(p) for p in profile.get("output_paths", [])], + "notes": profile.get("notes", []) + ([f"目标服务:{target['url']};模型:{target['model']}(原配置已复核)"] if target else [])} + + +def public_plan(plan): + keys = ("mode", "profile_id", "plan_token", "ready", "checks", "command_preview", "cwd", "user", + "impact", "interactive", "output_paths", "notes", "timeout_seconds", "source_sha256") + return {key: plan[key] for key in keys} diff --git a/console/native_rpc.py b/console/native_rpc.py new file mode 100644 index 0000000..7d33139 --- /dev/null +++ b/console/native_rpc.py @@ -0,0 +1,95 @@ +"""Private, bounded worker RPC. It exposes registered plans, never shell commands.""" +import json +import os +from pathlib import Path +import re +import socket +import socketserver +import stat +import threading +import uuid +from . import native_plans, test_catalog +from .catalog import safe_path +from .store import Store + + +def handle(settings, store, data): + operation = data.get("op") + if operation == "input": + identity, text = data.get("run_id", ""), data.get("text") + if not re.fullmatch(r"[a-f0-9]{32}", identity) or not isinstance(text, str) or "\x00" in text or len(text.encode()) > 1024: + raise ValueError("菜单输入格式不正确") + if not re.fullmatch(r"(?:[0-9 ,:./+\-]*|[yYnNqQ])", text): + raise ValueError("菜单通道仅接受数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据") + run = store.run(identity) + execution = run.get("execution", {}) + if run["state"] != "running" or execution.get("mode") != "native" or not execution.get("interactive"): + raise ValueError("当前任务不接受菜单输入") + path = safe_path(settings.native_socket.parent / ("input-" + identity), settings.native_socket.parent) + if not stat.S_ISFIFO(path.lstat().st_mode): + raise ValueError("菜单输入通道不可用") + try: + descriptor = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + payload = (text.rstrip("\r\n") + "\n").encode() + if os.write(descriptor, payload) != len(payload): + raise ValueError("菜单输入未完整送达") + finally:os.close(descriptor) + except OSError as exc: + raise ValueError("菜单暂未就绪或已结束") from exc + return {"accepted": True} + if operation not in {"preflight", "queue"}: + raise ValueError("未知原环境操作") + identity = data.get("script_id", "") + if not re.fullmatch(r"[a-f0-9]{32}", identity): + raise ValueError("测试项目编号不合法") + if operation == "queue": + request_id = str(uuid.UUID(data.get("request_id", ""))) + previous = store.run_for_request(request_id) + if previous: + if previous["script_id"] != identity: + raise ValueError("请求编号已被其他测试使用") + return previous + script = test_catalog.describe(settings, store.script(identity), store.scripts()) + plan = native_plans.build_plan(settings, script) + if operation == "preflight": + return native_plans.public_plan(plan) + if not script["enabled"] or script["status"] != "ready": + raise ValueError("请先配置并启用测试项目") + if not plan["ready"]: + raise ValueError("原环境预检未通过,请查看缺失条件") + if data.get("plan_token") != plan["plan_token"]: + raise ValueError("原入口、参数或配置已变化,请重新预检并确认") + acknowledged = data.get("impact_acknowledged") is True + if plan["impact"]["requires_confirmation"] and not acknowledged: + raise ValueError("此测试会使用共享资源或切换服务,需要本次运行的明确确认") + execution = native_plans.public_plan(plan) + execution.update(impact_acknowledged=acknowledged, verification="启动方案已核对;本次结果以真实运行记录为准") + return store.queue(script, request_id, data.get("source_ip", "unknown"), execution=execution) + + +def start(settings, store): + folder = settings.native_socket.parent + safe_path(folder, settings.root, must_exist=False).mkdir(mode=0o750, exist_ok=True) + if settings.native_socket.exists(): + if not stat.S_ISSOCK(settings.native_socket.lstat().st_mode): + raise ValueError("原环境 IPC 路径不是 socket") + settings.native_socket.unlink() + class Handler(socketserver.StreamRequestHandler): + def handle(self): + self.connection.settimeout(15) + try: + raw = self.rfile.readline(8193) + if len(raw) > 8192: + raise ValueError("原环境请求过大") + result = handle(settings, store, json.loads(raw)) + except (ValueError, KeyError) as exc: + result = {"error": str(exc.args[0])} + except Exception: + result = {"error": "原环境操作失败,请检查执行器状态"} + try:self.wfile.write(json.dumps(result).encode() + b"\n") + except OSError:pass + server = socketserver.UnixStreamServer(str(settings.native_socket), Handler) + os.chmod(settings.native_socket, 0o600) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server diff --git a/console/requirements-test.txt b/console/requirements-test.txt new file mode 100644 index 0000000..a6f6b37 --- /dev/null +++ b/console/requirements-test.txt @@ -0,0 +1,4 @@ +-r requirements.txt +certifi==2026.7.22 +httpcore==1.0.9 +httpx==0.28.1 diff --git a/console/requirements.txt b/console/requirements.txt new file mode 100644 index 0000000..b1f8a53 --- /dev/null +++ b/console/requirements.txt @@ -0,0 +1,16 @@ +# Resolved and verified in the isolated project environment on 2026-10-13. +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +click==8.5.0 +fastapi==0.143.0 +h11==0.16.0 +idna==3.20 +opentelemetry-api==1.45.1 +pydantic==2.14.1 +pydantic_core==2.50.1 +python-multipart==0.0.32 +starlette==1.8.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.54.0 diff --git a/console/settings.py b/console/settings.py new file mode 100644 index 0000000..531fd1e --- /dev/null +++ b/console/settings.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass, field +import json +import os +from pathlib import Path + + +@dataclass +class Settings: + root: Path + sources: list + allowed_hosts: list = field(default_factory=lambda: ["10.88.118.188:14442", "10.88.118.188:14440"]) + allowed_networks: list = field(default_factory=lambda: ["10.88.0.0/17", "10.20.0.0/21"]) + test_mode: bool = False + max_upload: int = 1024 * 1024 + max_log: int = 16 * 1024 * 1024 + max_artifact: int = 32 * 1024 * 1024 + max_run_files: int = 64 + test_definitions: list = field(default_factory=list) + example_script_ids: list = field(default_factory=list) + native_catalog: dict = field(default_factory=lambda: {"profiles": [], "targets": []}) + + @property + def native_socket(self): + return self.root / "native-ipc" / "control.sock" + + @property + def library(self): + return self.root / "library" + + @property + def runs(self): + return self.root / "runs" + + @property + def db(self): + return self.root / "console-data" / "console.sqlite3" + + def prepare(self): + self.validate_paths() + for directory in (self.root, self.library, self.runs, self.db.parent): + directory.mkdir(parents=True, exist_ok=True) + + def validate_paths(self): + for directory in (self.root, self.library, self.runs, self.db.parent): + if not directory.is_absolute(): + raise ValueError("Runtime must be an absolute path") + for part in (directory, *directory.parents): + if part.is_symlink(): + raise ValueError("Runtime paths must not contain symlinks") + for path in (self.db, Path(str(self.db) + "-wal"), Path(str(self.db) + "-shm")): + if path.is_symlink(): + raise ValueError("Database symlinks are not supported") + + @classmethod + def from_env(cls): + project = Path(__file__).resolve().parents[1] + catalog = json.loads((project / "catalog/external-sources.json").read_text(encoding="utf-8")) + tests = json.loads((project / "catalog/test-definitions.json").read_text(encoding="utf-8")) + native_file = project / "catalog/native-profiles.json" + native = json.loads(native_file.read_text(encoding="utf-8")) if native_file.exists() else {"profiles": [], "targets": []} + return cls(Path(os.environ.get("XAS_CONSOLE_ROOT", str(project / "runtime"))), catalog["sources"], + test_definitions=tests["tests"], example_script_ids=tests.get("example_script_ids", []), native_catalog=native) diff --git a/console/static/app.js b/console/static/app.js new file mode 100644 index 0000000..4ecdf8d --- /dev/null +++ b/console/static/app.js @@ -0,0 +1,61 @@ +import {api, el, note, heading, errorBox, pageContext, bytes, date} from './ui.js'; +import {scriptList, scriptDetail} from './scripts.js'; +import {runList, runDetail} from './runs.js'; + +const main = document.querySelector('#main'); +let context; +let summaryBusy = false; + +async function summary() { + if (summaryBusy || document.visibilityState !== 'visible') return; + summaryBusy = true; + const health = document.querySelector('#worker-status'); + const notice = document.querySelector('#global-notice'); + try { + const data = await api('/api/summary'); + document.querySelector('#nav-count').textContent = data.scripts; + document.querySelector('#summary').replaceChildren(...[ + ['测试项目', data.scripts, '按测试行为统计', ''], ['可运行', data.ready, '业务测试已就绪', 'accent'], + ['进行中', data.active, '排队 / 执行', ''], ['运行记录', data.runs, `归档 ${bytes(data.storage.run_bytes)}`, ''] + ].map(([label, value, hint, style]) => el('div', {class: `stat ${style}`}, el('span', {class: 'stat-label'}, label), el('strong', {class: 'stat-value'}, value), el('span', {class: 'stat-foot'}, hint)))); + health.replaceChildren(el('span', {class: `worker-light ${data.worker.online ? '' : 'offline'}`, 'aria-hidden': 'true'}), data.worker.online ? '执行器在线 · 单任务队列' : '执行器离线'); + health.title = `最近心跳:${date(data.worker.last_seen)}`; + notice.hidden = data.worker.online; + notice.textContent = data.worker.online ? '' : `执行器当前离线,排队任务需要等待执行器恢复。最近心跳:${date(data.worker.last_seen)}。`; + } catch (error) { + health.replaceChildren(el('span', {class: 'worker-light offline', 'aria-hidden': 'true'}), '连接状态未知'); + notice.hidden = false; + notice.textContent = `工作台状态更新失败:${error.message} 显示的数据可能已过期。`; + } finally { summaryBusy = false; } +} + +async function route() { + context?.dispose(); + context = pageContext(main); + main.setAttribute('aria-busy', 'true'); + const value = location.hash.slice(1) || '/scripts'; + const [path, query = ''] = value.split('?'); + const segments = path.split('/').filter(Boolean); + const current = segments[0] || 'scripts'; + document.querySelectorAll('[data-nav]').forEach(nav => { + if (nav.dataset.nav === current) nav.setAttribute('aria-current', 'page'); + else nav.removeAttribute('aria-current'); + }); + document.title = `${current === 'runs' ? '运行记录' : '测试项目'} · XAS 测试控制台`; + const ctx = context; + summary(); + try { + if (segments.length > 2) throw new Error('页面地址无效。'); + const id = segments[1] ? decodeURIComponent(segments[1]) : null; + if (current === 'scripts') await (id ? scriptDetail(ctx, id) : scriptList(ctx)); + else if (current === 'runs') await (id ? runDetail(ctx, id) : runList(ctx, new URLSearchParams(query).get('script_id') || '')); + else ctx.show(heading('找不到此页面', '请选择左侧的测试项目或运行记录。'), note('页面地址不存在。')); + } catch (error) { if (ctx.alive) ctx.show(errorBox(error, route)); } +} + +document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); main.focus(); }); +window.addEventListener('hashchange', route); +window.addEventListener('pagehide', () => context?.dispose()); +document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') summary(); }); +setInterval(summary, 15000); +route(); diff --git a/console/static/core.js b/console/static/core.js new file mode 100644 index 0000000..a4c37e3 --- /dev/null +++ b/console/static/core.js @@ -0,0 +1,29 @@ +export function parseLines(value) { + return value.split(/\r?\n/).map(line => line.trim()).filter(Boolean); +} + +export function parseArgs(value) { + if (!value.trim().startsWith('[')) return parseLines(value); + let args; + try { args = JSON.parse(value); } + catch { throw new Error('参数 JSON 格式不正确,请填写字符串数组。'); } + if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) { + throw new Error('参数必须是 JSON 字符串数组,或每行一个参数。'); + } + return args; +} + +export function appendLog(previous, incoming, limit = 240000) { + const all = previous + incoming; + return {text: all.slice(-limit), trimmed: all.length > limit}; +} + +export function requestId() { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + // LAN HTTP can lack randomUUID; getRandomValues remains available there. + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/console/static/index.html b/console/static/index.html new file mode 100644 index 0000000..287d320 --- /dev/null +++ b/console/static/index.html @@ -0,0 +1,37 @@ + + +
+ + + + +B1l4ab=>Wzf>0xTHb0$P>KuG$ zVlr+VD^ERnC~pbeg?LGQxn%mSFV z3yjRPq<@EW&3?2`R~pOwtf77)rGlci{nMqXQhmDWtBBr1r0?lnZZ*!rd4SBZ%swg= z6>U}cT%+slJB8-afk1=jV}T7WCKvr`zbuLR07cy}4D;pjfY)(72$36B$PKJTgFh6k z>F*0Pi-p9Txv1;ZYx+|&PERu^s2M4^o6PKcFzsFWpIWu#h-!$T`pfwM6@-Wl==*v& z->b6zuj<#@IiJ$}odhIOqg_WYTxuFBECR{O4fU+3(bXr?!=e{k`BIW^c{>@KDY(A2 z;Kn3>VBgd0#CnUJ0NAdvWa45yFv>cRn9-~f(Wtoo;E!!=LBf+fzqX#`Wc$bFdFB2W zc*Q!mUHL!YHQYZqyZYA?-5#wXD-v{z-AmY7EU#Tdq+WOjg;s5$4r6XN1u!v1EuCfl z ;id3P!q50e-!*HHt5Ik!-Il5-V>Ll;J7v|249JnN5d zx WDS|(St4+ZV`?vAhpokBaFoo!0#dT5sRCDbkPPl(scC;Tg@T!u!uoz*Z zAehXlZ&Pb2j3V^GyJJ5VS*`3VO@5BAy@Z`W-m95x$(WYRiM3xn19YldrP|y!N)Nnb zk4_^GC6?myM!&>>{5)mVw>Ip!Xm(gC6`tdmS{7CKyW94Rn?~AJvSkB99L yN(iW70UYST6^DcijKVrqmh>5R$}4; z<(N_RP-1S7HtHyLmwGWpSo>F8I?Xa|Y451e&1BOe_&*VQu<~rbzuuLWWK59~(&NJW z?y;i!FVOoTAuI7U&W@yC%;$A4dT^_-n^Ivv)__!>N+6d}T*Fq1N?+8*Ck9g~wF!>5 zQ|CR~8Qw~~dH#s|HH{~RF~=GewqXm^?JbS})dKFDk8wwzbymqZZ^TNmyGw_OYU2lr znTJ k-n8MaW1Y6zFdCQJs zXnN!Lbpt_~1TpUXN(`T>=BAG=kqCq(b(zvyv&ra~y~ZXoQ>)nh8Zi`=Y)>pxs&!UI zniFipd?Cf<5tHQL$>Cyq#YDSo&X1BZK@M`ZuduJZ8FnVdKStQTH~i(i;OpXMZ0-m2 zeTToPF4_#NC=+`XjI%$prruyIpJ1=A0X{J9F)}h~=UQ!jkI6^D3vdYPO>AbFK7u7u zQPB?YRI|?Au=5&iBgSmO<6i`V3`OCwjG1dHkJj05Rdmyef4L5zk4Y0?Qq*Q=Dypig zf24Fa8LMPQ&ZtKVd%PMP@-=agb4a29op*ZRCo59mO-K2NR3VU*$hnCL 3^yr@WLJ9}iFaY| zuJ#$R+qaamMomUSi1F9G_;NVuG=x7*UA>1NAv3$5TX;)l?X%q9va6qY`=n?esag9% zw#$YzZd;PAZY>S-?!ta&CvkIe{+_8_>+{`5x8_*J078CpAkSr zrI`ET#xMPx_$xipTb4uN%F|O|cDD&(5D!_*j0+z6hr8+q!B)xNG|N~O%>ID>Vfs4G zs4 Nuxi 1*`=+4C9p*WvcSc zkJvF^P^{kQFp1qrr65S^gADm&CTLXb9HJJT@{%%XLwc&JF)!^Z$%BG0CzTK*D()#+ z^ d= 9Cfc#RM*dRiHKqT#54TiL5h@M>M4WzYY(i! wGLpH%Ym?6w_lFAr{ mDy3c&3Cn?ErxIu$&D|TsPb!kgW1V9_b-8pTUs sHv^cf2)I_4Q(5SgzlK4#6*pYgZ!p?7~ zU0j^k2`GbxvF8zss+{6R%{P=+Q_?1Gq}}e0m(UkaH#KMZeanfiF%`!sc8ha`qH@<~ zWM(POn6vnTYVB)mhEov6nVUuz%AIrV3B|^W9s5e$CN$WLs2yHSu@{t8&BC~NNlm_@ z%3r@-Sokcj7!lb4RpsA)XYirDNA*vaS|Wlu_T~ z=cPy4I^!zvU=AegXelw-ITe{Qz6s%7PLDC-RGEL=a$xjp+K5!SK%MwcXGrhddRAj_ zY6#@EQw;iQ>>NyxPJ)iu6Qi&=+$A2n3#!D+UocYdZA04k#cwD6ek12AQS;RG*G~=S zo7Gg4{Y?Y4%nGXLYp%2`-(FH+Ge*~3 !9MmDqi&4~7xeCF z+A^5 D!;&U$xj*l6Kcl-b!PN#*b0`AEPIAC;)6tQn+0YQh)hLn=~CF)(yZga2wf z?~s ^_K25M==3r?{hHL!qt>N8LyVm{fE$kNpe zZc9~8-7FKPZhd<)R))!e^Xv6a1=}NXy+u4VY8cDulv~$o5+MqX$mTpWpY0zP`U|X` zEn}qyPU*$sat5ux-TELf_dHi3!!6k&=&`hb`=!rajM8u$nh*n~ZtTn-d$@1LQjZJ` z8PItM=sbN64;wL7PDqa~j0~P42puAPxa7yH;#8h0l!p7rn9!^{l0E(Cf=->Sg@R}O z-1mp2s3MdtP)+$iX1b6fTtI-Ew2iI`o3IhmCypR+C=h=?0f)~V6f~R;wlCN@*=yth zw(dUI&<=`z;&pEeV2Yi9`q5Kc&xhvbOPq$PRB!>n@rl*(zsQ`QBGa {)nb>O$Y%j7|d<^wBc%WT(Xl=&<+Ka+oM<2Gv62?=Ky$KE*A9WLg9&xT4uKW}YawbJ-iQU9^?GIieq3uz zG}}T>2O;r2mNtz=gWJggRMpK@E#nqC=JnYJBsZgp4nsy{+KLMl_h}y A=q8h+<$w~&+;Qi1WdUZ1fo4`QlI%RpL0+;MK{DI9lO&gXI>Gjt|r&-Qu= zuxuJ&2;!Zk@#Azutsz5!k3GRrg6=qF+>cWJ3N$e77K(rA^Sz%MPxA38H{ >*O!?7pFgr&ht%0Bc93cpNKWaYu|7yZStq4{3( z4Sx&jJ;t O=5MlCB&m z!M28}sLm?2XCJA!$rDd*UK5+qjLlz8#YPL$Rw)g8c6PZR0HTxusZWPN ~7J?69lv7$W)ClZjD(F z&L%>uPyCEVO>%05R)--@4q*#?EOgy>-wr~B#h%oN!>Hq?N=VzHn=cMogphY+kNT9~ z78a@O_?K|oh8Oj+7~Z^968n)=$|}H8B+9kA@ilX7_l0~Pl<0J6%l?I^o9|ws%GUVL zFnneB#_xnHuT97 T!82Bc-Ghn{<+pZM|JdyS)u?^bu$uIr8wTXwF@O z)c;Jo9J&22-n;_?NwA1(B;@NfT7q)eo|G)jg<+Qv-;l%hE}FjlH0vSl{IRoHOQu81 z(58qVKzZ!%%dH*$h1K2`-AJvha!QJM0Q2&+d!w<%F6nZ_d*WL?U2gd2mgz|t{}txF zFe|M%H+4J}<_aPbV!SWig&S+VMyrrYA%upl@A?aC8R7_>0j|ky4oJ&iE_Vm+ecFr@`zpZ8w(FqYWBPX-+5zO#k9zlsELmniV# 7?^60;24ORv`Qy(yOu7e -XRxX@Ql){m%I8!-qkpB}b#Mc*_m;%k|6*by9?P_`(O89=D;IR)G64m)CQ8CryO zC!~z__SU-?W1C}3()D&ZjM2>yu7ttOX;geZx$ZS39H{4dtmy5GpT<@=9;#wUEfr;& zCLG$-u{H+asc&?ujNKCJ^kdn*s&v1*bT@33aM_C(%r{&^)IMbqypBspQ QaVlf zDWF5J&a_3F`N0~C`fbmVND@BYX^US&;aGr|w!E_p-E0Dh*J&<7T_57A!Hk8DvjHsi z0{ZD20kJxIUM-X7@a_Fs@m5U23hT#4x7H(X3|)F8Y= G-%9ORaP- k<4-Ajk{V9KT;hs48F?Npzx}`y7GJeDuO;AxJ+_r_aKL?7GB7Io zbvQLL?U`~XEDu*eiTHh2?sq6jbBLR4eZmqmt|KKJ;=XeVX>DtnuUvUS6sGjbWy|Vg zyn_$k;^{L^%eJlkcH?S%-!iPR$IaI;g4sPDtp#LS_tFH~K%Y`Fu z<*oRB@cEZdz#I1kmIT?U)-lYHcaGagt0>!U)L)_Gz}7Bw7X;#h`f^CSUSM==!F|~2 zIVQ)nVZO}0<)s`V!>(KqaCJ KI;B#))F`A-fxE*p3>?;`Osk zy-8EjQ%=9qLnd6VF&vmMoIK{y)XaHrs(qa?s_)Q3AQKyfuC~X#4=2VMLe9h({Z_ks zSWhVu$0a=;G3;G3=YY=u4;|fbRDJz*4#{?3Tg!I*#y90{lxaMgA+V>7fiVN=M*B1g zLclmb+`k@2><)ZQNP=`-D#y2_rh`5Ld7Ghm5C;< zDf!{Y-MFB>1jQ+r;b_lD4m>6}zU05S^@{T?bACDwI;3>O44YlKW;BGbd^VitluppN ztz}zes(KZQIIHGy$-~eEFFH!PT3iIu9*cxdp}s f-Zx{j-JmoLFQ_ ztc{A0K8MND(-^**Tl25=(wOH44uV(ZboW=13IdskkA=>jYnsN5=Uk=lu7H$wNxUYB z*B_HcISR8!Mq8`6j+r!2@W#7}Z_pU^Hd2<5LEQE4GD&XlH~|aSR^l A~32U!HzW4Bz!O2epse-{KJ*bz^W fc6H)zun|(YszyYX-PezX>-HtbrTE4Se zwYDgosTFs(a#OX6(a8UL5nwzo-f#}PBMc)a>6`VnMLln-Y5X) {smft z){?fXw$G*`QEKADjgPlO84)If43CO8l>0h#mev z|DDGF @41byKYdfl_pb9O2#n6I=4#oRyD)=DDxN(oqUT^y zGikc0u$U;>MQ7&Rlg41OQ}cy?4(XVshZDh99-wrm4m*lSs%^|EXpUa|6S3>rHhu_T zpU}KaDj5h{zin4mr@wnkuL6)07jC^$>z!q%>`i}=bHbFc#pA(nhrWA;TMwb$B6l6; z2R=PzI${*QhxP=Cm||{W6O$Ykar)$dT&DMx4{SAa+M{0Ax8FVXvJS4AjCv5n8|`<> z8pc@WDQyCvPYo=mJO`|_Y*XurL|Huc!L4;%J?U+uQNEtTtZW=k-hTk~R#o`P+1q0y zVYbCy 8Pjm92>&K@hg+1VpKvT8c4H36aAM%@+}0s)J*8$e6hU>Qq~!@O zmh~+eW-rZDz Ev}1q`8I z)+5|iqg_D` aHZUW=f2>4@DJma}ue+Re488MZ-9h(!cCay(6AY#6YSXEdIf5 z59ESUWm&8hr+T8l1*-FUks}SZ 3I>^DMZY;k>37G zqnQ!SY)nucmeUwZ# YCk4kNyH>iQr?cN~!~lX8x^?zXy>mM+GG%Gt#+- zr`F^v1m!(>qAtnHvbfi_k$^IG$$t e>LTs&0Zk5*rU` 8~CmE9K=--PFJE?;K~TN7x^^PKe#pJD v!A6Nq{gK6yDEe$qGLvySK(yrWO^d)_4JToaiD zW7RcN%0YQ@?8mce38IMXDxL9R?05&JDFSpnNkT7}i*j~#H5eZb^OSfo3#+o#=^9JJ zLP;;+)#h;0>BmfRP0=$uNw+=!oo@X+FB=efMEs@IZv+%UwoJrZREr}uR2$Cvb)o+j z9q6wRUXp)t!l5ySjU~PcVnb0H&&9~f*Bt2YTerY<7VVFNOc#6x^XslM;Cy$wp}bci zHp2k9_fJN#>9oLR>h}kgA^5s?_}noGSgSQ>vzo Th9 zQ-XP!yx(C$#rIWm%v7K{vqqz=Dhfh7+FI>Qe6lQRnRN9S)8b%K%joIxX>!R@4Wtr+ zwFhNc9Be^xvz%nlBPihreiu{Sy}Cp}6`~Tbms=gjcWy?cbZ6=KV2gU}G2?7N1~w z9UlYbn8PAjA$mDFAD*||iLkor*Y|rt7QzHtW3NNZsB-*GX^(e_cwF;Ch_o}qmu;G? z8?aQnmlS+Oj*7ctafn92EdEH2mGcw2Cit01NvK1PzQ&1rp%J_-V-7`RY={4rT4#C7 z3q|t^p!l2=M%_mN4Pfe*TDwamkLX)woeKus@a8ttlM5^x4AG ugu Ac+hp>Nl#oq|B--|zw zIol`q&>97#axQ<=nHg0}A-d7G;!+m)j40N+MaE(unxci%xGmLtcC^Nr+UAzso`mD~ z9$wUY%XU7M5gCZd_K=* -yVLi&JL*q zOnW;x)4Mf}c nZ zK}`svCgQ+F4#a;Mi^FP$^=gAnV&QeuJ%aOIyGGh5@x)%lDeR&Dtg$1$x&>9Bz;c)M zv>J|<^#(?M4SrgI|4wpGwilnFIVg2eRi&B$YUr{c;E6<=wMkcQv6l28zU;aSLf9PF zJV-C)0i-UPa|s8#-yyi)ae8NcV6vmsf4Y^4c+)E_s)fOh?C =OCqprRf2ex2NDu}V`X=v~~3M(@`;m;$-FGa32y$u|FF?u5^MKIbldJ#pm* zn?aI>#)r}d=3>jR0<)U(SQF|#6VITEo4B-Q(u>#i5%{mn7|tI~o)uFW*E~U1(=i+t zccUwk|7`A0_AAlsI54=}CT6u1nDmeOhpV{SQqzYy-=S-&pWX>%5RGdYc?1an64E6> zO&9lp#1UZ^KADx(nf4U9RLrc&MZ;Nc%Y>EE>rGV`ke!`(FO>)(SZIouF3)QG6J!yI zfuYl1+RKr&XNk+9PaJxCtZYRRIzT~H%Ov^R{6C&0ZMNI!{Nrk3Vbcam;lC30MsPzG z0bz_*C;Yknb(B+x$L}f**nvnlT0v)c_?)ml6L*Ckr^!lZWUkCQhja`a;(a5hx!DIP zdG}Pw9s3wmJD$7IPn?e+9lG6*4-o$xuNKq-wcYqUk$jdM ViPl?^a%|U1mkO|pv$83RCxzx`myR4RTB9%&>)fD5LDuRHrKmTjJwG_s8w*d zAeGJQl`pQgv<)gqHAz!oA0QjmxN-5)J-0moKL&WlAr@46&9^Hpe#p2>D)Pu7khRWP zuu#jLok_7#{bR49 v@D^(NvC^<`{E=7k*3Ev>GDaUxYHQAF#E9 Yr*DxT7F66is`ZCOIfJP>!0K6);TeCRrF2 zv1F&5y?2#Z)${S5Z#k}ID&>$MkiX+i$j;tbY=bI0wu&BeHYtDliM#kLmz3&g7}1LB z5VfN6e`(1pv}dEaA6+GD^?Kv3`&_HBI)l7!qNe=xHwUDrV2nKyBe6PS3?9$o=$$|4 z=n*MWr7Mh=?WUILW-6G|3uT}L*1LfJ(DohVr>~K)Qfw$yt+NkjB#H1UAM{Y}XFB$~ z?h!4!e+Pg?GGm0`5WJ?@*36>#%PWQ1XNY~OH~}go8WALWiFN|*`|!&Ae+alOvyHnR z%W%WSSfff;`y;TU8ShQzRZdl}>K10m8Rp|#pE!n$*{DbdR1v$3b2bQU?#V{gDCqTL zf6JjuwOMnqleJs>f0+Vp$&1nmMLdhZsm%3#9`(ZXc<8jQtT|@q2~7Hw$@2t(F?x39 z?dTNj_Vid^H~akj$zz^i0xtwa3^5^LiX~Dc1 0I1-R%C@dS4 z1S)nio8%A#O8&AV6P5>))h 2X|~c$D$nxqhyDur8cXU zjo-Pb#yU(74qt?q&uX0M;(Jd595nw}Di0JI?L~S4l^xybIhsOeeFK+K|2nIR49{3% z(pcJk5fBcS&omIQC*fTSu93?DIk2z>nWt~gl%To)P@(nz@>)MLrO%T;)xq+#7wMUb z_Ai#H^g92HW}h&owfD@?USxv+u2J3JE<3Y)&3;<8rNX?HNf~$PkJqMlU~$QdVvUep z{qzNHG?Ax$yGE4gJwdqh9U;fN$>gIeg`p3!O(k8{z? zln?hNz9T$D6HHDalFj@pCYRho?u)Cux?=vwBkmuR`QXd6!^xI#N9}PrfHc+8Lb9-i zvs@{zfj`-x_^QUfTh_|rHy_lNglqK*t7f-7Ux1V+IIBNRPww|YDykbtH`s$D+34m| zZ4(xnB2UNT)8K)O63bs=ZmY{|W(KWGQ;sr}W@Auaw;tbrS(*<`Ve$2>uroNJFlINl z*XVbLzo*`_r&LO3a&Ss5w2KFB|Lu8>p>OW{2Q6CAGt%Oz)N7R)V4xbWi>g*=oXo^( zx~^$!J1g03-rX*^=hM=PDbC>q&0-j=1=%ufteUlTuX@_<=PKy^?Y?*w!C*qYgO=>l z2hmR@>m_MV=Z~x)(t8hmT?F&+wrA36?C=zV;rBeHA@9f3;EUr&W3Gxey5ym g_7}y5yH#Sb*P2G6`%}LrL9GO@ zDeT`%?KHPEW >La;u%^LoR{sdYon7#8?_p8u)N0Pnv-N?Jwrf$(e{Mt9g!xZ)V zWW$R=I2uPKKqg>|{$%}~RuFCFnpa8g`0YgW^}*`&g09LJNN70st+|*mK7Ax1jUy3Y zK+6$uIWiL~f|Ax|^W-#zQZ~kE)L2=P1pVT52L7qDGj-&ktDcW6Bdq@=14+Gf?to0Z zAT+Ek^x-|@ 4%vD&+a2BX!?AP~Oz~3MJbEj2l)xS2RQzL} zdFYh3%5X0Yc&E1DeH>I>Nkwg=d8N )6fuUigtUUR_QFhM zSl)7LAdU=nsy4Q0!MH_Q2Da>A_~kW;ah-L0$JdC{%mqS1-PaNtSsip<0y~>K!%Zw} zFQD-Ymz1GkMR^((AIrk;8FioALD*9!&wcXT*<*G-S62U9G pl3W~25WKiPrM*mF>GB$;1mR_s zs5Vnp?^9t_hEiy#89w9T@%JRkOT`LkRfgY{(<9X=G<{X@EY_-1F^8zdd%XF5A{xYE z%}s$l_bJj#S5|RuQ7XS5Q-3X(vpEc@oyW+%p(4Hbc19t?Q4M;)6#s7{1NY05R|u)B zIa4xk{Mja(H`{^vHksMrN)Nq39*q$FkKADMPuqXPZC-Mu?q4S*Z2H^~L-M-B1zv<; z-+ku9`ASs%!=13>_lK?HbP$4Ei b+X7`g&Edqwcpj=M9 z 1H zs@*?Ze>QJYiD9g^i~Y15eTsh#XQl|kamE#f&$REQk8M{p4UAV+3x;==;iwORL2^Cg znH9VuzHxD}hMEaIJ)_<~e27~?pVDgeW1`igqr0qB%_k3(!*&|@MG5%c5{=D#-)d}c zvv5CybMAhMpUvU#+5*FV9AeJ?QH7NDwKThY0k0(=C@1oN#kG0vUov?3Q8yRdcw)6K z&D!W7NOBLYAmBcofU)eJKY3xLUYm@jFNqnLcR8M07g%xr6IB!C#FuYES|Bv!z!Djc zlG1&zprc&VlEzLrezCtd1?l5JC2Z)i2-|o%GaW6d44?3&{^=60NFKG4+>%?(7C`c@ zoaBaH7!Gy-z_?9ki? qwn>mHk|TqPz;y-LTZWm5rF8FMk7UYvL= zRXN`r85djO*9J+_L+%p8T&JXi>WM(dk77+PBR6T&j^j;ONba{B&@)gSYjw;Xra*X4 zkA3fLGP7HrcRC{gz5Po2IfWYtPbG5y(B$e%6Q9};WSz!5Ci#0dwOSYzRlXs-0VT4F zctqJzU~&mH(g3|tFTyPBBlCovQjpA;*Pf0GtIV&p?r$EnFHKqP46NCOF%4+;!+;q1 z;kdX0wPUGT*Vd 6q z7p8m!NN(Ya_;v!YBvCCybX4mvM*9Z!?3WDN@`#NJ*{Y0segzs?9rl-g d3B{L%(4xtXQ&YsC{{n zbka)R`iAk33O*Ac{#7x5rzC`@^dA$H0h%N|+M#l@ML+0^AN !plUwE^%S|fOO{h%@>KRDjymL*!EH5LdC#)a!|-u7J9$)8-kF7_ ziG1evSVbDdZx;2qD+UEXuF0PB3i1rz<{I0Bw6^?|cja4)dgY6}DQ#cO0IP#m3JUY{ z^^bR5&Au4uXD^S|UHs8F*stNWk$stj$m7 HEEq zriLE9LwP>WwjC$qNoqsm^>kY2`RJL+ppGyiAEu$|a}#Bp7>!KYcN|ZnatQ{$c3(7o zDF1`3(07^O MQSf$T{OZ;uxTgsw;A2@pfnr%&Z$q| z&4LBxsq_n@A_;|H3wK0lbJ9Z#pU!i;Tj)B&cv?jBLh(oNO}RFh5hY9u2mM64yx^F$ zj2sxFxU+HO-VRXajY%DxG21^ooNN5XE3<)(|4*}!xN(I*-MqXJ=!|{E{co0T?w>+d zL2KVp5VC(q{#_4U&l03s1fu)B2LJr~w^hy<$8~|$CS)vgz2OEecGBq10GCGpd6I zM&72oqn>v#ilTqeqCh#9dbM4-AF<4_UGM6)(9|OeZ^;PK|KJ|1MmxKQ4!cGdO|P24 z8}%q$@>5kx@9Fx-q*9;LxrkE|&ivZua2DVC0eCG03{;q&tvIXEy{fpe6$`(*f7vo_ z*gNC60jZg_zRbv ;F5I;~?fN*v?P?cVvKGHbaYoOv7e}ADTwykj;2@O2cvNc(&qsC7+(3dtn zm#u<^<7a(cQ0o`>-e*QYa=Ia)PaH^02i{5l!9I0$b6`2! z$9&fClkFR7hN_BXD-&B=o+B>QquDRmLNa}8E;vnTeQ(W; u;kV?ih6RD;Wdgo zx}o;A5VFH va4rOh)cB{ zl{A9<+*i(Lg77g`S)JnyRO0pVH3JC)^i-vp(?wdt7D-WqzVhX&k7XGEe&OwH;pB>@ z41#tm$0bUauThd0UztZ2S{MwgJIo(v{$RJsy(>?mYL5P6F`8k}CHT&0#_s=W0Y!_S zb5!w>G3%;;poKdG{%v| dAn|^DYa={%jisbvgE)8E2 zsUBWsXJ(BAWbyzBy+Pn;y oX(B>Ju^P^82G)4%~XzIBhvWYEvf ze!chYs*3?#zU-%*GO_aFE@oqIu|m4>s~LLOZ(ajv2ZXgqHbwStygf|-jC1IQAzbPQ zbLau$4KDLDU2dLZ$!uE2V|y?`Wh0E9fB&s}eYS>&=52g<(Xqx@nZPNRjysi(XY%za zSxKv7JxfLL@HAJeEFgI)<;J(T{2J#UecLZ&)R1Ju&Bn**AZS?UxG6dI$g`e>`<|+v z(tWX)+w* HJEU+tKwosfM~jiK#XE`YENxcezOr7Yk!w zzkgfP$-p6HNUlemsYH)>mzj+XMNtcaQZ}>aMG)7QsI*eRKO8@~5dSG`1i{y8wF|2n z_1eDU+F*EF(0r^}*?~g4xmO&Q-4V*mJ} A`~$riA(P6DkjP%CrHhJWXEXxA#?^bV_b^HsHO}`($le7v%k- zOJ jyUO6dH*@l6}oSAD}!mVb3yVb8Imt+{`avAtL!DNL=bFc;{!> zqt4`A^M@$gvwc1)GbN&BTUPA|2^Tx+Wn?sal7-BDWV->Isn5Zwg_$>Qh>ox~Xw|L^ zz2#v_TPNhlzFrT{L!=^9aq~n#m6d(07MsbW=_9C5yWo?`pQF!HWd1LZ(eW>smuxp) zjxpJ$cP#=w%I|mkGZl7FV<^N&*Vd zr@-u^V^nCOxGsvTX?nk3{ zJ6Dx#Q-p2O11YtJpPn dV`CmaftlRZDz*48kCy9;sld?PD(bdIM_VAO#ZKN`avsb}Io1_)0)m>)xuZXf zexkf!^AKGc65}4}AGvCN%#7b!qIVZ{QO-yFT_D5<*{6_*W5X@g@=@mGZ~J{vyy)IH zlxH|zo Uwk|JV!Ky*~%H$qDWoc|1W^ zZeB_^1ci3esoIPl(r6onY+CEDrh4V%qnqVX2$ngeWL}f$&4&K8&ntK&-R=$@N*N%L zk`Q-fHFgKJ9TKfee!Y`VTN^rw4LGkvTx3A;H!N^_!SL>5^b##BV4!p;x#+BD(bdWu zIVhaWHdlTpQR&;`;0h^}5nyqvg$Vm! -nS7`;H5NUfn4E7iwjkGs5{0cR+|`i*r_jF99%ee5)B zHh;@cbrX7=FKIJ~`I~6pWDkH8hOdh!#(Nb)n6W+Q#Vm}gvW_5Lw0}S9bk}P%+4v&F z{dFjEo>|(!$uwDq8z$X;ax6h}A=;K5X28L5dNLNdcwL|gT1J=x*2aT)oHBs3-R_VO z313~|#bh0iNW`{!FktoF_4i6T!ID>Suy1T}F-V{q`VP^-7!7Ob6zg;`rcAAc_Z zM~CV+LrM$wGu+R2LAITzjH$k3716`S#<)8_spXChyBXjf0GZMn_dw4cK6v +ze|ZJnXS;p)iM>;TFAk4W%0 z(IjH4am>EAJHt%@%}Y#LM$Hn2(TiDc`g`7tR`L@*!@gDh{3ld9fR>qWLbe6lo^QbZ zWBEPP`5w1vMpM~;kF6Fjm>-rpnIA>D2j`m+f|Xe{v?n6^F1?)Z-e+Jh)8)S6
|tFpfK1wmf82^!c74)8fouN#R}J9oE83J&bme!$bH1yQ04Gqh4=YY(Pom z0R3&>>9Jv;Uh?UX7Zc&jv%z;m!aSQz+k5<&@0~FM9qW!s>6>rblPh4ZWjbwl@iaR` zz7b q;|0wdm3cldM`zgAY^0Yj-=A1X}$@<;f{-@*le#XsY3Z?u0Vo>|%t z-n8++)`Y{-vpBjOBFj{7;ARqfxuRFd>#)*d(Z0dk6dEzqC}17MmJ9NJNXCS%smi!P zVq}s+S21mr$;jS$EH0m8j}m?{rbQa@Rm))4Bb>t`iY8u8OzIYmIfnb5F)S4=`yrWc zWwGzuyAK%&mK%@GaTZ~0KI_gJ0`~iQy_(;3RbK_10C;brejXEwgbx8w1PirsbdURM znI)GXj+tR3`NtKSRnrlEY5XA4(aIg9jAU#l7CKs1rYKok-aKyiH$B2YhB`Lyp^M|R zKAB8xewhJshrwhxai3u+LC+w|t4#st*oJ1Oq5YMHfZcMLRYD*Lu_ `y9BLl z^&->hl=WAs$71DBe0Ar%Pz3o6ZchuboyeU9Oc^_l$Lz(_f8y=0oyRK+EOa^=h#l%U z(3gc49zTidP(Wrl8-KuBt;F2UJ24>lj4x+<3|pbjl^x3z6z6->D5EugMUkb8p*F9; z+?q%8vm}ZfnCZJ(Y+GRiWu*EZpQw6qY@OPL7r{A|s@5lPvD21r%CyneP^sh~{B &nsv7vucyHL-jlYfj+tYR9 zT_(|;2eBkG?a$vALn6&2VL;|;QjdX@UHhQt%JND4&qMhXEVn~UNqx*{@wKOhn_$(f zaG~IrjRGess@Qmbc6RVTAN$X%Mv@?<@N@q>m-WLwg19pN>aEq#FsrY&i{i}2zZblD z<2F)D*&&qlrT;K*%rudC@xt{w{pXwiom)(T&@XFDya#e*Jzv=LY@1-|FY- zI{x-kf1_l(4@az#S6z$Q)YN=N{v@i>Wk1?kTZYWQUd1TB`KvSD(+m2y&;R#h{kR(U zUgt-9xTLJ$vxm%wsxxyZ!?jy9^j9+b- 8x?HAtN(0PWj{q8B;$+x;qfHoL z3yJ;y-;3TX CdWaZ#;eP@qaeVZX?;L8vng&|L;=C|K9ku%ci~~jI3^x z8)c*!S#7^d_x}%i{QCFVP6boZ843vAp8YTtO`M7)Vi#*k7;Du{@wglH@5}v?_rFw` z5cv;R{bqx8J{X^n_=zy}{)fF2vPx?m8z) %fBsAZv4pOdkroaT!;Uk={B+BJ{8V?Z-$TG2@`oD zHrT_wEbUwKtEF!)>_W^zaKM;T3GaF)CxTdzb7AmJ6z3xi+#5grbR_;J2LmWYXkytX ztg15yL*ZD@(tpU+8p(fsf !0RVu2-O)vb%KC43fF4X;A#yVQw@ zcK<;7`W8ipt?yO9tv&aByf)#U6OjPV=t#`;vg %f0tNaF~z2m6KG1D1gY zeSMDP5>w;lqYc}f;ES!WasxzSbH>@RtucZ0aZzQ}g2q103U`EwG~?;P!jOMZk9;DT z`ZY1g7f9R9L1Gl&R_#cVWBb$2t7(bWm&PkEyn`c}uVn?Ex-8-Vq tji4*X9F zMJ+^`Y0_L V;S$T)=CoL?2yHaNzwYFnYD0j{U}ih{ye5|y|&0* z5xS@HSz@-}`6flMHt~t=Oq*HZ2^*?d1)w^o)@JHzy!x*Bm)fb6pG)wY{v3L0rX~(M z82@Z71Mf6|<#`R+t(XMk{;0VqudD1C;N}q#8MT*K08*M;x$4c^rC{L@$nY@-eR>j_ zZ5(9Z>Rb8}Bx&E@TTuZL5Sft4o*r}b(69vnF8*<2bx6WYKh8emQh5OD&K&D_%DL1H zSZWTthRDV+KqDh)&~jNEDqc0lJy=aO=}o|8-~`JQn7`D4t2$3SgbRnX2H9U`P~ Z!%@71wTGO+$Hr Co5&6dSgA_J@G_ jGR3h!OTaPRuCy%yQ?7l}|%EEyYzyLpvGYW-%jLzJ;Q?6YW)NC`HnOJO?sP19qU zuy1 p>OZ^Xo{AZ9I;ZTat;d~KiCMP~o=nm%6*_CdUt{iJmutT9 zXHSqxlqYWfWYR_aGwg>LT{-UBYebckt!bxHhmf#qV-R| Y)UATc?(&$|_LQ}Zq(8Cd4>#Pa z`%N(uOt$?~><-S}FaqEzL$9v_03^nmL_N6`y&_!QPg>e6xVxM=)IpjUd+n}PNjOt_ z9I>0P?I@9va5MaMKU?hV>ac 7tO0O_8I|$!;zi@Fk8qwoucWM6>4QbpLp2~V40sio`VQz&hA& xtow8npwe^|4h@5xL2R%2cK0j|1Xz_5ZyO6avcQU9mcsU>?sUF>9s?kux z@lNC7*2(LFD*Sq$p@-Cb0@re*+aE)LAJxXzbaQ2~&`!EjE$IJ5)tTKoyOq1lqQESO zYT(D#y6YR?_j_;P;hzGjX*DTK)@aH{x=|vMfz)hbW?5;pX)O+zien )|U2d)g|w{<-C3!WH?;21lt} z?JMs4rxVy}3QXaN#?f@tD1!9Ft72
w15()84NU+KUKZ&tseX@()iku$B)J7Gev z^7z{|N3JKMl>HERmzWmz(Vz~Y6+A|jCjw|%a@y+_c%u BjkMfGqT=M9~q ~;%gI9|Eoh=nYe5?&C4CXGj?O1<> 5VjD|0<3)cb+A;f_;_Vh(I;Rl2b073V`jQt2n_XLNGvhmU%&3fD_cDPQ!*-xm% zC^g?2lsQ~Sf^q{}q~z}(xJA~9)yt{8=O(+nbK5*!4Q{V>(zQhU^QmW#<=HNK#0cBW zYfYz<#JJcD!VqQKkCtxiUYj2jeK{(d`yb-y>TH0T+RV+nJm;-{vwWaCPLC^RWjGbc zSoD2hQ=dDvCBItf%<9+-2@ULOy{T_*Pw&XY)tKaGnNZA1B}7l5%-jXwA@@WkOk5OO z7k8JN4>ycb5|lx~wF tLp7t`tf9L0qmEkLWcpW3LY~OkSMs?+Caf;vdxv=T zIJ41YO+YrATv_i%S@kJ~1?WcQ{1fyKnrdoka(XY;lvh^*>>4%;*Z#s8>tMLp^}d $a&O=h1**N^lU57G4_$g9Z!KC7;HBfA5g+{Z+-PhZTJ zTKGBvW%z_;o)}qpOE|KqSZ5My)^8z%V xu}`A&6u_6pWB^D@unMbDVDTjOenGgx-Kmrg#6ee>x}Teb1!K5cMbFSPK*G z)Y!I|NN@x;O>b6w4O--L(j7TCPLTGk>p7G!OYvDr-d-7ScI6wIdoyZKkk>r^s~ek> zXCpi4O^9xgq~2qnueWLoIH9H3PTpX1R*c#dQ%U?bzTLaMO0$5mK*`u9ZNJ^DkH %TVJCTn zL#t;CcJ*|V>-@6A@*AiRr9uf2tu`$E`3J!4(dpF%yr+;6oL& @>NoR@QiEoHATc6Obxyv`Dc2bX~8{QO^3~_D|DUB Yp;W2bNE%&J(sP?L! zbxceR#eHN~up69K-)m0|py=J_^!BOi?CZy2Hq!|Sc;AWXX v`7o zu=`B^xwhIZSJv;}*tS0@Fe(Pbn%FjAQ-cp()O?k-JuEpBY9d3!qrhH{a*}qo;`eeH zj*yPrj7)V5Ha~aiv9z(H6V~bAnug}&ZqJ5iuFcUL`Ej!%DhiQ10crbWC3=3-kmHSV zxvPw%KPQ?}Kf6RHNgc1aDZd7@i+?c!VPYGqhb9_9$l}0dHbrVbtNi+Xnv1`O9p>Pq zpiRW`YC9+APB@!#A*?>!Gi^8_%V!jDCActXoY3rs$$S#2Y~! KDqSuelll)CBt>sJx_#hcYy%Z( 2jR|o&+C&)9$yjdJ)?$N<&vf2m47&>vj;!Vx zvq^?wQuq^e%;f#)ydX=-S!o3EJ1RAU-Mgq)Ak0+q9b8F;6udlQwY;}G*_py=-$QYm zKf~%!G#^%b?Mlwr8~F#n+r*OXIuRIou~o=-=Ki6kp~K-i8Cc)`gJ48RvH+-;7YbGx zcph;J;^xK_k9qzaimojg&T;f3Lz^YShNXGLWu(O~ja%!I0~Bi72S)viY;Z%^n4Ikz zelL%U!J`?CNp<=ibml}d0gc_( z3O?nXKZ3{Y^fPvlKtD}yiSKCIlY8xm3N%olPIJw}x6qyu?tNZu6GBY-alG-f(1ldI z<1I&eOL-T)U43#wc3Y1zf`{pf2f5>XUhO9>$M~us!s`Q&OU+mudR-w-*4cT3P=>DV z s(gVk8zG*bZYVl^y%Y=vDvqI k` zhAwip1WiW)g<;e-pLuyK;sZI_(p!=82}m=VTaJj$E#~b)I&kWKA3`-4B|3X$D8;MI zSc{w)$$uAb_xT)nVYGHn_BS7p(Jm(b9DF@X^gp$LY@!EDPLeXq_B3}HNf|ujIRSj< zTHjBfoFj$k!K~`A{3UQ~NuCHjVd+h FEc7Hu4V+~QHYxual;$A0r$_BM~mzO&lS5Dm_Q z=IV4)pY1_46m&NrNi?BjF2`4iS+TxvcA95J#ju{A9P`o9i9Zh72SMRzt?E7` oY^@T-0% zaeb1x)!=YG{dK%TxV1T`0&_-D5-dvoMygCAbOb+xxnzl59t6cd%N51&HfO`L==ZCt ztsP^hU=ofS5E?k1NL$$|y6lQ)pQIV7HqA4Y);DnZWl$$}CY!JLdwfP#tDoNyrsgZ3 z$-?U v{iZUjk&^+V*`u54Jsp13Eeog+Mc~fI{ zeVI&x!;}1Wb IGRrTM1a;XrE#+robx~2TG8iFpv~-h#vMwU4A!d z^th@`PL{pqz+zcLQBl oI_^l5P^dDOXNEhL?@A8l*4X1Nre0TWFX+$sXjK_PWAOX zV}2(MmuuEj>1EEg6}}pZ#1o>^1j6sWXGwetu@b<&(u+fp;V<;4qY~Sp-|5X%kj@Q! zgn`29V6h+uT6G)l6KX^cTM4%{%)ZAYuiPPDh^*{V+a-DanEF~lp{&hR5C;YvQPaot z^aSO?@|Uuw$r;y*+2Zu&D^Kf*JBTC2*@`ylIv45%t$H(qSpwYRoAqBsl
%(KQy)%Pvr z@n5j0jQ4`wJtho%^8?rp@(osJ=`3j5$;7+hMMUysW|U&ek{=i^)p8$sAooBEPl9jx zy!uEFBGi