diff --git a/Cargo.lock b/Cargo.lock index 98ce472d..8b8cffc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2948,40 +2948,6 @@ dependencies = [ "librocksdb-sys", ] -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn 2.0.111", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "sha2", - "walkdir", -] - [[package]] name = "rust-multipart-rfc7578_2" version = "0.8.0" @@ -3528,7 +3494,6 @@ dependencies = [ "reqwest", "rmcp", "rocksdb", - "rust-embed", "rustls 0.23.35", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 41a087e0..4b148047 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,11 @@ path = "src/apps/sms/main.rs" name = "spearlet" path = "src/apps/spearlet/main.rs" +[[bin]] +name = "debug-server" +path = "src/apps/debug_server/main.rs" +required-features = ["debug-server-bin"] + [dependencies] # Async runtime / 异步运行时 tokio = { version = "1.0", features = ["full"] } @@ -62,7 +67,6 @@ anyhow = "1.0" thiserror = "1.0" # Static file embedding / 静态文件嵌入 -rust-embed = "8.0" # Cryptographic hashing / 加密哈希 sha2 = "0.10" @@ -140,3 +144,4 @@ evmap = ["dep:evmap"] wasmedge = ["dep:wasmedge-sdk", "dep:wasmedge-sys"] mic-device = ["dep:cpal"] debug-reporter = [] +debug-server-bin = [] diff --git a/README.md b/README.md index b8a896ae..b401e235 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,143 @@ # SPEAR Next -SPEAR Next is the Rust/async implementation of SPEAR’s core services: - -- **SMS**: the metadata/control-plane server. -- **SPEARlet**: the node-side agent/runtime. +SPEAR Next is the Rust/async implementation of SPEAR’s core platform. +It provides a control plane, node runtime, browser console, and admin UI for running and operating task/execution workloads. Chinese README: [README.zh.md](./README.zh.md) -## Repository layout +## What SPEAR Is -- `src/apps/sms`: SMS binary entrypoint -- `src/apps/spearlet`: SPEARlet binary entrypoint -- `web-admin/`: Web Admin frontend source -- `assets/admin/`: built Web Admin static assets embedded/served by SMS -- `samples/wasm-c/`: C-based WASM samples (WASI) -- `docs/`: design notes and usage guides +SPEAR is designed for scenarios where you need to: + +- register and manage tasks centrally +- run workloads on node-side runtimes +- expose browser-facing endpoints and live sessions +- operate files, executions, and AI backends from a web UI + +In this repository, the two core runtime services are: + +- **SMS**: control plane, metadata service, HTTP/gRPC gateway, Console host, Web Admin host +- **SPEARlet**: node agent and execution runtime that connects to SMS and runs workloads ## Architecture ![SPEAR architecture](docs/diagrams/spear-architecture.png) -## Quick start - -### Prerequisites +Typical request path: -- Rust toolchain (latest stable recommended) -- Docker (Docker Desktop on macOS/Windows, or Docker Engine on Linux) -- Docker Compose v2 (`docker compose`) +1. A user connects through Console, API, or endpoint gateway. +2. SMS resolves metadata, tasks, routing, and session state. +3. SPEARlet executes the workload on a node. +4. Results, streams, logs, and status flow back through SMS. -This repo uses `protoc-bin-vendored`, so you typically don’t need to install `protoc` manually. +## First-Time Setup -### Run locally with Docker Compose (SMS + SPEARlet) +### Prerequisites -This is the recommended cross-platform local setup (no Kubernetes required). It runs: +- Docker +- Docker Compose v2 via `docker compose` -- SMS (gRPC + HTTP gateway + optional Web Admin) -- SPEARlet (agent/runtime) connecting to SMS via the Compose network +For most first-time users, Docker Compose is the recommended way to start. -Use the provided Compose file: +## Run With Docker Compose -- `deploy/docker/compose.local.yaml` -- The local Compose stack builds SMS with the `rocksdb` feature enabled and persists both admin metadata and event KV under the `sms-data` volume. +### HTTP Deployment -Start: +Start the default local stack: ```bash docker compose -f deploy/docker/compose.local.yaml up -d --build ``` -Useful endpoints (default host ports): +Default URLs: -- SMS health: `http://127.0.0.1:18080/health` +- Console: `http://127.0.0.1:18080/console` +- SMS API / health: `http://127.0.0.1:18080/health` - SMS Swagger: `http://127.0.0.1:18080/swagger-ui/` -- SPEAR Console (served by SMS): `http://127.0.0.1:18080/console` -- SMS Web Admin: `http://127.0.0.1:18082/` +- Web Admin: `http://127.0.0.1:18082/` - SPEARlet health: `http://127.0.0.1:18081/health` +- Debug Server: `http://127.0.0.1:17777/` -Stop: +Stop the stack: ```bash docker compose -f deploy/docker/compose.local.yaml down ``` -Remove local data (volumes): +Remove local data: ```bash docker compose -f deploy/docker/compose.local.yaml down -v ``` -Common build/network notes: - -- If Docker Hub is not reachable, override base images via environment variables (example mirrors): - -```bash -export NODE_IMAGE=docker.m.daocloud.io/library/node:20-bookworm-slim -export RUST_IMAGE=docker.m.daocloud.io/library/rust:1.91-bookworm -export DEBIAN_IMAGE=docker.m.daocloud.io/library/debian:trixie-slim -docker compose -f deploy/docker/compose.local.yaml up -d --build -``` - -- If you use Local AI Models (llama.cpp), SPEARlet needs `llama-server`. The Compose file defaults to a build target that includes it. You can override: - -```bash -SPEARLET_BUILD_TARGET=runtime_with_node_and_llama docker compose -f deploy/docker/compose.local.yaml up -d --build spearlet -``` - -### Build - -```bash -make build - -# release -make build-release - -# build with Rust features (e.g. sled / rocksdb) -make FEATURES=sled build - -# enable local microphone capture implementation (optional) -make FEATURES=mic-device build - -# macOS shortcut (equivalent to FEATURES+=mic-device) -make mac-build -``` - -### Run SMS - -```bash -./target/debug/sms - -# enable Web Admin -./target/debug/sms --enable-web-admin --web-admin-addr 127.0.0.1:8081 -``` - -Useful endpoints: - -- HTTP gateway: `http://127.0.0.1:8080` -- Swagger UI: `http://127.0.0.1:8080/swagger-ui/` -- OpenAPI spec: `http://127.0.0.1:8080/api/openapi.json` -- gRPC: `127.0.0.1:50051` -- Web Admin (when enabled): `http://127.0.0.1:8081/admin` +### HTTPS Deployment -### Run SPEARlet +SPEAR supports a local HTTPS overlay for browser-facing pages. +The current Compose setup keeps internal service traffic on HTTP and terminates TLS at Caddy, which is the recommended local deployment pattern in this repository. -SPEARlet connects to SMS once you provide `--sms-grpc-addr` (then it auto-registers by default). +Start HTTP + HTTPS together: ```bash -./target/debug/spearlet --sms-grpc-addr 127.0.0.1:50051 +docker compose \ + -f deploy/docker/compose.local.yaml \ + -f deploy/docker/compose.https.yaml \ + up -d --build ``` -## Configuration - -### Config file locations - -- SMS: `~/.sms/config.toml` (or `--config `) -- SPEARlet: `~/.spear/config.toml` (or `--config `) - -Repo-shipped examples: - -- SMS: `config/sms/config.toml` -- SPEARlet: `config/spearlet/config.toml` - -### Priority - -1. CLI `--config` file -2. Home config (`~/.sms/config.toml` or `~/.spear/config.toml`) -3. Environment variables (`SMS_*`, `SPEARLET_*`) -4. Built-in defaults +Default HTTPS URLs: -### Secrets +- Web Admin: `https://127.0.0.1:18443/admin` +- Console: `https://127.0.0.1:18444/console` +- Debug Server: `https://127.0.0.1:18445/` -Do not put secrets into config files. Use `spearlet.ai.credentials[].api_key_env` to reference environment variables and bind them from backends via `credential_ref`. +Detailed HTTPS notes: -AI backend notes: +- [deploy/docker/README-https.md](./deploy/docker/README-https.md) -- `[[spearlet.ai.backends]] hosting` is required and must be `local` or `remote`. -- `credential_ref` is optional. If set, the referenced env var must exist (otherwise the backend is filtered). If not set, the backend is treated as “no-auth” (useful for self-hosted proxies). +### What The Compose Stack Starts -### Ollama discovery +- `sms`: control plane and browser-facing gateway +- `spearlet`: node runtime connected to SMS +- `debug-server`: runtime debug log viewer +- `https-proxy`: optional Caddy-based TLS entrypoint when `compose.https.yaml` is included -SPEARlet can import models from a local Ollama on startup and materialize them as AI backends. +## Where To Start In The UI -- Docs: `docs/ollama-discovery-en.md` +After the stack is up: -## Routing and debugging +- Open **Console** to verify browser access and endpoint sessions +- Open **Web Admin** to inspect nodes, tasks, files, executions, and AI backends -- **Route by model**: if some backends are configured with `model = "..."`, requests can be routed by setting only `model` (no explicit `backend` required). -- **Observe the selected backend**: - - `cchat_recv` JSON includes a top-level `_spear.backend` / `_spear.model`. - - Router emits a `router selected backend` debug log after selection. +If you are using SPEAR for the first time, the usual flow is: -## Web Admin +1. Start the Compose stack +2. Open Web Admin +3. Upload files or register tasks +4. Open Console and connect to an endpoint or execution -Web Admin provides Nodes, Tasks, Files, AI Backends, AI Models, Credentials, MCP, and Execution History pages. +## Repository Map -- AI Backends is the control-plane write surface for backend definitions, placements, and credentials. -- AI Models provides a read-only aggregated view across nodes, split into Local and Remote. -- The AI Backends page now exposes separate create flows: - - `Create Remote Backend` - - `Create Local Backend` -- Placement is created during backend creation: - - remote backends default to `All Nodes` - - local backends default to `Single Node` -- Backend detail pages expose: - - placements - - per-node status - - read-model views +These paths matter most for first-time exploration: -Local model provisioning (`llamacpp`): - -- `model` is the routing / display key; the node-local runtime uses metadata-backed runtime parameters. -- The Web Admin local dialog surfaces the most common `llamacpp` fields directly and persists them into backend metadata. -- Common fields: - - `model_url`: http/https URL to a `.gguf` file. - - `model_path`: absolute path, or relative to `spearlet.local_models_dir`. - - `skip_download=1`: fail if the model file is missing instead of downloading. - - `download_timeout_s`: total download budget in seconds (default: 3600). - - `threads`: forwarded to `llama-server --threads`. - - `ctx_size`: forwarded to `llama-server --ctx-size`. -- Advanced metadata keys still supported by runtime: - - `server_mode` - - `server_cmd` - - `server_cmd_args` - - `ready_probe` - - `start_timeout_s` - -Local `vllm` remains a scaffolded / external-endpoint-oriented path rather than a fully managed local process mode. - -Docs: - -- `docs/web-admin-overview-en.md` -- `docs/web-admin-ui-guide-en.md` - -## WASM samples - -```bash -make samples -``` - -Artifacts are written to `samples/build/` (WASM-C), `samples/build/js/` (WASM-JS), and `samples/build/rust/` (WASM-Rust). - -Docs: - -- `docs/samples-build-guide-en.md` -- `samples/README-en.md` -- `samples/wasm-js/README-en.md` -- `samples/wasm-rust/README-en.md` -- `sdk/rust/crates/spear-boa/README.md` -- `sdk/rust/crates/spear-wasm-helper/README.md` -- `docs/spear-console-voice-input-design-en.md` - -## Development - -```bash -make help -make dev -make ci -``` - -UI tests (Playwright): - -```bash -make test-ui -``` - -## Documentation - -- `docs/INDEX.md` +- `src/apps/sms`: SMS binary entrypoint +- `src/apps/spearlet`: SPEARlet binary entrypoint +- `src/debug_server`: Rust debug server +- `web-admin/`: admin frontend source +- `web-console/`: console frontend source +- `deploy/docker/`: local Docker Compose deployment files +- `docs/`: architecture, usage, and design documentation +- `samples/`: WASM and streaming examples + +## Key Docs + +- Architecture overview: [docs/project-architecture-overview-en.md](./docs/project-architecture-overview-en.md) +- Docs index: [docs/INDEX.md](./docs/INDEX.md) +- Web Admin overview: [docs/web-admin-overview-en.md](./docs/web-admin-overview-en.md) +- Web Admin guide: [docs/web-admin-ui-guide-en.md](./docs/web-admin-ui-guide-en.md) +- Console overview: [docs/spear-console-overview-en.md](./docs/spear-console-overview-en.md) +- Samples guide: [docs/samples-build-guide-en.md](./docs/samples-build-guide-en.md) +- HTTPS deployment: [deploy/docker/README-https.md](./deploy/docker/README-https.md) ## License -Apache-2.0. See `LICENSE`. +Apache-2.0. See [LICENSE](./LICENSE). diff --git a/README.zh.md b/README.zh.md index 6800a1f3..b7856a10 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,60 +1,62 @@ # SPEAR Next -SPEAR Next 是 SPEAR 核心服务的 Rust/async 实现: - -- **SMS**:元数据/控制面服务。 -- **SPEARlet**:节点侧代理与运行时。 +SPEAR Next 是 SPEAR 核心平台的 Rust/async 实现。 +它提供控制面、节点运行时、浏览器 Console,以及 Web Admin,用于运行和管理 task / execution 类工作负载。 English README: [README.md](./README.md) -## 目录结构 +## SPEAR 是什么 -- `src/apps/sms`:SMS 二进制入口 -- `src/apps/spearlet`:SPEARlet 二进制入口 -- `web-admin/`:Web Admin 前端源码 -- `assets/admin/`:构建后的 Web Admin 静态资源(由 SMS 内嵌/托管) -- `samples/wasm-c/`:基于 C 的 WASM 示例(WASI) -- `docs/`:设计与使用文档 +SPEAR 适合下面这类场景: -## 架构示意图 +- 在中心控制面注册和管理任务 +- 在节点侧运行 workload +- 通过浏览器访问 endpoint、会话和流式结果 +- 通过 Web UI 运维文件、执行记录和 AI backend -![SPEAR 架构](docs/diagrams/spear-architecture.png) +这个仓库里的两个核心服务是: -## 快速开始 +- **SMS**:控制面、元数据服务、HTTP/gRPC 网关、Console 托管服务、Web Admin 托管服务 +- **SPEARlet**:节点侧 agent 与执行运行时,连接到 SMS 并实际运行 workload -### 前置依赖 +## 架构 + +![SPEAR 架构](docs/diagrams/spear-architecture.png) + +一个典型请求链路是: -- Rust toolchain(建议使用最新 stable) -- Docker(macOS/Windows 使用 Docker Desktop,Linux 使用 Docker Engine) -- Docker Compose v2(`docker compose`) +1. 用户从 Console、API 或 endpoint gateway 发起请求 +2. SMS 负责元数据、任务、路由和会话状态 +3. SPEARlet 在节点上执行 workload +4. 结果、流式数据、日志和状态再回到 SMS -说明:本项目使用 `protoc-bin-vendored`,通常无需手动安装 `protoc`。 +## 首次上手 -### 使用 Docker Compose 本地运行(SMS + SPEARlet) +### 前置依赖 -这是推荐的跨平台本地部署方式(无需 Kubernetes)。会启动: +- Docker +- Docker Compose v2,也就是 `docker compose` -- SMS(gRPC + HTTP 网关 + 可选 Web Admin) -- SPEARlet(节点 Agent/Runtime),通过 Compose 网络连接 SMS +对于第一次接触 SPEAR 的使用者,最推荐的方式就是直接用 Docker Compose 启动。 -使用仓库自带 Compose 文件: +## 用 Docker Compose 运行 -- `deploy/docker/compose.local.yaml` -- 本地 Compose 栈会用 `rocksdb` feature 构建 SMS,并把 admin metadata 与 event KV 一起持久化到 `sms-data` volume。 +### HTTP 部署 -启动: +启动默认本地栈: ```bash docker compose -f deploy/docker/compose.local.yaml up -d --build ``` -常用地址(默认宿主机端口): +默认访问地址: -- SMS health:`http://127.0.0.1:18080/health` +- Console:`http://127.0.0.1:18080/console` +- SMS API / health:`http://127.0.0.1:18080/health` - SMS Swagger:`http://127.0.0.1:18080/swagger-ui/` -- SPEAR Console(由 SMS 托管):`http://127.0.0.1:18080/console` -- SMS Web Admin:`http://127.0.0.1:18082/` +- Web Admin:`http://127.0.0.1:18082/` - SPEARlet health:`http://127.0.0.1:18081/health` +- Debug Server:`http://127.0.0.1:17777/` 停止: @@ -62,191 +64,78 @@ docker compose -f deploy/docker/compose.local.yaml up -d --build docker compose -f deploy/docker/compose.local.yaml down ``` -清理本地数据(volumes): +清理本地数据: ```bash docker compose -f deploy/docker/compose.local.yaml down -v ``` -常见构建/网络问题: - -- 如果 Docker Hub 无法访问,可通过环境变量覆盖基础镜像(示例使用镜像站): - -```bash -export NODE_IMAGE=docker.m.daocloud.io/library/node:20-bookworm-slim -export RUST_IMAGE=docker.m.daocloud.io/library/rust:1.91-bookworm -export DEBIAN_IMAGE=docker.m.daocloud.io/library/debian:trixie-slim -docker compose -f deploy/docker/compose.local.yaml up -d --build -``` - -- 如果你要使用 Local AI Models(llama.cpp),SPEARlet 需要包含 `llama-server`。Compose 默认会使用包含 `llama-server` 的构建 target,也可以显式覆盖: - -```bash -SPEARLET_BUILD_TARGET=runtime_with_node_and_llama docker compose -f deploy/docker/compose.local.yaml up -d --build spearlet -``` - -### 构建 - -```bash -make build - -# release -make build-release - -# 指定 Rust features(例如 sled / rocksdb) -make FEATURES=sled build - -# 启用本机麦克风采集实现(可选) -make FEATURES=mic-device build +### HTTPS 部署 -# macOS 便捷入口(等价于 FEATURES+=mic-device) -make mac-build -``` +SPEAR 支持通过本地 HTTPS overlay 暴露浏览器页面。 +当前仓库推荐的本地模式是:内部服务仍走 HTTP,由 Caddy 做 TLS 终止。这也是这里的最佳实践实现方式。 -### 运行 SMS +同时启动 HTTP 基础栈和 HTTPS overlay: ```bash -./target/debug/sms - -# 启用 Web Admin -./target/debug/sms --enable-web-admin --web-admin-addr 127.0.0.1:8081 +docker compose \ + -f deploy/docker/compose.local.yaml \ + -f deploy/docker/compose.https.yaml \ + up -d --build ``` -常用地址: - -- HTTP 网关:`http://127.0.0.1:8080` -- Swagger UI:`http://127.0.0.1:8080/swagger-ui/` -- OpenAPI:`http://127.0.0.1:8080/api/openapi.json` -- gRPC:`127.0.0.1:50051` -- Web Admin(启用后):`http://127.0.0.1:8081/admin` - -### 运行 SPEARlet - -当提供 `--sms-grpc-addr` 后,SPEARlet 会连接 SMS 并默认自动注册。 - -```bash -./target/debug/spearlet --sms-grpc-addr 127.0.0.1:50051 -``` +默认 HTTPS 地址: -## 配置 +- Web Admin:`https://127.0.0.1:18443/admin` +- Console:`https://127.0.0.1:18444/console` +- Debug Server:`https://127.0.0.1:18445/` -### 配置文件路径 +HTTPS 详细说明: -- SMS:`~/.sms/config.toml`(或 `--config `) -- SPEARlet:`~/.spear/config.toml`(或 `--config `) +- [deploy/docker/README-https.md](./deploy/docker/README-https.md) -仓库内示例: +### Compose 会启动哪些服务 -- SMS:`config/sms/config.toml` -- SPEARlet:`config/spearlet/config.toml` +- `sms`:控制面和浏览器入口 +- `spearlet`:节点运行时 +- `debug-server`:运行时调试日志查看服务 +- `https-proxy`:当叠加 `compose.https.yaml` 时启用的 Caddy TLS 入口 -### 配置优先级 +## 启动后先看什么 -1. CLI `--config` -2. 家目录配置(`~/.sms/config.toml` 或 `~/.spear/config.toml`) -3. 环境变量(`SMS_*`、`SPEARLET_*`) -4. 代码默认值 +服务启动后,建议先访问: -### 密钥/凭证 +- **Console**:确认浏览器入口和 endpoint/session 是否可连接 +- **Web Admin**:查看 nodes、tasks、files、executions、AI backends -不要把密钥写入配置文件。使用 `spearlet.ai.credentials[].api_key_env` 引用环境变量,并在 backend 上通过 `credential_ref` 进行绑定。 +第一次使用 SPEAR 的推荐顺序是: -AI backend 注意事项: +1. 启动 Compose 栈 +2. 打开 Web Admin +3. 上传文件或注册任务 +4. 打开 Console,连接 endpoint 或 execution -- `[[spearlet.ai.backends]] hosting` 为必填,只允许 `local` 或 `remote`。 -- `credential_ref` 为可选:配置后要求对应 env 存在(否则 backend 会被过滤);不配置则视为“无需鉴权”(适用于自建代理等场景)。 +## 仓库里最值得先看的目录 -### Ollama 模型导入 - -SPEARlet 支持在启动时从本机 Ollama 导入模型并生成对应的 AI backend。 - -- 文档:`docs/ollama-discovery-zh.md` - -## 路由与排障 - -- **按模型路由**:当某些 backend 配置了 `model = "..."` 时,guest 只设置 `model` 也能完成路由(无需显式指定 `backend`)。 -- **如何确认最终路由到哪个 backend**: - - `cchat_recv` 返回 JSON 顶层包含 `_spear.backend` / `_spear.model` - - Router 在选中 backend 后会输出 `router selected backend` 的 debug 日志 - -## Web Admin - -Web Admin 提供 Nodes、Tasks、Files、AI Backends、AI Models、Credentials、MCP 和 Execution History 等页面。 - -- AI Backends 是 backend 定义、placement 与 credentials 的控制面写入口 -- AI Models 提供跨节点只读聚合视图,并区分 Local / Remote -- AI Backends 页面现在提供两条创建入口: - - `Create Remote Backend` - - `Create Local Backend` -- backend 创建时会一并配置 placement: - - remote 默认 `All Nodes` - - local 默认 `Single Node` -- backend 详情页可查看: - - placements - - 按节点的 runtime status - - read model views - -本地模型拉起(`llamacpp`): - -- `model` 是路由 / 展示用 key;节点侧运行时使用 metadata 中的本地运行参数。 -- Web Admin 的 local 创建对话框已经把最常用的 `llamacpp` 字段提成显式输入,并在保存时自动写回 backend metadata。 -- 常用字段: - - `model_url`:指向 `.gguf` 的 http/https URL。 - - `model_path`:绝对路径,或相对于 `spearlet.local_models_dir` 的相对路径。 - - `skip_download=1`:模型文件不存在时直接失败,不执行下载。 - - `download_timeout_s`:总下载超时预算(秒,默认 3600)。 - - `threads`:映射到 `llama-server --threads`。 - - `ctx_size`:映射到 `llama-server --ctx-size`。 -- 运行时仍支持的高级 metadata 字段: - - `server_mode` - - `server_cmd` - - `server_cmd_args` - - `ready_probe` - - `start_timeout_s` - -本地 `vllm` 当前仍以脚手架 / external endpoint 场景为主,并非完整托管的本地进程模式。 - -文档: - -- `docs/web-admin-overview-zh.md` -- `docs/web-admin-ui-guide-zh.md` - -## WASM 示例 - -```bash -make samples -``` - -产物输出到 `samples/build/`(WASM-C)、`samples/build/js/`(WASM-JS)以及 `samples/build/rust/`(WASM-Rust)。 - -文档: - -- `docs/samples-build-guide-zh.md` -- `samples/README-zh.md` -- `samples/wasm-js/README-zh.md` -- `samples/wasm-rust/README-zh.md` -- `sdk/rust/crates/spear-boa/README.zh.md` -- `sdk/rust/crates/spear-wasm-helper/README.zh.md` -- `docs/spear-console-voice-input-design-zh.md` - -## 开发 - -```bash -make help -make dev -make ci -``` - -UI 测试(Playwright): - -```bash -make test-ui -``` - -## 文档索引 - -- `docs/INDEX.md` +- `src/apps/sms`:SMS 二进制入口 +- `src/apps/spearlet`:SPEARlet 二进制入口 +- `src/debug_server`:Rust 版 debug server +- `web-admin/`:管理端前端源码 +- `web-console/`:控制台前端源码 +- `deploy/docker/`:本地 Docker Compose 部署文件 +- `docs/`:架构、使用和设计文档 +- `samples/`:WASM 与流式示例 + +## 关键文档 + +- 架构总览:[docs/project-architecture-overview-zh.md](./docs/project-architecture-overview-zh.md) +- 文档索引:[docs/INDEX.md](./docs/INDEX.md) +- Web Admin 概览:[docs/web-admin-overview-zh.md](./docs/web-admin-overview-zh.md) +- Web Admin 使用指南:[docs/web-admin-ui-guide-zh.md](./docs/web-admin-ui-guide-zh.md) +- Console 概览:[docs/spear-console-overview-zh.md](./docs/spear-console-overview-zh.md) +- Samples 构建指南:[docs/samples-build-guide-zh.md](./docs/samples-build-guide-zh.md) +- HTTPS 部署说明:[deploy/docker/README-https.md](./deploy/docker/README-https.md) ## License -Apache-2.0,见 `LICENSE`。 +Apache-2.0,见 [LICENSE](./LICENSE)。 diff --git a/assets/admin/main.css b/assets/admin/main.css index 1d3cfa2a..fd80aba2 100644 --- a/assets/admin/main.css +++ b/assets/admin/main.css @@ -1 +1 @@ -:root,.light,html[data-theme=light]{color-scheme:light;--background: 0 0% 100%;--foreground: 222.2 47% 11%;--card: 0 0% 100%;--card-foreground: 222.2 47% 11%;--popover: 0 0% 100%;--popover-foreground: 222.2 47% 11%;--primary: 222.2 47% 11%;--primary-foreground: 210 40% 98%;--secondary: 210 25% 96%;--secondary-foreground: 222.2 47% 11%;--muted: 210 22% 96%;--muted-foreground: 215 14% 40%;--accent: 210 22% 94%;--accent-foreground: 222.2 47% 11%;--destructive: 0 74% 52%;--destructive-foreground: 210 40% 98%;--success: 142 72% 35%;--success-foreground: 210 40% 98%;--warning: 35 92% 45%;--warning-foreground: 222.2 47% 11%;--border: 214 20% 88%;--input: 214 20% 88%;--ring: 221 39% 11%;--overlay: 222.2 47% 11%;--shadow-color: 222.2 47% 11%;--radius: 8px}.dark,html[data-theme=dark]{color-scheme:dark;--background: 222.2 47% 7%;--foreground: 210 40% 98%;--card: 222.2 47% 9%;--card-foreground: 210 40% 98%;--popover: 222.2 47% 9%;--popover-foreground: 210 40% 98%;--primary: 210 40% 98%;--primary-foreground: 222.2 47% 11%;--secondary: 217 30% 14%;--secondary-foreground: 210 40% 98%;--muted: 217 30% 14%;--muted-foreground: 215 20% 70%;--accent: 217 30% 16%;--accent-foreground: 210 40% 98%;--destructive: 0 62% 34%;--destructive-foreground: 210 40% 98%;--success: 142 62% 44%;--success-foreground: 210 40% 98%;--warning: 38 92% 54%;--warning-foreground: 222.2 47% 11%;--border: 217 30% 18%;--input: 217 30% 18%;--ring: 212 27% 84%;--overlay: 222.2 47% 4%;--shadow-color: 222.2 47% 4%}html,body,#root{height:100%}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Apple Color Emoji,Segoe UI Emoji;background:hsl(var(--background));color:hsl(var(--foreground))}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-3{left:.75rem}.right-0{right:0}.top-0{top:0}.top-1\/2{top:50%}.top-2\.5{top:.625rem}.z-10{z-index:10}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.col-span-4{grid-column:span 4 / span 4}.col-span-7{grid-column:span 7 / span 7}.-mx-5{margin-left:-1.25rem;margin-right:-1.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-5{margin-top:-1.25rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-24{height:6rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[220px\]{max-height:220px}.max-h-\[520px\]{max-height:520px}.max-h-\[560px\]{max-height:560px}.max-h-\[60vh\]{max-height:60vh}.max-h-\[620px\]{max-height:620px}.max-h-\[80vh\]{max-height:80vh}.max-h-\[calc\(100vh-24px\)\]{max-height:calc(100vh - 24px)}.min-h-0{min-height:0px}.min-h-24{min-height:6rem}.min-h-\[72px\]{min-height:72px}.min-h-\[88px\]{min-height:88px}.w-4{width:1rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[min\(520px\,calc\(100vw-24px\)\)\]{width:min(520px,calc(100vw - 24px))}.w-\[min\(560px\,calc\(100vw-24px\)\)\]{width:min(560px,calc(100vw - 24px))}.w-\[min\(640px\,calc\(100vw-24px\)\)\]{width:min(640px,calc(100vw - 24px))}.w-\[min\(820px\,calc\(100vw-24px\)\)\]{width:min(820px,calc(100vw - 24px))}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[1400px\]{max-width:1400px}.max-w-\[70\%\]{max-width:70%}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded-\[calc\(var\(--radius\)-2px\)\]{border-radius:calc(var(--radius) - 2px)}.rounded-\[calc\(var\(--radius\)-4px\)\]{border-radius:calc(var(--radius) - 4px)}.rounded-\[var\(--radius\)\]{border-radius:var(--radius)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[hsl\(var\(--border\)\)\]{border-color:hsl(var(--border))}.border-\[hsl\(var\(--destructive\)\)\]{border-color:hsl(var(--destructive))}.border-\[hsl\(var\(--input\)\)\]{border-color:hsl(var(--input))}.border-\[hsl\(var\(--primary\)\)\]{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.bg-\[hsl\(var\(--background\)\)\]{background-color:hsl(var(--background))}.bg-\[hsl\(var\(--card\)\)\]{background-color:hsl(var(--card))}.bg-\[hsl\(var\(--destructive\)\)\/0\.08\]{background-color:hsl(var(--destructive))/.08}.bg-\[hsl\(var\(--destructive\)\)\]{background-color:hsl(var(--destructive))}.bg-\[hsl\(var\(--muted\)\)\]{background-color:hsl(var(--muted))}.bg-\[hsl\(var\(--primary\)\)\]{background-color:hsl(var(--primary))}.bg-\[hsl\(var\(--secondary\)\)\]{background-color:hsl(var(--secondary))}.bg-black\/50{background-color:#00000080}.bg-emerald-600{--tw-bg-opacity: 1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-1{padding-right:.25rem}.pt-0{padding-top:0}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-\[hsl\(var\(--card-foreground\)\)\]{color:hsl(var(--card-foreground))}.text-\[hsl\(var\(--destructive\)\)\]{color:hsl(var(--destructive))}.text-\[hsl\(var\(--destructive-foreground\)\)\]{color:hsl(var(--destructive-foreground))}.text-\[hsl\(var\(--foreground\)\)\]{color:hsl(var(--foreground))}.text-\[hsl\(var\(--muted-foreground\)\)\]{color:hsl(var(--muted-foreground))}.text-\[hsl\(var\(--primary\)\)\]{color:hsl(var(--primary))}.text-\[hsl\(var\(--primary-foreground\)\)\]{color:hsl(var(--primary-foreground))}.text-\[hsl\(var\(--secondary-foreground\)\)\]{color:hsl(var(--secondary-foreground))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.opacity-50{opacity:.5}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-offset-\[hsl\(var\(--background\)\)\]{--tw-ring-offset-color: hsl(var(--background))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-\[hsl\(var\(--muted-foreground\)\)\]::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-\[hsl\(var\(--muted-foreground\)\)\]::placeholder{color:hsl(var(--muted-foreground))}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:bg-\[hsl\(var\(--accent\)\)\]:hover{background-color:hsl(var(--accent))}.hover\:bg-\[hsl\(var\(--muted\)\)\]:hover{background-color:hsl(var(--muted))}.hover\:text-\[hsl\(var\(--foreground\)\)\]:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-\[hsl\(var\(--ring\)\)\]:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:768px){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}} +:root,.light,html[data-theme=light]{color-scheme:light;--background: 0 0% 100%;--foreground: 222.2 47% 11%;--card: 0 0% 100%;--card-foreground: 222.2 47% 11%;--popover: 0 0% 100%;--popover-foreground: 222.2 47% 11%;--primary: 222.2 47% 11%;--primary-foreground: 210 40% 98%;--secondary: 210 25% 96%;--secondary-foreground: 222.2 47% 11%;--muted: 210 22% 96%;--muted-foreground: 215 14% 40%;--accent: 210 22% 94%;--accent-foreground: 222.2 47% 11%;--destructive: 0 74% 52%;--destructive-foreground: 210 40% 98%;--success: 142 72% 35%;--success-foreground: 210 40% 98%;--warning: 35 92% 45%;--warning-foreground: 222.2 47% 11%;--border: 214 20% 88%;--input: 214 20% 88%;--ring: 221 39% 11%;--overlay: 222.2 47% 11%;--shadow-color: 222.2 47% 11%;--radius: 8px}.dark,html[data-theme=dark]{color-scheme:dark;--background: 222.2 47% 7%;--foreground: 210 40% 98%;--card: 222.2 47% 9%;--card-foreground: 210 40% 98%;--popover: 222.2 47% 9%;--popover-foreground: 210 40% 98%;--primary: 210 40% 98%;--primary-foreground: 222.2 47% 11%;--secondary: 217 30% 14%;--secondary-foreground: 210 40% 98%;--muted: 217 30% 14%;--muted-foreground: 215 20% 70%;--accent: 217 30% 16%;--accent-foreground: 210 40% 98%;--destructive: 0 62% 34%;--destructive-foreground: 210 40% 98%;--success: 142 62% 44%;--success-foreground: 210 40% 98%;--warning: 38 92% 54%;--warning-foreground: 222.2 47% 11%;--border: 217 30% 18%;--input: 217 30% 18%;--ring: 212 27% 84%;--overlay: 222.2 47% 4%;--shadow-color: 222.2 47% 4%}html,body,#root{height:100%}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Apple Color Emoji,Segoe UI Emoji;background:hsl(var(--background));color:hsl(var(--foreground))}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-3{left:.75rem}.right-0{right:0}.top-0{top:0}.top-1\/2{top:50%}.top-2\.5{top:.625rem}.z-10{z-index:10}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.col-span-4{grid-column:span 4 / span 4}.col-span-7{grid-column:span 7 / span 7}.-mx-5{margin-left:-1.25rem;margin-right:-1.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-5{margin-top:-1.25rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-24{height:6rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[220px\]{max-height:220px}.max-h-\[520px\]{max-height:520px}.max-h-\[560px\]{max-height:560px}.max-h-\[60vh\]{max-height:60vh}.max-h-\[620px\]{max-height:620px}.max-h-\[80vh\]{max-height:80vh}.max-h-\[calc\(100vh-24px\)\]{max-height:calc(100vh - 24px)}.min-h-0{min-height:0px}.min-h-24{min-height:6rem}.min-h-\[72px\]{min-height:72px}.min-h-\[88px\]{min-height:88px}.w-4{width:1rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[min\(520px\,calc\(100vw-24px\)\)\]{width:min(520px,calc(100vw - 24px))}.w-\[min\(560px\,calc\(100vw-24px\)\)\]{width:min(560px,calc(100vw - 24px))}.w-\[min\(640px\,calc\(100vw-24px\)\)\]{width:min(640px,calc(100vw - 24px))}.w-\[min\(820px\,calc\(100vw-24px\)\)\]{width:min(820px,calc(100vw - 24px))}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[1400px\]{max-width:1400px}.max-w-\[240px\]{max-width:240px}.max-w-\[70\%\]{max-width:70%}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded-\[calc\(var\(--radius\)-2px\)\]{border-radius:calc(var(--radius) - 2px)}.rounded-\[calc\(var\(--radius\)-4px\)\]{border-radius:calc(var(--radius) - 4px)}.rounded-\[var\(--radius\)\]{border-radius:var(--radius)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[hsl\(var\(--border\)\)\]{border-color:hsl(var(--border))}.border-\[hsl\(var\(--destructive\)\)\]{border-color:hsl(var(--destructive))}.border-\[hsl\(var\(--input\)\)\]{border-color:hsl(var(--input))}.border-\[hsl\(var\(--primary\)\)\]{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.bg-\[hsl\(var\(--background\)\)\]{background-color:hsl(var(--background))}.bg-\[hsl\(var\(--card\)\)\]{background-color:hsl(var(--card))}.bg-\[hsl\(var\(--destructive\)\)\/0\.08\]{background-color:hsl(var(--destructive))/.08}.bg-\[hsl\(var\(--destructive\)\)\]{background-color:hsl(var(--destructive))}.bg-\[hsl\(var\(--muted\)\)\]{background-color:hsl(var(--muted))}.bg-\[hsl\(var\(--primary\)\)\]{background-color:hsl(var(--primary))}.bg-\[hsl\(var\(--secondary\)\)\]{background-color:hsl(var(--secondary))}.bg-black\/50{background-color:#00000080}.bg-emerald-600{--tw-bg-opacity: 1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-1{padding-right:.25rem}.pt-0{padding-top:0}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-\[hsl\(var\(--card-foreground\)\)\]{color:hsl(var(--card-foreground))}.text-\[hsl\(var\(--destructive\)\)\]{color:hsl(var(--destructive))}.text-\[hsl\(var\(--destructive-foreground\)\)\]{color:hsl(var(--destructive-foreground))}.text-\[hsl\(var\(--foreground\)\)\]{color:hsl(var(--foreground))}.text-\[hsl\(var\(--muted-foreground\)\)\]{color:hsl(var(--muted-foreground))}.text-\[hsl\(var\(--primary\)\)\]{color:hsl(var(--primary))}.text-\[hsl\(var\(--primary-foreground\)\)\]{color:hsl(var(--primary-foreground))}.text-\[hsl\(var\(--secondary-foreground\)\)\]{color:hsl(var(--secondary-foreground))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.opacity-50{opacity:.5}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-offset-\[hsl\(var\(--background\)\)\]{--tw-ring-offset-color: hsl(var(--background))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-\[hsl\(var\(--muted-foreground\)\)\]::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-\[hsl\(var\(--muted-foreground\)\)\]::placeholder{color:hsl(var(--muted-foreground))}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:bg-\[hsl\(var\(--accent\)\)\]:hover{background-color:hsl(var(--accent))}.hover\:bg-\[hsl\(var\(--muted\)\)\]:hover{background-color:hsl(var(--muted))}.hover\:text-\[hsl\(var\(--foreground\)\)\]:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-\[hsl\(var\(--ring\)\)\]:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:768px){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}} diff --git a/assets/admin/main.js b/assets/admin/main.js index e8218ea0..1d7891a6 100644 --- a/assets/admin/main.js +++ b/assets/admin/main.js @@ -1,18 +1,18 @@ -function Jx(t,s){for(var i=0;io[c]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const s=document.createElement("link").relList;if(s&&s.supports&&s.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))o(c);new MutationObserver(c=>{for(const u of c)if(u.type==="childList")for(const h of u.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&o(h)}).observe(document,{childList:!0,subtree:!0});function i(c){const u={};return c.integrity&&(u.integrity=c.integrity),c.referrerPolicy&&(u.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?u.credentials="include":c.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function o(c){if(c.ep)return;c.ep=!0;const u=i(c);fetch(c.href,u)}})();function Yh(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var tc={exports:{}},fa={},nc={exports:{}},_e={};var Wf;function Zx(){if(Wf)return _e;Wf=1;var t=Symbol.for("react.element"),s=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),u=Symbol.for("react.provider"),h=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),v=Symbol.for("react.suspense"),g=Symbol.for("react.memo"),w=Symbol.for("react.lazy"),p=Symbol.iterator;function N(_){return _===null||typeof _!="object"?null:(_=p&&_[p]||_["@@iterator"],typeof _=="function"?_:null)}var E={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},O=Object.assign,j={};function b(_,A,K){this.props=_,this.context=A,this.refs=j,this.updater=K||E}b.prototype.isReactComponent={},b.prototype.setState=function(_,A){if(typeof _!="object"&&typeof _!="function"&&_!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,_,A,"setState")},b.prototype.forceUpdate=function(_){this.updater.enqueueForceUpdate(this,_,"forceUpdate")};function S(){}S.prototype=b.prototype;function F(_,A,K){this.props=_,this.context=A,this.refs=j,this.updater=K||E}var z=F.prototype=new S;z.constructor=F,O(z,b.prototype),z.isPureReactComponent=!0;var V=Array.isArray,G=Object.prototype.hasOwnProperty,$={current:null},B={key:!0,ref:!0,__self:!0,__source:!0};function U(_,A,K){var Z,ae={},xe=null,he=null;if(A!=null)for(Z in A.ref!==void 0&&(he=A.ref),A.key!==void 0&&(xe=""+A.key),A)G.call(A,Z)&&!B.hasOwnProperty(Z)&&(ae[Z]=A[Z]);var de=arguments.length-2;if(de===1)ae.children=K;else if(1>>1,A=P[_];if(0>>1;_c(ae,I))xec(he,ae)?(P[_]=he,P[xe]=I,_=xe):(P[_]=ae,P[Z]=I,_=Z);else if(xec(he,I))P[_]=he,P[xe]=I,_=xe;else break e}}return X}function c(P,X){var I=P.sortIndex-X.sortIndex;return I!==0?I:P.id-X.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;t.unstable_now=function(){return u.now()}}else{var h=Date,m=h.now();t.unstable_now=function(){return h.now()-m}}var v=[],g=[],w=1,p=null,N=3,E=!1,O=!1,j=!1,b=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,F=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function z(P){for(var X=i(g);X!==null;){if(X.callback===null)o(g);else if(X.startTime<=P)o(g),X.sortIndex=X.expirationTime,s(v,X);else break;X=i(g)}}function V(P){if(j=!1,z(P),!O)if(i(v)!==null)O=!0,ce(G);else{var X=i(g);X!==null&&M(V,X.startTime-P)}}function G(P,X){O=!1,j&&(j=!1,S(U),U=-1),E=!0;var I=N;try{for(z(X),p=i(v);p!==null&&(!(p.expirationTime>X)||P&&!Pe());){var _=p.callback;if(typeof _=="function"){p.callback=null,N=p.priorityLevel;var A=_(p.expirationTime<=X);X=t.unstable_now(),typeof A=="function"?p.callback=A:p===i(v)&&o(v),z(X)}else o(v);p=i(v)}if(p!==null)var K=!0;else{var Z=i(g);Z!==null&&M(V,Z.startTime-X),K=!1}return K}finally{p=null,N=I,E=!1}}var $=!1,B=null,U=-1,ie=5,ue=-1;function Pe(){return!(t.unstable_now()-ueP||125_?(P.sortIndex=I,s(g,P),i(v)===null&&P===i(g)&&(j?(S(U),U=-1):j=!0,M(V,I-_))):(P.sortIndex=A,s(v,P),O||E||(O=!0,ce(G))),P},t.unstable_shouldYield=Pe,t.unstable_wrapCallback=function(P){var X=N;return function(){var I=N;N=X;try{return P.apply(this,arguments)}finally{N=I}}}})(ac)),ac}var Yf;function rg(){return Yf||(Yf=1,sc.exports=ng()),sc.exports}var Xf;function sg(){if(Xf)return Ot;Xf=1;var t=Qc(),s=rg();function i(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,a=1;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),v=Object.prototype.hasOwnProperty,g=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,w={},p={};function N(e){return v.call(p,e)?!0:v.call(w,e)?!1:g.test(e)?p[e]=!0:(w[e]=!0,!1)}function E(e,n,a,l){if(a!==null&&a.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return l?!1:a!==null?!a.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function O(e,n,a,l){if(n===null||typeof n>"u"||E(e,n,a,l))return!0;if(l)return!1;if(a!==null)switch(a.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function j(e,n,a,l,d,f,y){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=l,this.attributeNamespace=d,this.mustUseProperty=a,this.propertyName=e,this.type=n,this.sanitizeURL=f,this.removeEmptyString=y}var b={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){b[e]=new j(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];b[n]=new j(n,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){b[e]=new j(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){b[e]=new j(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){b[e]=new j(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){b[e]=new j(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){b[e]=new j(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){b[e]=new j(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){b[e]=new j(e,5,!1,e.toLowerCase(),null,!1,!1)});var S=/[\-:]([a-z])/g;function F(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(S,F);b[n]=new j(n,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(S,F);b[n]=new j(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(S,F);b[n]=new j(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){b[e]=new j(e,1,!1,e.toLowerCase(),null,!1,!1)}),b.xlinkHref=new j("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){b[e]=new j(e,1,!1,e.toLowerCase(),null,!0,!0)});function z(e,n,a,l){var d=b.hasOwnProperty(n)?b[n]:null;(d!==null?d.type!==0:l||!(2o[c]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const s=document.createElement("link").relList;if(s&&s.supports&&s.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))o(c);new MutationObserver(c=>{for(const u of c)if(u.type==="childList")for(const h of u.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&o(h)}).observe(document,{childList:!0,subtree:!0});function i(c){const u={};return c.integrity&&(u.integrity=c.integrity),c.referrerPolicy&&(u.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?u.credentials="include":c.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function o(c){if(c.ep)return;c.ep=!0;const u=i(c);fetch(c.href,u)}})();function Xh(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var ec={exports:{}},ca={},tc={exports:{}},Ce={};var Wf;function sg(){if(Wf)return Ce;Wf=1;var t=Symbol.for("react.element"),s=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),u=Symbol.for("react.provider"),h=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),v=Symbol.for("react.suspense"),g=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),p=Symbol.iterator;function N(R){return R===null||typeof R!="object"?null:(R=p&&R[p]||R["@@iterator"],typeof R=="function"?R:null)}var E={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},O=Object.assign,j={};function w(R,C,z){this.props=R,this.context=C,this.refs=j,this.updater=z||E}w.prototype.isReactComponent={},w.prototype.setState=function(R,C){if(typeof R!="object"&&typeof R!="function"&&R!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,R,C,"setState")},w.prototype.forceUpdate=function(R){this.updater.enqueueForceUpdate(this,R,"forceUpdate")};function _(){}_.prototype=w.prototype;function F(R,C,z){this.props=R,this.context=C,this.refs=j,this.updater=z||E}var A=F.prototype=new _;A.constructor=F,O(A,w.prototype),A.isPureReactComponent=!0;var K=Array.isArray,G=Object.prototype.hasOwnProperty,U={current:null},B={key:!0,ref:!0,__self:!0,__source:!0};function $(R,C,z){var Z,oe={},me=null,ue=null;if(C!=null)for(Z in C.ref!==void 0&&(ue=C.ref),C.key!==void 0&&(me=""+C.key),C)G.call(C,Z)&&!B.hasOwnProperty(Z)&&(oe[Z]=C[Z]);var ne=arguments.length-2;if(ne===1)oe.children=z;else if(1>>1,C=T[R];if(0>>1;Rc(oe,W))mec(ue,oe)?(T[R]=ue,T[me]=W,R=me):(T[R]=oe,T[Z]=W,R=Z);else if(mec(ue,W))T[R]=ue,T[me]=W,R=me;else break e}}return X}function c(T,X){var W=T.sortIndex-X.sortIndex;return W!==0?W:T.id-X.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;t.unstable_now=function(){return u.now()}}else{var h=Date,m=h.now();t.unstable_now=function(){return h.now()-m}}var v=[],g=[],b=1,p=null,N=3,E=!1,O=!1,j=!1,w=typeof setTimeout=="function"?setTimeout:null,_=typeof clearTimeout=="function"?clearTimeout:null,F=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function A(T){for(var X=i(g);X!==null;){if(X.callback===null)o(g);else if(X.startTime<=T)o(g),X.sortIndex=X.expirationTime,s(v,X);else break;X=i(g)}}function K(T){if(j=!1,A(T),!O)if(i(v)!==null)O=!0,de(G);else{var X=i(g);X!==null&&D(K,X.startTime-T)}}function G(T,X){O=!1,j&&(j=!1,_($),$=-1),E=!0;var W=N;try{for(A(X),p=i(v);p!==null&&(!(p.expirationTime>X)||T&&!Oe());){var R=p.callback;if(typeof R=="function"){p.callback=null,N=p.priorityLevel;var C=R(p.expirationTime<=X);X=t.unstable_now(),typeof C=="function"?p.callback=C:p===i(v)&&o(v),A(X)}else o(v);p=i(v)}if(p!==null)var z=!0;else{var Z=i(g);Z!==null&&D(K,Z.startTime-X),z=!1}return z}finally{p=null,N=W,E=!1}}var U=!1,B=null,$=-1,ie=5,fe=-1;function Oe(){return!(t.unstable_now()-feT||125R?(T.sortIndex=W,s(g,T),i(v)===null&&T===i(g)&&(j?(_($),$=-1):j=!0,D(K,W-R))):(T.sortIndex=C,s(v,T),O||E||(O=!0,de(G))),T},t.unstable_shouldYield=Oe,t.unstable_wrapCallback=function(T){var X=N;return function(){var W=N;N=X;try{return T.apply(this,arguments)}finally{N=W}}}})(sc)),sc}var Yf;function lg(){return Yf||(Yf=1,rc.exports=og()),rc.exports}var Xf;function cg(){if(Xf)return Tt;Xf=1;var t=Qc(),s=lg();function i(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,a=1;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),v=Object.prototype.hasOwnProperty,g=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,b={},p={};function N(e){return v.call(p,e)?!0:v.call(b,e)?!1:g.test(e)?p[e]=!0:(b[e]=!0,!1)}function E(e,n,a,l){if(a!==null&&a.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return l?!1:a!==null?!a.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function O(e,n,a,l){if(n===null||typeof n>"u"||E(e,n,a,l))return!0;if(l)return!1;if(a!==null)switch(a.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function j(e,n,a,l,d,f,y){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=l,this.attributeNamespace=d,this.mustUseProperty=a,this.propertyName=e,this.type=n,this.sanitizeURL=f,this.removeEmptyString=y}var w={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){w[e]=new j(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];w[n]=new j(n,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){w[e]=new j(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){w[e]=new j(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){w[e]=new j(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){w[e]=new j(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){w[e]=new j(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){w[e]=new j(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){w[e]=new j(e,5,!1,e.toLowerCase(),null,!1,!1)});var _=/[\-:]([a-z])/g;function F(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(_,F);w[n]=new j(n,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(_,F);w[n]=new j(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(_,F);w[n]=new j(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){w[e]=new j(e,1,!1,e.toLowerCase(),null,!1,!1)}),w.xlinkHref=new j("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){w[e]=new j(e,1,!1,e.toLowerCase(),null,!0,!0)});function A(e,n,a,l){var d=w.hasOwnProperty(n)?w[n]:null;(d!==null?d.type!==0:l||!(2k||d[y]!==f[k]){var C=` -`+d[y].replace(" at new "," at ");return e.displayName&&C.includes("")&&(C=C.replace("",e.displayName)),C}while(1<=y&&0<=k);break}}}finally{K=!1,Error.prepareStackTrace=a}return(e=e?e.displayName||e.name:"")?A(e):""}function ae(e){switch(e.tag){case 5:return A(e.type);case 16:return A("Lazy");case 13:return A("Suspense");case 19:return A("SuspenseList");case 0:case 2:case 15:return e=Z(e.type,!1),e;case 11:return e=Z(e.type.render,!1),e;case 1:return e=Z(e.type,!0),e;default:return""}}function xe(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case B:return"Fragment";case $:return"Portal";case ie:return"Profiler";case U:return"StrictMode";case Te:return"Suspense";case Fe:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Pe:return(e.displayName||"Context")+".Consumer";case ue:return(e._context.displayName||"Context")+".Provider";case Ee:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ie:return n=e.displayName||null,n!==null?n:xe(e.type)||"Memo";case ce:n=e._payload,e=e._init;try{return xe(e(n))}catch{}}return null}function he(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return xe(n);case 8:return n===U?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function de(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Re(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function He(e){var n=Re(e)?"checked":"value",a=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),l=""+e[n];if(!e.hasOwnProperty(n)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var d=a.get,f=a.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return d.call(this)},set:function(y){l=""+y,f.call(this,y)}}),Object.defineProperty(e,n,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(y){l=""+y},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function It(e){e._valueTracker||(e._valueTracker=He(e))}function St(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var a=n.getValue(),l="";return e&&(l=Re(e)?e.checked?"true":"false":e.value),e=l,e!==a?(n.setValue(e),!0):!1}function Hr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function vr(e,n){var a=n.checked;return I({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??e._wrapperState.initialChecked})}function ka(e,n){var a=n.defaultValue==null?"":n.defaultValue,l=n.checked!=null?n.checked:n.defaultChecked;a=de(n.value!=null?n.value:a),e._wrapperState={initialChecked:l,initialValue:a,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function _s(e,n){n=n.checked,n!=null&&z(e,"checked",n,!1)}function yn(e,n){_s(e,n);var a=de(n.value),l=n.type;if(a!=null)l==="number"?(a===0&&e.value===""||e.value!=a)&&(e.value=""+a):e.value!==""+a&&(e.value=""+a);else if(l==="submit"||l==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?Mt(e,n.type,a):n.hasOwnProperty("defaultValue")&&Mt(e,n.type,de(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function Cs(e,n,a){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var l=n.type;if(!(l!=="submit"&&l!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,a||n===e.value||(e.value=n),e.defaultValue=n}a=e.name,a!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,a!==""&&(e.name=a)}function Mt(e,n,a){(n!=="number"||Hr(e.ownerDocument)!==e)&&(a==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+a&&(e.defaultValue=""+a))}var xr=Array.isArray;function Ln(e,n,a,l){if(e=e.options,n){n={};for(var d=0;d"+n.valueOf().toString()+"",n=yr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function $t(e,n){if(n){var a=e.firstChild;if(a&&a===e.lastChild&&a.nodeType===3){a.nodeValue=n;return}}e.textContent=n}var Fn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},An=["Webkit","ms","Moz","O"];Object.keys(Fn).forEach(function(e){An.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),Fn[n]=Fn[e]})});function Ca(e,n,a){return n==null||typeof n=="boolean"||n===""?"":a||typeof n!="number"||n===0||Fn.hasOwnProperty(e)&&Fn[e]?(""+n).trim():n+"px"}function Ea(e,n){e=e.style;for(var a in n)if(n.hasOwnProperty(a)){var l=a.indexOf("--")===0,d=Ca(a,n[a],l);a==="float"&&(a="cssFloat"),l?e.setProperty(a,d):e[a]=d}}var Ra=I({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Wr(e,n){if(n){if(Ra[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(i(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(i(61))}if(n.style!=null&&typeof n.style!="object")throw Error(i(62))}}function Rs(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var jr=null;function Bt(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ps=null,zn=null,bn=null;function Os(e){if(e=Xs(e)){if(typeof Ps!="function")throw Error(i(280));var n=e.stateNode;n&&(n=Ya(n),Ps(e.stateNode,e.type,n))}}function je(e){zn?bn?bn.push(e):bn=[e]:zn=e}function Ge(){if(zn){var e=zn,n=bn;if(bn=zn=null,Os(e),n)for(e=0;e>>=0,e===0?32:31-(mv(e)/pv|0)|0}var Ta=64,Ia=4194304;function Is(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ma(e,n){var a=e.pendingLanes;if(a===0)return 0;var l=0,d=e.suspendedLanes,f=e.pingedLanes,y=a&268435455;if(y!==0){var k=y&~d;k!==0?l=Is(k):(f&=y,f!==0&&(l=Is(f)))}else y=a&~d,y!==0?l=Is(y):f!==0&&(l=Is(f));if(l===0)return 0;if(n!==0&&n!==l&&(n&d)===0&&(d=l&-l,f=n&-n,d>=f||d===16&&(f&4194240)!==0))return n;if((l&4)!==0&&(l|=a&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=l;0a;a++)n.push(e);return n}function Ms(e,n,a){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Jt(n),e[n]=a}function yv(e,n){var a=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var l=e.eventTimes;for(e=e.expirationTimes;0=Bs),Fd=" ",Ad=!1;function zd(e,n){switch(e){case"keyup":return Vv.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ud(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Kr=!1;function Gv(e,n){switch(e){case"compositionend":return Ud(n);case"keypress":return n.which!==32?null:(Ad=!0,Fd);case"textInput":return e=n.data,e===Fd&&Ad?null:e;default:return null}}function Yv(e,n){if(Kr)return e==="compositionend"||!Do&&zd(e,n)?(e=Od(),za=Ro=Vn=null,Kr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:a,offset:n-e};e=l}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=Vd(a)}}function Gd(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Gd(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Yd(){for(var e=window,n=Hr();n instanceof e.HTMLIFrameElement;){try{var a=typeof n.contentWindow.location.href=="string"}catch{a=!1}if(a)e=n.contentWindow;else break;n=Hr(e.document)}return n}function Ao(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function ax(e){var n=Yd(),a=e.focusedElem,l=e.selectionRange;if(n!==a&&a&&a.ownerDocument&&Gd(a.ownerDocument.documentElement,a)){if(l!==null&&Ao(a)){if(n=l.start,e=l.end,e===void 0&&(e=n),"selectionStart"in a)a.selectionStart=n,a.selectionEnd=Math.min(e,a.value.length);else if(e=(n=a.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var d=a.textContent.length,f=Math.min(l.start,d);l=l.end===void 0?f:Math.min(l.end,d),!e.extend&&f>l&&(d=l,l=f,f=d),d=Kd(a,f);var y=Kd(a,l);d&&y&&(e.rangeCount!==1||e.anchorNode!==d.node||e.anchorOffset!==d.offset||e.focusNode!==y.node||e.focusOffset!==y.offset)&&(n=n.createRange(),n.setStart(d.node,d.offset),e.removeAllRanges(),f>l?(e.addRange(n),e.extend(y.node,y.offset)):(n.setEnd(y.node,y.offset),e.addRange(n)))}}for(n=[],e=a;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof a.focus=="function"&&a.focus(),a=0;a=document.documentMode,Gr=null,zo=null,qs=null,Uo=!1;function Xd(e,n,a){var l=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;Uo||Gr==null||Gr!==Hr(l)||(l=Gr,"selectionStart"in l&&Ao(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),qs&&Ws(qs,l)||(qs=l,l=Va(zo,"onSelect"),0es||(e.current=Jo[es],Jo[es]=null,es--)}function Ae(e,n){es++,Jo[es]=e.current,e.current=n}var Xn={},vt=Yn(Xn),_t=Yn(!1),Nr=Xn;function ts(e,n){var a=e.type.contextTypes;if(!a)return Xn;var l=e.stateNode;if(l&&l.__reactInternalMemoizedUnmaskedChildContext===n)return l.__reactInternalMemoizedMaskedChildContext;var d={},f;for(f in a)d[f]=n[f];return l&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=d),d}function Ct(e){return e=e.childContextTypes,e!=null}function Xa(){Ue(_t),Ue(vt)}function fu(e,n,a){if(vt.current!==Xn)throw Error(i(168));Ae(vt,n),Ae(_t,a)}function hu(e,n,a){var l=e.stateNode;if(n=n.childContextTypes,typeof l.getChildContext!="function")return a;l=l.getChildContext();for(var d in l)if(!(d in n))throw Error(i(108,he(e)||"Unknown",d));return I({},a,l)}function Ja(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Xn,Nr=vt.current,Ae(vt,e),Ae(_t,_t.current),!0}function mu(e,n,a){var l=e.stateNode;if(!l)throw Error(i(169));a?(e=hu(e,n,Nr),l.__reactInternalMemoizedMergedChildContext=e,Ue(_t),Ue(vt),Ae(vt,e)):Ue(_t),Ae(_t,a)}var Nn=null,Za=!1,Zo=!1;function pu(e){Nn===null?Nn=[e]:Nn.push(e)}function xx(e){Za=!0,pu(e)}function Jn(){if(!Zo&&Nn!==null){Zo=!0;var e=0,n=Le;try{var a=Nn;for(Le=1;e>=y,d-=y,kn=1<<32-Jt(n)+d|a<ye?(ct=pe,pe=null):ct=pe.sibling;var De=H(T,pe,D[ye],Y);if(De===null){pe===null&&(pe=ct);break}e&&pe&&De.alternate===null&&n(T,pe),R=f(De,R,ye),me===null?le=De:me.sibling=De,me=De,pe=ct}if(ye===D.length)return a(T,pe),Be&&Sr(T,ye),le;if(pe===null){for(;yeye?(ct=pe,pe=null):ct=pe.sibling;var or=H(T,pe,De.value,Y);if(or===null){pe===null&&(pe=ct);break}e&&pe&&or.alternate===null&&n(T,pe),R=f(or,R,ye),me===null?le=or:me.sibling=or,me=or,pe=ct}if(De.done)return a(T,pe),Be&&Sr(T,ye),le;if(pe===null){for(;!De.done;ye++,De=D.next())De=q(T,De.value,Y),De!==null&&(R=f(De,R,ye),me===null?le=De:me.sibling=De,me=De);return Be&&Sr(T,ye),le}for(pe=l(T,pe);!De.done;ye++,De=D.next())De=ee(pe,T,ye,De.value,Y),De!==null&&(e&&De.alternate!==null&&pe.delete(De.key===null?ye:De.key),R=f(De,R,ye),me===null?le=De:me.sibling=De,me=De);return e&&pe.forEach(function(Xx){return n(T,Xx)}),Be&&Sr(T,ye),le}function Xe(T,R,D,Y){if(typeof D=="object"&&D!==null&&D.type===B&&D.key===null&&(D=D.props.children),typeof D=="object"&&D!==null){switch(D.$$typeof){case G:e:{for(var le=D.key,me=R;me!==null;){if(me.key===le){if(le=D.type,le===B){if(me.tag===7){a(T,me.sibling),R=d(me,D.props.children),R.return=T,T=R;break e}}else if(me.elementType===le||typeof le=="object"&&le!==null&&le.$$typeof===ce&&bu(le)===me.type){a(T,me.sibling),R=d(me,D.props),R.ref=Js(T,me,D),R.return=T,T=R;break e}a(T,me);break}else n(T,me);me=me.sibling}D.type===B?(R=Ir(D.props.children,T.mode,Y,D.key),R.return=T,T=R):(Y=Ci(D.type,D.key,D.props,null,T.mode,Y),Y.ref=Js(T,R,D),Y.return=T,T=Y)}return y(T);case $:e:{for(me=D.key;R!==null;){if(R.key===me)if(R.tag===4&&R.stateNode.containerInfo===D.containerInfo&&R.stateNode.implementation===D.implementation){a(T,R.sibling),R=d(R,D.children||[]),R.return=T,T=R;break e}else{a(T,R);break}else n(T,R);R=R.sibling}R=Yl(D,T.mode,Y),R.return=T,T=R}return y(T);case ce:return me=D._init,Xe(T,R,me(D._payload),Y)}if(xr(D))return ne(T,R,D,Y);if(X(D))return oe(T,R,D,Y);ri(T,D)}return typeof D=="string"&&D!==""||typeof D=="number"?(D=""+D,R!==null&&R.tag===6?(a(T,R.sibling),R=d(R,D),R.return=T,T=R):(a(T,R),R=Gl(D,T.mode,Y),R.return=T,T=R),y(T)):a(T,R)}return Xe}var as=wu(!0),Nu=wu(!1),si=Yn(null),ai=null,is=null,al=null;function il(){al=is=ai=null}function ol(e){var n=si.current;Ue(si),e._currentValue=n}function ll(e,n,a){for(;e!==null;){var l=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,l!==null&&(l.childLanes|=n)):l!==null&&(l.childLanes&n)!==n&&(l.childLanes|=n),e===a)break;e=e.return}}function os(e,n){ai=e,al=is=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&n)!==0&&(Et=!0),e.firstContext=null)}function qt(e){var n=e._currentValue;if(al!==e)if(e={context:e,memoizedValue:n,next:null},is===null){if(ai===null)throw Error(i(308));is=e,ai.dependencies={lanes:0,firstContext:e}}else is=is.next=e;return n}var _r=null;function cl(e){_r===null?_r=[e]:_r.push(e)}function ku(e,n,a,l){var d=n.interleaved;return d===null?(a.next=a,cl(n)):(a.next=d.next,d.next=a),n.interleaved=a,_n(e,l)}function _n(e,n){e.lanes|=n;var a=e.alternate;for(a!==null&&(a.lanes|=n),a=e,e=e.return;e!==null;)e.childLanes|=n,a=e.alternate,a!==null&&(a.childLanes|=n),a=e,e=e.return;return a.tag===3?a.stateNode:null}var Zn=!1;function dl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Su(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Cn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function er(e,n,a){var l=e.updateQueue;if(l===null)return null;if(l=l.shared,(Me&2)!==0){var d=l.pending;return d===null?n.next=n:(n.next=d.next,d.next=n),l.pending=n,_n(e,a)}return d=l.interleaved,d===null?(n.next=n,cl(l)):(n.next=d.next,d.next=n),l.interleaved=n,_n(e,a)}function ii(e,n,a){if(n=n.updateQueue,n!==null&&(n=n.shared,(a&4194240)!==0)){var l=n.lanes;l&=e.pendingLanes,a|=l,n.lanes=a,ko(e,a)}}function _u(e,n){var a=e.updateQueue,l=e.alternate;if(l!==null&&(l=l.updateQueue,a===l)){var d=null,f=null;if(a=a.firstBaseUpdate,a!==null){do{var y={eventTime:a.eventTime,lane:a.lane,tag:a.tag,payload:a.payload,callback:a.callback,next:null};f===null?d=f=y:f=f.next=y,a=a.next}while(a!==null);f===null?d=f=n:f=f.next=n}else d=f=n;a={baseState:l.baseState,firstBaseUpdate:d,lastBaseUpdate:f,shared:l.shared,effects:l.effects},e.updateQueue=a;return}e=a.lastBaseUpdate,e===null?a.firstBaseUpdate=n:e.next=n,a.lastBaseUpdate=n}function oi(e,n,a,l){var d=e.updateQueue;Zn=!1;var f=d.firstBaseUpdate,y=d.lastBaseUpdate,k=d.shared.pending;if(k!==null){d.shared.pending=null;var C=k,L=C.next;C.next=null,y===null?f=L:y.next=L,y=C;var Q=e.alternate;Q!==null&&(Q=Q.updateQueue,k=Q.lastBaseUpdate,k!==y&&(k===null?Q.firstBaseUpdate=L:k.next=L,Q.lastBaseUpdate=C))}if(f!==null){var q=d.baseState;y=0,Q=L=C=null,k=f;do{var H=k.lane,ee=k.eventTime;if((l&H)===H){Q!==null&&(Q=Q.next={eventTime:ee,lane:0,tag:k.tag,payload:k.payload,callback:k.callback,next:null});e:{var ne=e,oe=k;switch(H=n,ee=a,oe.tag){case 1:if(ne=oe.payload,typeof ne=="function"){q=ne.call(ee,q,H);break e}q=ne;break e;case 3:ne.flags=ne.flags&-65537|128;case 0:if(ne=oe.payload,H=typeof ne=="function"?ne.call(ee,q,H):ne,H==null)break e;q=I({},q,H);break e;case 2:Zn=!0}}k.callback!==null&&k.lane!==0&&(e.flags|=64,H=d.effects,H===null?d.effects=[k]:H.push(k))}else ee={eventTime:ee,lane:H,tag:k.tag,payload:k.payload,callback:k.callback,next:null},Q===null?(L=Q=ee,C=q):Q=Q.next=ee,y|=H;if(k=k.next,k===null){if(k=d.shared.pending,k===null)break;H=k,k=H.next,H.next=null,d.lastBaseUpdate=H,d.shared.pending=null}}while(!0);if(Q===null&&(C=q),d.baseState=C,d.firstBaseUpdate=L,d.lastBaseUpdate=Q,n=d.shared.interleaved,n!==null){d=n;do y|=d.lane,d=d.next;while(d!==n)}else f===null&&(d.shared.lanes=0);Rr|=y,e.lanes=y,e.memoizedState=q}}function Cu(e,n,a){if(e=n.effects,n.effects=null,e!==null)for(n=0;na?a:4,e(!0);var l=pl.transition;pl.transition={};try{e(!1),n()}finally{Le=a,pl.transition=l}}function qu(){return Vt().memoizedState}function bx(e,n,a){var l=sr(e);if(a={lane:l,action:a,hasEagerState:!1,eagerState:null,next:null},Vu(e))Ku(n,a);else if(a=ku(e,n,a,l),a!==null){var d=wt();sn(a,e,l,d),Gu(a,n,l)}}function wx(e,n,a){var l=sr(e),d={lane:l,action:a,hasEagerState:!1,eagerState:null,next:null};if(Vu(e))Ku(n,d);else{var f=e.alternate;if(e.lanes===0&&(f===null||f.lanes===0)&&(f=n.lastRenderedReducer,f!==null))try{var y=n.lastRenderedState,k=f(y,a);if(d.hasEagerState=!0,d.eagerState=k,Zt(k,y)){var C=n.interleaved;C===null?(d.next=d,cl(n)):(d.next=C.next,C.next=d),n.interleaved=d;return}}catch{}a=ku(e,n,d,l),a!==null&&(d=wt(),sn(a,e,l,d),Gu(a,n,l))}}function Vu(e){var n=e.alternate;return e===We||n!==null&&n===We}function Ku(e,n){na=di=!0;var a=e.pending;a===null?n.next=n:(n.next=a.next,a.next=n),e.pending=n}function Gu(e,n,a){if((a&4194240)!==0){var l=n.lanes;l&=e.pendingLanes,a|=l,n.lanes=a,ko(e,a)}}var hi={readContext:qt,useCallback:xt,useContext:xt,useEffect:xt,useImperativeHandle:xt,useInsertionEffect:xt,useLayoutEffect:xt,useMemo:xt,useReducer:xt,useRef:xt,useState:xt,useDebugValue:xt,useDeferredValue:xt,useTransition:xt,useMutableSource:xt,useSyncExternalStore:xt,useId:xt,unstable_isNewReconciler:!1},Nx={readContext:qt,useCallback:function(e,n){return fn().memoizedState=[e,n===void 0?null:n],e},useContext:qt,useEffect:Au,useImperativeHandle:function(e,n,a){return a=a!=null?a.concat([e]):null,ui(4194308,4,$u.bind(null,n,e),a)},useLayoutEffect:function(e,n){return ui(4194308,4,e,n)},useInsertionEffect:function(e,n){return ui(4,2,e,n)},useMemo:function(e,n){var a=fn();return n=n===void 0?null:n,e=e(),a.memoizedState=[e,n],e},useReducer:function(e,n,a){var l=fn();return n=a!==void 0?a(n):n,l.memoizedState=l.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},l.queue=e,e=e.dispatch=bx.bind(null,We,e),[l.memoizedState,e]},useRef:function(e){var n=fn();return e={current:e},n.memoizedState=e},useState:Lu,useDebugValue:wl,useDeferredValue:function(e){return fn().memoizedState=e},useTransition:function(){var e=Lu(!1),n=e[0];return e=jx.bind(null,e[1]),fn().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,a){var l=We,d=fn();if(Be){if(a===void 0)throw Error(i(407));a=a()}else{if(a=n(),lt===null)throw Error(i(349));(Er&30)!==0||Ou(l,n,a)}d.memoizedState=a;var f={value:a,getSnapshot:n};return d.queue=f,Au(Iu.bind(null,l,f,e),[e]),l.flags|=2048,aa(9,Tu.bind(null,l,f,a,n),void 0,null),a},useId:function(){var e=fn(),n=lt.identifierPrefix;if(Be){var a=Sn,l=kn;a=(l&~(1<<32-Jt(l)-1)).toString(32)+a,n=":"+n+"R"+a,a=ra++,0k||d[y]!==f[k]){var S=` +`+d[y].replace(" at new "," at ");return e.displayName&&S.includes("")&&(S=S.replace("",e.displayName)),S}while(1<=y&&0<=k);break}}}finally{z=!1,Error.prepareStackTrace=a}return(e=e?e.displayName||e.name:"")?C(e):""}function oe(e){switch(e.tag){case 5:return C(e.type);case 16:return C("Lazy");case 13:return C("Suspense");case 19:return C("SuspenseList");case 0:case 2:case 15:return e=Z(e.type,!1),e;case 11:return e=Z(e.type.render,!1),e;case 1:return e=Z(e.type,!0),e;default:return""}}function me(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case B:return"Fragment";case U:return"Portal";case ie:return"Profiler";case $:return"StrictMode";case Ie:return"Suspense";case Fe:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Oe:return(e.displayName||"Context")+".Consumer";case fe:return(e._context.displayName||"Context")+".Provider";case Re:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Pe:return n=e.displayName||null,n!==null?n:me(e.type)||"Memo";case de:n=e._payload,e=e._init;try{return me(e(n))}catch{}}return null}function ue(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return me(n);case 8:return n===$?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function ne(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function _e(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function He(e){var n=_e(e)?"checked":"value",a=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),l=""+e[n];if(!e.hasOwnProperty(n)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var d=a.get,f=a.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return d.call(this)},set:function(y){l=""+y,f.call(this,y)}}),Object.defineProperty(e,n,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(y){l=""+y},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function Mt(e){e._valueTracker||(e._valueTracker=He(e))}function St(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var a=n.getValue(),l="";return e&&(l=_e(e)?e.checked?"true":"false":e.value),e=l,e!==a?(n.setValue(e),!0):!1}function Br(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function pr(e,n){var a=n.checked;return W({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??e._wrapperState.initialChecked})}function Na(e,n){var a=n.defaultValue==null?"":n.defaultValue,l=n.checked!=null?n.checked:n.defaultChecked;a=ne(n.value!=null?n.value:a),e._wrapperState={initialChecked:l,initialValue:a,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function Ns(e,n){n=n.checked,n!=null&&A(e,"checked",n,!1)}function gn(e,n){Ns(e,n);var a=ne(n.value),l=n.type;if(a!=null)l==="number"?(a===0&&e.value===""||e.value!=a)&&(e.value=""+a):e.value!==""+a&&(e.value=""+a);else if(l==="submit"||l==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?Dt(e,n.type,a):n.hasOwnProperty("defaultValue")&&Dt(e,n.type,ne(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function ks(e,n,a){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var l=n.type;if(!(l!=="submit"&&l!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,a||n===e.value||(e.value=n),e.defaultValue=n}a=e.name,a!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,a!==""&&(e.name=a)}function Dt(e,n,a){(n!=="number"||Br(e.ownerDocument)!==e)&&(a==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+a&&(e.defaultValue=""+a))}var vr=Array.isArray;function Dn(e,n,a,l){if(e=e.options,n){n={};for(var d=0;d"+n.valueOf().toString()+"",n=gr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function Bt(e,n){if(n){var a=e.firstChild;if(a&&a===e.lastChild&&a.nodeType===3){a.nodeValue=n;return}}e.textContent=n}var Ln={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Fn=["Webkit","ms","Moz","O"];Object.keys(Ln).forEach(function(e){Fn.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),Ln[n]=Ln[e]})});function Sa(e,n,a){return n==null||typeof n=="boolean"||n===""?"":a||typeof n!="number"||n===0||Ln.hasOwnProperty(e)&&Ln[e]?(""+n).trim():n+"px"}function Ca(e,n){e=e.style;for(var a in n)if(n.hasOwnProperty(a)){var l=a.indexOf("--")===0,d=Sa(a,n[a],l);a==="float"&&(a="cssFloat"),l?e.setProperty(a,d):e[a]=d}}var Ea=W({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Qr(e,n){if(n){if(Ea[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(i(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(i(61))}if(n.style!=null&&typeof n.style!="object")throw Error(i(62))}}function Ss(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var yr=null;function Ht(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Cs=null,An=null,jn=null;function Es(e){if(e=Ks(e)){if(typeof Cs!="function")throw Error(i(280));var n=e.stateNode;n&&(n=Ga(n),Cs(e.stateNode,e.type,n))}}function je(e){An?jn?jn.push(e):jn=[e]:An=e}function Ge(){if(An){var e=An,n=jn;if(jn=An=null,Es(e),n)for(e=0;e>>=0,e===0?32:31-(yv(e)/jv|0)|0}var Oa=64,Ta=4194304;function Ps(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ia(e,n){var a=e.pendingLanes;if(a===0)return 0;var l=0,d=e.suspendedLanes,f=e.pingedLanes,y=a&268435455;if(y!==0){var k=y&~d;k!==0?l=Ps(k):(f&=y,f!==0&&(l=Ps(f)))}else y=a&~d,y!==0?l=Ps(y):f!==0&&(l=Ps(f));if(l===0)return 0;if(n!==0&&n!==l&&(n&d)===0&&(d=l&-l,f=n&-n,d>=f||d===16&&(f&4194240)!==0))return n;if((l&4)!==0&&(l|=a&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=l;0a;a++)n.push(e);return n}function Os(e,n,a){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Zt(n),e[n]=a}function kv(e,n){var a=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var l=e.eventTimes;for(e=e.expirationTimes;0=zs),Fd=" ",Ad=!1;function zd(e,n){switch(e){case"keyup":return Jv.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function $d(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Vr=!1;function ex(e,n){switch(e){case"compositionend":return $d(n);case"keypress":return n.which!==32?null:(Ad=!0,Fd);case"textInput":return e=n.data,e===Fd&&Ad?null:e;default:return null}}function tx(e,n){if(Vr)return e==="compositionend"||!Mo&&zd(e,n)?(e=Od(),Aa=Eo=qn=null,Vr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:a,offset:n-e};e=l}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=Vd(a)}}function Gd(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Gd(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Yd(){for(var e=window,n=Br();n instanceof e.HTMLIFrameElement;){try{var a=typeof n.contentWindow.location.href=="string"}catch{a=!1}if(a)e=n.contentWindow;else break;n=Br(e.document)}return n}function Fo(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function dx(e){var n=Yd(),a=e.focusedElem,l=e.selectionRange;if(n!==a&&a&&a.ownerDocument&&Gd(a.ownerDocument.documentElement,a)){if(l!==null&&Fo(a)){if(n=l.start,e=l.end,e===void 0&&(e=n),"selectionStart"in a)a.selectionStart=n,a.selectionEnd=Math.min(e,a.value.length);else if(e=(n=a.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var d=a.textContent.length,f=Math.min(l.start,d);l=l.end===void 0?f:Math.min(l.end,d),!e.extend&&f>l&&(d=l,l=f,f=d),d=Kd(a,f);var y=Kd(a,l);d&&y&&(e.rangeCount!==1||e.anchorNode!==d.node||e.anchorOffset!==d.offset||e.focusNode!==y.node||e.focusOffset!==y.offset)&&(n=n.createRange(),n.setStart(d.node,d.offset),e.removeAllRanges(),f>l?(e.addRange(n),e.extend(y.node,y.offset)):(n.setEnd(y.node,y.offset),e.addRange(n)))}}for(n=[],e=a;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof a.focus=="function"&&a.focus(),a=0;a=document.documentMode,Kr=null,Ao=null,Hs=null,zo=!1;function Xd(e,n,a){var l=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;zo||Kr==null||Kr!==Br(l)||(l=Kr,"selectionStart"in l&&Fo(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),Hs&&Bs(Hs,l)||(Hs=l,l=qa(Ao,"onSelect"),0Zr||(e.current=Xo[Zr],Xo[Zr]=null,Zr--)}function Ae(e,n){Zr++,Xo[Zr]=e.current,e.current=n}var Yn={},xt=Gn(Yn),Ct=Gn(!1),wr=Yn;function es(e,n){var a=e.type.contextTypes;if(!a)return Yn;var l=e.stateNode;if(l&&l.__reactInternalMemoizedUnmaskedChildContext===n)return l.__reactInternalMemoizedMaskedChildContext;var d={},f;for(f in a)d[f]=n[f];return l&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=d),d}function Et(e){return e=e.childContextTypes,e!=null}function Ya(){Ue(Ct),Ue(xt)}function fu(e,n,a){if(xt.current!==Yn)throw Error(i(168));Ae(xt,n),Ae(Ct,a)}function hu(e,n,a){var l=e.stateNode;if(n=n.childContextTypes,typeof l.getChildContext!="function")return a;l=l.getChildContext();for(var d in l)if(!(d in n))throw Error(i(108,ue(e)||"Unknown",d));return W({},a,l)}function Xa(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Yn,wr=xt.current,Ae(xt,e),Ae(Ct,Ct.current),!0}function mu(e,n,a){var l=e.stateNode;if(!l)throw Error(i(169));a?(e=hu(e,n,wr),l.__reactInternalMemoizedMergedChildContext=e,Ue(Ct),Ue(xt),Ae(xt,e)):Ue(Ct),Ae(Ct,a)}var wn=null,Ja=!1,Jo=!1;function pu(e){wn===null?wn=[e]:wn.push(e)}function wx(e){Ja=!0,pu(e)}function Xn(){if(!Jo&&wn!==null){Jo=!0;var e=0,n=Le;try{var a=wn;for(Le=1;e>=y,d-=y,Nn=1<<32-Zt(n)+d|a<ye?(dt=ve,ve=null):dt=ve.sibling;var De=H(I,ve,M[ye],Y);if(De===null){ve===null&&(ve=dt);break}e&&ve&&De.alternate===null&&n(I,ve),P=f(De,P,ye),pe===null?ce=De:pe.sibling=De,pe=De,ve=dt}if(ye===M.length)return a(I,ve),Be&&kr(I,ye),ce;if(ve===null){for(;yeye?(dt=ve,ve=null):dt=ve.sibling;var ir=H(I,ve,De.value,Y);if(ir===null){ve===null&&(ve=dt);break}e&&ve&&ir.alternate===null&&n(I,ve),P=f(ir,P,ye),pe===null?ce=ir:pe.sibling=ir,pe=ir,ve=dt}if(De.done)return a(I,ve),Be&&kr(I,ye),ce;if(ve===null){for(;!De.done;ye++,De=M.next())De=V(I,De.value,Y),De!==null&&(P=f(De,P,ye),pe===null?ce=De:pe.sibling=De,pe=De);return Be&&kr(I,ye),ce}for(ve=l(I,ve);!De.done;ye++,De=M.next())De=ee(ve,I,ye,De.value,Y),De!==null&&(e&&De.alternate!==null&&ve.delete(De.key===null?ye:De.key),P=f(De,P,ye),pe===null?ce=De:pe.sibling=De,pe=De);return e&&ve.forEach(function(ng){return n(I,ng)}),Be&&kr(I,ye),ce}function Xe(I,P,M,Y){if(typeof M=="object"&&M!==null&&M.type===B&&M.key===null&&(M=M.props.children),typeof M=="object"&&M!==null){switch(M.$$typeof){case G:e:{for(var ce=M.key,pe=P;pe!==null;){if(pe.key===ce){if(ce=M.type,ce===B){if(pe.tag===7){a(I,pe.sibling),P=d(pe,M.props.children),P.return=I,I=P;break e}}else if(pe.elementType===ce||typeof ce=="object"&&ce!==null&&ce.$$typeof===de&&bu(ce)===pe.type){a(I,pe.sibling),P=d(pe,M.props),P.ref=Gs(I,pe,M),P.return=I,I=P;break e}a(I,pe);break}else n(I,pe);pe=pe.sibling}M.type===B?(P=Tr(M.props.children,I.mode,Y,M.key),P.return=I,I=P):(Y=Si(M.type,M.key,M.props,null,I.mode,Y),Y.ref=Gs(I,P,M),Y.return=I,I=Y)}return y(I);case U:e:{for(pe=M.key;P!==null;){if(P.key===pe)if(P.tag===4&&P.stateNode.containerInfo===M.containerInfo&&P.stateNode.implementation===M.implementation){a(I,P.sibling),P=d(P,M.children||[]),P.return=I,I=P;break e}else{a(I,P);break}else n(I,P);P=P.sibling}P=Gl(M,I.mode,Y),P.return=I,I=P}return y(I);case de:return pe=M._init,Xe(I,P,pe(M._payload),Y)}if(vr(M))return re(I,P,M,Y);if(X(M))return le(I,P,M,Y);ni(I,M)}return typeof M=="string"&&M!==""||typeof M=="number"?(M=""+M,P!==null&&P.tag===6?(a(I,P.sibling),P=d(P,M),P.return=I,I=P):(a(I,P),P=Kl(M,I.mode,Y),P.return=I,I=P),y(I)):a(I,P)}return Xe}var ss=wu(!0),Nu=wu(!1),ri=Gn(null),si=null,as=null,sl=null;function al(){sl=as=si=null}function il(e){var n=ri.current;Ue(ri),e._currentValue=n}function ol(e,n,a){for(;e!==null;){var l=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,l!==null&&(l.childLanes|=n)):l!==null&&(l.childLanes&n)!==n&&(l.childLanes|=n),e===a)break;e=e.return}}function is(e,n){si=e,sl=as=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&n)!==0&&(Rt=!0),e.firstContext=null)}function Vt(e){var n=e._currentValue;if(sl!==e)if(e={context:e,memoizedValue:n,next:null},as===null){if(si===null)throw Error(i(308));as=e,si.dependencies={lanes:0,firstContext:e}}else as=as.next=e;return n}var _r=null;function ll(e){_r===null?_r=[e]:_r.push(e)}function ku(e,n,a,l){var d=n.interleaved;return d===null?(a.next=a,ll(n)):(a.next=d.next,d.next=a),n.interleaved=a,_n(e,l)}function _n(e,n){e.lanes|=n;var a=e.alternate;for(a!==null&&(a.lanes|=n),a=e,e=e.return;e!==null;)e.childLanes|=n,a=e.alternate,a!==null&&(a.childLanes|=n),a=e,e=e.return;return a.tag===3?a.stateNode:null}var Jn=!1;function cl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function _u(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Sn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function Zn(e,n,a){var l=e.updateQueue;if(l===null)return null;if(l=l.shared,(Me&2)!==0){var d=l.pending;return d===null?n.next=n:(n.next=d.next,d.next=n),l.pending=n,_n(e,a)}return d=l.interleaved,d===null?(n.next=n,ll(l)):(n.next=d.next,d.next=n),l.interleaved=n,_n(e,a)}function ai(e,n,a){if(n=n.updateQueue,n!==null&&(n=n.shared,(a&4194240)!==0)){var l=n.lanes;l&=e.pendingLanes,a|=l,n.lanes=a,No(e,a)}}function Su(e,n){var a=e.updateQueue,l=e.alternate;if(l!==null&&(l=l.updateQueue,a===l)){var d=null,f=null;if(a=a.firstBaseUpdate,a!==null){do{var y={eventTime:a.eventTime,lane:a.lane,tag:a.tag,payload:a.payload,callback:a.callback,next:null};f===null?d=f=y:f=f.next=y,a=a.next}while(a!==null);f===null?d=f=n:f=f.next=n}else d=f=n;a={baseState:l.baseState,firstBaseUpdate:d,lastBaseUpdate:f,shared:l.shared,effects:l.effects},e.updateQueue=a;return}e=a.lastBaseUpdate,e===null?a.firstBaseUpdate=n:e.next=n,a.lastBaseUpdate=n}function ii(e,n,a,l){var d=e.updateQueue;Jn=!1;var f=d.firstBaseUpdate,y=d.lastBaseUpdate,k=d.shared.pending;if(k!==null){d.shared.pending=null;var S=k,L=S.next;S.next=null,y===null?f=L:y.next=L,y=S;var Q=e.alternate;Q!==null&&(Q=Q.updateQueue,k=Q.lastBaseUpdate,k!==y&&(k===null?Q.firstBaseUpdate=L:k.next=L,Q.lastBaseUpdate=S))}if(f!==null){var V=d.baseState;y=0,Q=L=S=null,k=f;do{var H=k.lane,ee=k.eventTime;if((l&H)===H){Q!==null&&(Q=Q.next={eventTime:ee,lane:0,tag:k.tag,payload:k.payload,callback:k.callback,next:null});e:{var re=e,le=k;switch(H=n,ee=a,le.tag){case 1:if(re=le.payload,typeof re=="function"){V=re.call(ee,V,H);break e}V=re;break e;case 3:re.flags=re.flags&-65537|128;case 0:if(re=le.payload,H=typeof re=="function"?re.call(ee,V,H):re,H==null)break e;V=W({},V,H);break e;case 2:Jn=!0}}k.callback!==null&&k.lane!==0&&(e.flags|=64,H=d.effects,H===null?d.effects=[k]:H.push(k))}else ee={eventTime:ee,lane:H,tag:k.tag,payload:k.payload,callback:k.callback,next:null},Q===null?(L=Q=ee,S=V):Q=Q.next=ee,y|=H;if(k=k.next,k===null){if(k=d.shared.pending,k===null)break;H=k,k=H.next,H.next=null,d.lastBaseUpdate=H,d.shared.pending=null}}while(!0);if(Q===null&&(S=V),d.baseState=S,d.firstBaseUpdate=L,d.lastBaseUpdate=Q,n=d.shared.interleaved,n!==null){d=n;do y|=d.lane,d=d.next;while(d!==n)}else f===null&&(d.shared.lanes=0);Er|=y,e.lanes=y,e.memoizedState=V}}function Cu(e,n,a){if(e=n.effects,n.effects=null,e!==null)for(n=0;na?a:4,e(!0);var l=ml.transition;ml.transition={};try{e(!1),n()}finally{Le=a,ml.transition=l}}function qu(){return Kt().memoizedState}function Sx(e,n,a){var l=rr(e);if(a={lane:l,action:a,hasEagerState:!1,eagerState:null,next:null},Vu(e))Ku(n,a);else if(a=ku(e,n,a,l),a!==null){var d=kt();an(a,e,l,d),Gu(a,n,l)}}function Cx(e,n,a){var l=rr(e),d={lane:l,action:a,hasEagerState:!1,eagerState:null,next:null};if(Vu(e))Ku(n,d);else{var f=e.alternate;if(e.lanes===0&&(f===null||f.lanes===0)&&(f=n.lastRenderedReducer,f!==null))try{var y=n.lastRenderedState,k=f(y,a);if(d.hasEagerState=!0,d.eagerState=k,en(k,y)){var S=n.interleaved;S===null?(d.next=d,ll(n)):(d.next=S.next,S.next=d),n.interleaved=d;return}}catch{}a=ku(e,n,d,l),a!==null&&(d=kt(),an(a,e,l,d),Gu(a,n,l))}}function Vu(e){var n=e.alternate;return e===We||n!==null&&n===We}function Ku(e,n){Zs=ci=!0;var a=e.pending;a===null?n.next=n:(n.next=a.next,a.next=n),e.pending=n}function Gu(e,n,a){if((a&4194240)!==0){var l=n.lanes;l&=e.pendingLanes,a|=l,n.lanes=a,No(e,a)}}var fi={readContext:Vt,useCallback:gt,useContext:gt,useEffect:gt,useImperativeHandle:gt,useInsertionEffect:gt,useLayoutEffect:gt,useMemo:gt,useReducer:gt,useRef:gt,useState:gt,useDebugValue:gt,useDeferredValue:gt,useTransition:gt,useMutableSource:gt,useSyncExternalStore:gt,useId:gt,unstable_isNewReconciler:!1},Ex={readContext:Vt,useCallback:function(e,n){return fn().memoizedState=[e,n===void 0?null:n],e},useContext:Vt,useEffect:Au,useImperativeHandle:function(e,n,a){return a=a!=null?a.concat([e]):null,di(4194308,4,Uu.bind(null,n,e),a)},useLayoutEffect:function(e,n){return di(4194308,4,e,n)},useInsertionEffect:function(e,n){return di(4,2,e,n)},useMemo:function(e,n){var a=fn();return n=n===void 0?null:n,e=e(),a.memoizedState=[e,n],e},useReducer:function(e,n,a){var l=fn();return n=a!==void 0?a(n):n,l.memoizedState=l.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},l.queue=e,e=e.dispatch=Sx.bind(null,We,e),[l.memoizedState,e]},useRef:function(e){var n=fn();return e={current:e},n.memoizedState=e},useState:Lu,useDebugValue:bl,useDeferredValue:function(e){return fn().memoizedState=e},useTransition:function(){var e=Lu(!1),n=e[0];return e=_x.bind(null,e[1]),fn().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,a){var l=We,d=fn();if(Be){if(a===void 0)throw Error(i(407));a=a()}else{if(a=n(),ct===null)throw Error(i(349));(Cr&30)!==0||Ou(l,n,a)}d.memoizedState=a;var f={value:a,getSnapshot:n};return d.queue=f,Au(Iu.bind(null,l,f,e),[e]),l.flags|=2048,na(9,Tu.bind(null,l,f,a,n),void 0,null),a},useId:function(){var e=fn(),n=ct.identifierPrefix;if(Be){var a=kn,l=Nn;a=(l&~(1<<32-Zt(l)-1)).toString(32)+a,n=":"+n+"R"+a,a=ea++,0<\/script>",e=e.removeChild(e.firstChild)):typeof l.is=="string"?e=y.createElement(a,{is:l.is}):(e=y.createElement(a),a==="select"&&(y=e,l.multiple?y.multiple=!0:l.size&&(y.size=l.size))):e=y.createElementNS(e,a),e[dn]=n,e[Ys]=l,vf(e,n,!1,!1),n.stateNode=e;e:{switch(y=Rs(a,l),a){case"dialog":ze("cancel",e),ze("close",e),d=l;break;case"iframe":case"object":case"embed":ze("load",e),d=l;break;case"video":case"audio":for(d=0;dfs&&(n.flags|=128,l=!0,ia(f,!1),n.lanes=4194304)}else{if(!l)if(e=li(y),e!==null){if(n.flags|=128,l=!0,a=e.updateQueue,a!==null&&(n.updateQueue=a,n.flags|=4),ia(f,!0),f.tail===null&&f.tailMode==="hidden"&&!y.alternate&&!Be)return gt(n),null}else 2*Ye()-f.renderingStartTime>fs&&a!==1073741824&&(n.flags|=128,l=!0,ia(f,!1),n.lanes=4194304);f.isBackwards?(y.sibling=n.child,n.child=y):(a=f.last,a!==null?a.sibling=y:n.child=y,f.last=y)}return f.tail!==null?(n=f.tail,f.rendering=n,f.tail=n.sibling,f.renderingStartTime=Ye(),n.sibling=null,a=Qe.current,Ae(Qe,l?a&1|2:a&1),n):(gt(n),null);case 22:case 23:return ql(),l=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==l&&(n.flags|=8192),l&&(n.mode&1)!==0?(zt&1073741824)!==0&&(gt(n),n.subtreeFlags&6&&(n.flags|=8192)):gt(n),null;case 24:return null;case 25:return null}throw Error(i(156,n.tag))}function Ox(e,n){switch(tl(n),n.tag){case 1:return Ct(n.type)&&Xa(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return ls(),Ue(_t),Ue(vt),ml(),e=n.flags,(e&65536)!==0&&(e&128)===0?(n.flags=e&-65537|128,n):null;case 5:return fl(n),null;case 13:if(Ue(Qe),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(i(340));ss()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return Ue(Qe),null;case 4:return ls(),null;case 10:return ol(n.type._context),null;case 22:case 23:return ql(),null;case 24:return null;default:return null}}var xi=!1,yt=!1,Tx=typeof WeakSet=="function"?WeakSet:Set,te=null;function ds(e,n){var a=e.ref;if(a!==null)if(typeof a=="function")try{a(null)}catch(l){Ve(e,n,l)}else a.current=null}function Ml(e,n,a){try{a()}catch(l){Ve(e,n,l)}}var yf=!1;function Ix(e,n){if(qo=Fa,e=Yd(),Ao(e)){if("selectionStart"in e)var a={start:e.selectionStart,end:e.selectionEnd};else e:{a=(a=e.ownerDocument)&&a.defaultView||window;var l=a.getSelection&&a.getSelection();if(l&&l.rangeCount!==0){a=l.anchorNode;var d=l.anchorOffset,f=l.focusNode;l=l.focusOffset;try{a.nodeType,f.nodeType}catch{a=null;break e}var y=0,k=-1,C=-1,L=0,Q=0,q=e,H=null;t:for(;;){for(var ee;q!==a||d!==0&&q.nodeType!==3||(k=y+d),q!==f||l!==0&&q.nodeType!==3||(C=y+l),q.nodeType===3&&(y+=q.nodeValue.length),(ee=q.firstChild)!==null;)H=q,q=ee;for(;;){if(q===e)break t;if(H===a&&++L===d&&(k=y),H===f&&++Q===l&&(C=y),(ee=q.nextSibling)!==null)break;q=H,H=q.parentNode}q=ee}a=k===-1||C===-1?null:{start:k,end:C}}else a=null}a=a||{start:0,end:0}}else a=null;for(Vo={focusedElem:e,selectionRange:a},Fa=!1,te=n;te!==null;)if(n=te,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,te=e;else for(;te!==null;){n=te;try{var ne=n.alternate;if((n.flags&1024)!==0)switch(n.tag){case 0:case 11:case 15:break;case 1:if(ne!==null){var oe=ne.memoizedProps,Xe=ne.memoizedState,T=n.stateNode,R=T.getSnapshotBeforeUpdate(n.elementType===n.type?oe:tn(n.type,oe),Xe);T.__reactInternalSnapshotBeforeUpdate=R}break;case 3:var D=n.stateNode.containerInfo;D.nodeType===1?D.textContent="":D.nodeType===9&&D.documentElement&&D.removeChild(D.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(Y){Ve(n,n.return,Y)}if(e=n.sibling,e!==null){e.return=n.return,te=e;break}te=n.return}return ne=yf,yf=!1,ne}function oa(e,n,a){var l=n.updateQueue;if(l=l!==null?l.lastEffect:null,l!==null){var d=l=l.next;do{if((d.tag&e)===e){var f=d.destroy;d.destroy=void 0,f!==void 0&&Ml(n,a,f)}d=d.next}while(d!==l)}}function gi(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var a=n=n.next;do{if((a.tag&e)===e){var l=a.create;a.destroy=l()}a=a.next}while(a!==n)}}function Dl(e){var n=e.ref;if(n!==null){var a=e.stateNode;e.tag,e=a,typeof n=="function"?n(e):n.current=e}}function jf(e){var n=e.alternate;n!==null&&(e.alternate=null,jf(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[dn],delete n[Ys],delete n[Xo],delete n[px],delete n[vx])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bf(e){return e.tag===5||e.tag===3||e.tag===4}function wf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||bf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ll(e,n,a){var l=e.tag;if(l===5||l===6)e=e.stateNode,n?a.nodeType===8?a.parentNode.insertBefore(e,n):a.insertBefore(e,n):(a.nodeType===8?(n=a.parentNode,n.insertBefore(e,a)):(n=a,n.appendChild(e)),a=a._reactRootContainer,a!=null||n.onclick!==null||(n.onclick=Ga));else if(l!==4&&(e=e.child,e!==null))for(Ll(e,n,a),e=e.sibling;e!==null;)Ll(e,n,a),e=e.sibling}function Fl(e,n,a){var l=e.tag;if(l===5||l===6)e=e.stateNode,n?a.insertBefore(e,n):a.appendChild(e);else if(l!==4&&(e=e.child,e!==null))for(Fl(e,n,a),e=e.sibling;e!==null;)Fl(e,n,a),e=e.sibling}var ht=null,nn=!1;function tr(e,n,a){for(a=a.child;a!==null;)Nf(e,n,a),a=a.sibling}function Nf(e,n,a){if(cn&&typeof cn.onCommitFiberUnmount=="function")try{cn.onCommitFiberUnmount(Oa,a)}catch{}switch(a.tag){case 5:yt||ds(a,n);case 6:var l=ht,d=nn;ht=null,tr(e,n,a),ht=l,nn=d,ht!==null&&(nn?(e=ht,a=a.stateNode,e.nodeType===8?e.parentNode.removeChild(a):e.removeChild(a)):ht.removeChild(a.stateNode));break;case 18:ht!==null&&(nn?(e=ht,a=a.stateNode,e.nodeType===8?Yo(e.parentNode,a):e.nodeType===1&&Yo(e,a),zs(e)):Yo(ht,a.stateNode));break;case 4:l=ht,d=nn,ht=a.stateNode.containerInfo,nn=!0,tr(e,n,a),ht=l,nn=d;break;case 0:case 11:case 14:case 15:if(!yt&&(l=a.updateQueue,l!==null&&(l=l.lastEffect,l!==null))){d=l=l.next;do{var f=d,y=f.destroy;f=f.tag,y!==void 0&&((f&2)!==0||(f&4)!==0)&&Ml(a,n,y),d=d.next}while(d!==l)}tr(e,n,a);break;case 1:if(!yt&&(ds(a,n),l=a.stateNode,typeof l.componentWillUnmount=="function"))try{l.props=a.memoizedProps,l.state=a.memoizedState,l.componentWillUnmount()}catch(k){Ve(a,n,k)}tr(e,n,a);break;case 21:tr(e,n,a);break;case 22:a.mode&1?(yt=(l=yt)||a.memoizedState!==null,tr(e,n,a),yt=l):tr(e,n,a);break;default:tr(e,n,a)}}function kf(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var a=e.stateNode;a===null&&(a=e.stateNode=new Tx),n.forEach(function(l){var d=Bx.bind(null,e,l);a.has(l)||(a.add(l),l.then(d,d))})}}function rn(e,n){var a=n.deletions;if(a!==null)for(var l=0;ld&&(d=y),l&=~f}if(l=d,l=Ye()-l,l=(120>l?120:480>l?480:1080>l?1080:1920>l?1920:3e3>l?3e3:4320>l?4320:1960*Dx(l/1960))-l,10e?16:e,rr===null)var l=!1;else{if(e=rr,rr=null,Ni=0,(Me&6)!==0)throw Error(i(331));var d=Me;for(Me|=4,te=e.current;te!==null;){var f=te,y=f.child;if((te.flags&16)!==0){var k=f.deletions;if(k!==null){for(var C=0;CYe()-Ul?Or(e,0):zl|=a),Pt(e,n)}function Ff(e,n){n===0&&((e.mode&1)===0?n=1:(n=Ia,Ia<<=1,(Ia&130023424)===0&&(Ia=4194304)));var a=wt();e=_n(e,n),e!==null&&(Ms(e,n,a),Pt(e,a))}function $x(e){var n=e.memoizedState,a=0;n!==null&&(a=n.retryLane),Ff(e,a)}function Bx(e,n){var a=0;switch(e.tag){case 13:var l=e.stateNode,d=e.memoizedState;d!==null&&(a=d.retryLane);break;case 19:l=e.stateNode;break;default:throw Error(i(314))}l!==null&&l.delete(n),Ff(e,a)}var Af;Af=function(e,n,a){if(e!==null)if(e.memoizedProps!==n.pendingProps||_t.current)Et=!0;else{if((e.lanes&a)===0&&(n.flags&128)===0)return Et=!1,Rx(e,n,a);Et=(e.flags&131072)!==0}else Et=!1,Be&&(n.flags&1048576)!==0&&vu(n,ti,n.index);switch(n.lanes=0,n.tag){case 2:var l=n.type;vi(e,n),e=n.pendingProps;var d=ts(n,vt.current);os(n,a),d=xl(null,n,l,e,d,a);var f=gl();return n.flags|=1,typeof d=="object"&&d!==null&&typeof d.render=="function"&&d.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,Ct(l)?(f=!0,Ja(n)):f=!1,n.memoizedState=d.state!==null&&d.state!==void 0?d.state:null,dl(n),d.updater=mi,n.stateNode=d,d._reactInternals=n,kl(n,l,e,a),n=El(null,n,l,!0,f,a)):(n.tag=0,Be&&f&&el(n),bt(null,n,d,a),n=n.child),n;case 16:l=n.elementType;e:{switch(vi(e,n),e=n.pendingProps,d=l._init,l=d(l._payload),n.type=l,d=n.tag=Qx(l),e=tn(l,e),d){case 0:n=Cl(null,n,l,e,a);break e;case 1:n=df(null,n,l,e,a);break e;case 11:n=sf(null,n,l,e,a);break e;case 14:n=af(null,n,l,tn(l.type,e),a);break e}throw Error(i(306,l,""))}return n;case 0:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:tn(l,d),Cl(e,n,l,d,a);case 1:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:tn(l,d),df(e,n,l,d,a);case 3:e:{if(uf(n),e===null)throw Error(i(387));l=n.pendingProps,f=n.memoizedState,d=f.element,Su(e,n),oi(n,l,null,a);var y=n.memoizedState;if(l=y.element,f.isDehydrated)if(f={element:l,isDehydrated:!1,cache:y.cache,pendingSuspenseBoundaries:y.pendingSuspenseBoundaries,transitions:y.transitions},n.updateQueue.baseState=f,n.memoizedState=f,n.flags&256){d=cs(Error(i(423)),n),n=ff(e,n,l,a,d);break e}else if(l!==d){d=cs(Error(i(424)),n),n=ff(e,n,l,a,d);break e}else for(At=Gn(n.stateNode.containerInfo.firstChild),Ft=n,Be=!0,en=null,a=Nu(n,null,l,a),n.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling;else{if(ss(),l===d){n=En(e,n,a);break e}bt(e,n,l,a)}n=n.child}return n;case 5:return Eu(n),e===null&&rl(n),l=n.type,d=n.pendingProps,f=e!==null?e.memoizedProps:null,y=d.children,Ko(l,d)?y=null:f!==null&&Ko(l,f)&&(n.flags|=32),cf(e,n),bt(e,n,y,a),n.child;case 6:return e===null&&rl(n),null;case 13:return hf(e,n,a);case 4:return ul(n,n.stateNode.containerInfo),l=n.pendingProps,e===null?n.child=as(n,null,l,a):bt(e,n,l,a),n.child;case 11:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:tn(l,d),sf(e,n,l,d,a);case 7:return bt(e,n,n.pendingProps,a),n.child;case 8:return bt(e,n,n.pendingProps.children,a),n.child;case 12:return bt(e,n,n.pendingProps.children,a),n.child;case 10:e:{if(l=n.type._context,d=n.pendingProps,f=n.memoizedProps,y=d.value,Ae(si,l._currentValue),l._currentValue=y,f!==null)if(Zt(f.value,y)){if(f.children===d.children&&!_t.current){n=En(e,n,a);break e}}else for(f=n.child,f!==null&&(f.return=n);f!==null;){var k=f.dependencies;if(k!==null){y=f.child;for(var C=k.firstContext;C!==null;){if(C.context===l){if(f.tag===1){C=Cn(-1,a&-a),C.tag=2;var L=f.updateQueue;if(L!==null){L=L.shared;var Q=L.pending;Q===null?C.next=C:(C.next=Q.next,Q.next=C),L.pending=C}}f.lanes|=a,C=f.alternate,C!==null&&(C.lanes|=a),ll(f.return,a,n),k.lanes|=a;break}C=C.next}}else if(f.tag===10)y=f.type===n.type?null:f.child;else if(f.tag===18){if(y=f.return,y===null)throw Error(i(341));y.lanes|=a,k=y.alternate,k!==null&&(k.lanes|=a),ll(y,a,n),y=f.sibling}else y=f.child;if(y!==null)y.return=f;else for(y=f;y!==null;){if(y===n){y=null;break}if(f=y.sibling,f!==null){f.return=y.return,y=f;break}y=y.return}f=y}bt(e,n,d.children,a),n=n.child}return n;case 9:return d=n.type,l=n.pendingProps.children,os(n,a),d=qt(d),l=l(d),n.flags|=1,bt(e,n,l,a),n.child;case 14:return l=n.type,d=tn(l,n.pendingProps),d=tn(l.type,d),af(e,n,l,d,a);case 15:return of(e,n,n.type,n.pendingProps,a);case 17:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:tn(l,d),vi(e,n),n.tag=1,Ct(l)?(e=!0,Ja(n)):e=!1,os(n,a),Xu(n,l,d),kl(n,l,d,a),El(null,n,l,!0,e,a);case 19:return pf(e,n,a);case 22:return lf(e,n,a)}throw Error(i(156,n.tag))};function zf(e,n){return xd(e,n)}function Hx(e,n,a,l){this.tag=e,this.key=a,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=l,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Gt(e,n,a,l){return new Hx(e,n,a,l)}function Kl(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Qx(e){if(typeof e=="function")return Kl(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ee)return 11;if(e===Ie)return 14}return 2}function ir(e,n){var a=e.alternate;return a===null?(a=Gt(e.tag,n,e.key,e.mode),a.elementType=e.elementType,a.type=e.type,a.stateNode=e.stateNode,a.alternate=e,e.alternate=a):(a.pendingProps=n,a.type=e.type,a.flags=0,a.subtreeFlags=0,a.deletions=null),a.flags=e.flags&14680064,a.childLanes=e.childLanes,a.lanes=e.lanes,a.child=e.child,a.memoizedProps=e.memoizedProps,a.memoizedState=e.memoizedState,a.updateQueue=e.updateQueue,n=e.dependencies,a.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},a.sibling=e.sibling,a.index=e.index,a.ref=e.ref,a}function Ci(e,n,a,l,d,f){var y=2;if(l=e,typeof e=="function")Kl(e)&&(y=1);else if(typeof e=="string")y=5;else e:switch(e){case B:return Ir(a.children,d,f,n);case U:y=8,d|=8;break;case ie:return e=Gt(12,a,n,d|2),e.elementType=ie,e.lanes=f,e;case Te:return e=Gt(13,a,n,d),e.elementType=Te,e.lanes=f,e;case Fe:return e=Gt(19,a,n,d),e.elementType=Fe,e.lanes=f,e;case M:return Ei(a,d,f,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ue:y=10;break e;case Pe:y=9;break e;case Ee:y=11;break e;case Ie:y=14;break e;case ce:y=16,l=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return n=Gt(y,a,n,d),n.elementType=e,n.type=l,n.lanes=f,n}function Ir(e,n,a,l){return e=Gt(7,e,l,n),e.lanes=a,e}function Ei(e,n,a,l){return e=Gt(22,e,l,n),e.elementType=M,e.lanes=a,e.stateNode={isHidden:!1},e}function Gl(e,n,a){return e=Gt(6,e,null,n),e.lanes=a,e}function Yl(e,n,a){return n=Gt(4,e.children!==null?e.children:[],e.key,n),n.lanes=a,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function Wx(e,n,a,l,d){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=No(0),this.expirationTimes=No(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=No(0),this.identifierPrefix=l,this.onRecoverableError=d,this.mutableSourceEagerHydrationData=null}function Xl(e,n,a,l,d,f,y,k,C){return e=new Wx(e,n,a,k,C),n===1?(n=1,f===!0&&(n|=8)):n=0,f=Gt(3,null,null,n),e.current=f,f.stateNode=e,f.memoizedState={element:l,isDehydrated:a,cache:null,transitions:null,pendingSuspenseBoundaries:null},dl(f),e}function qx(e,n,a){var l=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(s){console.error(s)}}return t(),rc.exports=sg(),rc.exports}var Zf;function ag(){if(Zf)return Di;Zf=1;var t=Jh();return Di.createRoot=t.createRoot,Di.hydrateRoot=t.hydrateRoot,Di}var ig=ag(),zr=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(t){return this.listeners.add(t),this.onSubscribe(),()=>{this.listeners.delete(t),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},og={setTimeout:(t,s)=>setTimeout(t,s),clearTimeout:t=>clearTimeout(t),setInterval:(t,s)=>setInterval(t,s),clearInterval:t=>clearInterval(t)},lg=class{#t=og;#e=!1;setTimeoutProvider(t){this.#t=t}setTimeout(t,s){return this.#t.setTimeout(t,s)}clearTimeout(t){this.#t.clearTimeout(t)}setInterval(t,s){return this.#t.setInterval(t,s)}clearInterval(t){this.#t.clearInterval(t)}},Dr=new lg;function cg(t){setTimeout(t,0)}var Lr=typeof window>"u"||"Deno"in globalThis;function jt(){}function dg(t,s){return typeof t=="function"?t(s):t}function kc(t){return typeof t=="number"&&t>=0&&t!==1/0}function Zh(t,s){return Math.max(t+(s||0)-Date.now(),0)}function dr(t,s){return typeof t=="function"?t(s):t}function Yt(t,s){return typeof t=="function"?t(s):t}function eh(t,s){const{type:i="all",exact:o,fetchStatus:c,predicate:u,queryKey:h,stale:m}=t;if(h){if(o){if(s.queryHash!==Wc(h,s.options))return!1}else if(!pa(s.queryKey,h))return!1}if(i!=="all"){const v=s.isActive();if(i==="active"&&!v||i==="inactive"&&v)return!1}return!(typeof m=="boolean"&&s.isStale()!==m||c&&c!==s.state.fetchStatus||u&&!u(s))}function th(t,s){const{exact:i,status:o,predicate:c,mutationKey:u}=t;if(u){if(!s.options.mutationKey)return!1;if(i){if(Fr(s.options.mutationKey)!==Fr(u))return!1}else if(!pa(s.options.mutationKey,u))return!1}return!(o&&s.state.status!==o||c&&!c(s))}function Wc(t,s){return(s?.queryKeyHashFn||Fr)(t)}function Fr(t){return JSON.stringify(t,(s,i)=>Sc(i)?Object.keys(i).sort().reduce((o,c)=>(o[c]=i[c],o),{}):i)}function pa(t,s){return t===s?!0:typeof t!=typeof s?!1:t&&s&&typeof t=="object"&&typeof s=="object"?Object.keys(s).every(i=>pa(t[i],s[i])):!1}var ug=Object.prototype.hasOwnProperty;function qc(t,s){if(t===s)return t;const i=nh(t)&&nh(s);if(!i&&!(Sc(t)&&Sc(s)))return s;const c=(i?t:Object.keys(t)).length,u=i?s:Object.keys(s),h=u.length,m=i?new Array(h):{};let v=0;for(let g=0;g{Dr.setTimeout(s,t)})}function _c(t,s,i){return typeof i.structuralSharing=="function"?i.structuralSharing(t,s):i.structuralSharing!==!1?qc(t,s):s}function hg(t,s,i=0){const o=[...t,s];return i&&o.length>i?o.slice(1):o}function mg(t,s,i=0){const o=[s,...t];return i&&o.length>i?o.slice(0,-1):o}var Vc=Symbol();function em(t,s){return!t.queryFn&&s?.initialPromise?()=>s.initialPromise:!t.queryFn||t.queryFn===Vc?()=>Promise.reject(new Error(`Missing queryFn: '${t.queryHash}'`)):t.queryFn}function Kc(t,s){return typeof t=="function"?t(...s):!!t}function pg(t,s,i){let o=!1,c;return Object.defineProperty(t,"signal",{enumerable:!0,get:()=>(c??=s(),o||(o=!0,c.aborted?i():c.addEventListener("abort",i,{once:!0})),c)}),t}var vg=class extends zr{#t;#e;#n;constructor(){super(),this.#n=t=>{if(!Lr&&window.addEventListener){const s=()=>t();return window.addEventListener("visibilitychange",s,!1),()=>{window.removeEventListener("visibilitychange",s)}}}}onSubscribe(){this.#e||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#e?.(),this.#e=void 0)}setEventListener(t){this.#n=t,this.#e?.(),this.#e=t(s=>{typeof s=="boolean"?this.setFocused(s):this.onFocus()})}setFocused(t){this.#t!==t&&(this.#t=t,this.onFocus())}onFocus(){const t=this.isFocused();this.listeners.forEach(s=>{s(t)})}isFocused(){return typeof this.#t=="boolean"?this.#t:globalThis.document?.visibilityState!=="hidden"}},Gc=new vg;function Cc(){let t,s;const i=new Promise((c,u)=>{t=c,s=u});i.status="pending",i.catch(()=>{});function o(c){Object.assign(i,c),delete i.resolve,delete i.reject}return i.resolve=c=>{o({status:"fulfilled",value:c}),t(c)},i.reject=c=>{o({status:"rejected",reason:c}),s(c)},i}var xg=cg;function gg(){let t=[],s=0,i=m=>{m()},o=m=>{m()},c=xg;const u=m=>{s?t.push(m):c(()=>{i(m)})},h=()=>{const m=t;t=[],m.length&&c(()=>{o(()=>{m.forEach(v=>{i(v)})})})};return{batch:m=>{let v;s++;try{v=m()}finally{s--,s||h()}return v},batchCalls:m=>(...v)=>{u(()=>{m(...v)})},schedule:u,setNotifyFunction:m=>{i=m},setBatchNotifyFunction:m=>{o=m},setScheduler:m=>{c=m}}}var Ke=gg(),yg=class extends zr{#t=!0;#e;#n;constructor(){super(),this.#n=t=>{if(!Lr&&window.addEventListener){const s=()=>t(!0),i=()=>t(!1);return window.addEventListener("online",s,!1),window.addEventListener("offline",i,!1),()=>{window.removeEventListener("online",s),window.removeEventListener("offline",i)}}}}onSubscribe(){this.#e||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#e?.(),this.#e=void 0)}setEventListener(t){this.#n=t,this.#e?.(),this.#e=t(this.setOnline.bind(this))}setOnline(t){this.#t!==t&&(this.#t=t,this.listeners.forEach(i=>{i(t)}))}isOnline(){return this.#t}},to=new yg;function jg(t){return Math.min(1e3*2**t,3e4)}function tm(t){return(t??"online")==="online"?to.isOnline():!0}var Ec=class extends Error{constructor(t){super("CancelledError"),this.revert=t?.revert,this.silent=t?.silent}};function nm(t){let s=!1,i=0,o;const c=Cc(),u=()=>c.status!=="pending",h=j=>{if(!u()){const b=new Ec(j);N(b),t.onCancel?.(b)}},m=()=>{s=!0},v=()=>{s=!1},g=()=>Gc.isFocused()&&(t.networkMode==="always"||to.isOnline())&&t.canRun(),w=()=>tm(t.networkMode)&&t.canRun(),p=j=>{u()||(o?.(),c.resolve(j))},N=j=>{u()||(o?.(),c.reject(j))},E=()=>new Promise(j=>{o=b=>{(u()||g())&&j(b)},t.onPause?.()}).then(()=>{o=void 0,u()||t.onContinue?.()}),O=()=>{if(u())return;let j;const b=i===0?t.initialPromise:void 0;try{j=b??t.fn()}catch(S){j=Promise.reject(S)}Promise.resolve(j).then(p).catch(S=>{if(u())return;const F=t.retry??(Lr?0:3),z=t.retryDelay??jg,V=typeof z=="function"?z(i,S):z,G=F===!0||typeof F=="number"&&ig()?void 0:E()).then(()=>{s?N(S):O()})})};return{promise:c,status:()=>c.status,cancel:h,continue:()=>(o?.(),c),cancelRetry:m,continueRetry:v,canStart:w,start:()=>(w()?O():E().then(O),c)}}var rm=class{#t;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),kc(this.gcTime)&&(this.#t=Dr.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(t){this.gcTime=Math.max(this.gcTime||0,t??(Lr?1/0:300*1e3))}clearGcTimeout(){this.#t&&(Dr.clearTimeout(this.#t),this.#t=void 0)}},bg=class extends rm{#t;#e;#n;#s;#r;#a;#o;constructor(t){super(),this.#o=!1,this.#a=t.defaultOptions,this.setOptions(t.options),this.observers=[],this.#s=t.client,this.#n=this.#s.getQueryCache(),this.queryKey=t.queryKey,this.queryHash=t.queryHash,this.#t=ah(this.options),this.state=t.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get promise(){return this.#r?.promise}setOptions(t){if(this.options={...this.#a,...t},this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){const s=ah(this.options);s.data!==void 0&&(this.setState(sh(s.data,s.dataUpdatedAt)),this.#t=s)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus==="idle"&&this.#n.remove(this)}setData(t,s){const i=_c(this.state.data,t,this.options);return this.#i({data:i,type:"success",dataUpdatedAt:s?.updatedAt,manual:s?.manual}),i}setState(t,s){this.#i({type:"setState",state:t,setStateOptions:s})}cancel(t){const s=this.#r?.promise;return this.#r?.cancel(t),s?s.then(jt).catch(jt):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}reset(){this.destroy(),this.setState(this.#t)}isActive(){return this.observers.some(t=>Yt(t.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===Vc||this.state.dataUpdateCount+this.state.errorUpdateCount===0}isStatic(){return this.getObserversCount()>0?this.observers.some(t=>dr(t.options.staleTime,this)==="static"):!1}isStale(){return this.getObserversCount()>0?this.observers.some(t=>t.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(t=0){return this.state.data===void 0?!0:t==="static"?!1:this.state.isInvalidated?!0:!Zh(this.state.dataUpdatedAt,t)}onFocus(){this.observers.find(s=>s.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#r?.continue()}onOnline(){this.observers.find(s=>s.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#r?.continue()}addObserver(t){this.observers.includes(t)||(this.observers.push(t),this.clearGcTimeout(),this.#n.notify({type:"observerAdded",query:this,observer:t}))}removeObserver(t){this.observers.includes(t)&&(this.observers=this.observers.filter(s=>s!==t),this.observers.length||(this.#r&&(this.#o?this.#r.cancel({revert:!0}):this.#r.cancelRetry()),this.scheduleGc()),this.#n.notify({type:"observerRemoved",query:this,observer:t}))}getObserversCount(){return this.observers.length}invalidate(){this.state.isInvalidated||this.#i({type:"invalidate"})}async fetch(t,s){if(this.state.fetchStatus!=="idle"&&this.#r?.status()!=="rejected"){if(this.state.data!==void 0&&s?.cancelRefetch)this.cancel({silent:!0});else if(this.#r)return this.#r.continueRetry(),this.#r.promise}if(t&&this.setOptions(t),!this.options.queryFn){const m=this.observers.find(v=>v.options.queryFn);m&&this.setOptions(m.options)}const i=new AbortController,o=m=>{Object.defineProperty(m,"signal",{enumerable:!0,get:()=>(this.#o=!0,i.signal)})},c=()=>{const m=em(this.options,s),g=(()=>{const w={client:this.#s,queryKey:this.queryKey,meta:this.meta};return o(w),w})();return this.#o=!1,this.options.persister?this.options.persister(m,g,this):m(g)},h=(()=>{const m={fetchOptions:s,options:this.options,queryKey:this.queryKey,client:this.#s,state:this.state,fetchFn:c};return o(m),m})();this.options.behavior?.onFetch(h,this),this.#e=this.state,(this.state.fetchStatus==="idle"||this.state.fetchMeta!==h.fetchOptions?.meta)&&this.#i({type:"fetch",meta:h.fetchOptions?.meta}),this.#r=nm({initialPromise:s?.initialPromise,fn:h.fetchFn,onCancel:m=>{m instanceof Ec&&m.revert&&this.setState({...this.#e,fetchStatus:"idle"}),i.abort()},onFail:(m,v)=>{this.#i({type:"failed",failureCount:m,error:v})},onPause:()=>{this.#i({type:"pause"})},onContinue:()=>{this.#i({type:"continue"})},retry:h.options.retry,retryDelay:h.options.retryDelay,networkMode:h.options.networkMode,canRun:()=>!0});try{const m=await this.#r.start();if(m===void 0)throw new Error(`${this.queryHash} data is undefined`);return this.setData(m),this.#n.config.onSuccess?.(m,this),this.#n.config.onSettled?.(m,this.state.error,this),m}catch(m){if(m instanceof Ec){if(m.silent)return this.#r.promise;if(m.revert){if(this.state.data===void 0)throw m;return this.state.data}}throw this.#i({type:"error",error:m}),this.#n.config.onError?.(m,this),this.#n.config.onSettled?.(this.state.data,m,this),m}finally{this.scheduleGc()}}#i(t){const s=i=>{switch(t.type){case"failed":return{...i,fetchFailureCount:t.failureCount,fetchFailureReason:t.error};case"pause":return{...i,fetchStatus:"paused"};case"continue":return{...i,fetchStatus:"fetching"};case"fetch":return{...i,...sm(i.data,this.options),fetchMeta:t.meta??null};case"success":const o={...i,...sh(t.data,t.dataUpdatedAt),dataUpdateCount:i.dataUpdateCount+1,...!t.manual&&{fetchStatus:"idle",fetchFailureCount:0,fetchFailureReason:null}};return this.#e=t.manual?o:void 0,o;case"error":const c=t.error;return{...i,error:c,errorUpdateCount:i.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:i.fetchFailureCount+1,fetchFailureReason:c,fetchStatus:"idle",status:"error",isInvalidated:!0};case"invalidate":return{...i,isInvalidated:!0};case"setState":return{...i,...t.state}}};this.state=s(this.state),Ke.batch(()=>{this.observers.forEach(i=>{i.onQueryUpdate()}),this.#n.notify({query:this,type:"updated",action:t})})}};function sm(t,s){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:tm(s.networkMode)?"fetching":"paused",...t===void 0&&{error:null,status:"pending"}}}function sh(t,s){return{data:t,dataUpdatedAt:s??Date.now(),error:null,isInvalidated:!1,status:"success"}}function ah(t){const s=typeof t.initialData=="function"?t.initialData():t.initialData,i=s!==void 0,o=i?typeof t.initialDataUpdatedAt=="function"?t.initialDataUpdatedAt():t.initialDataUpdatedAt:0;return{data:s,dataUpdateCount:0,dataUpdatedAt:i?o??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:i?"success":"pending",fetchStatus:"idle"}}var oo=class extends zr{constructor(t,s){super(),this.options=s,this.#t=t,this.#i=null,this.#o=Cc(),this.bindMethods(),this.setOptions(s)}#t;#e=void 0;#n=void 0;#s=void 0;#r;#a;#o;#i;#h;#u;#f;#c;#d;#l;#p=new Set;bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(this.#e.addObserver(this),ih(this.#e,this.options)?this.#m():this.updateResult(),this.#y())}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return Rc(this.#e,this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return Rc(this.#e,this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,this.#j(),this.#b(),this.#e.removeObserver(this)}setOptions(t){const s=this.options,i=this.#e;if(this.options=this.#t.defaultQueryOptions(t),this.options.enabled!==void 0&&typeof this.options.enabled!="boolean"&&typeof this.options.enabled!="function"&&typeof Yt(this.options.enabled,this.#e)!="boolean")throw new Error("Expected enabled to be a boolean or a callback that returns a boolean");this.#w(),this.#e.setOptions(this.options),s._defaulted&&!va(this.options,s)&&this.#t.getQueryCache().notify({type:"observerOptionsUpdated",query:this.#e,observer:this});const o=this.hasListeners();o&&oh(this.#e,i,this.options,s)&&this.#m(),this.updateResult(),o&&(this.#e!==i||Yt(this.options.enabled,this.#e)!==Yt(s.enabled,this.#e)||dr(this.options.staleTime,this.#e)!==dr(s.staleTime,this.#e))&&this.#v();const c=this.#x();o&&(this.#e!==i||Yt(this.options.enabled,this.#e)!==Yt(s.enabled,this.#e)||c!==this.#l)&&this.#g(c)}getOptimisticResult(t){const s=this.#t.getQueryCache().build(this.#t,t),i=this.createResult(s,t);return Ng(this,i)&&(this.#s=i,this.#a=this.options,this.#r=this.#e.state),i}getCurrentResult(){return this.#s}trackResult(t,s){return new Proxy(t,{get:(i,o)=>(this.trackProp(o),s?.(o),o==="promise"&&(this.trackProp("data"),!this.options.experimental_prefetchInRender&&this.#o.status==="pending"&&this.#o.reject(new Error("experimental_prefetchInRender feature flag is not enabled"))),Reflect.get(i,o))})}trackProp(t){this.#p.add(t)}getCurrentQuery(){return this.#e}refetch({...t}={}){return this.fetch({...t})}fetchOptimistic(t){const s=this.#t.defaultQueryOptions(t),i=this.#t.getQueryCache().build(this.#t,s);return i.fetch().then(()=>this.createResult(i,s))}fetch(t){return this.#m({...t,cancelRefetch:t.cancelRefetch??!0}).then(()=>(this.updateResult(),this.#s))}#m(t){this.#w();let s=this.#e.fetch(this.options,t);return t?.throwOnError||(s=s.catch(jt)),s}#v(){this.#j();const t=dr(this.options.staleTime,this.#e);if(Lr||this.#s.isStale||!kc(t))return;const i=Zh(this.#s.dataUpdatedAt,t)+1;this.#c=Dr.setTimeout(()=>{this.#s.isStale||this.updateResult()},i)}#x(){return(typeof this.options.refetchInterval=="function"?this.options.refetchInterval(this.#e):this.options.refetchInterval)??!1}#g(t){this.#b(),this.#l=t,!(Lr||Yt(this.options.enabled,this.#e)===!1||!kc(this.#l)||this.#l===0)&&(this.#d=Dr.setInterval(()=>{(this.options.refetchIntervalInBackground||Gc.isFocused())&&this.#m()},this.#l))}#y(){this.#v(),this.#g(this.#x())}#j(){this.#c&&(Dr.clearTimeout(this.#c),this.#c=void 0)}#b(){this.#d&&(Dr.clearInterval(this.#d),this.#d=void 0)}createResult(t,s){const i=this.#e,o=this.options,c=this.#s,u=this.#r,h=this.#a,v=t!==i?t.state:this.#n,{state:g}=t;let w={...g},p=!1,N;if(s._optimisticResults){const U=this.hasListeners(),ie=!U&&ih(t,s),ue=U&&oh(t,i,s,o);(ie||ue)&&(w={...w,...sm(g.data,t.options)}),s._optimisticResults==="isRestoring"&&(w.fetchStatus="idle")}let{error:E,errorUpdatedAt:O,status:j}=w;N=w.data;let b=!1;if(s.placeholderData!==void 0&&N===void 0&&j==="pending"){let U;c?.isPlaceholderData&&s.placeholderData===h?.placeholderData?(U=c.data,b=!0):U=typeof s.placeholderData=="function"?s.placeholderData(this.#f?.state.data,this.#f):s.placeholderData,U!==void 0&&(j="success",N=_c(c?.data,U,s),p=!0)}if(s.select&&N!==void 0&&!b)if(c&&N===u?.data&&s.select===this.#h)N=this.#u;else try{this.#h=s.select,N=s.select(N),N=_c(c?.data,N,s),this.#u=N,this.#i=null}catch(U){this.#i=U}this.#i&&(E=this.#i,N=this.#u,O=Date.now(),j="error");const S=w.fetchStatus==="fetching",F=j==="pending",z=j==="error",V=F&&S,G=N!==void 0,B={status:j,fetchStatus:w.fetchStatus,isPending:F,isSuccess:j==="success",isError:z,isInitialLoading:V,isLoading:V,data:N,dataUpdatedAt:w.dataUpdatedAt,error:E,errorUpdatedAt:O,failureCount:w.fetchFailureCount,failureReason:w.fetchFailureReason,errorUpdateCount:w.errorUpdateCount,isFetched:w.dataUpdateCount>0||w.errorUpdateCount>0,isFetchedAfterMount:w.dataUpdateCount>v.dataUpdateCount||w.errorUpdateCount>v.errorUpdateCount,isFetching:S,isRefetching:S&&!F,isLoadingError:z&&!G,isPaused:w.fetchStatus==="paused",isPlaceholderData:p,isRefetchError:z&&G,isStale:Yc(t,s),refetch:this.refetch,promise:this.#o,isEnabled:Yt(s.enabled,t)!==!1};if(this.options.experimental_prefetchInRender){const U=Pe=>{B.status==="error"?Pe.reject(B.error):B.data!==void 0&&Pe.resolve(B.data)},ie=()=>{const Pe=this.#o=B.promise=Cc();U(Pe)},ue=this.#o;switch(ue.status){case"pending":t.queryHash===i.queryHash&&U(ue);break;case"fulfilled":(B.status==="error"||B.data!==ue.value)&&ie();break;case"rejected":(B.status!=="error"||B.error!==ue.reason)&&ie();break}}return B}updateResult(){const t=this.#s,s=this.createResult(this.#e,this.options);if(this.#r=this.#e.state,this.#a=this.options,this.#r.data!==void 0&&(this.#f=this.#e),va(s,t))return;this.#s=s;const i=()=>{if(!t)return!0;const{notifyOnChangeProps:o}=this.options,c=typeof o=="function"?o():o;if(c==="all"||!c&&!this.#p.size)return!0;const u=new Set(c??this.#p);return this.options.throwOnError&&u.add("error"),Object.keys(this.#s).some(h=>{const m=h;return this.#s[m]!==t[m]&&u.has(m)})};this.#N({listeners:i()})}#w(){const t=this.#t.getQueryCache().build(this.#t,this.options);if(t===this.#e)return;const s=this.#e;this.#e=t,this.#n=t.state,this.hasListeners()&&(s?.removeObserver(this),t.addObserver(this))}onQueryUpdate(){this.updateResult(),this.hasListeners()&&this.#y()}#N(t){Ke.batch(()=>{t.listeners&&this.listeners.forEach(s=>{s(this.#s)}),this.#t.getQueryCache().notify({query:this.#e,type:"observerResultsUpdated"})})}};function wg(t,s){return Yt(s.enabled,t)!==!1&&t.state.data===void 0&&!(t.state.status==="error"&&s.retryOnMount===!1)}function ih(t,s){return wg(t,s)||t.state.data!==void 0&&Rc(t,s,s.refetchOnMount)}function Rc(t,s,i){if(Yt(s.enabled,t)!==!1&&dr(s.staleTime,t)!=="static"){const o=typeof i=="function"?i(t):i;return o==="always"||o!==!1&&Yc(t,s)}return!1}function oh(t,s,i,o){return(t!==s||Yt(o.enabled,t)===!1)&&(!i.suspense||t.state.status!=="error")&&Yc(t,i)}function Yc(t,s){return Yt(s.enabled,t)!==!1&&t.isStaleByTime(dr(s.staleTime,t))}function Ng(t,s){return!va(t.getCurrentResult(),s)}function no(t){return{onFetch:(s,i)=>{const o=s.options,c=s.fetchOptions?.meta?.fetchMore?.direction,u=s.state.data?.pages||[],h=s.state.data?.pageParams||[];let m={pages:[],pageParams:[]},v=0;const g=async()=>{let w=!1;const p=O=>{pg(O,()=>s.signal,()=>w=!0)},N=em(s.options,s.fetchOptions),E=async(O,j,b)=>{if(w)return Promise.reject();if(j==null&&O.pages.length)return Promise.resolve(O);const F=(()=>{const $={client:s.client,queryKey:s.queryKey,pageParam:j,direction:b?"backward":"forward",meta:s.options.meta};return p($),$})(),z=await N(F),{maxPages:V}=s.options,G=b?mg:hg;return{pages:G(O.pages,z,V),pageParams:G(O.pageParams,j,V)}};if(c&&u.length){const O=c==="backward",j=O?am:Pc,b={pages:u,pageParams:h},S=j(o,b);m=await E(b,S,O)}else{const O=t??u.length;do{const j=v===0?h[0]??o.initialPageParam:Pc(o,m);if(v>0&&j==null)break;m=await E(m,j),v++}while(vs.options.persister?.(g,{client:s.client,queryKey:s.queryKey,meta:s.options.meta,signal:s.signal},i):s.fetchFn=g}}}function Pc(t,{pages:s,pageParams:i}){const o=s.length-1;return s.length>0?t.getNextPageParam(s[o],s,i[o],i):void 0}function am(t,{pages:s,pageParams:i}){return s.length>0?t.getPreviousPageParam?.(s[0],s,i[0],i):void 0}function kg(t,s){return s?Pc(t,s)!=null:!1}function Sg(t,s){return!s||!t.getPreviousPageParam?!1:am(t,s)!=null}var _g=class extends oo{constructor(t,s){super(t,s)}bindMethods(){super.bindMethods(),this.fetchNextPage=this.fetchNextPage.bind(this),this.fetchPreviousPage=this.fetchPreviousPage.bind(this)}setOptions(t){super.setOptions({...t,behavior:no()})}getOptimisticResult(t){return t.behavior=no(),super.getOptimisticResult(t)}fetchNextPage(t){return this.fetch({...t,meta:{fetchMore:{direction:"forward"}}})}fetchPreviousPage(t){return this.fetch({...t,meta:{fetchMore:{direction:"backward"}}})}createResult(t,s){const{state:i}=t,o=super.createResult(t,s),{isFetching:c,isRefetching:u,isError:h,isRefetchError:m}=o,v=i.fetchMeta?.fetchMore?.direction,g=h&&v==="forward",w=c&&v==="forward",p=h&&v==="backward",N=c&&v==="backward";return{...o,fetchNextPage:this.fetchNextPage,fetchPreviousPage:this.fetchPreviousPage,hasNextPage:kg(s,i.data),hasPreviousPage:Sg(s,i.data),isFetchNextPageError:g,isFetchingNextPage:w,isFetchPreviousPageError:p,isFetchingPreviousPage:N,isRefetchError:m&&!g&&!p,isRefetching:u&&!w&&!N}}},Cg=class extends rm{#t;#e;#n;#s;constructor(t){super(),this.#t=t.client,this.mutationId=t.mutationId,this.#n=t.mutationCache,this.#e=[],this.state=t.state||im(),this.setOptions(t.options),this.scheduleGc()}setOptions(t){this.options=t,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(t){this.#e.includes(t)||(this.#e.push(t),this.clearGcTimeout(),this.#n.notify({type:"observerAdded",mutation:this,observer:t}))}removeObserver(t){this.#e=this.#e.filter(s=>s!==t),this.scheduleGc(),this.#n.notify({type:"observerRemoved",mutation:this,observer:t})}optionalRemove(){this.#e.length||(this.state.status==="pending"?this.scheduleGc():this.#n.remove(this))}continue(){return this.#s?.continue()??this.execute(this.state.variables)}async execute(t){const s=()=>{this.#r({type:"continue"})},i={client:this.#t,meta:this.options.meta,mutationKey:this.options.mutationKey};this.#s=nm({fn:()=>this.options.mutationFn?this.options.mutationFn(t,i):Promise.reject(new Error("No mutationFn found")),onFail:(u,h)=>{this.#r({type:"failed",failureCount:u,error:h})},onPause:()=>{this.#r({type:"pause"})},onContinue:s,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)});const o=this.state.status==="pending",c=!this.#s.canStart();try{if(o)s();else{this.#r({type:"pending",variables:t,isPaused:c}),await this.#n.config.onMutate?.(t,this,i);const h=await this.options.onMutate?.(t,i);h!==this.state.context&&this.#r({type:"pending",context:h,variables:t,isPaused:c})}const u=await this.#s.start();return await this.#n.config.onSuccess?.(u,t,this.state.context,this,i),await this.options.onSuccess?.(u,t,this.state.context,i),await this.#n.config.onSettled?.(u,null,this.state.variables,this.state.context,this,i),await this.options.onSettled?.(u,null,t,this.state.context,i),this.#r({type:"success",data:u}),u}catch(u){try{await this.#n.config.onError?.(u,t,this.state.context,this,i)}catch(h){Promise.reject(h)}try{await this.options.onError?.(u,t,this.state.context,i)}catch(h){Promise.reject(h)}try{await this.#n.config.onSettled?.(void 0,u,this.state.variables,this.state.context,this,i)}catch(h){Promise.reject(h)}try{await this.options.onSettled?.(void 0,u,t,this.state.context,i)}catch(h){Promise.reject(h)}throw this.#r({type:"error",error:u}),u}finally{this.#n.runNext(this)}}#r(t){const s=i=>{switch(t.type){case"failed":return{...i,failureCount:t.failureCount,failureReason:t.error};case"pause":return{...i,isPaused:!0};case"continue":return{...i,isPaused:!1};case"pending":return{...i,context:t.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:t.isPaused,status:"pending",variables:t.variables,submittedAt:Date.now()};case"success":return{...i,data:t.data,failureCount:0,failureReason:null,error:null,status:"success",isPaused:!1};case"error":return{...i,data:void 0,error:t.error,failureCount:i.failureCount+1,failureReason:t.error,isPaused:!1,status:"error"}}};this.state=s(this.state),Ke.batch(()=>{this.#e.forEach(i=>{i.onMutationUpdate(t)}),this.#n.notify({mutation:this,type:"updated",action:t})})}};function im(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:"idle",variables:void 0,submittedAt:0}}var Eg=class extends zr{constructor(t={}){super(),this.config=t,this.#t=new Set,this.#e=new Map,this.#n=0}#t;#e;#n;build(t,s,i){const o=new Cg({client:t,mutationCache:this,mutationId:++this.#n,options:t.defaultMutationOptions(s),state:i});return this.add(o),o}add(t){this.#t.add(t);const s=Li(t);if(typeof s=="string"){const i=this.#e.get(s);i?i.push(t):this.#e.set(s,[t])}this.notify({type:"added",mutation:t})}remove(t){if(this.#t.delete(t)){const s=Li(t);if(typeof s=="string"){const i=this.#e.get(s);if(i)if(i.length>1){const o=i.indexOf(t);o!==-1&&i.splice(o,1)}else i[0]===t&&this.#e.delete(s)}}this.notify({type:"removed",mutation:t})}canRun(t){const s=Li(t);if(typeof s=="string"){const o=this.#e.get(s)?.find(c=>c.state.status==="pending");return!o||o===t}else return!0}runNext(t){const s=Li(t);return typeof s=="string"?this.#e.get(s)?.find(o=>o!==t&&o.state.isPaused)?.continue()??Promise.resolve():Promise.resolve()}clear(){Ke.batch(()=>{this.#t.forEach(t=>{this.notify({type:"removed",mutation:t})}),this.#t.clear(),this.#e.clear()})}getAll(){return Array.from(this.#t)}find(t){const s={exact:!0,...t};return this.getAll().find(i=>th(s,i))}findAll(t={}){return this.getAll().filter(s=>th(t,s))}notify(t){Ke.batch(()=>{this.listeners.forEach(s=>{s(t)})})}resumePausedMutations(){const t=this.getAll().filter(s=>s.state.isPaused);return Ke.batch(()=>Promise.all(t.map(s=>s.continue().catch(jt))))}};function Li(t){return t.options.scope?.id}var Rg=class extends zr{#t;#e=void 0;#n;#s;constructor(s,i){super(),this.#t=s,this.setOptions(i),this.bindMethods(),this.#r()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(s){const i=this.options;this.options=this.#t.defaultMutationOptions(s),va(this.options,i)||this.#t.getMutationCache().notify({type:"observerOptionsUpdated",mutation:this.#n,observer:this}),i?.mutationKey&&this.options.mutationKey&&Fr(i.mutationKey)!==Fr(this.options.mutationKey)?this.reset():this.#n?.state.status==="pending"&&this.#n.setOptions(this.options)}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(s){this.#r(),this.#a(s)}getCurrentResult(){return this.#e}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#r(),this.#a()}mutate(s,i){return this.#s=i,this.#n?.removeObserver(this),this.#n=this.#t.getMutationCache().build(this.#t,this.options),this.#n.addObserver(this),this.#n.execute(s)}#r(){const s=this.#n?.state??im();this.#e={...s,isPending:s.status==="pending",isSuccess:s.status==="success",isError:s.status==="error",isIdle:s.status==="idle",mutate:this.mutate,reset:this.reset}}#a(s){Ke.batch(()=>{if(this.#s&&this.hasListeners()){const i=this.#e.variables,o=this.#e.context,c={client:this.#t,meta:this.options.meta,mutationKey:this.options.mutationKey};if(s?.type==="success"){try{this.#s.onSuccess?.(s.data,i,o,c)}catch(u){Promise.reject(u)}try{this.#s.onSettled?.(s.data,null,i,o,c)}catch(u){Promise.reject(u)}}else if(s?.type==="error"){try{this.#s.onError?.(s.error,i,o,c)}catch(u){Promise.reject(u)}try{this.#s.onSettled?.(void 0,s.error,i,o,c)}catch(u){Promise.reject(u)}}}this.listeners.forEach(i=>{i(this.#e)})})}};function lh(t,s){const i=new Set(s);return t.filter(o=>!i.has(o))}function Pg(t,s,i){const o=t.slice(0);return o[s]=i,o}var Og=class extends zr{#t;#e;#n;#s;#r;#a;#o;#i;#h=[];constructor(t,s,i){super(),this.#t=t,this.#s=i,this.#n=[],this.#r=[],this.#e=[],this.setQueries(s)}onSubscribe(){this.listeners.size===1&&this.#r.forEach(t=>{t.subscribe(s=>{this.#d(t,s)})})}onUnsubscribe(){this.listeners.size||this.destroy()}destroy(){this.listeners=new Set,this.#r.forEach(t=>{t.destroy()})}setQueries(t,s){this.#n=t,this.#s=s,Ke.batch(()=>{const i=this.#r,o=this.#c(this.#n);o.forEach(w=>w.observer.setOptions(w.defaultedQueryOptions));const c=o.map(w=>w.observer),u=c.map(w=>w.getCurrentResult()),h=i.length!==c.length,m=c.some((w,p)=>w!==i[p]),v=h||m,g=v?!0:u.some((w,p)=>{const N=this.#e[p];return!N||!va(w,N)});!v&&!g||(v&&(this.#h=o,this.#r=c),this.#e=u,this.hasListeners()&&(v&&(lh(i,c).forEach(w=>{w.destroy()}),lh(c,i).forEach(w=>{w.subscribe(p=>{this.#d(w,p)})})),this.#l()))})}getCurrentResult(){return this.#e}getQueries(){return this.#r.map(t=>t.getCurrentQuery())}getObservers(){return this.#r}getOptimisticResult(t,s){const i=this.#c(t),o=i.map(c=>c.observer.getOptimisticResult(c.defaultedQueryOptions));return[o,c=>this.#f(c??o,s),()=>this.#u(o,i)]}#u(t,s){return s.map((i,o)=>{const c=t[o];return i.defaultedQueryOptions.notifyOnChangeProps?c:i.observer.trackResult(c,u=>{s.forEach(h=>{h.observer.trackProp(u)})})})}#f(t,s){return s?((!this.#a||this.#e!==this.#i||s!==this.#o)&&(this.#o=s,this.#i=this.#e,this.#a=qc(this.#a,s(t))),this.#a):t}#c(t){const s=new Map;this.#r.forEach(o=>{const c=o.options.queryHash;if(!c)return;const u=s.get(c);u?u.push(o):s.set(c,[o])});const i=[];return t.forEach(o=>{const c=this.#t.defaultQueryOptions(o),h=s.get(c.queryHash)?.shift()??new oo(this.#t,c);i.push({defaultedQueryOptions:c,observer:h})}),i}#d(t,s){const i=this.#r.indexOf(t);i!==-1&&(this.#e=Pg(this.#e,i,s),this.#l())}#l(){if(this.hasListeners()){const t=this.#a,s=this.#u(this.#e,this.#h),i=this.#f(s,this.#s?.combine);t!==i&&Ke.batch(()=>{this.listeners.forEach(o=>{o(this.#e)})})}}},Tg=class extends zr{constructor(t={}){super(),this.config=t,this.#t=new Map}#t;build(t,s,i){const o=s.queryKey,c=s.queryHash??Wc(o,s);let u=this.get(c);return u||(u=new bg({client:t,queryKey:o,queryHash:c,options:t.defaultQueryOptions(s),state:i,defaultOptions:t.getQueryDefaults(o)}),this.add(u)),u}add(t){this.#t.has(t.queryHash)||(this.#t.set(t.queryHash,t),this.notify({type:"added",query:t}))}remove(t){const s=this.#t.get(t.queryHash);s&&(t.destroy(),s===t&&this.#t.delete(t.queryHash),this.notify({type:"removed",query:t}))}clear(){Ke.batch(()=>{this.getAll().forEach(t=>{this.remove(t)})})}get(t){return this.#t.get(t)}getAll(){return[...this.#t.values()]}find(t){const s={exact:!0,...t};return this.getAll().find(i=>eh(s,i))}findAll(t={}){const s=this.getAll();return Object.keys(t).length>0?s.filter(i=>eh(t,i)):s}notify(t){Ke.batch(()=>{this.listeners.forEach(s=>{s(t)})})}onFocus(){Ke.batch(()=>{this.getAll().forEach(t=>{t.onFocus()})})}onOnline(){Ke.batch(()=>{this.getAll().forEach(t=>{t.onOnline()})})}},Ig=class{#t;#e;#n;#s;#r;#a;#o;#i;constructor(t={}){this.#t=t.queryCache||new Tg,this.#e=t.mutationCache||new Eg,this.#n=t.defaultOptions||{},this.#s=new Map,this.#r=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=Gc.subscribe(async t=>{t&&(await this.resumePausedMutations(),this.#t.onFocus())}),this.#i=to.subscribe(async t=>{t&&(await this.resumePausedMutations(),this.#t.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#i?.(),this.#i=void 0)}isFetching(t){return this.#t.findAll({...t,fetchStatus:"fetching"}).length}isMutating(t){return this.#e.findAll({...t,status:"pending"}).length}getQueryData(t){const s=this.defaultQueryOptions({queryKey:t});return this.#t.get(s.queryHash)?.state.data}ensureQueryData(t){const s=this.defaultQueryOptions(t),i=this.#t.build(this,s),o=i.state.data;return o===void 0?this.fetchQuery(t):(t.revalidateIfStale&&i.isStaleByTime(dr(s.staleTime,i))&&this.prefetchQuery(s),Promise.resolve(o))}getQueriesData(t){return this.#t.findAll(t).map(({queryKey:s,state:i})=>{const o=i.data;return[s,o]})}setQueryData(t,s,i){const o=this.defaultQueryOptions({queryKey:t}),u=this.#t.get(o.queryHash)?.state.data,h=dg(s,u);if(h!==void 0)return this.#t.build(this,o).setData(h,{...i,manual:!0})}setQueriesData(t,s,i){return Ke.batch(()=>this.#t.findAll(t).map(({queryKey:o})=>[o,this.setQueryData(o,s,i)]))}getQueryState(t){const s=this.defaultQueryOptions({queryKey:t});return this.#t.get(s.queryHash)?.state}removeQueries(t){const s=this.#t;Ke.batch(()=>{s.findAll(t).forEach(i=>{s.remove(i)})})}resetQueries(t,s){const i=this.#t;return Ke.batch(()=>(i.findAll(t).forEach(o=>{o.reset()}),this.refetchQueries({type:"active",...t},s)))}cancelQueries(t,s={}){const i={revert:!0,...s},o=Ke.batch(()=>this.#t.findAll(t).map(c=>c.cancel(i)));return Promise.all(o).then(jt).catch(jt)}invalidateQueries(t,s={}){return Ke.batch(()=>(this.#t.findAll(t).forEach(i=>{i.invalidate()}),t?.refetchType==="none"?Promise.resolve():this.refetchQueries({...t,type:t?.refetchType??t?.type??"active"},s)))}refetchQueries(t,s={}){const i={...s,cancelRefetch:s.cancelRefetch??!0},o=Ke.batch(()=>this.#t.findAll(t).filter(c=>!c.isDisabled()&&!c.isStatic()).map(c=>{let u=c.fetch(void 0,i);return i.throwOnError||(u=u.catch(jt)),c.state.fetchStatus==="paused"?Promise.resolve():u}));return Promise.all(o).then(jt)}fetchQuery(t){const s=this.defaultQueryOptions(t);s.retry===void 0&&(s.retry=!1);const i=this.#t.build(this,s);return i.isStaleByTime(dr(s.staleTime,i))?i.fetch(s):Promise.resolve(i.state.data)}prefetchQuery(t){return this.fetchQuery(t).then(jt).catch(jt)}fetchInfiniteQuery(t){return t.behavior=no(t.pages),this.fetchQuery(t)}prefetchInfiniteQuery(t){return this.fetchInfiniteQuery(t).then(jt).catch(jt)}ensureInfiniteQueryData(t){return t.behavior=no(t.pages),this.ensureQueryData(t)}resumePausedMutations(){return to.isOnline()?this.#e.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#t}getMutationCache(){return this.#e}getDefaultOptions(){return this.#n}setDefaultOptions(t){this.#n=t}setQueryDefaults(t,s){this.#s.set(Fr(t),{queryKey:t,defaultOptions:s})}getQueryDefaults(t){const s=[...this.#s.values()],i={};return s.forEach(o=>{pa(t,o.queryKey)&&Object.assign(i,o.defaultOptions)}),i}setMutationDefaults(t,s){this.#r.set(Fr(t),{mutationKey:t,defaultOptions:s})}getMutationDefaults(t){const s=[...this.#r.values()],i={};return s.forEach(o=>{pa(t,o.mutationKey)&&Object.assign(i,o.defaultOptions)}),i}defaultQueryOptions(t){if(t._defaulted)return t;const s={...this.#n.queries,...this.getQueryDefaults(t.queryKey),...t,_defaulted:!0};return s.queryHash||(s.queryHash=Wc(s.queryKey,s)),s.refetchOnReconnect===void 0&&(s.refetchOnReconnect=s.networkMode!=="always"),s.throwOnError===void 0&&(s.throwOnError=!!s.suspense),!s.networkMode&&s.persister&&(s.networkMode="offlineFirst"),s.queryFn===Vc&&(s.enabled=!1),s}defaultMutationOptions(t){return t?._defaulted?t:{...this.#n.mutations,...t?.mutationKey&&this.getMutationDefaults(t.mutationKey),...t,_defaulted:!0}}clear(){this.#t.clear(),this.#e.clear()}},om=x.createContext(void 0),Ur=t=>{const s=x.useContext(om);if(!s)throw new Error("No QueryClient set, use QueryClientProvider to set one");return s},Mg=({client:t,children:s})=>(x.useEffect(()=>(t.mount(),()=>{t.unmount()}),[t]),r.jsx(om.Provider,{value:t,children:s})),lm=x.createContext(!1),cm=()=>x.useContext(lm);lm.Provider;function Dg(){let t=!1;return{clearReset:()=>{t=!1},reset:()=>{t=!0},isReset:()=>t}}var Lg=x.createContext(Dg()),dm=()=>x.useContext(Lg),um=(t,s,i)=>{const o=i?.state.error&&typeof t.throwOnError=="function"?Kc(t.throwOnError,[i.state.error,i]):t.throwOnError;(t.suspense||t.experimental_prefetchInRender||o)&&(s.isReset()||(t.retryOnMount=!1))},fm=t=>{x.useEffect(()=>{t.clearReset()},[t])},hm=({result:t,errorResetBoundary:s,throwOnError:i,query:o,suspense:c})=>t.isError&&!s.isReset()&&!t.isFetching&&o&&(c&&t.data===void 0||Kc(i,[t.error,o])),mm=t=>{if(t.suspense){const i=c=>c==="static"?c:Math.max(c??1e3,1e3),o=t.staleTime;t.staleTime=typeof o=="function"?(...c)=>i(o(...c)):i(o),typeof t.gcTime=="number"&&(t.gcTime=Math.max(t.gcTime,1e3))}},pm=(t,s)=>t.isLoading&&t.isFetching&&!s,Oc=(t,s)=>t?.suspense&&s.isPending,ro=(t,s,i)=>s.fetchOptimistic(t).catch(()=>{i.clearReset()});function Fg({queries:t,...s},i){const o=Ur(),c=cm(),u=dm(),h=x.useMemo(()=>t.map(j=>{const b=o.defaultQueryOptions(j);return b._optimisticResults=c?"isRestoring":"optimistic",b}),[t,o,c]);h.forEach(j=>{mm(j);const b=o.getQueryCache().get(j.queryHash);um(j,u,b)}),fm(u);const[m]=x.useState(()=>new Og(o,h,s)),[v,g,w]=m.getOptimisticResult(h,s.combine),p=!c&&s.subscribed!==!1;x.useSyncExternalStore(x.useCallback(j=>p?m.subscribe(Ke.batchCalls(j)):jt,[m,p]),()=>m.getCurrentResult(),()=>m.getCurrentResult()),x.useEffect(()=>{m.setQueries(h,s)},[h,s,m]);const E=v.some((j,b)=>Oc(h[b],j))?v.flatMap((j,b)=>{const S=h[b];if(S){const F=new oo(o,S);if(Oc(S,j))return ro(S,F,u);pm(j,c)&&ro(S,F,u)}return[]}):[];if(E.length>0)throw Promise.all(E);const O=v.find((j,b)=>{const S=h[b];return S&&hm({result:j,errorResetBoundary:u,throwOnError:S.throwOnError,query:o.getQueryCache().get(S.queryHash),suspense:S.suspense})});if(O?.error)throw O.error;return g(w())}function vm(t,s,i){const o=cm(),c=dm(),u=Ur(),h=u.defaultQueryOptions(t);u.getDefaultOptions().queries?._experimental_beforeQuery?.(h);const m=u.getQueryCache().get(h.queryHash);h._optimisticResults=o?"isRestoring":"optimistic",mm(h),um(h,c,m),fm(c);const v=!u.getQueryCache().get(h.queryHash),[g]=x.useState(()=>new s(u,h)),w=g.getOptimisticResult(h),p=!o&&t.subscribed!==!1;if(x.useSyncExternalStore(x.useCallback(N=>{const E=p?g.subscribe(Ke.batchCalls(N)):jt;return g.updateResult(),E},[g,p]),()=>g.getCurrentResult(),()=>g.getCurrentResult()),x.useEffect(()=>{g.setOptions(h)},[h,g]),Oc(h,w))throw ro(h,g,c);if(hm({result:w,errorResetBoundary:c,throwOnError:h.throwOnError,query:m,suspense:h.suspense}))throw w.error;return u.getDefaultOptions().queries?._experimental_afterQuery?.(h,w),h.experimental_prefetchInRender&&!Lr&&pm(w,o)&&(v?ro(h,g,c):m?.promise)?.catch(jt).finally(()=>{g.updateResult()}),h.notifyOnChangeProps?w:g.trackResult(w)}function $e(t,s){return vm(t,oo)}function On(t,s){const i=Ur(),[o]=x.useState(()=>new Rg(i,t));x.useEffect(()=>{o.setOptions(t)},[o,t]);const c=x.useSyncExternalStore(x.useCallback(h=>o.subscribe(Ke.batchCalls(h)),[o]),()=>o.getCurrentResult(),()=>o.getCurrentResult()),u=x.useCallback((h,m)=>{o.mutate(h,m).catch(jt)},[o]);if(c.error&&Kc(o.options.throwOnError,[c.error]))throw c.error;return{...c,mutate:u,mutateAsync:c.mutate}}function xm(t,s){return vm(t,_g)}var ch="popstate";function Ag(t={}){function s(c,u){let{pathname:h="/",search:m="",hash:v=""}=$r(c.location.hash.substring(1));return!h.startsWith("/")&&!h.startsWith(".")&&(h="/"+h),Tc("",{pathname:h,search:m,hash:v},u.state&&u.state.usr||null,u.state&&u.state.key||"default")}function i(c,u){let h=c.document.querySelector("base"),m="";if(h&&h.getAttribute("href")){let v=c.location.href,g=v.indexOf("#");m=g===-1?v:v.slice(0,g)}return m+"#"+(typeof u=="string"?u:xa(u))}function o(c,u){Ut(c.pathname.charAt(0)==="/",`relative pathnames are not supported in hash history.push(${JSON.stringify(u)})`)}return Ug(s,i,o,t)}function qe(t,s){if(t===!1||t===null||typeof t>"u")throw new Error(s)}function Ut(t,s){if(!t){typeof console<"u"&&console.warn(s);try{throw new Error(s)}catch{}}}function zg(){return Math.random().toString(36).substring(2,10)}function dh(t,s){return{usr:t.state,key:t.key,idx:s}}function Tc(t,s,i=null,o){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof s=="string"?$r(s):s,state:i,key:s&&s.key||o||zg()}}function xa({pathname:t="/",search:s="",hash:i=""}){return s&&s!=="?"&&(t+=s.charAt(0)==="?"?s:"?"+s),i&&i!=="#"&&(t+=i.charAt(0)==="#"?i:"#"+i),t}function $r(t){let s={};if(t){let i=t.indexOf("#");i>=0&&(s.hash=t.substring(i),t=t.substring(0,i));let o=t.indexOf("?");o>=0&&(s.search=t.substring(o),t=t.substring(0,o)),t&&(s.pathname=t)}return s}function Ug(t,s,i,o={}){let{window:c=document.defaultView,v5Compat:u=!1}=o,h=c.history,m="POP",v=null,g=w();g==null&&(g=0,h.replaceState({...h.state,idx:g},""));function w(){return(h.state||{idx:null}).idx}function p(){m="POP";let b=w(),S=b==null?null:b-g;g=b,v&&v({action:m,location:j.location,delta:S})}function N(b,S){m="PUSH";let F=Tc(j.location,b,S);i&&i(F,b),g=w()+1;let z=dh(F,g),V=j.createHref(F);try{h.pushState(z,"",V)}catch(G){if(G instanceof DOMException&&G.name==="DataCloneError")throw G;c.location.assign(V)}u&&v&&v({action:m,location:j.location,delta:1})}function E(b,S){m="REPLACE";let F=Tc(j.location,b,S);i&&i(F,b),g=w();let z=dh(F,g),V=j.createHref(F);h.replaceState(z,"",V),u&&v&&v({action:m,location:j.location,delta:0})}function O(b){return $g(b)}let j={get action(){return m},get location(){return t(c,h)},listen(b){if(v)throw new Error("A history only accepts one active listener");return c.addEventListener(ch,p),v=b,()=>{c.removeEventListener(ch,p),v=null}},createHref(b){return s(c,b)},createURL:O,encodeLocation(b){let S=O(b);return{pathname:S.pathname,search:S.search,hash:S.hash}},push:N,replace:E,go(b){return h.go(b)}};return j}function $g(t,s=!1){let i="http://localhost";typeof window<"u"&&(i=window.location.origin!=="null"?window.location.origin:window.location.href),qe(i,"No window.location.(origin|href) available to create URL");let o=typeof t=="string"?t:xa(t);return o=o.replace(/ $/,"%20"),!s&&o.startsWith("//")&&(o=i+o),new URL(o,i)}function gm(t,s,i="/"){return Bg(t,s,i,!1)}function Bg(t,s,i,o){let c=typeof s=="string"?$r(s):s,u=In(c.pathname||"/",i);if(u==null)return null;let h=ym(t);Hg(h);let m=null;for(let v=0;m==null&&v{let w={relativePath:g===void 0?h.path||"":g,caseSensitive:h.caseSensitive===!0,childrenIndex:m,route:h};if(w.relativePath.startsWith("/")){if(!w.relativePath.startsWith(o)&&v)return;qe(w.relativePath.startsWith(o),`Absolute route path "${w.relativePath}" nested under path "${o}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),w.relativePath=w.relativePath.slice(o.length)}let p=Tn([o,w.relativePath]),N=i.concat(w);h.children&&h.children.length>0&&(qe(h.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${p}".`),ym(h.children,s,N,p,v)),!(h.path==null&&!h.index)&&s.push({path:p,score:Yg(p,h.index),routesMeta:N})};return t.forEach((h,m)=>{if(h.path===""||!h.path?.includes("?"))u(h,m);else for(let v of jm(h.path))u(h,m,!0,v)}),s}function jm(t){let s=t.split("/");if(s.length===0)return[];let[i,...o]=s,c=i.endsWith("?"),u=i.replace(/\?$/,"");if(o.length===0)return c?[u,""]:[u];let h=jm(o.join("/")),m=[];return m.push(...h.map(v=>v===""?u:[u,v].join("/"))),c&&m.push(...h),m.map(v=>t.startsWith("/")&&v===""?"/":v)}function Hg(t){t.sort((s,i)=>s.score!==i.score?i.score-s.score:Xg(s.routesMeta.map(o=>o.childrenIndex),i.routesMeta.map(o=>o.childrenIndex)))}var Qg=/^:[\w-]+$/,Wg=3,qg=2,Vg=1,Kg=10,Gg=-2,uh=t=>t==="*";function Yg(t,s){let i=t.split("/"),o=i.length;return i.some(uh)&&(o+=Gg),s&&(o+=qg),i.filter(c=>!uh(c)).reduce((c,u)=>c+(Qg.test(u)?Wg:u===""?Vg:Kg),o)}function Xg(t,s){return t.length===s.length&&t.slice(0,-1).every((o,c)=>o===s[c])?t[t.length-1]-s[s.length-1]:0}function Jg(t,s,i=!1){let{routesMeta:o}=t,c={},u="/",h=[];for(let m=0;m{if(w==="*"){let O=m[N]||"";h=u.slice(0,u.length-O.length).replace(/(.)\/+$/,"$1")}const E=m[N];return p&&!E?g[w]=void 0:g[w]=(E||"").replace(/%2F/g,"/"),g},{}),pathname:u,pathnameBase:h,pattern:t}}function Zg(t,s=!1,i=!0){Ut(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let o=[],c="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(h,m,v)=>(o.push({paramName:m,isOptional:v!=null}),v?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(o.push({paramName:"*"}),c+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):i?c+="\\/*$":t!==""&&t!=="/"&&(c+="(?:(?=\\/|$))"),[new RegExp(c,s?void 0:"i"),o]}function ey(t){try{return t.split("/").map(s=>decodeURIComponent(s).replace(/\//g,"%2F")).join("/")}catch(s){return Ut(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${s}).`),t}}function In(t,s){if(s==="/")return t;if(!t.toLowerCase().startsWith(s.toLowerCase()))return null;let i=s.endsWith("/")?s.length-1:s.length,o=t.charAt(i);return o&&o!=="/"?null:t.slice(i)||"/"}var bm=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ty=t=>bm.test(t);function ny(t,s="/"){let{pathname:i,search:o="",hash:c=""}=typeof t=="string"?$r(t):t,u;if(i)if(ty(i))u=i;else{if(i.includes("//")){let h=i;i=i.replace(/\/\/+/g,"/"),Ut(!1,`Pathnames cannot have embedded double slashes - normalizing ${h} -> ${i}`)}i.startsWith("/")?u=fh(i.substring(1),"/"):u=fh(i,s)}else u=s;return{pathname:u,search:ay(o),hash:iy(c)}}function fh(t,s){let i=s.replace(/\/+$/,"").split("/");return t.split("/").forEach(c=>{c===".."?i.length>1&&i.pop():c!=="."&&i.push(c)}),i.length>1?i.join("/"):"/"}function ic(t,s,i,o){return`Cannot include a '${t}' character in a manually specified \`to.${s}\` field [${JSON.stringify(o)}]. Please separate it out to the \`to.${i}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function ry(t){return t.filter((s,i)=>i===0||s.route.path&&s.route.path.length>0)}function wm(t){let s=ry(t);return s.map((i,o)=>o===s.length-1?i.pathname:i.pathnameBase)}function Nm(t,s,i,o=!1){let c;typeof t=="string"?c=$r(t):(c={...t},qe(!c.pathname||!c.pathname.includes("?"),ic("?","pathname","search",c)),qe(!c.pathname||!c.pathname.includes("#"),ic("#","pathname","hash",c)),qe(!c.search||!c.search.includes("#"),ic("#","search","hash",c)));let u=t===""||c.pathname==="",h=u?"/":c.pathname,m;if(h==null)m=i;else{let p=s.length-1;if(!o&&h.startsWith("..")){let N=h.split("/");for(;N[0]==="..";)N.shift(),p-=1;c.pathname=N.join("/")}m=p>=0?s[p]:"/"}let v=ny(c,m),g=h&&h!=="/"&&h.endsWith("/"),w=(u||h===".")&&i.endsWith("/");return!v.pathname.endsWith("/")&&(g||w)&&(v.pathname+="/"),v}var Tn=t=>t.join("/").replace(/\/\/+/g,"/"),sy=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),ay=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,iy=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,oy=class{constructor(t,s,i,o=!1){this.status=t,this.statusText=s||"",this.internal=o,i instanceof Error?(this.data=i.toString(),this.error=i):this.data=i}};function ly(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function cy(t){return t.map(s=>s.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var km=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Sm(t,s){let i=t;if(typeof i!="string"||!bm.test(i))return{absoluteURL:void 0,isExternal:!1,to:i};let o=i,c=!1;if(km)try{let u=new URL(window.location.href),h=i.startsWith("//")?new URL(u.protocol+i):new URL(i),m=In(h.pathname,s);h.origin===u.origin&&m!=null?i=m+h.search+h.hash:c=!0}catch{Ut(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:o,isExternal:c,to:i}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var _m=["POST","PUT","PATCH","DELETE"];new Set(_m);var dy=["GET",..._m];new Set(dy);var bs=x.createContext(null);bs.displayName="DataRouter";var lo=x.createContext(null);lo.displayName="DataRouterState";var uy=x.createContext(!1),Cm=x.createContext({isTransitioning:!1});Cm.displayName="ViewTransition";var fy=x.createContext(new Map);fy.displayName="Fetchers";var hy=x.createContext(null);hy.displayName="Await";var Xt=x.createContext(null);Xt.displayName="Navigation";var ja=x.createContext(null);ja.displayName="Location";var xn=x.createContext({outlet:null,matches:[],isDataRoute:!1});xn.displayName="Route";var Xc=x.createContext(null);Xc.displayName="RouteError";var Em="REACT_ROUTER_ERROR",my="REDIRECT",py="ROUTE_ERROR_RESPONSE";function vy(t){if(t.startsWith(`${Em}:${my}:{`))try{let s=JSON.parse(t.slice(28));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string"&&typeof s.location=="string"&&typeof s.reloadDocument=="boolean"&&typeof s.replace=="boolean")return s}catch{}}function xy(t){if(t.startsWith(`${Em}:${py}:{`))try{let s=JSON.parse(t.slice(40));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string")return new oy(s.status,s.statusText,s.data)}catch{}}function gy(t,{relative:s}={}){qe(ba(),"useHref() may be used only in the context of a component.");let{basename:i,navigator:o}=x.useContext(Xt),{hash:c,pathname:u,search:h}=wa(t,{relative:s}),m=u;return i!=="/"&&(m=u==="/"?i:Tn([i,u])),o.createHref({pathname:m,search:h,hash:c})}function ba(){return x.useContext(ja)!=null}function fr(){return qe(ba(),"useLocation() may be used only in the context of a component."),x.useContext(ja).location}var Rm="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Pm(t){x.useContext(Xt).static||x.useLayoutEffect(t)}function gn(){let{isDataRoute:t}=x.useContext(xn);return t?Oy():yy()}function yy(){qe(ba(),"useNavigate() may be used only in the context of a component.");let t=x.useContext(bs),{basename:s,navigator:i}=x.useContext(Xt),{matches:o}=x.useContext(xn),{pathname:c}=fr(),u=JSON.stringify(wm(o)),h=x.useRef(!1);return Pm(()=>{h.current=!0}),x.useCallback((v,g={})=>{if(Ut(h.current,Rm),!h.current)return;if(typeof v=="number"){i.go(v);return}let w=Nm(v,JSON.parse(u),c,g.relative==="path");t==null&&s!=="/"&&(w.pathname=w.pathname==="/"?s:Tn([s,w.pathname])),(g.replace?i.replace:i.push)(w,g.state,g)},[s,i,u,c,t])}x.createContext(null);function Mn(){let{matches:t}=x.useContext(xn),s=t[t.length-1];return s?s.params:{}}function wa(t,{relative:s}={}){let{matches:i}=x.useContext(xn),{pathname:o}=fr(),c=JSON.stringify(wm(i));return x.useMemo(()=>Nm(t,JSON.parse(c),o,s==="path"),[t,c,o,s])}function jy(t,s){return Om(t,s)}function Om(t,s,i,o,c){qe(ba(),"useRoutes() may be used only in the context of a component.");let{navigator:u}=x.useContext(Xt),{matches:h}=x.useContext(xn),m=h[h.length-1],v=m?m.params:{},g=m?m.pathname:"/",w=m?m.pathnameBase:"/",p=m&&m.route;{let F=p&&p.path||"";Im(g,!p||F.endsWith("*")||F.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${g}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. +`+f.stack}return{value:e,source:n,stack:d,digest:null}}function kl(e,n,a){return{value:e,source:null,stack:a??null,digest:n??null}}function _l(e,n){try{console.error(n.value)}catch(a){setTimeout(function(){throw a})}}var Ox=typeof WeakMap=="function"?WeakMap:Map;function Zu(e,n,a){a=Sn(-1,a),a.tag=3,a.payload={element:null};var l=n.value;return a.callback=function(){ji||(ji=!0,$l=l),_l(e,n)},a}function ef(e,n,a){a=Sn(-1,a),a.tag=3;var l=e.type.getDerivedStateFromError;if(typeof l=="function"){var d=n.value;a.payload=function(){return l(d)},a.callback=function(){_l(e,n)}}var f=e.stateNode;return f!==null&&typeof f.componentDidCatch=="function"&&(a.callback=function(){_l(e,n),typeof l!="function"&&(tr===null?tr=new Set([this]):tr.add(this));var y=n.stack;this.componentDidCatch(n.value,{componentStack:y!==null?y:""})}),a}function tf(e,n,a){var l=e.pingCache;if(l===null){l=e.pingCache=new Ox;var d=new Set;l.set(n,d)}else d=l.get(n),d===void 0&&(d=new Set,l.set(n,d));d.has(a)||(d.add(a),e=Wx.bind(null,e,n,a),n.then(e,e))}function nf(e){do{var n;if((n=e.tag===13)&&(n=e.memoizedState,n=n!==null?n.dehydrated!==null:!0),n)return e;e=e.return}while(e!==null);return null}function rf(e,n,a,l,d){return(e.mode&1)===0?(e===n?e.flags|=65536:(e.flags|=128,a.flags|=131072,a.flags&=-52805,a.tag===1&&(a.alternate===null?a.tag=17:(n=Sn(-1,1),n.tag=2,Zn(a,n,1))),a.lanes|=1),e):(e.flags|=65536,e.lanes=d,e)}var Tx=K.ReactCurrentOwner,Rt=!1;function Nt(e,n,a,l){n.child=e===null?Nu(n,null,a,l):ss(n,e.child,a,l)}function sf(e,n,a,l,d){a=a.render;var f=n.ref;return is(n,d),l=vl(e,n,a,l,f,d),a=xl(),e!==null&&!Rt?(n.updateQueue=e.updateQueue,n.flags&=-2053,e.lanes&=~d,Cn(e,n,d)):(Be&&a&&Zo(n),n.flags|=1,Nt(e,n,l,d),n.child)}function af(e,n,a,l,d){if(e===null){var f=a.type;return typeof f=="function"&&!Vl(f)&&f.defaultProps===void 0&&a.compare===null&&a.defaultProps===void 0?(n.tag=15,n.type=f,of(e,n,f,l,d)):(e=Si(a.type,null,l,n,n.mode,d),e.ref=n.ref,e.return=n,n.child=e)}if(f=e.child,(e.lanes&d)===0){var y=f.memoizedProps;if(a=a.compare,a=a!==null?a:Bs,a(y,l)&&e.ref===n.ref)return Cn(e,n,d)}return n.flags|=1,e=ar(f,l),e.ref=n.ref,e.return=n,n.child=e}function of(e,n,a,l,d){if(e!==null){var f=e.memoizedProps;if(Bs(f,l)&&e.ref===n.ref)if(Rt=!1,n.pendingProps=l=f,(e.lanes&d)!==0)(e.flags&131072)!==0&&(Rt=!0);else return n.lanes=e.lanes,Cn(e,n,d)}return Sl(e,n,a,l,d)}function lf(e,n,a){var l=n.pendingProps,d=l.children,f=e!==null?e.memoizedState:null;if(l.mode==="hidden")if((n.mode&1)===0)n.memoizedState={baseLanes:0,cachePool:null,transitions:null},Ae(ds,$t),$t|=a;else{if((a&1073741824)===0)return e=f!==null?f.baseLanes|a:a,n.lanes=n.childLanes=1073741824,n.memoizedState={baseLanes:e,cachePool:null,transitions:null},n.updateQueue=null,Ae(ds,$t),$t|=e,null;n.memoizedState={baseLanes:0,cachePool:null,transitions:null},l=f!==null?f.baseLanes:a,Ae(ds,$t),$t|=l}else f!==null?(l=f.baseLanes|a,n.memoizedState=null):l=a,Ae(ds,$t),$t|=l;return Nt(e,n,d,a),n.child}function cf(e,n){var a=n.ref;(e===null&&a!==null||e!==null&&e.ref!==a)&&(n.flags|=512,n.flags|=2097152)}function Sl(e,n,a,l,d){var f=Et(a)?wr:xt.current;return f=es(n,f),is(n,d),a=vl(e,n,a,l,f,d),l=xl(),e!==null&&!Rt?(n.updateQueue=e.updateQueue,n.flags&=-2053,e.lanes&=~d,Cn(e,n,d)):(Be&&l&&Zo(n),n.flags|=1,Nt(e,n,a,d),n.child)}function df(e,n,a,l,d){if(Et(a)){var f=!0;Xa(n)}else f=!1;if(is(n,d),n.stateNode===null)pi(e,n),Xu(n,a,l),Nl(n,a,l,d),l=!0;else if(e===null){var y=n.stateNode,k=n.memoizedProps;y.props=k;var S=y.context,L=a.contextType;typeof L=="object"&&L!==null?L=Vt(L):(L=Et(a)?wr:xt.current,L=es(n,L));var Q=a.getDerivedStateFromProps,V=typeof Q=="function"||typeof y.getSnapshotBeforeUpdate=="function";V||typeof y.UNSAFE_componentWillReceiveProps!="function"&&typeof y.componentWillReceiveProps!="function"||(k!==l||S!==L)&&Ju(n,y,l,L),Jn=!1;var H=n.memoizedState;y.state=H,ii(n,l,y,d),S=n.memoizedState,k!==l||H!==S||Ct.current||Jn?(typeof Q=="function"&&(wl(n,a,Q,l),S=n.memoizedState),(k=Jn||Yu(n,a,k,l,H,S,L))?(V||typeof y.UNSAFE_componentWillMount!="function"&&typeof y.componentWillMount!="function"||(typeof y.componentWillMount=="function"&&y.componentWillMount(),typeof y.UNSAFE_componentWillMount=="function"&&y.UNSAFE_componentWillMount()),typeof y.componentDidMount=="function"&&(n.flags|=4194308)):(typeof y.componentDidMount=="function"&&(n.flags|=4194308),n.memoizedProps=l,n.memoizedState=S),y.props=l,y.state=S,y.context=L,l=k):(typeof y.componentDidMount=="function"&&(n.flags|=4194308),l=!1)}else{y=n.stateNode,_u(e,n),k=n.memoizedProps,L=n.type===n.elementType?k:nn(n.type,k),y.props=L,V=n.pendingProps,H=y.context,S=a.contextType,typeof S=="object"&&S!==null?S=Vt(S):(S=Et(a)?wr:xt.current,S=es(n,S));var ee=a.getDerivedStateFromProps;(Q=typeof ee=="function"||typeof y.getSnapshotBeforeUpdate=="function")||typeof y.UNSAFE_componentWillReceiveProps!="function"&&typeof y.componentWillReceiveProps!="function"||(k!==V||H!==S)&&Ju(n,y,l,S),Jn=!1,H=n.memoizedState,y.state=H,ii(n,l,y,d);var re=n.memoizedState;k!==V||H!==re||Ct.current||Jn?(typeof ee=="function"&&(wl(n,a,ee,l),re=n.memoizedState),(L=Jn||Yu(n,a,L,l,H,re,S)||!1)?(Q||typeof y.UNSAFE_componentWillUpdate!="function"&&typeof y.componentWillUpdate!="function"||(typeof y.componentWillUpdate=="function"&&y.componentWillUpdate(l,re,S),typeof y.UNSAFE_componentWillUpdate=="function"&&y.UNSAFE_componentWillUpdate(l,re,S)),typeof y.componentDidUpdate=="function"&&(n.flags|=4),typeof y.getSnapshotBeforeUpdate=="function"&&(n.flags|=1024)):(typeof y.componentDidUpdate!="function"||k===e.memoizedProps&&H===e.memoizedState||(n.flags|=4),typeof y.getSnapshotBeforeUpdate!="function"||k===e.memoizedProps&&H===e.memoizedState||(n.flags|=1024),n.memoizedProps=l,n.memoizedState=re),y.props=l,y.state=re,y.context=S,l=L):(typeof y.componentDidUpdate!="function"||k===e.memoizedProps&&H===e.memoizedState||(n.flags|=4),typeof y.getSnapshotBeforeUpdate!="function"||k===e.memoizedProps&&H===e.memoizedState||(n.flags|=1024),l=!1)}return Cl(e,n,a,l,f,d)}function Cl(e,n,a,l,d,f){cf(e,n);var y=(n.flags&128)!==0;if(!l&&!y)return d&&mu(n,a,!1),Cn(e,n,f);l=n.stateNode,Tx.current=n;var k=y&&typeof a.getDerivedStateFromError!="function"?null:l.render();return n.flags|=1,e!==null&&y?(n.child=ss(n,e.child,null,f),n.child=ss(n,null,k,f)):Nt(e,n,k,f),n.memoizedState=l.state,d&&mu(n,a,!0),n.child}function uf(e){var n=e.stateNode;n.pendingContext?fu(e,n.pendingContext,n.pendingContext!==n.context):n.context&&fu(e,n.context,!1),dl(e,n.containerInfo)}function ff(e,n,a,l,d){return rs(),rl(d),n.flags|=256,Nt(e,n,a,l),n.child}var El={dehydrated:null,treeContext:null,retryLane:0};function Rl(e){return{baseLanes:e,cachePool:null,transitions:null}}function hf(e,n,a){var l=n.pendingProps,d=Qe.current,f=!1,y=(n.flags&128)!==0,k;if((k=y)||(k=e!==null&&e.memoizedState===null?!1:(d&2)!==0),k?(f=!0,n.flags&=-129):(e===null||e.memoizedState!==null)&&(d|=1),Ae(Qe,d&1),e===null)return nl(n),e=n.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?((n.mode&1)===0?n.lanes=1:e.data==="$!"?n.lanes=8:n.lanes=1073741824,null):(y=l.children,e=l.fallback,f?(l=n.mode,f=n.child,y={mode:"hidden",children:y},(l&1)===0&&f!==null?(f.childLanes=0,f.pendingProps=y):f=Ci(y,l,0,null),e=Tr(e,l,a,null),f.return=n,e.return=n,f.sibling=e,n.child=f,n.child.memoizedState=Rl(a),n.memoizedState=El,e):Pl(n,y));if(d=e.memoizedState,d!==null&&(k=d.dehydrated,k!==null))return Ix(e,n,y,l,k,d,a);if(f){f=l.fallback,y=n.mode,d=e.child,k=d.sibling;var S={mode:"hidden",children:l.children};return(y&1)===0&&n.child!==d?(l=n.child,l.childLanes=0,l.pendingProps=S,n.deletions=null):(l=ar(d,S),l.subtreeFlags=d.subtreeFlags&14680064),k!==null?f=ar(k,f):(f=Tr(f,y,a,null),f.flags|=2),f.return=n,l.return=n,l.sibling=f,n.child=l,l=f,f=n.child,y=e.child.memoizedState,y=y===null?Rl(a):{baseLanes:y.baseLanes|a,cachePool:null,transitions:y.transitions},f.memoizedState=y,f.childLanes=e.childLanes&~a,n.memoizedState=El,l}return f=e.child,e=f.sibling,l=ar(f,{mode:"visible",children:l.children}),(n.mode&1)===0&&(l.lanes=a),l.return=n,l.sibling=null,e!==null&&(a=n.deletions,a===null?(n.deletions=[e],n.flags|=16):a.push(e)),n.child=l,n.memoizedState=null,l}function Pl(e,n){return n=Ci({mode:"visible",children:n},e.mode,0,null),n.return=e,e.child=n}function mi(e,n,a,l){return l!==null&&rl(l),ss(n,e.child,null,a),e=Pl(n,n.pendingProps.children),e.flags|=2,n.memoizedState=null,e}function Ix(e,n,a,l,d,f,y){if(a)return n.flags&256?(n.flags&=-257,l=kl(Error(i(422))),mi(e,n,y,l)):n.memoizedState!==null?(n.child=e.child,n.flags|=128,null):(f=l.fallback,d=n.mode,l=Ci({mode:"visible",children:l.children},d,0,null),f=Tr(f,d,y,null),f.flags|=2,l.return=n,f.return=n,l.sibling=f,n.child=l,(n.mode&1)!==0&&ss(n,e.child,null,y),n.child.memoizedState=Rl(y),n.memoizedState=El,f);if((n.mode&1)===0)return mi(e,n,y,null);if(d.data==="$!"){if(l=d.nextSibling&&d.nextSibling.dataset,l)var k=l.dgst;return l=k,f=Error(i(419)),l=kl(f,l,void 0),mi(e,n,y,l)}if(k=(y&e.childLanes)!==0,Rt||k){if(l=ct,l!==null){switch(y&-y){case 4:d=2;break;case 16:d=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:d=32;break;case 536870912:d=268435456;break;default:d=0}d=(d&(l.suspendedLanes|y))!==0?0:d,d!==0&&d!==f.retryLane&&(f.retryLane=d,_n(e,d),an(l,e,d,-1))}return ql(),l=kl(Error(i(421))),mi(e,n,y,l)}return d.data==="$?"?(n.flags|=128,n.child=e.child,n=qx.bind(null,e),d._reactRetry=n,null):(e=f.treeContext,zt=Kn(d.nextSibling),At=n,Be=!0,tn=null,e!==null&&(Wt[qt++]=Nn,Wt[qt++]=kn,Wt[qt++]=Nr,Nn=e.id,kn=e.overflow,Nr=n),n=Pl(n,l.children),n.flags|=4096,n)}function mf(e,n,a){e.lanes|=n;var l=e.alternate;l!==null&&(l.lanes|=n),ol(e.return,n,a)}function Ol(e,n,a,l,d){var f=e.memoizedState;f===null?e.memoizedState={isBackwards:n,rendering:null,renderingStartTime:0,last:l,tail:a,tailMode:d}:(f.isBackwards=n,f.rendering=null,f.renderingStartTime=0,f.last=l,f.tail=a,f.tailMode=d)}function pf(e,n,a){var l=n.pendingProps,d=l.revealOrder,f=l.tail;if(Nt(e,n,l.children,a),l=Qe.current,(l&2)!==0)l=l&1|2,n.flags|=128;else{if(e!==null&&(e.flags&128)!==0)e:for(e=n.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&mf(e,a,n);else if(e.tag===19)mf(e,a,n);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===n)break e;for(;e.sibling===null;){if(e.return===null||e.return===n)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}l&=1}if(Ae(Qe,l),(n.mode&1)===0)n.memoizedState=null;else switch(d){case"forwards":for(a=n.child,d=null;a!==null;)e=a.alternate,e!==null&&oi(e)===null&&(d=a),a=a.sibling;a=d,a===null?(d=n.child,n.child=null):(d=a.sibling,a.sibling=null),Ol(n,!1,d,a,f);break;case"backwards":for(a=null,d=n.child,n.child=null;d!==null;){if(e=d.alternate,e!==null&&oi(e)===null){n.child=d;break}e=d.sibling,d.sibling=a,a=d,d=e}Ol(n,!0,a,null,f);break;case"together":Ol(n,!1,null,null,void 0);break;default:n.memoizedState=null}return n.child}function pi(e,n){(n.mode&1)===0&&e!==null&&(e.alternate=null,n.alternate=null,n.flags|=2)}function Cn(e,n,a){if(e!==null&&(n.dependencies=e.dependencies),Er|=n.lanes,(a&n.childLanes)===0)return null;if(e!==null&&n.child!==e.child)throw Error(i(153));if(n.child!==null){for(e=n.child,a=ar(e,e.pendingProps),n.child=a,a.return=n;e.sibling!==null;)e=e.sibling,a=a.sibling=ar(e,e.pendingProps),a.return=n;a.sibling=null}return n.child}function Mx(e,n,a){switch(n.tag){case 3:uf(n),rs();break;case 5:Eu(n);break;case 1:Et(n.type)&&Xa(n);break;case 4:dl(n,n.stateNode.containerInfo);break;case 10:var l=n.type._context,d=n.memoizedProps.value;Ae(ri,l._currentValue),l._currentValue=d;break;case 13:if(l=n.memoizedState,l!==null)return l.dehydrated!==null?(Ae(Qe,Qe.current&1),n.flags|=128,null):(a&n.child.childLanes)!==0?hf(e,n,a):(Ae(Qe,Qe.current&1),e=Cn(e,n,a),e!==null?e.sibling:null);Ae(Qe,Qe.current&1);break;case 19:if(l=(a&n.childLanes)!==0,(e.flags&128)!==0){if(l)return pf(e,n,a);n.flags|=128}if(d=n.memoizedState,d!==null&&(d.rendering=null,d.tail=null,d.lastEffect=null),Ae(Qe,Qe.current),l)break;return null;case 22:case 23:return n.lanes=0,lf(e,n,a)}return Cn(e,n,a)}var vf,Tl,xf,gf;vf=function(e,n){for(var a=n.child;a!==null;){if(a.tag===5||a.tag===6)e.appendChild(a.stateNode);else if(a.tag!==4&&a.child!==null){a.child.return=a,a=a.child;continue}if(a===n)break;for(;a.sibling===null;){if(a.return===null||a.return===n)return;a=a.return}a.sibling.return=a.return,a=a.sibling}},Tl=function(){},xf=function(e,n,a,l){var d=e.memoizedProps;if(d!==l){e=n.stateNode,Sr(un.current);var f=null;switch(a){case"input":d=pr(e,d),l=pr(e,l),f=[];break;case"select":d=W({},d,{value:void 0}),l=W({},l,{value:void 0}),f=[];break;case"textarea":d=ut(e,d),l=ut(e,l),f=[];break;default:typeof d.onClick!="function"&&typeof l.onClick=="function"&&(e.onclick=Ka)}Qr(a,l);var y;a=null;for(L in d)if(!l.hasOwnProperty(L)&&d.hasOwnProperty(L)&&d[L]!=null)if(L==="style"){var k=d[L];for(y in k)k.hasOwnProperty(y)&&(a||(a={}),a[y]="")}else L!=="dangerouslySetInnerHTML"&&L!=="children"&&L!=="suppressContentEditableWarning"&&L!=="suppressHydrationWarning"&&L!=="autoFocus"&&(c.hasOwnProperty(L)?f||(f=[]):(f=f||[]).push(L,null));for(L in l){var S=l[L];if(k=d?.[L],l.hasOwnProperty(L)&&S!==k&&(S!=null||k!=null))if(L==="style")if(k){for(y in k)!k.hasOwnProperty(y)||S&&S.hasOwnProperty(y)||(a||(a={}),a[y]="");for(y in S)S.hasOwnProperty(y)&&k[y]!==S[y]&&(a||(a={}),a[y]=S[y])}else a||(f||(f=[]),f.push(L,a)),a=S;else L==="dangerouslySetInnerHTML"?(S=S?S.__html:void 0,k=k?k.__html:void 0,S!=null&&k!==S&&(f=f||[]).push(L,S)):L==="children"?typeof S!="string"&&typeof S!="number"||(f=f||[]).push(L,""+S):L!=="suppressContentEditableWarning"&&L!=="suppressHydrationWarning"&&(c.hasOwnProperty(L)?(S!=null&&L==="onScroll"&&$e("scroll",e),f||k===S||(f=[])):(f=f||[]).push(L,S))}a&&(f=f||[]).push("style",a);var L=f;(n.updateQueue=L)&&(n.flags|=4)}},gf=function(e,n,a,l){a!==l&&(n.flags|=4)};function ra(e,n){if(!Be)switch(e.tailMode){case"hidden":n=e.tail;for(var a=null;n!==null;)n.alternate!==null&&(a=n),n=n.sibling;a===null?e.tail=null:a.sibling=null;break;case"collapsed":a=e.tail;for(var l=null;a!==null;)a.alternate!==null&&(l=a),a=a.sibling;l===null?n||e.tail===null?e.tail=null:e.tail.sibling=null:l.sibling=null}}function yt(e){var n=e.alternate!==null&&e.alternate.child===e.child,a=0,l=0;if(n)for(var d=e.child;d!==null;)a|=d.lanes|d.childLanes,l|=d.subtreeFlags&14680064,l|=d.flags&14680064,d.return=e,d=d.sibling;else for(d=e.child;d!==null;)a|=d.lanes|d.childLanes,l|=d.subtreeFlags,l|=d.flags,d.return=e,d=d.sibling;return e.subtreeFlags|=l,e.childLanes=a,n}function Dx(e,n,a){var l=n.pendingProps;switch(el(n),n.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return yt(n),null;case 1:return Et(n.type)&&Ya(),yt(n),null;case 3:return l=n.stateNode,os(),Ue(Ct),Ue(xt),hl(),l.pendingContext&&(l.context=l.pendingContext,l.pendingContext=null),(e===null||e.child===null)&&(ti(n)?n.flags|=4:e===null||e.memoizedState.isDehydrated&&(n.flags&256)===0||(n.flags|=1024,tn!==null&&(Hl(tn),tn=null))),Tl(e,n),yt(n),null;case 5:ul(n);var d=Sr(Js.current);if(a=n.type,e!==null&&n.stateNode!=null)xf(e,n,a,l,d),e.ref!==n.ref&&(n.flags|=512,n.flags|=2097152);else{if(!l){if(n.stateNode===null)throw Error(i(166));return yt(n),null}if(e=Sr(un.current),ti(n)){l=n.stateNode,a=n.type;var f=n.memoizedProps;switch(l[dn]=n,l[Vs]=f,e=(n.mode&1)!==0,a){case"dialog":$e("cancel",l),$e("close",l);break;case"iframe":case"object":case"embed":$e("load",l);break;case"video":case"audio":for(d=0;d<\/script>",e=e.removeChild(e.firstChild)):typeof l.is=="string"?e=y.createElement(a,{is:l.is}):(e=y.createElement(a),a==="select"&&(y=e,l.multiple?y.multiple=!0:l.size&&(y.size=l.size))):e=y.createElementNS(e,a),e[dn]=n,e[Vs]=l,vf(e,n,!1,!1),n.stateNode=e;e:{switch(y=Ss(a,l),a){case"dialog":$e("cancel",e),$e("close",e),d=l;break;case"iframe":case"object":case"embed":$e("load",e),d=l;break;case"video":case"audio":for(d=0;dus&&(n.flags|=128,l=!0,ra(f,!1),n.lanes=4194304)}else{if(!l)if(e=oi(y),e!==null){if(n.flags|=128,l=!0,a=e.updateQueue,a!==null&&(n.updateQueue=a,n.flags|=4),ra(f,!0),f.tail===null&&f.tailMode==="hidden"&&!y.alternate&&!Be)return yt(n),null}else 2*Ye()-f.renderingStartTime>us&&a!==1073741824&&(n.flags|=128,l=!0,ra(f,!1),n.lanes=4194304);f.isBackwards?(y.sibling=n.child,n.child=y):(a=f.last,a!==null?a.sibling=y:n.child=y,f.last=y)}return f.tail!==null?(n=f.tail,f.rendering=n,f.tail=n.sibling,f.renderingStartTime=Ye(),n.sibling=null,a=Qe.current,Ae(Qe,l?a&1|2:a&1),n):(yt(n),null);case 22:case 23:return Wl(),l=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==l&&(n.flags|=8192),l&&(n.mode&1)!==0?($t&1073741824)!==0&&(yt(n),n.subtreeFlags&6&&(n.flags|=8192)):yt(n),null;case 24:return null;case 25:return null}throw Error(i(156,n.tag))}function Lx(e,n){switch(el(n),n.tag){case 1:return Et(n.type)&&Ya(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return os(),Ue(Ct),Ue(xt),hl(),e=n.flags,(e&65536)!==0&&(e&128)===0?(n.flags=e&-65537|128,n):null;case 5:return ul(n),null;case 13:if(Ue(Qe),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(i(340));rs()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return Ue(Qe),null;case 4:return os(),null;case 10:return il(n.type._context),null;case 22:case 23:return Wl(),null;case 24:return null;default:return null}}var vi=!1,jt=!1,Fx=typeof WeakSet=="function"?WeakSet:Set,te=null;function cs(e,n){var a=e.ref;if(a!==null)if(typeof a=="function")try{a(null)}catch(l){Ve(e,n,l)}else a.current=null}function Il(e,n,a){try{a()}catch(l){Ve(e,n,l)}}var yf=!1;function Ax(e,n){if(Wo=La,e=Yd(),Fo(e)){if("selectionStart"in e)var a={start:e.selectionStart,end:e.selectionEnd};else e:{a=(a=e.ownerDocument)&&a.defaultView||window;var l=a.getSelection&&a.getSelection();if(l&&l.rangeCount!==0){a=l.anchorNode;var d=l.anchorOffset,f=l.focusNode;l=l.focusOffset;try{a.nodeType,f.nodeType}catch{a=null;break e}var y=0,k=-1,S=-1,L=0,Q=0,V=e,H=null;t:for(;;){for(var ee;V!==a||d!==0&&V.nodeType!==3||(k=y+d),V!==f||l!==0&&V.nodeType!==3||(S=y+l),V.nodeType===3&&(y+=V.nodeValue.length),(ee=V.firstChild)!==null;)H=V,V=ee;for(;;){if(V===e)break t;if(H===a&&++L===d&&(k=y),H===f&&++Q===l&&(S=y),(ee=V.nextSibling)!==null)break;V=H,H=V.parentNode}V=ee}a=k===-1||S===-1?null:{start:k,end:S}}else a=null}a=a||{start:0,end:0}}else a=null;for(qo={focusedElem:e,selectionRange:a},La=!1,te=n;te!==null;)if(n=te,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,te=e;else for(;te!==null;){n=te;try{var re=n.alternate;if((n.flags&1024)!==0)switch(n.tag){case 0:case 11:case 15:break;case 1:if(re!==null){var le=re.memoizedProps,Xe=re.memoizedState,I=n.stateNode,P=I.getSnapshotBeforeUpdate(n.elementType===n.type?le:nn(n.type,le),Xe);I.__reactInternalSnapshotBeforeUpdate=P}break;case 3:var M=n.stateNode.containerInfo;M.nodeType===1?M.textContent="":M.nodeType===9&&M.documentElement&&M.removeChild(M.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(Y){Ve(n,n.return,Y)}if(e=n.sibling,e!==null){e.return=n.return,te=e;break}te=n.return}return re=yf,yf=!1,re}function sa(e,n,a){var l=n.updateQueue;if(l=l!==null?l.lastEffect:null,l!==null){var d=l=l.next;do{if((d.tag&e)===e){var f=d.destroy;d.destroy=void 0,f!==void 0&&Il(n,a,f)}d=d.next}while(d!==l)}}function xi(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var a=n=n.next;do{if((a.tag&e)===e){var l=a.create;a.destroy=l()}a=a.next}while(a!==n)}}function Ml(e){var n=e.ref;if(n!==null){var a=e.stateNode;e.tag,e=a,typeof n=="function"?n(e):n.current=e}}function jf(e){var n=e.alternate;n!==null&&(e.alternate=null,jf(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[dn],delete n[Vs],delete n[Yo],delete n[jx],delete n[bx])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bf(e){return e.tag===5||e.tag===3||e.tag===4}function wf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||bf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Dl(e,n,a){var l=e.tag;if(l===5||l===6)e=e.stateNode,n?a.nodeType===8?a.parentNode.insertBefore(e,n):a.insertBefore(e,n):(a.nodeType===8?(n=a.parentNode,n.insertBefore(e,a)):(n=a,n.appendChild(e)),a=a._reactRootContainer,a!=null||n.onclick!==null||(n.onclick=Ka));else if(l!==4&&(e=e.child,e!==null))for(Dl(e,n,a),e=e.sibling;e!==null;)Dl(e,n,a),e=e.sibling}function Ll(e,n,a){var l=e.tag;if(l===5||l===6)e=e.stateNode,n?a.insertBefore(e,n):a.appendChild(e);else if(l!==4&&(e=e.child,e!==null))for(Ll(e,n,a),e=e.sibling;e!==null;)Ll(e,n,a),e=e.sibling}var ht=null,rn=!1;function er(e,n,a){for(a=a.child;a!==null;)Nf(e,n,a),a=a.sibling}function Nf(e,n,a){if(cn&&typeof cn.onCommitFiberUnmount=="function")try{cn.onCommitFiberUnmount(Pa,a)}catch{}switch(a.tag){case 5:jt||cs(a,n);case 6:var l=ht,d=rn;ht=null,er(e,n,a),ht=l,rn=d,ht!==null&&(rn?(e=ht,a=a.stateNode,e.nodeType===8?e.parentNode.removeChild(a):e.removeChild(a)):ht.removeChild(a.stateNode));break;case 18:ht!==null&&(rn?(e=ht,a=a.stateNode,e.nodeType===8?Go(e.parentNode,a):e.nodeType===1&&Go(e,a),Ls(e)):Go(ht,a.stateNode));break;case 4:l=ht,d=rn,ht=a.stateNode.containerInfo,rn=!0,er(e,n,a),ht=l,rn=d;break;case 0:case 11:case 14:case 15:if(!jt&&(l=a.updateQueue,l!==null&&(l=l.lastEffect,l!==null))){d=l=l.next;do{var f=d,y=f.destroy;f=f.tag,y!==void 0&&((f&2)!==0||(f&4)!==0)&&Il(a,n,y),d=d.next}while(d!==l)}er(e,n,a);break;case 1:if(!jt&&(cs(a,n),l=a.stateNode,typeof l.componentWillUnmount=="function"))try{l.props=a.memoizedProps,l.state=a.memoizedState,l.componentWillUnmount()}catch(k){Ve(a,n,k)}er(e,n,a);break;case 21:er(e,n,a);break;case 22:a.mode&1?(jt=(l=jt)||a.memoizedState!==null,er(e,n,a),jt=l):er(e,n,a);break;default:er(e,n,a)}}function kf(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var a=e.stateNode;a===null&&(a=e.stateNode=new Fx),n.forEach(function(l){var d=Vx.bind(null,e,l);a.has(l)||(a.add(l),l.then(d,d))})}}function sn(e,n){var a=n.deletions;if(a!==null)for(var l=0;ld&&(d=y),l&=~f}if(l=d,l=Ye()-l,l=(120>l?120:480>l?480:1080>l?1080:1920>l?1920:3e3>l?3e3:4320>l?4320:1960*$x(l/1960))-l,10e?16:e,nr===null)var l=!1;else{if(e=nr,nr=null,wi=0,(Me&6)!==0)throw Error(i(331));var d=Me;for(Me|=4,te=e.current;te!==null;){var f=te,y=f.child;if((te.flags&16)!==0){var k=f.deletions;if(k!==null){for(var S=0;SYe()-zl?Pr(e,0):Al|=a),Ot(e,n)}function Ff(e,n){n===0&&((e.mode&1)===0?n=1:(n=Ta,Ta<<=1,(Ta&130023424)===0&&(Ta=4194304)));var a=kt();e=_n(e,n),e!==null&&(Os(e,n,a),Ot(e,a))}function qx(e){var n=e.memoizedState,a=0;n!==null&&(a=n.retryLane),Ff(e,a)}function Vx(e,n){var a=0;switch(e.tag){case 13:var l=e.stateNode,d=e.memoizedState;d!==null&&(a=d.retryLane);break;case 19:l=e.stateNode;break;default:throw Error(i(314))}l!==null&&l.delete(n),Ff(e,a)}var Af;Af=function(e,n,a){if(e!==null)if(e.memoizedProps!==n.pendingProps||Ct.current)Rt=!0;else{if((e.lanes&a)===0&&(n.flags&128)===0)return Rt=!1,Mx(e,n,a);Rt=(e.flags&131072)!==0}else Rt=!1,Be&&(n.flags&1048576)!==0&&vu(n,ei,n.index);switch(n.lanes=0,n.tag){case 2:var l=n.type;pi(e,n),e=n.pendingProps;var d=es(n,xt.current);is(n,a),d=vl(null,n,l,e,d,a);var f=xl();return n.flags|=1,typeof d=="object"&&d!==null&&typeof d.render=="function"&&d.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,Et(l)?(f=!0,Xa(n)):f=!1,n.memoizedState=d.state!==null&&d.state!==void 0?d.state:null,cl(n),d.updater=hi,n.stateNode=d,d._reactInternals=n,Nl(n,l,e,a),n=Cl(null,n,l,!0,f,a)):(n.tag=0,Be&&f&&Zo(n),Nt(null,n,d,a),n=n.child),n;case 16:l=n.elementType;e:{switch(pi(e,n),e=n.pendingProps,d=l._init,l=d(l._payload),n.type=l,d=n.tag=Gx(l),e=nn(l,e),d){case 0:n=Sl(null,n,l,e,a);break e;case 1:n=df(null,n,l,e,a);break e;case 11:n=sf(null,n,l,e,a);break e;case 14:n=af(null,n,l,nn(l.type,e),a);break e}throw Error(i(306,l,""))}return n;case 0:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:nn(l,d),Sl(e,n,l,d,a);case 1:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:nn(l,d),df(e,n,l,d,a);case 3:e:{if(uf(n),e===null)throw Error(i(387));l=n.pendingProps,f=n.memoizedState,d=f.element,_u(e,n),ii(n,l,null,a);var y=n.memoizedState;if(l=y.element,f.isDehydrated)if(f={element:l,isDehydrated:!1,cache:y.cache,pendingSuspenseBoundaries:y.pendingSuspenseBoundaries,transitions:y.transitions},n.updateQueue.baseState=f,n.memoizedState=f,n.flags&256){d=ls(Error(i(423)),n),n=ff(e,n,l,a,d);break e}else if(l!==d){d=ls(Error(i(424)),n),n=ff(e,n,l,a,d);break e}else for(zt=Kn(n.stateNode.containerInfo.firstChild),At=n,Be=!0,tn=null,a=Nu(n,null,l,a),n.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling;else{if(rs(),l===d){n=Cn(e,n,a);break e}Nt(e,n,l,a)}n=n.child}return n;case 5:return Eu(n),e===null&&nl(n),l=n.type,d=n.pendingProps,f=e!==null?e.memoizedProps:null,y=d.children,Vo(l,d)?y=null:f!==null&&Vo(l,f)&&(n.flags|=32),cf(e,n),Nt(e,n,y,a),n.child;case 6:return e===null&&nl(n),null;case 13:return hf(e,n,a);case 4:return dl(n,n.stateNode.containerInfo),l=n.pendingProps,e===null?n.child=ss(n,null,l,a):Nt(e,n,l,a),n.child;case 11:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:nn(l,d),sf(e,n,l,d,a);case 7:return Nt(e,n,n.pendingProps,a),n.child;case 8:return Nt(e,n,n.pendingProps.children,a),n.child;case 12:return Nt(e,n,n.pendingProps.children,a),n.child;case 10:e:{if(l=n.type._context,d=n.pendingProps,f=n.memoizedProps,y=d.value,Ae(ri,l._currentValue),l._currentValue=y,f!==null)if(en(f.value,y)){if(f.children===d.children&&!Ct.current){n=Cn(e,n,a);break e}}else for(f=n.child,f!==null&&(f.return=n);f!==null;){var k=f.dependencies;if(k!==null){y=f.child;for(var S=k.firstContext;S!==null;){if(S.context===l){if(f.tag===1){S=Sn(-1,a&-a),S.tag=2;var L=f.updateQueue;if(L!==null){L=L.shared;var Q=L.pending;Q===null?S.next=S:(S.next=Q.next,Q.next=S),L.pending=S}}f.lanes|=a,S=f.alternate,S!==null&&(S.lanes|=a),ol(f.return,a,n),k.lanes|=a;break}S=S.next}}else if(f.tag===10)y=f.type===n.type?null:f.child;else if(f.tag===18){if(y=f.return,y===null)throw Error(i(341));y.lanes|=a,k=y.alternate,k!==null&&(k.lanes|=a),ol(y,a,n),y=f.sibling}else y=f.child;if(y!==null)y.return=f;else for(y=f;y!==null;){if(y===n){y=null;break}if(f=y.sibling,f!==null){f.return=y.return,y=f;break}y=y.return}f=y}Nt(e,n,d.children,a),n=n.child}return n;case 9:return d=n.type,l=n.pendingProps.children,is(n,a),d=Vt(d),l=l(d),n.flags|=1,Nt(e,n,l,a),n.child;case 14:return l=n.type,d=nn(l,n.pendingProps),d=nn(l.type,d),af(e,n,l,d,a);case 15:return of(e,n,n.type,n.pendingProps,a);case 17:return l=n.type,d=n.pendingProps,d=n.elementType===l?d:nn(l,d),pi(e,n),n.tag=1,Et(l)?(e=!0,Xa(n)):e=!1,is(n,a),Xu(n,l,d),Nl(n,l,d,a),Cl(null,n,l,!0,e,a);case 19:return pf(e,n,a);case 22:return lf(e,n,a)}throw Error(i(156,n.tag))};function zf(e,n){return xd(e,n)}function Kx(e,n,a,l){this.tag=e,this.key=a,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=l,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Yt(e,n,a,l){return new Kx(e,n,a,l)}function Vl(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Gx(e){if(typeof e=="function")return Vl(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Re)return 11;if(e===Pe)return 14}return 2}function ar(e,n){var a=e.alternate;return a===null?(a=Yt(e.tag,n,e.key,e.mode),a.elementType=e.elementType,a.type=e.type,a.stateNode=e.stateNode,a.alternate=e,e.alternate=a):(a.pendingProps=n,a.type=e.type,a.flags=0,a.subtreeFlags=0,a.deletions=null),a.flags=e.flags&14680064,a.childLanes=e.childLanes,a.lanes=e.lanes,a.child=e.child,a.memoizedProps=e.memoizedProps,a.memoizedState=e.memoizedState,a.updateQueue=e.updateQueue,n=e.dependencies,a.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},a.sibling=e.sibling,a.index=e.index,a.ref=e.ref,a}function Si(e,n,a,l,d,f){var y=2;if(l=e,typeof e=="function")Vl(e)&&(y=1);else if(typeof e=="string")y=5;else e:switch(e){case B:return Tr(a.children,d,f,n);case $:y=8,d|=8;break;case ie:return e=Yt(12,a,n,d|2),e.elementType=ie,e.lanes=f,e;case Ie:return e=Yt(13,a,n,d),e.elementType=Ie,e.lanes=f,e;case Fe:return e=Yt(19,a,n,d),e.elementType=Fe,e.lanes=f,e;case D:return Ci(a,d,f,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case fe:y=10;break e;case Oe:y=9;break e;case Re:y=11;break e;case Pe:y=14;break e;case de:y=16,l=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return n=Yt(y,a,n,d),n.elementType=e,n.type=l,n.lanes=f,n}function Tr(e,n,a,l){return e=Yt(7,e,l,n),e.lanes=a,e}function Ci(e,n,a,l){return e=Yt(22,e,l,n),e.elementType=D,e.lanes=a,e.stateNode={isHidden:!1},e}function Kl(e,n,a){return e=Yt(6,e,null,n),e.lanes=a,e}function Gl(e,n,a){return n=Yt(4,e.children!==null?e.children:[],e.key,n),n.lanes=a,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function Yx(e,n,a,l,d){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=wo(0),this.expirationTimes=wo(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=wo(0),this.identifierPrefix=l,this.onRecoverableError=d,this.mutableSourceEagerHydrationData=null}function Yl(e,n,a,l,d,f,y,k,S){return e=new Yx(e,n,a,k,S),n===1?(n=1,f===!0&&(n|=8)):n=0,f=Yt(3,null,null,n),e.current=f,f.stateNode=e,f.memoizedState={element:l,isDehydrated:a,cache:null,transitions:null,pendingSuspenseBoundaries:null},cl(f),e}function Xx(e,n,a){var l=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(s){console.error(s)}}return t(),nc.exports=cg(),nc.exports}var Zf;function dg(){if(Zf)return Mi;Zf=1;var t=Zh();return Mi.createRoot=t.createRoot,Mi.hydrateRoot=t.hydrateRoot,Mi}var ug=dg(),Ar=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(t){return this.listeners.add(t),this.onSubscribe(),()=>{this.listeners.delete(t),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},fg={setTimeout:(t,s)=>setTimeout(t,s),clearTimeout:t=>clearTimeout(t),setInterval:(t,s)=>setInterval(t,s),clearInterval:t=>clearInterval(t)},hg=class{#t=fg;#e=!1;setTimeoutProvider(t){this.#t=t}setTimeout(t,s){return this.#t.setTimeout(t,s)}clearTimeout(t){this.#t.clearTimeout(t)}setInterval(t,s){return this.#t.setInterval(t,s)}clearInterval(t){this.#t.clearInterval(t)}},Mr=new hg;function mg(t){setTimeout(t,0)}var Dr=typeof window>"u"||"Deno"in globalThis;function bt(){}function pg(t,s){return typeof t=="function"?t(s):t}function kc(t){return typeof t=="number"&&t>=0&&t!==1/0}function em(t,s){return Math.max(t+(s||0)-Date.now(),0)}function cr(t,s){return typeof t=="function"?t(s):t}function Xt(t,s){return typeof t=="function"?t(s):t}function eh(t,s){const{type:i="all",exact:o,fetchStatus:c,predicate:u,queryKey:h,stale:m}=t;if(h){if(o){if(s.queryHash!==Wc(h,s.options))return!1}else if(!ma(s.queryKey,h))return!1}if(i!=="all"){const v=s.isActive();if(i==="active"&&!v||i==="inactive"&&v)return!1}return!(typeof m=="boolean"&&s.isStale()!==m||c&&c!==s.state.fetchStatus||u&&!u(s))}function th(t,s){const{exact:i,status:o,predicate:c,mutationKey:u}=t;if(u){if(!s.options.mutationKey)return!1;if(i){if(Lr(s.options.mutationKey)!==Lr(u))return!1}else if(!ma(s.options.mutationKey,u))return!1}return!(o&&s.state.status!==o||c&&!c(s))}function Wc(t,s){return(s?.queryKeyHashFn||Lr)(t)}function Lr(t){return JSON.stringify(t,(s,i)=>_c(i)?Object.keys(i).sort().reduce((o,c)=>(o[c]=i[c],o),{}):i)}function ma(t,s){return t===s?!0:typeof t!=typeof s?!1:t&&s&&typeof t=="object"&&typeof s=="object"?Object.keys(s).every(i=>ma(t[i],s[i])):!1}var vg=Object.prototype.hasOwnProperty;function qc(t,s){if(t===s)return t;const i=nh(t)&&nh(s);if(!i&&!(_c(t)&&_c(s)))return s;const c=(i?t:Object.keys(t)).length,u=i?s:Object.keys(s),h=u.length,m=i?new Array(h):{};let v=0;for(let g=0;g{Mr.setTimeout(s,t)})}function Sc(t,s,i){return typeof i.structuralSharing=="function"?i.structuralSharing(t,s):i.structuralSharing!==!1?qc(t,s):s}function gg(t,s,i=0){const o=[...t,s];return i&&o.length>i?o.slice(1):o}function yg(t,s,i=0){const o=[s,...t];return i&&o.length>i?o.slice(0,-1):o}var Vc=Symbol();function tm(t,s){return!t.queryFn&&s?.initialPromise?()=>s.initialPromise:!t.queryFn||t.queryFn===Vc?()=>Promise.reject(new Error(`Missing queryFn: '${t.queryHash}'`)):t.queryFn}function Kc(t,s){return typeof t=="function"?t(...s):!!t}function jg(t,s,i){let o=!1,c;return Object.defineProperty(t,"signal",{enumerable:!0,get:()=>(c??=s(),o||(o=!0,c.aborted?i():c.addEventListener("abort",i,{once:!0})),c)}),t}var bg=class extends Ar{#t;#e;#n;constructor(){super(),this.#n=t=>{if(!Dr&&window.addEventListener){const s=()=>t();return window.addEventListener("visibilitychange",s,!1),()=>{window.removeEventListener("visibilitychange",s)}}}}onSubscribe(){this.#e||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#e?.(),this.#e=void 0)}setEventListener(t){this.#n=t,this.#e?.(),this.#e=t(s=>{typeof s=="boolean"?this.setFocused(s):this.onFocus()})}setFocused(t){this.#t!==t&&(this.#t=t,this.onFocus())}onFocus(){const t=this.isFocused();this.listeners.forEach(s=>{s(t)})}isFocused(){return typeof this.#t=="boolean"?this.#t:globalThis.document?.visibilityState!=="hidden"}},Gc=new bg;function Cc(){let t,s;const i=new Promise((c,u)=>{t=c,s=u});i.status="pending",i.catch(()=>{});function o(c){Object.assign(i,c),delete i.resolve,delete i.reject}return i.resolve=c=>{o({status:"fulfilled",value:c}),t(c)},i.reject=c=>{o({status:"rejected",reason:c}),s(c)},i}var wg=mg;function Ng(){let t=[],s=0,i=m=>{m()},o=m=>{m()},c=wg;const u=m=>{s?t.push(m):c(()=>{i(m)})},h=()=>{const m=t;t=[],m.length&&c(()=>{o(()=>{m.forEach(v=>{i(v)})})})};return{batch:m=>{let v;s++;try{v=m()}finally{s--,s||h()}return v},batchCalls:m=>(...v)=>{u(()=>{m(...v)})},schedule:u,setNotifyFunction:m=>{i=m},setBatchNotifyFunction:m=>{o=m},setScheduler:m=>{c=m}}}var Ke=Ng(),kg=class extends Ar{#t=!0;#e;#n;constructor(){super(),this.#n=t=>{if(!Dr&&window.addEventListener){const s=()=>t(!0),i=()=>t(!1);return window.addEventListener("online",s,!1),window.addEventListener("offline",i,!1),()=>{window.removeEventListener("online",s),window.removeEventListener("offline",i)}}}}onSubscribe(){this.#e||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#e?.(),this.#e=void 0)}setEventListener(t){this.#n=t,this.#e?.(),this.#e=t(this.setOnline.bind(this))}setOnline(t){this.#t!==t&&(this.#t=t,this.listeners.forEach(i=>{i(t)}))}isOnline(){return this.#t}},eo=new kg;function _g(t){return Math.min(1e3*2**t,3e4)}function nm(t){return(t??"online")==="online"?eo.isOnline():!0}var Ec=class extends Error{constructor(t){super("CancelledError"),this.revert=t?.revert,this.silent=t?.silent}};function rm(t){let s=!1,i=0,o;const c=Cc(),u=()=>c.status!=="pending",h=j=>{if(!u()){const w=new Ec(j);N(w),t.onCancel?.(w)}},m=()=>{s=!0},v=()=>{s=!1},g=()=>Gc.isFocused()&&(t.networkMode==="always"||eo.isOnline())&&t.canRun(),b=()=>nm(t.networkMode)&&t.canRun(),p=j=>{u()||(o?.(),c.resolve(j))},N=j=>{u()||(o?.(),c.reject(j))},E=()=>new Promise(j=>{o=w=>{(u()||g())&&j(w)},t.onPause?.()}).then(()=>{o=void 0,u()||t.onContinue?.()}),O=()=>{if(u())return;let j;const w=i===0?t.initialPromise:void 0;try{j=w??t.fn()}catch(_){j=Promise.reject(_)}Promise.resolve(j).then(p).catch(_=>{if(u())return;const F=t.retry??(Dr?0:3),A=t.retryDelay??_g,K=typeof A=="function"?A(i,_):A,G=F===!0||typeof F=="number"&&ig()?void 0:E()).then(()=>{s?N(_):O()})})};return{promise:c,status:()=>c.status,cancel:h,continue:()=>(o?.(),c),cancelRetry:m,continueRetry:v,canStart:b,start:()=>(b()?O():E().then(O),c)}}var sm=class{#t;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),kc(this.gcTime)&&(this.#t=Mr.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(t){this.gcTime=Math.max(this.gcTime||0,t??(Dr?1/0:300*1e3))}clearGcTimeout(){this.#t&&(Mr.clearTimeout(this.#t),this.#t=void 0)}},Sg=class extends sm{#t;#e;#n;#s;#r;#a;#o;constructor(t){super(),this.#o=!1,this.#a=t.defaultOptions,this.setOptions(t.options),this.observers=[],this.#s=t.client,this.#n=this.#s.getQueryCache(),this.queryKey=t.queryKey,this.queryHash=t.queryHash,this.#t=ah(this.options),this.state=t.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get promise(){return this.#r?.promise}setOptions(t){if(this.options={...this.#a,...t},this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){const s=ah(this.options);s.data!==void 0&&(this.setState(sh(s.data,s.dataUpdatedAt)),this.#t=s)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus==="idle"&&this.#n.remove(this)}setData(t,s){const i=Sc(this.state.data,t,this.options);return this.#i({data:i,type:"success",dataUpdatedAt:s?.updatedAt,manual:s?.manual}),i}setState(t,s){this.#i({type:"setState",state:t,setStateOptions:s})}cancel(t){const s=this.#r?.promise;return this.#r?.cancel(t),s?s.then(bt).catch(bt):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}reset(){this.destroy(),this.setState(this.#t)}isActive(){return this.observers.some(t=>Xt(t.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===Vc||this.state.dataUpdateCount+this.state.errorUpdateCount===0}isStatic(){return this.getObserversCount()>0?this.observers.some(t=>cr(t.options.staleTime,this)==="static"):!1}isStale(){return this.getObserversCount()>0?this.observers.some(t=>t.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(t=0){return this.state.data===void 0?!0:t==="static"?!1:this.state.isInvalidated?!0:!em(this.state.dataUpdatedAt,t)}onFocus(){this.observers.find(s=>s.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#r?.continue()}onOnline(){this.observers.find(s=>s.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#r?.continue()}addObserver(t){this.observers.includes(t)||(this.observers.push(t),this.clearGcTimeout(),this.#n.notify({type:"observerAdded",query:this,observer:t}))}removeObserver(t){this.observers.includes(t)&&(this.observers=this.observers.filter(s=>s!==t),this.observers.length||(this.#r&&(this.#o?this.#r.cancel({revert:!0}):this.#r.cancelRetry()),this.scheduleGc()),this.#n.notify({type:"observerRemoved",query:this,observer:t}))}getObserversCount(){return this.observers.length}invalidate(){this.state.isInvalidated||this.#i({type:"invalidate"})}async fetch(t,s){if(this.state.fetchStatus!=="idle"&&this.#r?.status()!=="rejected"){if(this.state.data!==void 0&&s?.cancelRefetch)this.cancel({silent:!0});else if(this.#r)return this.#r.continueRetry(),this.#r.promise}if(t&&this.setOptions(t),!this.options.queryFn){const m=this.observers.find(v=>v.options.queryFn);m&&this.setOptions(m.options)}const i=new AbortController,o=m=>{Object.defineProperty(m,"signal",{enumerable:!0,get:()=>(this.#o=!0,i.signal)})},c=()=>{const m=tm(this.options,s),g=(()=>{const b={client:this.#s,queryKey:this.queryKey,meta:this.meta};return o(b),b})();return this.#o=!1,this.options.persister?this.options.persister(m,g,this):m(g)},h=(()=>{const m={fetchOptions:s,options:this.options,queryKey:this.queryKey,client:this.#s,state:this.state,fetchFn:c};return o(m),m})();this.options.behavior?.onFetch(h,this),this.#e=this.state,(this.state.fetchStatus==="idle"||this.state.fetchMeta!==h.fetchOptions?.meta)&&this.#i({type:"fetch",meta:h.fetchOptions?.meta}),this.#r=rm({initialPromise:s?.initialPromise,fn:h.fetchFn,onCancel:m=>{m instanceof Ec&&m.revert&&this.setState({...this.#e,fetchStatus:"idle"}),i.abort()},onFail:(m,v)=>{this.#i({type:"failed",failureCount:m,error:v})},onPause:()=>{this.#i({type:"pause"})},onContinue:()=>{this.#i({type:"continue"})},retry:h.options.retry,retryDelay:h.options.retryDelay,networkMode:h.options.networkMode,canRun:()=>!0});try{const m=await this.#r.start();if(m===void 0)throw new Error(`${this.queryHash} data is undefined`);return this.setData(m),this.#n.config.onSuccess?.(m,this),this.#n.config.onSettled?.(m,this.state.error,this),m}catch(m){if(m instanceof Ec){if(m.silent)return this.#r.promise;if(m.revert){if(this.state.data===void 0)throw m;return this.state.data}}throw this.#i({type:"error",error:m}),this.#n.config.onError?.(m,this),this.#n.config.onSettled?.(this.state.data,m,this),m}finally{this.scheduleGc()}}#i(t){const s=i=>{switch(t.type){case"failed":return{...i,fetchFailureCount:t.failureCount,fetchFailureReason:t.error};case"pause":return{...i,fetchStatus:"paused"};case"continue":return{...i,fetchStatus:"fetching"};case"fetch":return{...i,...am(i.data,this.options),fetchMeta:t.meta??null};case"success":const o={...i,...sh(t.data,t.dataUpdatedAt),dataUpdateCount:i.dataUpdateCount+1,...!t.manual&&{fetchStatus:"idle",fetchFailureCount:0,fetchFailureReason:null}};return this.#e=t.manual?o:void 0,o;case"error":const c=t.error;return{...i,error:c,errorUpdateCount:i.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:i.fetchFailureCount+1,fetchFailureReason:c,fetchStatus:"idle",status:"error",isInvalidated:!0};case"invalidate":return{...i,isInvalidated:!0};case"setState":return{...i,...t.state}}};this.state=s(this.state),Ke.batch(()=>{this.observers.forEach(i=>{i.onQueryUpdate()}),this.#n.notify({query:this,type:"updated",action:t})})}};function am(t,s){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:nm(s.networkMode)?"fetching":"paused",...t===void 0&&{error:null,status:"pending"}}}function sh(t,s){return{data:t,dataUpdatedAt:s??Date.now(),error:null,isInvalidated:!1,status:"success"}}function ah(t){const s=typeof t.initialData=="function"?t.initialData():t.initialData,i=s!==void 0,o=i?typeof t.initialDataUpdatedAt=="function"?t.initialDataUpdatedAt():t.initialDataUpdatedAt:0;return{data:s,dataUpdateCount:0,dataUpdatedAt:i?o??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:i?"success":"pending",fetchStatus:"idle"}}var io=class extends Ar{constructor(t,s){super(),this.options=s,this.#t=t,this.#i=null,this.#o=Cc(),this.bindMethods(),this.setOptions(s)}#t;#e=void 0;#n=void 0;#s=void 0;#r;#a;#o;#i;#h;#u;#f;#c;#d;#l;#p=new Set;bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(this.#e.addObserver(this),ih(this.#e,this.options)?this.#m():this.updateResult(),this.#y())}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return Rc(this.#e,this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return Rc(this.#e,this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,this.#j(),this.#b(),this.#e.removeObserver(this)}setOptions(t){const s=this.options,i=this.#e;if(this.options=this.#t.defaultQueryOptions(t),this.options.enabled!==void 0&&typeof this.options.enabled!="boolean"&&typeof this.options.enabled!="function"&&typeof Xt(this.options.enabled,this.#e)!="boolean")throw new Error("Expected enabled to be a boolean or a callback that returns a boolean");this.#w(),this.#e.setOptions(this.options),s._defaulted&&!pa(this.options,s)&&this.#t.getQueryCache().notify({type:"observerOptionsUpdated",query:this.#e,observer:this});const o=this.hasListeners();o&&oh(this.#e,i,this.options,s)&&this.#m(),this.updateResult(),o&&(this.#e!==i||Xt(this.options.enabled,this.#e)!==Xt(s.enabled,this.#e)||cr(this.options.staleTime,this.#e)!==cr(s.staleTime,this.#e))&&this.#v();const c=this.#x();o&&(this.#e!==i||Xt(this.options.enabled,this.#e)!==Xt(s.enabled,this.#e)||c!==this.#l)&&this.#g(c)}getOptimisticResult(t){const s=this.#t.getQueryCache().build(this.#t,t),i=this.createResult(s,t);return Eg(this,i)&&(this.#s=i,this.#a=this.options,this.#r=this.#e.state),i}getCurrentResult(){return this.#s}trackResult(t,s){return new Proxy(t,{get:(i,o)=>(this.trackProp(o),s?.(o),o==="promise"&&(this.trackProp("data"),!this.options.experimental_prefetchInRender&&this.#o.status==="pending"&&this.#o.reject(new Error("experimental_prefetchInRender feature flag is not enabled"))),Reflect.get(i,o))})}trackProp(t){this.#p.add(t)}getCurrentQuery(){return this.#e}refetch({...t}={}){return this.fetch({...t})}fetchOptimistic(t){const s=this.#t.defaultQueryOptions(t),i=this.#t.getQueryCache().build(this.#t,s);return i.fetch().then(()=>this.createResult(i,s))}fetch(t){return this.#m({...t,cancelRefetch:t.cancelRefetch??!0}).then(()=>(this.updateResult(),this.#s))}#m(t){this.#w();let s=this.#e.fetch(this.options,t);return t?.throwOnError||(s=s.catch(bt)),s}#v(){this.#j();const t=cr(this.options.staleTime,this.#e);if(Dr||this.#s.isStale||!kc(t))return;const i=em(this.#s.dataUpdatedAt,t)+1;this.#c=Mr.setTimeout(()=>{this.#s.isStale||this.updateResult()},i)}#x(){return(typeof this.options.refetchInterval=="function"?this.options.refetchInterval(this.#e):this.options.refetchInterval)??!1}#g(t){this.#b(),this.#l=t,!(Dr||Xt(this.options.enabled,this.#e)===!1||!kc(this.#l)||this.#l===0)&&(this.#d=Mr.setInterval(()=>{(this.options.refetchIntervalInBackground||Gc.isFocused())&&this.#m()},this.#l))}#y(){this.#v(),this.#g(this.#x())}#j(){this.#c&&(Mr.clearTimeout(this.#c),this.#c=void 0)}#b(){this.#d&&(Mr.clearInterval(this.#d),this.#d=void 0)}createResult(t,s){const i=this.#e,o=this.options,c=this.#s,u=this.#r,h=this.#a,v=t!==i?t.state:this.#n,{state:g}=t;let b={...g},p=!1,N;if(s._optimisticResults){const $=this.hasListeners(),ie=!$&&ih(t,s),fe=$&&oh(t,i,s,o);(ie||fe)&&(b={...b,...am(g.data,t.options)}),s._optimisticResults==="isRestoring"&&(b.fetchStatus="idle")}let{error:E,errorUpdatedAt:O,status:j}=b;N=b.data;let w=!1;if(s.placeholderData!==void 0&&N===void 0&&j==="pending"){let $;c?.isPlaceholderData&&s.placeholderData===h?.placeholderData?($=c.data,w=!0):$=typeof s.placeholderData=="function"?s.placeholderData(this.#f?.state.data,this.#f):s.placeholderData,$!==void 0&&(j="success",N=Sc(c?.data,$,s),p=!0)}if(s.select&&N!==void 0&&!w)if(c&&N===u?.data&&s.select===this.#h)N=this.#u;else try{this.#h=s.select,N=s.select(N),N=Sc(c?.data,N,s),this.#u=N,this.#i=null}catch($){this.#i=$}this.#i&&(E=this.#i,N=this.#u,O=Date.now(),j="error");const _=b.fetchStatus==="fetching",F=j==="pending",A=j==="error",K=F&&_,G=N!==void 0,B={status:j,fetchStatus:b.fetchStatus,isPending:F,isSuccess:j==="success",isError:A,isInitialLoading:K,isLoading:K,data:N,dataUpdatedAt:b.dataUpdatedAt,error:E,errorUpdatedAt:O,failureCount:b.fetchFailureCount,failureReason:b.fetchFailureReason,errorUpdateCount:b.errorUpdateCount,isFetched:b.dataUpdateCount>0||b.errorUpdateCount>0,isFetchedAfterMount:b.dataUpdateCount>v.dataUpdateCount||b.errorUpdateCount>v.errorUpdateCount,isFetching:_,isRefetching:_&&!F,isLoadingError:A&&!G,isPaused:b.fetchStatus==="paused",isPlaceholderData:p,isRefetchError:A&&G,isStale:Yc(t,s),refetch:this.refetch,promise:this.#o,isEnabled:Xt(s.enabled,t)!==!1};if(this.options.experimental_prefetchInRender){const $=Oe=>{B.status==="error"?Oe.reject(B.error):B.data!==void 0&&Oe.resolve(B.data)},ie=()=>{const Oe=this.#o=B.promise=Cc();$(Oe)},fe=this.#o;switch(fe.status){case"pending":t.queryHash===i.queryHash&&$(fe);break;case"fulfilled":(B.status==="error"||B.data!==fe.value)&&ie();break;case"rejected":(B.status!=="error"||B.error!==fe.reason)&&ie();break}}return B}updateResult(){const t=this.#s,s=this.createResult(this.#e,this.options);if(this.#r=this.#e.state,this.#a=this.options,this.#r.data!==void 0&&(this.#f=this.#e),pa(s,t))return;this.#s=s;const i=()=>{if(!t)return!0;const{notifyOnChangeProps:o}=this.options,c=typeof o=="function"?o():o;if(c==="all"||!c&&!this.#p.size)return!0;const u=new Set(c??this.#p);return this.options.throwOnError&&u.add("error"),Object.keys(this.#s).some(h=>{const m=h;return this.#s[m]!==t[m]&&u.has(m)})};this.#N({listeners:i()})}#w(){const t=this.#t.getQueryCache().build(this.#t,this.options);if(t===this.#e)return;const s=this.#e;this.#e=t,this.#n=t.state,this.hasListeners()&&(s?.removeObserver(this),t.addObserver(this))}onQueryUpdate(){this.updateResult(),this.hasListeners()&&this.#y()}#N(t){Ke.batch(()=>{t.listeners&&this.listeners.forEach(s=>{s(this.#s)}),this.#t.getQueryCache().notify({query:this.#e,type:"observerResultsUpdated"})})}};function Cg(t,s){return Xt(s.enabled,t)!==!1&&t.state.data===void 0&&!(t.state.status==="error"&&s.retryOnMount===!1)}function ih(t,s){return Cg(t,s)||t.state.data!==void 0&&Rc(t,s,s.refetchOnMount)}function Rc(t,s,i){if(Xt(s.enabled,t)!==!1&&cr(s.staleTime,t)!=="static"){const o=typeof i=="function"?i(t):i;return o==="always"||o!==!1&&Yc(t,s)}return!1}function oh(t,s,i,o){return(t!==s||Xt(o.enabled,t)===!1)&&(!i.suspense||t.state.status!=="error")&&Yc(t,i)}function Yc(t,s){return Xt(s.enabled,t)!==!1&&t.isStaleByTime(cr(s.staleTime,t))}function Eg(t,s){return!pa(t.getCurrentResult(),s)}function to(t){return{onFetch:(s,i)=>{const o=s.options,c=s.fetchOptions?.meta?.fetchMore?.direction,u=s.state.data?.pages||[],h=s.state.data?.pageParams||[];let m={pages:[],pageParams:[]},v=0;const g=async()=>{let b=!1;const p=O=>{jg(O,()=>s.signal,()=>b=!0)},N=tm(s.options,s.fetchOptions),E=async(O,j,w)=>{if(b)return Promise.reject();if(j==null&&O.pages.length)return Promise.resolve(O);const F=(()=>{const U={client:s.client,queryKey:s.queryKey,pageParam:j,direction:w?"backward":"forward",meta:s.options.meta};return p(U),U})(),A=await N(F),{maxPages:K}=s.options,G=w?yg:gg;return{pages:G(O.pages,A,K),pageParams:G(O.pageParams,j,K)}};if(c&&u.length){const O=c==="backward",j=O?im:Pc,w={pages:u,pageParams:h},_=j(o,w);m=await E(w,_,O)}else{const O=t??u.length;do{const j=v===0?h[0]??o.initialPageParam:Pc(o,m);if(v>0&&j==null)break;m=await E(m,j),v++}while(vs.options.persister?.(g,{client:s.client,queryKey:s.queryKey,meta:s.options.meta,signal:s.signal},i):s.fetchFn=g}}}function Pc(t,{pages:s,pageParams:i}){const o=s.length-1;return s.length>0?t.getNextPageParam(s[o],s,i[o],i):void 0}function im(t,{pages:s,pageParams:i}){return s.length>0?t.getPreviousPageParam?.(s[0],s,i[0],i):void 0}function Rg(t,s){return s?Pc(t,s)!=null:!1}function Pg(t,s){return!s||!t.getPreviousPageParam?!1:im(t,s)!=null}var Og=class extends io{constructor(t,s){super(t,s)}bindMethods(){super.bindMethods(),this.fetchNextPage=this.fetchNextPage.bind(this),this.fetchPreviousPage=this.fetchPreviousPage.bind(this)}setOptions(t){super.setOptions({...t,behavior:to()})}getOptimisticResult(t){return t.behavior=to(),super.getOptimisticResult(t)}fetchNextPage(t){return this.fetch({...t,meta:{fetchMore:{direction:"forward"}}})}fetchPreviousPage(t){return this.fetch({...t,meta:{fetchMore:{direction:"backward"}}})}createResult(t,s){const{state:i}=t,o=super.createResult(t,s),{isFetching:c,isRefetching:u,isError:h,isRefetchError:m}=o,v=i.fetchMeta?.fetchMore?.direction,g=h&&v==="forward",b=c&&v==="forward",p=h&&v==="backward",N=c&&v==="backward";return{...o,fetchNextPage:this.fetchNextPage,fetchPreviousPage:this.fetchPreviousPage,hasNextPage:Rg(s,i.data),hasPreviousPage:Pg(s,i.data),isFetchNextPageError:g,isFetchingNextPage:b,isFetchPreviousPageError:p,isFetchingPreviousPage:N,isRefetchError:m&&!g&&!p,isRefetching:u&&!b&&!N}}},Tg=class extends sm{#t;#e;#n;#s;constructor(t){super(),this.#t=t.client,this.mutationId=t.mutationId,this.#n=t.mutationCache,this.#e=[],this.state=t.state||om(),this.setOptions(t.options),this.scheduleGc()}setOptions(t){this.options=t,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(t){this.#e.includes(t)||(this.#e.push(t),this.clearGcTimeout(),this.#n.notify({type:"observerAdded",mutation:this,observer:t}))}removeObserver(t){this.#e=this.#e.filter(s=>s!==t),this.scheduleGc(),this.#n.notify({type:"observerRemoved",mutation:this,observer:t})}optionalRemove(){this.#e.length||(this.state.status==="pending"?this.scheduleGc():this.#n.remove(this))}continue(){return this.#s?.continue()??this.execute(this.state.variables)}async execute(t){const s=()=>{this.#r({type:"continue"})},i={client:this.#t,meta:this.options.meta,mutationKey:this.options.mutationKey};this.#s=rm({fn:()=>this.options.mutationFn?this.options.mutationFn(t,i):Promise.reject(new Error("No mutationFn found")),onFail:(u,h)=>{this.#r({type:"failed",failureCount:u,error:h})},onPause:()=>{this.#r({type:"pause"})},onContinue:s,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)});const o=this.state.status==="pending",c=!this.#s.canStart();try{if(o)s();else{this.#r({type:"pending",variables:t,isPaused:c}),await this.#n.config.onMutate?.(t,this,i);const h=await this.options.onMutate?.(t,i);h!==this.state.context&&this.#r({type:"pending",context:h,variables:t,isPaused:c})}const u=await this.#s.start();return await this.#n.config.onSuccess?.(u,t,this.state.context,this,i),await this.options.onSuccess?.(u,t,this.state.context,i),await this.#n.config.onSettled?.(u,null,this.state.variables,this.state.context,this,i),await this.options.onSettled?.(u,null,t,this.state.context,i),this.#r({type:"success",data:u}),u}catch(u){try{await this.#n.config.onError?.(u,t,this.state.context,this,i)}catch(h){Promise.reject(h)}try{await this.options.onError?.(u,t,this.state.context,i)}catch(h){Promise.reject(h)}try{await this.#n.config.onSettled?.(void 0,u,this.state.variables,this.state.context,this,i)}catch(h){Promise.reject(h)}try{await this.options.onSettled?.(void 0,u,t,this.state.context,i)}catch(h){Promise.reject(h)}throw this.#r({type:"error",error:u}),u}finally{this.#n.runNext(this)}}#r(t){const s=i=>{switch(t.type){case"failed":return{...i,failureCount:t.failureCount,failureReason:t.error};case"pause":return{...i,isPaused:!0};case"continue":return{...i,isPaused:!1};case"pending":return{...i,context:t.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:t.isPaused,status:"pending",variables:t.variables,submittedAt:Date.now()};case"success":return{...i,data:t.data,failureCount:0,failureReason:null,error:null,status:"success",isPaused:!1};case"error":return{...i,data:void 0,error:t.error,failureCount:i.failureCount+1,failureReason:t.error,isPaused:!1,status:"error"}}};this.state=s(this.state),Ke.batch(()=>{this.#e.forEach(i=>{i.onMutationUpdate(t)}),this.#n.notify({mutation:this,type:"updated",action:t})})}};function om(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:"idle",variables:void 0,submittedAt:0}}var Ig=class extends Ar{constructor(t={}){super(),this.config=t,this.#t=new Set,this.#e=new Map,this.#n=0}#t;#e;#n;build(t,s,i){const o=new Tg({client:t,mutationCache:this,mutationId:++this.#n,options:t.defaultMutationOptions(s),state:i});return this.add(o),o}add(t){this.#t.add(t);const s=Di(t);if(typeof s=="string"){const i=this.#e.get(s);i?i.push(t):this.#e.set(s,[t])}this.notify({type:"added",mutation:t})}remove(t){if(this.#t.delete(t)){const s=Di(t);if(typeof s=="string"){const i=this.#e.get(s);if(i)if(i.length>1){const o=i.indexOf(t);o!==-1&&i.splice(o,1)}else i[0]===t&&this.#e.delete(s)}}this.notify({type:"removed",mutation:t})}canRun(t){const s=Di(t);if(typeof s=="string"){const o=this.#e.get(s)?.find(c=>c.state.status==="pending");return!o||o===t}else return!0}runNext(t){const s=Di(t);return typeof s=="string"?this.#e.get(s)?.find(o=>o!==t&&o.state.isPaused)?.continue()??Promise.resolve():Promise.resolve()}clear(){Ke.batch(()=>{this.#t.forEach(t=>{this.notify({type:"removed",mutation:t})}),this.#t.clear(),this.#e.clear()})}getAll(){return Array.from(this.#t)}find(t){const s={exact:!0,...t};return this.getAll().find(i=>th(s,i))}findAll(t={}){return this.getAll().filter(s=>th(t,s))}notify(t){Ke.batch(()=>{this.listeners.forEach(s=>{s(t)})})}resumePausedMutations(){const t=this.getAll().filter(s=>s.state.isPaused);return Ke.batch(()=>Promise.all(t.map(s=>s.continue().catch(bt))))}};function Di(t){return t.options.scope?.id}var Mg=class extends Ar{#t;#e=void 0;#n;#s;constructor(s,i){super(),this.#t=s,this.setOptions(i),this.bindMethods(),this.#r()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(s){const i=this.options;this.options=this.#t.defaultMutationOptions(s),pa(this.options,i)||this.#t.getMutationCache().notify({type:"observerOptionsUpdated",mutation:this.#n,observer:this}),i?.mutationKey&&this.options.mutationKey&&Lr(i.mutationKey)!==Lr(this.options.mutationKey)?this.reset():this.#n?.state.status==="pending"&&this.#n.setOptions(this.options)}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(s){this.#r(),this.#a(s)}getCurrentResult(){return this.#e}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#r(),this.#a()}mutate(s,i){return this.#s=i,this.#n?.removeObserver(this),this.#n=this.#t.getMutationCache().build(this.#t,this.options),this.#n.addObserver(this),this.#n.execute(s)}#r(){const s=this.#n?.state??om();this.#e={...s,isPending:s.status==="pending",isSuccess:s.status==="success",isError:s.status==="error",isIdle:s.status==="idle",mutate:this.mutate,reset:this.reset}}#a(s){Ke.batch(()=>{if(this.#s&&this.hasListeners()){const i=this.#e.variables,o=this.#e.context,c={client:this.#t,meta:this.options.meta,mutationKey:this.options.mutationKey};if(s?.type==="success"){try{this.#s.onSuccess?.(s.data,i,o,c)}catch(u){Promise.reject(u)}try{this.#s.onSettled?.(s.data,null,i,o,c)}catch(u){Promise.reject(u)}}else if(s?.type==="error"){try{this.#s.onError?.(s.error,i,o,c)}catch(u){Promise.reject(u)}try{this.#s.onSettled?.(void 0,s.error,i,o,c)}catch(u){Promise.reject(u)}}}this.listeners.forEach(i=>{i(this.#e)})})}};function lh(t,s){const i=new Set(s);return t.filter(o=>!i.has(o))}function Dg(t,s,i){const o=t.slice(0);return o[s]=i,o}var Lg=class extends Ar{#t;#e;#n;#s;#r;#a;#o;#i;#h=[];constructor(t,s,i){super(),this.#t=t,this.#s=i,this.#n=[],this.#r=[],this.#e=[],this.setQueries(s)}onSubscribe(){this.listeners.size===1&&this.#r.forEach(t=>{t.subscribe(s=>{this.#d(t,s)})})}onUnsubscribe(){this.listeners.size||this.destroy()}destroy(){this.listeners=new Set,this.#r.forEach(t=>{t.destroy()})}setQueries(t,s){this.#n=t,this.#s=s,Ke.batch(()=>{const i=this.#r,o=this.#c(this.#n);o.forEach(b=>b.observer.setOptions(b.defaultedQueryOptions));const c=o.map(b=>b.observer),u=c.map(b=>b.getCurrentResult()),h=i.length!==c.length,m=c.some((b,p)=>b!==i[p]),v=h||m,g=v?!0:u.some((b,p)=>{const N=this.#e[p];return!N||!pa(b,N)});!v&&!g||(v&&(this.#h=o,this.#r=c),this.#e=u,this.hasListeners()&&(v&&(lh(i,c).forEach(b=>{b.destroy()}),lh(c,i).forEach(b=>{b.subscribe(p=>{this.#d(b,p)})})),this.#l()))})}getCurrentResult(){return this.#e}getQueries(){return this.#r.map(t=>t.getCurrentQuery())}getObservers(){return this.#r}getOptimisticResult(t,s){const i=this.#c(t),o=i.map(c=>c.observer.getOptimisticResult(c.defaultedQueryOptions));return[o,c=>this.#f(c??o,s),()=>this.#u(o,i)]}#u(t,s){return s.map((i,o)=>{const c=t[o];return i.defaultedQueryOptions.notifyOnChangeProps?c:i.observer.trackResult(c,u=>{s.forEach(h=>{h.observer.trackProp(u)})})})}#f(t,s){return s?((!this.#a||this.#e!==this.#i||s!==this.#o)&&(this.#o=s,this.#i=this.#e,this.#a=qc(this.#a,s(t))),this.#a):t}#c(t){const s=new Map;this.#r.forEach(o=>{const c=o.options.queryHash;if(!c)return;const u=s.get(c);u?u.push(o):s.set(c,[o])});const i=[];return t.forEach(o=>{const c=this.#t.defaultQueryOptions(o),h=s.get(c.queryHash)?.shift()??new io(this.#t,c);i.push({defaultedQueryOptions:c,observer:h})}),i}#d(t,s){const i=this.#r.indexOf(t);i!==-1&&(this.#e=Dg(this.#e,i,s),this.#l())}#l(){if(this.hasListeners()){const t=this.#a,s=this.#u(this.#e,this.#h),i=this.#f(s,this.#s?.combine);t!==i&&Ke.batch(()=>{this.listeners.forEach(o=>{o(this.#e)})})}}},Fg=class extends Ar{constructor(t={}){super(),this.config=t,this.#t=new Map}#t;build(t,s,i){const o=s.queryKey,c=s.queryHash??Wc(o,s);let u=this.get(c);return u||(u=new Sg({client:t,queryKey:o,queryHash:c,options:t.defaultQueryOptions(s),state:i,defaultOptions:t.getQueryDefaults(o)}),this.add(u)),u}add(t){this.#t.has(t.queryHash)||(this.#t.set(t.queryHash,t),this.notify({type:"added",query:t}))}remove(t){const s=this.#t.get(t.queryHash);s&&(t.destroy(),s===t&&this.#t.delete(t.queryHash),this.notify({type:"removed",query:t}))}clear(){Ke.batch(()=>{this.getAll().forEach(t=>{this.remove(t)})})}get(t){return this.#t.get(t)}getAll(){return[...this.#t.values()]}find(t){const s={exact:!0,...t};return this.getAll().find(i=>eh(s,i))}findAll(t={}){const s=this.getAll();return Object.keys(t).length>0?s.filter(i=>eh(t,i)):s}notify(t){Ke.batch(()=>{this.listeners.forEach(s=>{s(t)})})}onFocus(){Ke.batch(()=>{this.getAll().forEach(t=>{t.onFocus()})})}onOnline(){Ke.batch(()=>{this.getAll().forEach(t=>{t.onOnline()})})}},Ag=class{#t;#e;#n;#s;#r;#a;#o;#i;constructor(t={}){this.#t=t.queryCache||new Fg,this.#e=t.mutationCache||new Ig,this.#n=t.defaultOptions||{},this.#s=new Map,this.#r=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=Gc.subscribe(async t=>{t&&(await this.resumePausedMutations(),this.#t.onFocus())}),this.#i=eo.subscribe(async t=>{t&&(await this.resumePausedMutations(),this.#t.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#i?.(),this.#i=void 0)}isFetching(t){return this.#t.findAll({...t,fetchStatus:"fetching"}).length}isMutating(t){return this.#e.findAll({...t,status:"pending"}).length}getQueryData(t){const s=this.defaultQueryOptions({queryKey:t});return this.#t.get(s.queryHash)?.state.data}ensureQueryData(t){const s=this.defaultQueryOptions(t),i=this.#t.build(this,s),o=i.state.data;return o===void 0?this.fetchQuery(t):(t.revalidateIfStale&&i.isStaleByTime(cr(s.staleTime,i))&&this.prefetchQuery(s),Promise.resolve(o))}getQueriesData(t){return this.#t.findAll(t).map(({queryKey:s,state:i})=>{const o=i.data;return[s,o]})}setQueryData(t,s,i){const o=this.defaultQueryOptions({queryKey:t}),u=this.#t.get(o.queryHash)?.state.data,h=pg(s,u);if(h!==void 0)return this.#t.build(this,o).setData(h,{...i,manual:!0})}setQueriesData(t,s,i){return Ke.batch(()=>this.#t.findAll(t).map(({queryKey:o})=>[o,this.setQueryData(o,s,i)]))}getQueryState(t){const s=this.defaultQueryOptions({queryKey:t});return this.#t.get(s.queryHash)?.state}removeQueries(t){const s=this.#t;Ke.batch(()=>{s.findAll(t).forEach(i=>{s.remove(i)})})}resetQueries(t,s){const i=this.#t;return Ke.batch(()=>(i.findAll(t).forEach(o=>{o.reset()}),this.refetchQueries({type:"active",...t},s)))}cancelQueries(t,s={}){const i={revert:!0,...s},o=Ke.batch(()=>this.#t.findAll(t).map(c=>c.cancel(i)));return Promise.all(o).then(bt).catch(bt)}invalidateQueries(t,s={}){return Ke.batch(()=>(this.#t.findAll(t).forEach(i=>{i.invalidate()}),t?.refetchType==="none"?Promise.resolve():this.refetchQueries({...t,type:t?.refetchType??t?.type??"active"},s)))}refetchQueries(t,s={}){const i={...s,cancelRefetch:s.cancelRefetch??!0},o=Ke.batch(()=>this.#t.findAll(t).filter(c=>!c.isDisabled()&&!c.isStatic()).map(c=>{let u=c.fetch(void 0,i);return i.throwOnError||(u=u.catch(bt)),c.state.fetchStatus==="paused"?Promise.resolve():u}));return Promise.all(o).then(bt)}fetchQuery(t){const s=this.defaultQueryOptions(t);s.retry===void 0&&(s.retry=!1);const i=this.#t.build(this,s);return i.isStaleByTime(cr(s.staleTime,i))?i.fetch(s):Promise.resolve(i.state.data)}prefetchQuery(t){return this.fetchQuery(t).then(bt).catch(bt)}fetchInfiniteQuery(t){return t.behavior=to(t.pages),this.fetchQuery(t)}prefetchInfiniteQuery(t){return this.fetchInfiniteQuery(t).then(bt).catch(bt)}ensureInfiniteQueryData(t){return t.behavior=to(t.pages),this.ensureQueryData(t)}resumePausedMutations(){return eo.isOnline()?this.#e.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#t}getMutationCache(){return this.#e}getDefaultOptions(){return this.#n}setDefaultOptions(t){this.#n=t}setQueryDefaults(t,s){this.#s.set(Lr(t),{queryKey:t,defaultOptions:s})}getQueryDefaults(t){const s=[...this.#s.values()],i={};return s.forEach(o=>{ma(t,o.queryKey)&&Object.assign(i,o.defaultOptions)}),i}setMutationDefaults(t,s){this.#r.set(Lr(t),{mutationKey:t,defaultOptions:s})}getMutationDefaults(t){const s=[...this.#r.values()],i={};return s.forEach(o=>{ma(t,o.mutationKey)&&Object.assign(i,o.defaultOptions)}),i}defaultQueryOptions(t){if(t._defaulted)return t;const s={...this.#n.queries,...this.getQueryDefaults(t.queryKey),...t,_defaulted:!0};return s.queryHash||(s.queryHash=Wc(s.queryKey,s)),s.refetchOnReconnect===void 0&&(s.refetchOnReconnect=s.networkMode!=="always"),s.throwOnError===void 0&&(s.throwOnError=!!s.suspense),!s.networkMode&&s.persister&&(s.networkMode="offlineFirst"),s.queryFn===Vc&&(s.enabled=!1),s}defaultMutationOptions(t){return t?._defaulted?t:{...this.#n.mutations,...t?.mutationKey&&this.getMutationDefaults(t.mutationKey),...t,_defaulted:!0}}clear(){this.#t.clear(),this.#e.clear()}},lm=x.createContext(void 0),zr=t=>{const s=x.useContext(lm);if(!s)throw new Error("No QueryClient set, use QueryClientProvider to set one");return s},zg=({client:t,children:s})=>(x.useEffect(()=>(t.mount(),()=>{t.unmount()}),[t]),r.jsx(lm.Provider,{value:t,children:s})),cm=x.createContext(!1),dm=()=>x.useContext(cm);cm.Provider;function $g(){let t=!1;return{clearReset:()=>{t=!1},reset:()=>{t=!0},isReset:()=>t}}var Ug=x.createContext($g()),um=()=>x.useContext(Ug),fm=(t,s,i)=>{const o=i?.state.error&&typeof t.throwOnError=="function"?Kc(t.throwOnError,[i.state.error,i]):t.throwOnError;(t.suspense||t.experimental_prefetchInRender||o)&&(s.isReset()||(t.retryOnMount=!1))},hm=t=>{x.useEffect(()=>{t.clearReset()},[t])},mm=({result:t,errorResetBoundary:s,throwOnError:i,query:o,suspense:c})=>t.isError&&!s.isReset()&&!t.isFetching&&o&&(c&&t.data===void 0||Kc(i,[t.error,o])),pm=t=>{if(t.suspense){const i=c=>c==="static"?c:Math.max(c??1e3,1e3),o=t.staleTime;t.staleTime=typeof o=="function"?(...c)=>i(o(...c)):i(o),typeof t.gcTime=="number"&&(t.gcTime=Math.max(t.gcTime,1e3))}},vm=(t,s)=>t.isLoading&&t.isFetching&&!s,Oc=(t,s)=>t?.suspense&&s.isPending,no=(t,s,i)=>s.fetchOptimistic(t).catch(()=>{i.clearReset()});function Bg({queries:t,...s},i){const o=zr(),c=dm(),u=um(),h=x.useMemo(()=>t.map(j=>{const w=o.defaultQueryOptions(j);return w._optimisticResults=c?"isRestoring":"optimistic",w}),[t,o,c]);h.forEach(j=>{pm(j);const w=o.getQueryCache().get(j.queryHash);fm(j,u,w)}),hm(u);const[m]=x.useState(()=>new Lg(o,h,s)),[v,g,b]=m.getOptimisticResult(h,s.combine),p=!c&&s.subscribed!==!1;x.useSyncExternalStore(x.useCallback(j=>p?m.subscribe(Ke.batchCalls(j)):bt,[m,p]),()=>m.getCurrentResult(),()=>m.getCurrentResult()),x.useEffect(()=>{m.setQueries(h,s)},[h,s,m]);const E=v.some((j,w)=>Oc(h[w],j))?v.flatMap((j,w)=>{const _=h[w];if(_){const F=new io(o,_);if(Oc(_,j))return no(_,F,u);vm(j,c)&&no(_,F,u)}return[]}):[];if(E.length>0)throw Promise.all(E);const O=v.find((j,w)=>{const _=h[w];return _&&mm({result:j,errorResetBoundary:u,throwOnError:_.throwOnError,query:o.getQueryCache().get(_.queryHash),suspense:_.suspense})});if(O?.error)throw O.error;return g(b())}function xm(t,s,i){const o=dm(),c=um(),u=zr(),h=u.defaultQueryOptions(t);u.getDefaultOptions().queries?._experimental_beforeQuery?.(h);const m=u.getQueryCache().get(h.queryHash);h._optimisticResults=o?"isRestoring":"optimistic",pm(h),fm(h,c,m),hm(c);const v=!u.getQueryCache().get(h.queryHash),[g]=x.useState(()=>new s(u,h)),b=g.getOptimisticResult(h),p=!o&&t.subscribed!==!1;if(x.useSyncExternalStore(x.useCallback(N=>{const E=p?g.subscribe(Ke.batchCalls(N)):bt;return g.updateResult(),E},[g,p]),()=>g.getCurrentResult(),()=>g.getCurrentResult()),x.useEffect(()=>{g.setOptions(h)},[h,g]),Oc(h,b))throw no(h,g,c);if(mm({result:b,errorResetBoundary:c,throwOnError:h.throwOnError,query:m,suspense:h.suspense}))throw b.error;return u.getDefaultOptions().queries?._experimental_afterQuery?.(h,b),h.experimental_prefetchInRender&&!Dr&&vm(b,o)&&(v?no(h,g,c):m?.promise)?.catch(bt).finally(()=>{g.updateResult()}),h.notifyOnChangeProps?b:g.trackResult(b)}function ze(t,s){return xm(t,io)}function Pn(t,s){const i=zr(),[o]=x.useState(()=>new Mg(i,t));x.useEffect(()=>{o.setOptions(t)},[o,t]);const c=x.useSyncExternalStore(x.useCallback(h=>o.subscribe(Ke.batchCalls(h)),[o]),()=>o.getCurrentResult(),()=>o.getCurrentResult()),u=x.useCallback((h,m)=>{o.mutate(h,m).catch(bt)},[o]);if(c.error&&Kc(o.options.throwOnError,[c.error]))throw c.error;return{...c,mutate:u,mutateAsync:c.mutate}}function gm(t,s){return xm(t,Og)}var ch="popstate";function Hg(t={}){function s(c,u){let{pathname:h="/",search:m="",hash:v=""}=$r(c.location.hash.substring(1));return!h.startsWith("/")&&!h.startsWith(".")&&(h="/"+h),Tc("",{pathname:h,search:m,hash:v},u.state&&u.state.usr||null,u.state&&u.state.key||"default")}function i(c,u){let h=c.document.querySelector("base"),m="";if(h&&h.getAttribute("href")){let v=c.location.href,g=v.indexOf("#");m=g===-1?v:v.slice(0,g)}return m+"#"+(typeof u=="string"?u:va(u))}function o(c,u){Ut(c.pathname.charAt(0)==="/",`relative pathnames are not supported in hash history.push(${JSON.stringify(u)})`)}return Wg(s,i,o,t)}function qe(t,s){if(t===!1||t===null||typeof t>"u")throw new Error(s)}function Ut(t,s){if(!t){typeof console<"u"&&console.warn(s);try{throw new Error(s)}catch{}}}function Qg(){return Math.random().toString(36).substring(2,10)}function dh(t,s){return{usr:t.state,key:t.key,idx:s}}function Tc(t,s,i=null,o){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof s=="string"?$r(s):s,state:i,key:s&&s.key||o||Qg()}}function va({pathname:t="/",search:s="",hash:i=""}){return s&&s!=="?"&&(t+=s.charAt(0)==="?"?s:"?"+s),i&&i!=="#"&&(t+=i.charAt(0)==="#"?i:"#"+i),t}function $r(t){let s={};if(t){let i=t.indexOf("#");i>=0&&(s.hash=t.substring(i),t=t.substring(0,i));let o=t.indexOf("?");o>=0&&(s.search=t.substring(o),t=t.substring(0,o)),t&&(s.pathname=t)}return s}function Wg(t,s,i,o={}){let{window:c=document.defaultView,v5Compat:u=!1}=o,h=c.history,m="POP",v=null,g=b();g==null&&(g=0,h.replaceState({...h.state,idx:g},""));function b(){return(h.state||{idx:null}).idx}function p(){m="POP";let w=b(),_=w==null?null:w-g;g=w,v&&v({action:m,location:j.location,delta:_})}function N(w,_){m="PUSH";let F=Tc(j.location,w,_);i&&i(F,w),g=b()+1;let A=dh(F,g),K=j.createHref(F);try{h.pushState(A,"",K)}catch(G){if(G instanceof DOMException&&G.name==="DataCloneError")throw G;c.location.assign(K)}u&&v&&v({action:m,location:j.location,delta:1})}function E(w,_){m="REPLACE";let F=Tc(j.location,w,_);i&&i(F,w),g=b();let A=dh(F,g),K=j.createHref(F);h.replaceState(A,"",K),u&&v&&v({action:m,location:j.location,delta:0})}function O(w){return qg(w)}let j={get action(){return m},get location(){return t(c,h)},listen(w){if(v)throw new Error("A history only accepts one active listener");return c.addEventListener(ch,p),v=w,()=>{c.removeEventListener(ch,p),v=null}},createHref(w){return s(c,w)},createURL:O,encodeLocation(w){let _=O(w);return{pathname:_.pathname,search:_.search,hash:_.hash}},push:N,replace:E,go(w){return h.go(w)}};return j}function qg(t,s=!1){let i="http://localhost";typeof window<"u"&&(i=window.location.origin!=="null"?window.location.origin:window.location.href),qe(i,"No window.location.(origin|href) available to create URL");let o=typeof t=="string"?t:va(t);return o=o.replace(/ $/,"%20"),!s&&o.startsWith("//")&&(o=i+o),new URL(o,i)}function ym(t,s,i="/"){return Vg(t,s,i,!1)}function Vg(t,s,i,o){let c=typeof s=="string"?$r(s):s,u=Tn(c.pathname||"/",i);if(u==null)return null;let h=jm(t);Kg(h);let m=null;for(let v=0;m==null&&v{let b={relativePath:g===void 0?h.path||"":g,caseSensitive:h.caseSensitive===!0,childrenIndex:m,route:h};if(b.relativePath.startsWith("/")){if(!b.relativePath.startsWith(o)&&v)return;qe(b.relativePath.startsWith(o),`Absolute route path "${b.relativePath}" nested under path "${o}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),b.relativePath=b.relativePath.slice(o.length)}let p=On([o,b.relativePath]),N=i.concat(b);h.children&&h.children.length>0&&(qe(h.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${p}".`),jm(h.children,s,N,p,v)),!(h.path==null&&!h.index)&&s.push({path:p,score:ty(p,h.index),routesMeta:N})};return t.forEach((h,m)=>{if(h.path===""||!h.path?.includes("?"))u(h,m);else for(let v of bm(h.path))u(h,m,!0,v)}),s}function bm(t){let s=t.split("/");if(s.length===0)return[];let[i,...o]=s,c=i.endsWith("?"),u=i.replace(/\?$/,"");if(o.length===0)return c?[u,""]:[u];let h=bm(o.join("/")),m=[];return m.push(...h.map(v=>v===""?u:[u,v].join("/"))),c&&m.push(...h),m.map(v=>t.startsWith("/")&&v===""?"/":v)}function Kg(t){t.sort((s,i)=>s.score!==i.score?i.score-s.score:ny(s.routesMeta.map(o=>o.childrenIndex),i.routesMeta.map(o=>o.childrenIndex)))}var Gg=/^:[\w-]+$/,Yg=3,Xg=2,Jg=1,Zg=10,ey=-2,uh=t=>t==="*";function ty(t,s){let i=t.split("/"),o=i.length;return i.some(uh)&&(o+=ey),s&&(o+=Xg),i.filter(c=>!uh(c)).reduce((c,u)=>c+(Gg.test(u)?Yg:u===""?Jg:Zg),o)}function ny(t,s){return t.length===s.length&&t.slice(0,-1).every((o,c)=>o===s[c])?t[t.length-1]-s[s.length-1]:0}function ry(t,s,i=!1){let{routesMeta:o}=t,c={},u="/",h=[];for(let m=0;m{if(b==="*"){let O=m[N]||"";h=u.slice(0,u.length-O.length).replace(/(.)\/+$/,"$1")}const E=m[N];return p&&!E?g[b]=void 0:g[b]=(E||"").replace(/%2F/g,"/"),g},{}),pathname:u,pathnameBase:h,pattern:t}}function sy(t,s=!1,i=!0){Ut(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let o=[],c="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(h,m,v)=>(o.push({paramName:m,isOptional:v!=null}),v?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(o.push({paramName:"*"}),c+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):i?c+="\\/*$":t!==""&&t!=="/"&&(c+="(?:(?=\\/|$))"),[new RegExp(c,s?void 0:"i"),o]}function ay(t){try{return t.split("/").map(s=>decodeURIComponent(s).replace(/\//g,"%2F")).join("/")}catch(s){return Ut(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${s}).`),t}}function Tn(t,s){if(s==="/")return t;if(!t.toLowerCase().startsWith(s.toLowerCase()))return null;let i=s.endsWith("/")?s.length-1:s.length,o=t.charAt(i);return o&&o!=="/"?null:t.slice(i)||"/"}var wm=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,iy=t=>wm.test(t);function oy(t,s="/"){let{pathname:i,search:o="",hash:c=""}=typeof t=="string"?$r(t):t,u;if(i)if(iy(i))u=i;else{if(i.includes("//")){let h=i;i=i.replace(/\/\/+/g,"/"),Ut(!1,`Pathnames cannot have embedded double slashes - normalizing ${h} -> ${i}`)}i.startsWith("/")?u=fh(i.substring(1),"/"):u=fh(i,s)}else u=s;return{pathname:u,search:dy(o),hash:uy(c)}}function fh(t,s){let i=s.replace(/\/+$/,"").split("/");return t.split("/").forEach(c=>{c===".."?i.length>1&&i.pop():c!=="."&&i.push(c)}),i.length>1?i.join("/"):"/"}function ac(t,s,i,o){return`Cannot include a '${t}' character in a manually specified \`to.${s}\` field [${JSON.stringify(o)}]. Please separate it out to the \`to.${i}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function ly(t){return t.filter((s,i)=>i===0||s.route.path&&s.route.path.length>0)}function Nm(t){let s=ly(t);return s.map((i,o)=>o===s.length-1?i.pathname:i.pathnameBase)}function km(t,s,i,o=!1){let c;typeof t=="string"?c=$r(t):(c={...t},qe(!c.pathname||!c.pathname.includes("?"),ac("?","pathname","search",c)),qe(!c.pathname||!c.pathname.includes("#"),ac("#","pathname","hash",c)),qe(!c.search||!c.search.includes("#"),ac("#","search","hash",c)));let u=t===""||c.pathname==="",h=u?"/":c.pathname,m;if(h==null)m=i;else{let p=s.length-1;if(!o&&h.startsWith("..")){let N=h.split("/");for(;N[0]==="..";)N.shift(),p-=1;c.pathname=N.join("/")}m=p>=0?s[p]:"/"}let v=oy(c,m),g=h&&h!=="/"&&h.endsWith("/"),b=(u||h===".")&&i.endsWith("/");return!v.pathname.endsWith("/")&&(g||b)&&(v.pathname+="/"),v}var On=t=>t.join("/").replace(/\/\/+/g,"/"),cy=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),dy=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,uy=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,fy=class{constructor(t,s,i,o=!1){this.status=t,this.statusText=s||"",this.internal=o,i instanceof Error?(this.data=i.toString(),this.error=i):this.data=i}};function hy(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function my(t){return t.map(s=>s.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var _m=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Sm(t,s){let i=t;if(typeof i!="string"||!wm.test(i))return{absoluteURL:void 0,isExternal:!1,to:i};let o=i,c=!1;if(_m)try{let u=new URL(window.location.href),h=i.startsWith("//")?new URL(u.protocol+i):new URL(i),m=Tn(h.pathname,s);h.origin===u.origin&&m!=null?i=m+h.search+h.hash:c=!0}catch{Ut(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:o,isExternal:c,to:i}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var Cm=["POST","PUT","PATCH","DELETE"];new Set(Cm);var py=["GET",...Cm];new Set(py);var ys=x.createContext(null);ys.displayName="DataRouter";var oo=x.createContext(null);oo.displayName="DataRouterState";var vy=x.createContext(!1),Em=x.createContext({isTransitioning:!1});Em.displayName="ViewTransition";var xy=x.createContext(new Map);xy.displayName="Fetchers";var gy=x.createContext(null);gy.displayName="Await";var Jt=x.createContext(null);Jt.displayName="Navigation";var ya=x.createContext(null);ya.displayName="Location";var vn=x.createContext({outlet:null,matches:[],isDataRoute:!1});vn.displayName="Route";var Xc=x.createContext(null);Xc.displayName="RouteError";var Rm="REACT_ROUTER_ERROR",yy="REDIRECT",jy="ROUTE_ERROR_RESPONSE";function by(t){if(t.startsWith(`${Rm}:${yy}:{`))try{let s=JSON.parse(t.slice(28));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string"&&typeof s.location=="string"&&typeof s.reloadDocument=="boolean"&&typeof s.replace=="boolean")return s}catch{}}function wy(t){if(t.startsWith(`${Rm}:${jy}:{`))try{let s=JSON.parse(t.slice(40));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string")return new fy(s.status,s.statusText,s.data)}catch{}}function Ny(t,{relative:s}={}){qe(ja(),"useHref() may be used only in the context of a component.");let{basename:i,navigator:o}=x.useContext(Jt),{hash:c,pathname:u,search:h}=ba(t,{relative:s}),m=u;return i!=="/"&&(m=u==="/"?i:On([i,u])),o.createHref({pathname:m,search:h,hash:c})}function ja(){return x.useContext(ya)!=null}function ur(){return qe(ja(),"useLocation() may be used only in the context of a component."),x.useContext(ya).location}var Pm="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Om(t){x.useContext(Jt).static||x.useLayoutEffect(t)}function xn(){let{isDataRoute:t}=x.useContext(vn);return t?Ly():ky()}function ky(){qe(ja(),"useNavigate() may be used only in the context of a component.");let t=x.useContext(ys),{basename:s,navigator:i}=x.useContext(Jt),{matches:o}=x.useContext(vn),{pathname:c}=ur(),u=JSON.stringify(Nm(o)),h=x.useRef(!1);return Om(()=>{h.current=!0}),x.useCallback((v,g={})=>{if(Ut(h.current,Pm),!h.current)return;if(typeof v=="number"){i.go(v);return}let b=km(v,JSON.parse(u),c,g.relative==="path");t==null&&s!=="/"&&(b.pathname=b.pathname==="/"?s:On([s,b.pathname])),(g.replace?i.replace:i.push)(b,g.state,g)},[s,i,u,c,t])}x.createContext(null);function In(){let{matches:t}=x.useContext(vn),s=t[t.length-1];return s?s.params:{}}function ba(t,{relative:s}={}){let{matches:i}=x.useContext(vn),{pathname:o}=ur(),c=JSON.stringify(Nm(i));return x.useMemo(()=>km(t,JSON.parse(c),o,s==="path"),[t,c,o,s])}function _y(t,s){return Tm(t,s)}function Tm(t,s,i,o,c){qe(ja(),"useRoutes() may be used only in the context of a component.");let{navigator:u}=x.useContext(Jt),{matches:h}=x.useContext(vn),m=h[h.length-1],v=m?m.params:{},g=m?m.pathname:"/",b=m?m.pathnameBase:"/",p=m&&m.route;{let F=p&&p.path||"";Mm(g,!p||F.endsWith("*")||F.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${g}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let N=fr(),E;if(s){let F=typeof s=="string"?$r(s):s;qe(w==="/"||F.pathname?.startsWith(w),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${w}" but pathname "${F.pathname}" was given in the \`location\` prop.`),E=F}else E=N;let O=E.pathname||"/",j=O;if(w!=="/"){let F=w.replace(/^\//,"").split("/");j="/"+O.replace(/^\//,"").split("/").slice(F.length).join("/")}let b=gm(t,{pathname:j});Ut(p||b!=null,`No routes matched location "${E.pathname}${E.search}${E.hash}" `),Ut(b==null||b[b.length-1].route.element!==void 0||b[b.length-1].route.Component!==void 0||b[b.length-1].route.lazy!==void 0,`Matched leaf route at location "${E.pathname}${E.search}${E.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let S=Sy(b&&b.map(F=>Object.assign({},F,{params:Object.assign({},v,F.params),pathname:Tn([w,u.encodeLocation?u.encodeLocation(F.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:F.pathname]),pathnameBase:F.pathnameBase==="/"?w:Tn([w,u.encodeLocation?u.encodeLocation(F.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:F.pathnameBase])})),h,i,o,c);return s&&S?x.createElement(ja.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...E},navigationType:"POP"}},S):S}function by(){let t=Py(),s=ly(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),i=t instanceof Error?t.stack:null,o="rgba(200,200,200, 0.5)",c={padding:"0.5rem",backgroundColor:o},u={padding:"2px 4px",backgroundColor:o},h=null;return console.error("Error handled by React Router default ErrorBoundary:",t),h=x.createElement(x.Fragment,null,x.createElement("p",null,"💿 Hey developer 👋"),x.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",x.createElement("code",{style:u},"ErrorBoundary")," or"," ",x.createElement("code",{style:u},"errorElement")," prop on your route.")),x.createElement(x.Fragment,null,x.createElement("h2",null,"Unexpected Application Error!"),x.createElement("h3",{style:{fontStyle:"italic"}},s),i?x.createElement("pre",{style:c},i):null,h)}var wy=x.createElement(by,null),Tm=class extends x.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,s){return s.location!==t.location||s.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:s.error,location:s.location,revalidation:t.revalidation||s.revalidation}}componentDidCatch(t,s){this.props.onError?this.props.onError(t,s):console.error("React Router caught the following error during render",t)}render(){let t=this.state.error;if(this.context&&typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){const i=xy(t.digest);i&&(t=i)}let s=t!==void 0?x.createElement(xn.Provider,{value:this.props.routeContext},x.createElement(Xc.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?x.createElement(Ny,{error:t},s):s}};Tm.contextType=uy;var oc=new WeakMap;function Ny({children:t,error:s}){let{basename:i}=x.useContext(Xt);if(typeof s=="object"&&s&&"digest"in s&&typeof s.digest=="string"){let o=vy(s.digest);if(o){let c=oc.get(s);if(c)throw c;let u=Sm(o.location,i);if(km&&!oc.get(s))if(u.isExternal||o.reloadDocument)window.location.href=u.absoluteURL||u.to;else{const h=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(u.to,{replace:o.replace}));throw oc.set(s,h),h}return x.createElement("meta",{httpEquiv:"refresh",content:`0;url=${u.absoluteURL||u.to}`})}}return t}function ky({routeContext:t,match:s,children:i}){let o=x.useContext(bs);return o&&o.static&&o.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=s.route.id),x.createElement(xn.Provider,{value:t},i)}function Sy(t,s=[],i=null,o=null,c=null){if(t==null){if(!i)return null;if(i.errors)t=i.matches;else if(s.length===0&&!i.initialized&&i.matches.length>0)t=i.matches;else return null}let u=t,h=i?.errors;if(h!=null){let w=u.findIndex(p=>p.route.id&&h?.[p.route.id]!==void 0);qe(w>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(h).join(",")}`),u=u.slice(0,Math.min(u.length,w+1))}let m=!1,v=-1;if(i)for(let w=0;w=0?u=u.slice(0,v+1):u=[u[0]];break}}}let g=i&&o?(w,p)=>{o(w,{location:i.location,params:i.matches?.[0]?.params??{},unstable_pattern:cy(i.matches),errorInfo:p})}:void 0;return u.reduceRight((w,p,N)=>{let E,O=!1,j=null,b=null;i&&(E=h&&p.route.id?h[p.route.id]:void 0,j=p.route.errorElement||wy,m&&(v<0&&N===0?(Im("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),O=!0,b=null):v===N&&(O=!0,b=p.route.hydrateFallbackElement||null)));let S=s.concat(u.slice(0,N+1)),F=()=>{let z;return E?z=j:O?z=b:p.route.Component?z=x.createElement(p.route.Component,null):p.route.element?z=p.route.element:z=w,x.createElement(ky,{match:p,routeContext:{outlet:w,matches:S,isDataRoute:i!=null},children:z})};return i&&(p.route.ErrorBoundary||p.route.errorElement||N===0)?x.createElement(Tm,{location:i.location,revalidation:i.revalidation,component:j,error:E,children:F(),routeContext:{outlet:null,matches:S,isDataRoute:!0},onError:g}):F()},null)}function Jc(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function _y(t){let s=x.useContext(bs);return qe(s,Jc(t)),s}function Cy(t){let s=x.useContext(lo);return qe(s,Jc(t)),s}function Ey(t){let s=x.useContext(xn);return qe(s,Jc(t)),s}function Zc(t){let s=Ey(t),i=s.matches[s.matches.length-1];return qe(i.route.id,`${t} can only be used on routes that contain a unique "id"`),i.route.id}function Ry(){return Zc("useRouteId")}function Py(){let t=x.useContext(Xc),s=Cy("useRouteError"),i=Zc("useRouteError");return t!==void 0?t:s.errors?.[i]}function Oy(){let{router:t}=_y("useNavigate"),s=Zc("useNavigate"),i=x.useRef(!1);return Pm(()=>{i.current=!0}),x.useCallback(async(c,u={})=>{Ut(i.current,Rm),i.current&&(typeof c=="number"?await t.navigate(c):await t.navigate(c,{fromRouteId:s,...u}))},[t,s])}var hh={};function Im(t,s,i){!s&&!hh[t]&&(hh[t]=!0,Ut(!1,i))}x.memo(Ty);function Ty({routes:t,future:s,state:i,onError:o}){return Om(t,void 0,i,o,s)}function Je(t){qe(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Iy({basename:t="/",children:s=null,location:i,navigationType:o="POP",navigator:c,static:u=!1,unstable_useTransitions:h}){qe(!ba(),"You cannot render a inside another . You should never have more than one in your app.");let m=t.replace(/^\/*/,"/"),v=x.useMemo(()=>({basename:m,navigator:c,static:u,unstable_useTransitions:h,future:{}}),[m,c,u,h]);typeof i=="string"&&(i=$r(i));let{pathname:g="/",search:w="",hash:p="",state:N=null,key:E="default"}=i,O=x.useMemo(()=>{let j=In(g,m);return j==null?null:{location:{pathname:j,search:w,hash:p,state:N,key:E},navigationType:o}},[m,g,w,p,N,E,o]);return Ut(O!=null,` is not able to match the URL "${g}${w}${p}" because it does not start with the basename, so the won't render anything.`),O==null?null:x.createElement(Xt.Provider,{value:v},x.createElement(ja.Provider,{children:s,value:O}))}function My({children:t,location:s}){return jy(Ic(t),s)}function Ic(t,s=[]){let i=[];return x.Children.forEach(t,(o,c)=>{if(!x.isValidElement(o))return;let u=[...s,c];if(o.type===x.Fragment){i.push.apply(i,Ic(o.props.children,u));return}qe(o.type===Je,`[${typeof o.type=="string"?o.type:o.type.name}] is not a component. All component children of must be a or `),qe(!o.props.index||!o.props.children,"An index route cannot have child routes.");let h={id:o.props.id||u.join("-"),caseSensitive:o.props.caseSensitive,element:o.props.element,Component:o.props.Component,index:o.props.index,path:o.props.path,middleware:o.props.middleware,loader:o.props.loader,action:o.props.action,hydrateFallbackElement:o.props.hydrateFallbackElement,HydrateFallback:o.props.HydrateFallback,errorElement:o.props.errorElement,ErrorBoundary:o.props.ErrorBoundary,hasErrorBoundary:o.props.hasErrorBoundary===!0||o.props.ErrorBoundary!=null||o.props.errorElement!=null,shouldRevalidate:o.props.shouldRevalidate,handle:o.props.handle,lazy:o.props.lazy};o.props.children&&(h.children=Ic(o.props.children,u)),i.push(h)}),i}var Gi="get",Yi="application/x-www-form-urlencoded";function co(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Dy(t){return co(t)&&t.tagName.toLowerCase()==="button"}function Ly(t){return co(t)&&t.tagName.toLowerCase()==="form"}function Fy(t){return co(t)&&t.tagName.toLowerCase()==="input"}function Ay(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function zy(t,s){return t.button===0&&(!s||s==="_self")&&!Ay(t)}function Mc(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((s,i)=>{let o=t[i];return s.concat(Array.isArray(o)?o.map(c=>[i,c]):[[i,o]])},[]))}function Uy(t,s){let i=Mc(t);return s&&s.forEach((o,c)=>{i.has(c)||s.getAll(c).forEach(u=>{i.append(c,u)})}),i}var Fi=null;function $y(){if(Fi===null)try{new FormData(document.createElement("form"),0),Fi=!1}catch{Fi=!0}return Fi}var By=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function lc(t){return t!=null&&!By.has(t)?(Ut(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Yi}"`),null):t}function Hy(t,s){let i,o,c,u,h;if(Ly(t)){let m=t.getAttribute("action");o=m?In(m,s):null,i=t.getAttribute("method")||Gi,c=lc(t.getAttribute("enctype"))||Yi,u=new FormData(t)}else if(Dy(t)||Fy(t)&&(t.type==="submit"||t.type==="image")){let m=t.form;if(m==null)throw new Error('Cannot submit a + + +
+
+
+

Debug Streams

+
Inspect runtime debug events by session, client, and stream.
+
+
+
+
+
Filters
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
Selection
+
+
+
Session
+
{session}
+
+
+
Client
+
{client}
+
+
+
Stream
+
{stream}
+
+
+
Limit
+
{limit}
+
+
+
+
+
Actions
+
+ Refresh + Raw JSON + Hierarchy + +
+
+
+
+
+
+
+
Logs
+
Showing the latest {limit} lines for the selected stream.
+
+
+
+
{logs}
+
+
+
+
+
+ + + +"#, + session_options = session_options, + client_options = client_options, + stream_options = stream_options, + logs = logs, + limit = context.limit, + session = escape_html(&context.session), + client = escape_html(&context.client), + stream = escape_html(&context.stream), + session_q = url_encode(&context.session), + client_q = url_encode(&context.client), + stream_q = url_encode(&context.stream), + default_client = super::config::DEFAULT_CLIENT, + default_stream = super::config::DEFAULT_STREAM, + current_session = context.session.as_str(), + current_client = context.client.as_str(), + ) +} + +fn render_options(items: &[String], current: &str) -> String { + items + .iter() + .map(|item| { + if item == current { + format!( + r#""#, + escape_html(item), + escape_html(item) + ) + } else { + format!( + r#""#, + escape_html(item), + escape_html(item) + ) + } + }) + .collect::>() + .join("") +} + +fn url_encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/src/lib.rs b/src/lib.rs index 4c668466..0b7a28be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ #![recursion_limit = "512"] // Shared modules / 共享模块 +pub mod ai_backend_types; pub mod config; pub mod network; pub mod proto; diff --git a/src/sms/admin_api.rs b/src/sms/admin_api.rs new file mode 100644 index 00000000..f125428e --- /dev/null +++ b/src/sms/admin_api.rs @@ -0,0 +1,239 @@ +//! Shared admin API mapping helpers for SMS. +//! SMS 的管理端 API 共享映射辅助模块。 +//! +//! This module centralizes typed response models used by Web Admin handlers +//! so endpoint implementations can focus on orchestration and validation. +//! 此模块集中管理 Web Admin handler 使用的类型化响应模型,让接口实现专注于编排与校验。 + +use serde::Serialize; +use std::collections::HashMap; + +use crate::proto::sms::{CredentialInfo, McpServerRecord}; + +fn empty_string_as_optional(value: String) -> Option { + if value.is_empty() { + None + } else { + Some(value) + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminCredentialInfoResponse { + pub(crate) name: String, + pub(crate) provider_kind: String, + pub(crate) version: u64, + pub(crate) description: Option, + pub(crate) disabled: bool, + pub(crate) created_at_ms: i64, + pub(crate) updated_at_ms: i64, + pub(crate) referenced_by_count: usize, +} + +pub(crate) fn credential_info_to_response( + credential: CredentialInfo, + referenced_by_count: usize, +) -> AdminCredentialInfoResponse { + AdminCredentialInfoResponse { + name: credential.name, + provider_kind: credential.provider_kind, + version: credential.version, + description: empty_string_as_optional(credential.description), + disabled: credential.disabled, + created_at_ms: credential.created_at_ms, + updated_at_ms: credential.updated_at_ms, + referenced_by_count, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminCredentialListResponse { + pub(crate) success: bool, + pub(crate) revision: u64, + pub(crate) credentials: Vec, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminCredentialMutationResponse { + pub(crate) success: bool, + pub(crate) revision: u64, + pub(crate) deleted: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpStdioResponse { + pub(crate) command: String, + pub(crate) args: Vec, + pub(crate) env: HashMap, + pub(crate) cwd: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpHttpResponse { + pub(crate) url: String, + pub(crate) headers: HashMap, + pub(crate) auth_ref: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpApprovalPolicyResponse { + pub(crate) default_policy: String, + pub(crate) per_tool: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpBudgetsResponse { + pub(crate) tool_timeout_ms: u64, + pub(crate) max_concurrency: u64, + pub(crate) max_tool_output_bytes: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpServerResponse { + pub(crate) server_id: String, + pub(crate) display_name: String, + pub(crate) transport: i32, + pub(crate) stdio: Option, + pub(crate) http: Option, + pub(crate) tool_namespace: String, + pub(crate) allowed_tools: Vec, + pub(crate) approval_policy: Option, + pub(crate) budgets: Option, + pub(crate) updated_at_ms: u64, +} + +pub(crate) fn mcp_server_to_response(record: McpServerRecord) -> AdminMcpServerResponse { + AdminMcpServerResponse { + server_id: record.server_id, + display_name: record.display_name, + transport: record.transport, + stdio: record.stdio.map(|stdio| AdminMcpStdioResponse { + command: stdio.command, + args: stdio.args, + env: stdio.env, + cwd: stdio.cwd, + }), + http: record.http.map(|http| AdminMcpHttpResponse { + url: http.url, + headers: http.headers, + auth_ref: http.auth_ref, + }), + tool_namespace: record.tool_namespace, + allowed_tools: record.allowed_tools, + approval_policy: record + .approval_policy + .map(|policy| AdminMcpApprovalPolicyResponse { + default_policy: policy.default_policy, + per_tool: policy.per_tool, + }), + budgets: record.budgets.map(|budget| AdminMcpBudgetsResponse { + tool_timeout_ms: budget.tool_timeout_ms, + max_concurrency: budget.max_concurrency, + max_tool_output_bytes: budget.max_tool_output_bytes, + }), + updated_at_ms: record.updated_at_ms, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpServerListResponse { + pub(crate) success: bool, + pub(crate) revision: u64, + pub(crate) servers: Vec, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminMcpServerDetailResponse { + pub(crate) success: bool, + pub(crate) found: bool, + pub(crate) server: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminRevisionMutationResponse { + pub(crate) success: bool, + pub(crate) revision: u64, + pub(crate) message: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::sms::{ + McpApprovalPolicy, McpBudgets, McpHttpConfig, McpStdioConfig, McpTransport, + }; + + #[test] + fn credential_mapping_keeps_reference_count() { + let response = credential_info_to_response( + CredentialInfo { + name: "cred-a".to_string(), + provider_kind: "openai".to_string(), + version: 1, + description: "desc".to_string(), + disabled: false, + created_at_ms: 10, + updated_at_ms: 20, + }, + 3, + ); + + assert_eq!(response.name, "cred-a"); + assert_eq!(response.referenced_by_count, 3); + assert_eq!(response.description.as_deref(), Some("desc")); + } + + #[test] + fn mcp_server_mapping_keeps_nested_fields() { + let response = mcp_server_to_response(McpServerRecord { + server_id: "srv-1".to_string(), + display_name: "server".to_string(), + transport: McpTransport::Stdio as i32, + stdio: Some(McpStdioConfig { + command: "node".to_string(), + args: vec!["mcp.js".to_string()], + env: HashMap::from([("ENV".to_string(), "prod".to_string())]), + cwd: "/tmp".to_string(), + }), + http: Some(McpHttpConfig { + url: "https://example.com/mcp".to_string(), + headers: HashMap::from([("Authorization".to_string(), "Bearer token".to_string())]), + auth_ref: "auth-a".to_string(), + }), + tool_namespace: "tools".to_string(), + allowed_tools: vec!["tool-a".to_string()], + approval_policy: Some(McpApprovalPolicy { + default_policy: "auto".to_string(), + per_tool: HashMap::from([("tool-a".to_string(), "manual".to_string())]), + }), + budgets: Some(McpBudgets { + tool_timeout_ms: 1000, + max_concurrency: 2, + max_tool_output_bytes: 4096, + }), + updated_at_ms: 30, + }); + + assert_eq!(response.server_id, "srv-1"); + assert_eq!( + response + .stdio + .as_ref() + .and_then(|stdio| stdio.env.get("ENV")) + .map(String::as_str), + Some("prod") + ); + assert_eq!( + response + .approval_policy + .as_ref() + .and_then(|policy| policy.per_tool.get("tool-a")) + .map(String::as_str), + Some("manual") + ); + } +} diff --git a/src/sms/admin_credentials.rs b/src/sms/admin_credentials.rs index cb2f2c6f..057d2438 100644 --- a/src/sms/admin_credentials.rs +++ b/src/sms/admin_credentials.rs @@ -110,7 +110,8 @@ pub struct AdminCredentialsState { impl std::fmt::Debug for AdminCredentialsState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AdminCredentialsState").finish_non_exhaustive() + f.debug_struct("AdminCredentialsState") + .finish_non_exhaustive() } } @@ -148,7 +149,11 @@ impl AdminCredentialsState { let snap = self.snapshot.read().await; Ok(( snap.revision, - snap.credentials.iter().cloned().map(info_from_record).collect(), + snap.credentials + .iter() + .cloned() + .map(info_from_record) + .collect(), )) } @@ -162,9 +167,14 @@ impl AdminCredentialsState { Ok((snap.revision, materials)) } - pub async fn watch_infos(&self, since_revision: u64) -> Result, Status> { + pub async fn watch_infos( + &self, + since_revision: u64, + ) -> Result, Status> { let stream = self.watch.watch(since_revision, |e| e.revision).await?; - Ok(Box::pin(stream.map(|item| item.map(|event| WatchCredentialsResponse { event: Some(event) })))) + Ok(Box::pin(stream.map(|item| { + item.map(|event| WatchCredentialsResponse { event: Some(event) }) + }))) } pub async fn watch_materials( diff --git a/src/sms/ai_admin_api.rs b/src/sms/ai_admin_api.rs new file mode 100644 index 00000000..1f819cef --- /dev/null +++ b/src/sms/ai_admin_api.rs @@ -0,0 +1,810 @@ +//! Shared AI backend admin API mapping helpers for SMS. +//! SMS 的 AI backend 管理端 API 共享映射辅助模块。 +//! +//! This module centralizes typed admin response models for AI backend related +//! endpoints so Web Admin handlers can focus on orchestration. +//! 此模块集中管理 AI backend 相关接口的类型化管理端响应模型,让 Web Admin handler 专注于流程编排。 + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendSpecResponse { + pub(crate) name: String, + pub(crate) kind: String, + pub(crate) operations: Vec, + pub(crate) features: Vec, + pub(crate) transports: Vec, + pub(crate) weight: u32, + pub(crate) priority: i32, + pub(crate) base_url: String, + pub(crate) provider: String, + pub(crate) model: String, + pub(crate) credential_ref: Option, + pub(crate) origin: i32, + pub(crate) deployment_id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendRecordResponse { + pub(crate) backend_id: String, + pub(crate) display_name: String, + pub(crate) provider: String, + pub(crate) model: String, + pub(crate) hosting: String, + pub(crate) backend_kind: String, + pub(crate) desired_state: String, + pub(crate) management_mode: String, + pub(crate) credential_ref: Option, + pub(crate) spec: Option, + pub(crate) local: Option, + pub(crate) remote: Option, + pub(crate) labels: std::collections::HashMap, + pub(crate) metadata: serde_json::Value, + pub(crate) generation: u64, + pub(crate) created_at_ms: i64, + pub(crate) updated_at_ms: i64, +} + +/// Structured local provider config exposed by admin read APIs. +/// 管理端读接口暴露的结构化 local provider 配置。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "provider_family", content = "config", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendLocalProviderResponse { + LlamaCpp(AdminAiBackendLlamaCppLocalConfigResponse), + Vllm(AdminAiBackendVllmLocalConfigResponse), +} + +/// Structured llama.cpp local config exposed by admin read APIs. +/// 管理端读接口暴露的结构化 llama.cpp local 配置。 +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendLlamaCppLocalConfigResponse { + pub(crate) model_url: Option, + pub(crate) model_path: Option, + pub(crate) skip_download: Option, + pub(crate) download_timeout_s: Option, + pub(crate) server_mode: Option, + pub(crate) server_cmd: Option, + pub(crate) server_cmd_args: Option, + pub(crate) threads: Option, + pub(crate) ctx_size: Option, + pub(crate) ready_probe: Option, + pub(crate) start_timeout_s: Option, +} + +/// Structured vLLM local config exposed by admin read APIs. +/// 管理端读接口暴露的结构化 vLLM local 配置。 +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendVllmLocalConfigResponse { + pub(crate) mode: Option, + pub(crate) managed_externally: Option, +} + +/// Structured remote provider config exposed by admin read APIs. +/// 管理端读接口暴露的结构化 remote provider 配置。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "provider_family", content = "config", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendRemoteProviderResponse { + OpenAiCompatible(AdminAiBackendOpenAiCompatibleRemoteConfigResponse), + Ollama(AdminAiBackendOllamaRemoteConfigResponse), +} + +/// Structured OpenAI-compatible remote config exposed by admin read APIs. +/// 管理端读接口暴露的结构化 OpenAI-compatible remote 配置。 +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendOpenAiCompatibleRemoteConfigResponse { + pub(crate) base_url: Option, + pub(crate) credential_ref: Option, + pub(crate) operations: Vec, + pub(crate) features: Vec, + pub(crate) transports: Vec, +} + +/// Structured Ollama remote config exposed by admin read APIs. +/// 管理端读接口暴露的结构化 Ollama remote 配置。 +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendOllamaRemoteConfigResponse { + pub(crate) base_url: Option, + pub(crate) operations: Vec, + pub(crate) features: Vec, + pub(crate) transports: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendListResponse { + pub(crate) success: bool, + pub(crate) backends: Vec, + pub(crate) total_count: i32, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendDetailResponse { + pub(crate) success: bool, + pub(crate) found: bool, + pub(crate) backend: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendMutationResponse { + pub(crate) success: bool, + pub(crate) backend: Option, + pub(crate) deleted: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendPreflightCheckResponse { + pub(crate) name: String, + pub(crate) ok: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "source_kind", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendLocalModelPreflightSourceResponse { + Unavailable, + ModelPath { + effective_model_path: String, + }, + ModelUrl { + effective_model_path: String, + final_url: String, + http_status: u16, + content_length: Option, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendRemoteProviderPreflightOutcomeResponse { + Success { + phase: String, + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + }, + Failure { + phase: String, + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse { + ConnectivityVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + }, + AuthVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: bool, + }, + ModelAccessVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: bool, + model_accessible: bool, + }, + ConnectivityFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + }, + AuthFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: bool, + }, + ModelAccessFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: bool, + model_accessible: bool, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendOllamaPreflightOutcomeResponse { + ConnectivityVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + }, + ModelAccessVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + model_accessible: bool, + }, + ConnectivityFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + }, + ModelAccessFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + model_accessible: bool, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "provider_family", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendRemoteProviderPreflightResultResponse { + OpenAiCompatible { + outcome: AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse, + }, + Ollama { + outcome: AdminAiBackendOllamaPreflightOutcomeResponse, + }, + Unknown { + provider: String, + outcome: AdminAiBackendRemoteProviderPreflightOutcomeResponse, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum AdminAiBackendPreflightDetailsResponse { + LocalModel { + source: AdminAiBackendLocalModelPreflightSourceResponse, + }, + RemoteProvider { + result: AdminAiBackendRemoteProviderPreflightResultResponse, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendPreflightNodeResponse { + pub(crate) node_uuid: String, + pub(crate) success: bool, + pub(crate) details: AdminAiBackendPreflightDetailsResponse, + pub(crate) latency_ms: Option, + pub(crate) message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendPreflightResponse { + pub(crate) success: bool, + pub(crate) results: Vec, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendPlacementResponse { + pub(crate) placement_id: String, + pub(crate) backend_id: String, + pub(crate) node_uuid: String, + pub(crate) desired_state: String, + pub(crate) weight_override: Option, + pub(crate) priority_override: Option, + pub(crate) generation: u64, + pub(crate) created_at_ms: i64, + pub(crate) updated_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendPlacementListResponse { + pub(crate) success: bool, + pub(crate) placements: Vec, + pub(crate) total_count: i32, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendPlacementMutationResponse { + pub(crate) success: bool, + pub(crate) placement: Option, + pub(crate) deleted: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendNodeStatusResponse { + pub(crate) backend_id: String, + pub(crate) node_uuid: String, + pub(crate) observed_generation: u64, + pub(crate) status: String, + pub(crate) status_reason: String, + pub(crate) runtime_backend_name: Option, + pub(crate) endpoint: Option, + pub(crate) available: bool, + pub(crate) operations: Vec, + pub(crate) features: Vec, + pub(crate) transports: Vec, + pub(crate) last_heartbeat_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendNodeStatusListResponse { + pub(crate) success: bool, + pub(crate) statuses: Vec, + pub(crate) total_count: i32, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendAssignmentResponse { + pub(crate) backend: Option, + pub(crate) placement: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiBackendAssignmentListResponse { + pub(crate) success: bool, + pub(crate) assignments: Vec, + pub(crate) total_count: i32, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiModelViewInstanceResponse { + pub(crate) backend_id: String, + pub(crate) node_uuid: String, + pub(crate) placement_state: String, + pub(crate) backend_state: String, + pub(crate) runtime_status: Option, + pub(crate) runtime_backend_name: Option, + pub(crate) endpoint: Option, + pub(crate) available: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiModelViewResponse { + pub(crate) provider: String, + pub(crate) model: String, + pub(crate) hosting: String, + pub(crate) backend_ids: Vec, + pub(crate) operations: Vec, + pub(crate) features: Vec, + pub(crate) transports: Vec, + pub(crate) enabled_nodes: i32, + pub(crate) ready_nodes: i32, + pub(crate) total_nodes: i32, + pub(crate) instances: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminAiModelViewListResponse { + pub(crate) success: bool, + pub(crate) views: Vec, + pub(crate) total_count: i32, + pub(crate) message: Option, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|inner| { + let trimmed = inner.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +/// Read one optional string field from backend metadata JSON. +/// 从 backend metadata JSON 读取一个可选字符串字段。 +fn metadata_string_field(metadata: &serde_json::Value, key: &str) -> Option { + metadata + .as_object() + .and_then(|fields| fields.get(key)) + .and_then(|value| match value { + serde_json::Value::String(text) => trim_to_option(Some(text.clone())), + _ => None, + }) +} + +/// Read one optional boolean field from backend metadata JSON. +/// 从 backend metadata JSON 读取一个可选布尔字段。 +fn metadata_bool_field(metadata: &serde_json::Value, key: &str) -> Option { + metadata + .as_object() + .and_then(|fields| fields.get(key)) + .and_then(|value| match value { + serde_json::Value::Bool(flag) => Some(*flag), + serde_json::Value::String(text) => match text.trim().to_ascii_lowercase().as_str() { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + }, + _ => None, + }) +} + +/// Read one optional unsigned integer field from backend metadata JSON. +/// 从 backend metadata JSON 读取一个可选无符号整数字段。 +fn metadata_u64_field(metadata: &serde_json::Value, key: &str) -> Option { + metadata + .as_object() + .and_then(|fields| fields.get(key)) + .and_then(|value| match value { + serde_json::Value::Number(number) => number.as_u64(), + serde_json::Value::String(text) => text.trim().parse::().ok(), + _ => None, + }) +} + +/// Read one optional u32 field from backend metadata JSON. +/// 从 backend metadata JSON 读取一个可选 u32 字段。 +fn metadata_u32_field(metadata: &serde_json::Value, key: &str) -> Option { + metadata_u64_field(metadata, key).and_then(|value| u32::try_from(value).ok()) +} + +fn ai_backend_hosting_label(value: i32) -> &'static str { + match crate::proto::sms::AiBackendHosting::try_from(value) + .unwrap_or(crate::proto::sms::AiBackendHosting::Unspecified) + { + crate::proto::sms::AiBackendHosting::Local => "local", + crate::proto::sms::AiBackendHosting::Remote => "remote", + crate::proto::sms::AiBackendHosting::Unspecified => "unspecified", + } +} + +fn ai_backend_desired_state_label(value: i32) -> &'static str { + match crate::proto::sms::AiBackendDesiredState::try_from(value) + .unwrap_or(crate::proto::sms::AiBackendDesiredState::Unspecified) + { + crate::proto::sms::AiBackendDesiredState::Enabled => "enabled", + crate::proto::sms::AiBackendDesiredState::Disabled => "disabled", + crate::proto::sms::AiBackendDesiredState::Unspecified => "unspecified", + } +} + +fn ai_backend_management_mode_label(value: i32) -> &'static str { + match crate::proto::sms::AiBackendManagementMode::try_from(value) + .unwrap_or(crate::proto::sms::AiBackendManagementMode::Unspecified) + { + crate::proto::sms::AiBackendManagementMode::SmsLocal => "sms_local", + crate::proto::sms::AiBackendManagementMode::SmsRemote => "sms_remote", + crate::proto::sms::AiBackendManagementMode::Unspecified => "unspecified", + } +} + +fn ai_backend_node_status_label(value: i32) -> &'static str { + match crate::proto::sms::AiBackendNodeStatus::try_from(value) + .unwrap_or(crate::proto::sms::AiBackendNodeStatus::Unspecified) + { + crate::proto::sms::AiBackendNodeStatus::Pending => "pending", + crate::proto::sms::AiBackendNodeStatus::Reconciling => "reconciling", + crate::proto::sms::AiBackendNodeStatus::Ready => "ready", + crate::proto::sms::AiBackendNodeStatus::Degraded => "degraded", + crate::proto::sms::AiBackendNodeStatus::Error => "error", + crate::proto::sms::AiBackendNodeStatus::Disabled => "disabled", + crate::proto::sms::AiBackendNodeStatus::Unspecified => "unspecified", + } +} + +fn proto_value_to_json(value: &prost_types::Value) -> serde_json::Value { + use prost_types::value::Kind; + match &value.kind { + Some(Kind::NullValue(_)) | None => serde_json::Value::Null, + Some(Kind::NumberValue(number)) => serde_json::json!(number), + Some(Kind::StringValue(text)) => serde_json::json!(text), + Some(Kind::BoolValue(flag)) => serde_json::json!(flag), + Some(Kind::StructValue(struct_value)) => serde_json::Value::Object( + struct_value + .fields + .iter() + .map(|(key, val)| (key.clone(), proto_value_to_json(val))) + .collect(), + ), + Some(Kind::ListValue(list_value)) => { + serde_json::Value::Array(list_value.values.iter().map(proto_value_to_json).collect()) + } + } +} + +fn proto_struct_to_json(value: Option<&prost_types::Struct>) -> serde_json::Value { + let fields = value + .map(|inner| { + inner + .fields + .iter() + .map(|(key, val)| (key.clone(), proto_value_to_json(val))) + .collect() + }) + .unwrap_or_default(); + serde_json::Value::Object(fields) +} + +fn spec_to_response(spec: crate::proto::sms::BackendSpec) -> AdminAiBackendSpecResponse { + AdminAiBackendSpecResponse { + name: spec.name, + kind: spec.kind, + operations: spec.operations, + features: spec.features, + transports: spec.transports, + weight: spec.weight, + priority: spec.priority, + base_url: spec.base_url, + provider: spec.provider, + model: spec.model, + credential_ref: trim_to_option(Some(spec.credential_ref)), + origin: spec.origin, + deployment_id: spec.deployment_id, + } +} + +/// Derive one structured local read view from the canonical stored backend record. +/// 从规范化存储后的 backend record 派生一个结构化 local 读视图。 +fn backend_record_local_response( + record: &crate::proto::sms::AiBackendRecord, + metadata: &serde_json::Value, +) -> Option { + if ai_backend_hosting_label(record.hosting) != "local" { + return None; + } + match record.provider.trim().to_ascii_lowercase().as_str() { + "llamacpp" => Some(AdminAiBackendLocalProviderResponse::LlamaCpp( + AdminAiBackendLlamaCppLocalConfigResponse { + model_url: metadata_string_field(metadata, "model_url"), + model_path: metadata_string_field(metadata, "model_path"), + skip_download: metadata_bool_field(metadata, "skip_download"), + download_timeout_s: metadata_u64_field(metadata, "download_timeout_s"), + server_mode: metadata_string_field(metadata, "server_mode"), + server_cmd: metadata_string_field(metadata, "server_cmd"), + server_cmd_args: metadata_string_field(metadata, "server_cmd_args"), + threads: metadata_u32_field(metadata, "threads"), + ctx_size: metadata_u32_field(metadata, "ctx_size"), + ready_probe: metadata_string_field(metadata, "ready_probe"), + start_timeout_s: metadata_u64_field(metadata, "start_timeout_s"), + }, + )), + "vllm" => Some(AdminAiBackendLocalProviderResponse::Vllm( + AdminAiBackendVllmLocalConfigResponse { + mode: metadata_string_field(metadata, "mode"), + managed_externally: metadata_bool_field(metadata, "managed_externally"), + }, + )), + _ => None, + } +} + +/// Derive one structured remote read view from the canonical stored backend record. +/// 从规范化存储后的 backend record 派生一个结构化 remote 读视图。 +fn backend_record_remote_response( + record: &crate::proto::sms::AiBackendRecord, +) -> Option { + if ai_backend_hosting_label(record.hosting) != "remote" { + return None; + } + let spec = record.spec.as_ref()?; + let base_url = trim_to_option(Some(spec.base_url.clone())); + match record.provider.trim().to_ascii_lowercase().as_str() { + "openai" => Some(AdminAiBackendRemoteProviderResponse::OpenAiCompatible( + AdminAiBackendOpenAiCompatibleRemoteConfigResponse { + base_url, + credential_ref: trim_to_option(record.credential_ref.clone()), + operations: spec.operations.clone(), + features: spec.features.clone(), + transports: spec.transports.clone(), + }, + )), + "ollama" => Some(AdminAiBackendRemoteProviderResponse::Ollama( + AdminAiBackendOllamaRemoteConfigResponse { + base_url, + operations: spec.operations.clone(), + features: spec.features.clone(), + transports: spec.transports.clone(), + }, + )), + _ => None, + } +} + +pub(crate) fn backend_record_to_response( + record: crate::proto::sms::AiBackendRecord, +) -> AdminAiBackendRecordResponse { + let metadata = proto_struct_to_json(record.metadata.as_ref()); + let local = backend_record_local_response(&record, &metadata); + let remote = backend_record_remote_response(&record); + AdminAiBackendRecordResponse { + backend_id: record.backend_id, + display_name: record.display_name, + provider: record.provider, + model: record.model, + hosting: ai_backend_hosting_label(record.hosting).to_string(), + backend_kind: record.backend_kind, + desired_state: ai_backend_desired_state_label(record.desired_state).to_string(), + management_mode: ai_backend_management_mode_label(record.management_mode).to_string(), + credential_ref: trim_to_option(record.credential_ref), + spec: record.spec.map(spec_to_response), + local, + remote, + labels: record.labels, + metadata, + generation: record.generation, + created_at_ms: record.created_at_ms, + updated_at_ms: record.updated_at_ms, + } +} + +pub(crate) fn backend_placement_to_response( + record: crate::proto::sms::AiBackendPlacementRecord, +) -> AdminAiBackendPlacementResponse { + AdminAiBackendPlacementResponse { + placement_id: record.placement_id, + backend_id: record.backend_id, + node_uuid: record.node_uuid, + desired_state: ai_backend_desired_state_label(record.desired_state).to_string(), + weight_override: record.weight_override, + priority_override: record.priority_override, + generation: record.generation, + created_at_ms: record.created_at_ms, + updated_at_ms: record.updated_at_ms, + } +} + +pub(crate) fn backend_node_status_to_response( + record: crate::proto::sms::AiBackendNodeStatusRecord, +) -> AdminAiBackendNodeStatusResponse { + AdminAiBackendNodeStatusResponse { + backend_id: record.backend_id, + node_uuid: record.node_uuid, + observed_generation: record.observed_generation, + status: ai_backend_node_status_label(record.status).to_string(), + status_reason: record.status_reason, + runtime_backend_name: trim_to_option(Some(record.runtime_backend_name)), + endpoint: trim_to_option(Some(record.endpoint)), + available: record.available, + operations: record.operations, + features: record.features, + transports: record.transports, + last_heartbeat_at_ms: record.last_heartbeat_at_ms, + } +} + +pub(crate) fn backend_assignment_to_response( + assignment: crate::proto::sms::ResolvedAiBackendAssignment, +) -> AdminAiBackendAssignmentResponse { + AdminAiBackendAssignmentResponse { + backend: assignment.backend.map(backend_record_to_response), + placement: assignment.placement.map(backend_placement_to_response), + } +} + +fn model_view_instance_to_response( + instance: crate::proto::sms::AiModelViewInstance, +) -> AdminAiModelViewInstanceResponse { + AdminAiModelViewInstanceResponse { + backend_id: instance.backend_id, + node_uuid: instance.node_uuid, + placement_state: ai_backend_desired_state_label(instance.placement_state).to_string(), + backend_state: ai_backend_desired_state_label(instance.backend_state).to_string(), + runtime_status: instance + .runtime_status + .map(|status| ai_backend_node_status_label(status).to_string()), + runtime_backend_name: trim_to_option(instance.runtime_backend_name), + endpoint: trim_to_option(instance.endpoint), + available: instance.available, + } +} + +pub(crate) fn model_view_to_response( + view: crate::proto::sms::AiModelView, +) -> AdminAiModelViewResponse { + AdminAiModelViewResponse { + provider: view.provider, + model: view.model, + hosting: ai_backend_hosting_label(view.hosting).to_string(), + backend_ids: view.backend_ids, + operations: view.operations, + features: view.features, + transports: view.transports, + enabled_nodes: view.enabled_nodes, + ready_nodes: view.ready_nodes, + total_nodes: view.total_nodes, + instances: view + .instances + .into_iter() + .map(model_view_instance_to_response) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_record_mapping_keeps_labels_and_metadata() { + let mut metadata = prost_types::Struct::default(); + metadata.fields.insert( + "region".to_string(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("sg".to_string())), + }, + ); + + let response = backend_record_to_response(crate::proto::sms::AiBackendRecord { + backend_id: "backend-1".to_string(), + display_name: "Backend".to_string(), + provider: "openai".to_string(), + model: "gpt".to_string(), + hosting: crate::proto::sms::AiBackendHosting::Remote as i32, + backend_kind: "http".to_string(), + desired_state: crate::proto::sms::AiBackendDesiredState::Enabled as i32, + management_mode: crate::proto::sms::AiBackendManagementMode::SmsRemote as i32, + credential_ref: Some("cred-a".to_string()), + spec: None, + labels: std::collections::HashMap::from([("tier".to_string(), "prod".to_string())]), + metadata: Some(metadata), + generation: 1, + created_at_ms: 10, + updated_at_ms: 20, + }); + + assert_eq!(response.hosting, "remote"); + assert_eq!(response.desired_state, "enabled"); + assert_eq!( + response + .metadata + .get("region") + .and_then(|value| value.as_str()), + Some("sg") + ); + } + + #[test] + fn model_view_mapping_keeps_instance_runtime_status() { + let response = model_view_to_response(crate::proto::sms::AiModelView { + provider: "openai".to_string(), + model: "gpt".to_string(), + hosting: crate::proto::sms::AiBackendHosting::Local as i32, + backend_ids: vec!["backend-1".to_string()], + operations: vec!["chat".to_string()], + features: vec!["stream".to_string()], + transports: vec!["http".to_string()], + enabled_nodes: 1, + ready_nodes: 1, + total_nodes: 1, + instances: vec![crate::proto::sms::AiModelViewInstance { + backend_id: "backend-1".to_string(), + node_uuid: "node-1".to_string(), + placement_state: crate::proto::sms::AiBackendDesiredState::Enabled as i32, + backend_state: crate::proto::sms::AiBackendDesiredState::Enabled as i32, + runtime_status: Some(crate::proto::sms::AiBackendNodeStatus::Ready as i32), + runtime_backend_name: Some("runtime-a".to_string()), + endpoint: Some("http://127.0.0.1".to_string()), + available: true, + }], + }); + + assert_eq!(response.hosting, "local"); + assert_eq!( + response + .instances + .first() + .and_then(|instance| instance.runtime_status.as_deref()), + Some("ready") + ); + } +} diff --git a/src/sms/ai_backend_rpc.rs b/src/sms/ai_backend_rpc.rs index dadda2a3..b7513280 100644 --- a/src/sms/ai_backend_rpc.rs +++ b/src/sms/ai_backend_rpc.rs @@ -2,22 +2,23 @@ use tonic::{Request, Response, Status}; use crate::proto::sms::{ ai_backend_control_plane_service_server::AiBackendControlPlaneService as AiBackendControlPlaneServiceTrait, - AiBackendDesiredState as ProtoAiBackendDesiredState, AiBackendNodeStatus as ProtoAiBackendNodeStatus, - AiModelView as ProtoAiModelView, AiModelViewInstance as ProtoAiModelViewInstance, - CreateAiBackendRequest, CreateAiBackendResponse, DeleteAiBackendPlacementRequest, - DeleteAiBackendPlacementResponse, DeleteAiBackendRequest, DeleteAiBackendResponse, - GetAiBackendRequest, GetAiBackendResponse, ListAiBackendAssignmentsRequest, - ListAiBackendAssignmentsResponse, ListAiBackendNodeStatusesRequest, - ListAiBackendNodeStatusesResponse, ListAiBackendPlacementsRequest, - ListAiBackendPlacementsResponse, ListAiBackendsRequest, ListAiBackendsResponse, - ListAiModelViewsRequest, ListAiModelViewsResponse, ReportAiBackendNodeStatusesRequest, - ReportAiBackendNodeStatusesResponse, ResolvedAiBackendAssignment as ProtoResolvedAiBackendAssignment, + AiBackendDesiredState as ProtoAiBackendDesiredState, + AiBackendNodeStatus as ProtoAiBackendNodeStatus, AiModelView as ProtoAiModelView, + AiModelViewInstance as ProtoAiModelViewInstance, CreateAiBackendRequest, + CreateAiBackendResponse, DeleteAiBackendPlacementRequest, DeleteAiBackendPlacementResponse, + DeleteAiBackendRequest, DeleteAiBackendResponse, GetAiBackendRequest, GetAiBackendResponse, + ListAiBackendAssignmentsRequest, ListAiBackendAssignmentsResponse, + ListAiBackendNodeStatusesRequest, ListAiBackendNodeStatusesResponse, + ListAiBackendPlacementsRequest, ListAiBackendPlacementsResponse, ListAiBackendsRequest, + ListAiBackendsResponse, ListAiModelViewsRequest, ListAiModelViewsResponse, + ReportAiBackendNodeStatusesRequest, ReportAiBackendNodeStatusesResponse, + ResolvedAiBackendAssignment as ProtoResolvedAiBackendAssignment, SetAiBackendDesiredStateRequest, SetAiBackendDesiredStateResponse, UpdateAiBackendRequest, UpdateAiBackendResponse, UpsertAiBackendPlacementRequest, UpsertAiBackendPlacementResponse, }; use crate::sms::ai_backends::model::{ - AiBackendDesiredStateModel, AiBackendNodeRuntimeStatusModel, AiModelView, - AiModelViewInstance, ResolvedBackendAssignment, + AiBackendDesiredStateModel, AiBackendNodeRuntimeStatusModel, AiModelView, AiModelViewInstance, + ResolvedBackendAssignment, }; use crate::sms::ai_backends::placement_service::{AiBackendPlacementService, UpsertPlacementInput}; use crate::sms::ai_backends::proto_conv::{ @@ -25,8 +26,8 @@ use crate::sms::ai_backends::proto_conv::{ domain_status_from_proto, proto_backend_from_domain, proto_placement_from_domain, proto_status_from_domain, }; -use crate::sms::ai_backends::repository::AiBackendRepository; use crate::sms::ai_backends::read_model::build_ai_model_views; +use crate::sms::ai_backends::repository::AiBackendRepository; use crate::sms::ai_backends::service::{ AiBackendService, CreateAiBackendInput, UpdateAiBackendInput, }; @@ -120,7 +121,10 @@ impl AiBackendControlPlaneServiceTrait for SmsServiceImpl { let paged = paginate_slice(&filtered, req.limit, req.offset); Ok(Response::new(ListAiBackendsResponse { total_count, - backends: paged.iter().map(|backend| proto_backend_from_domain(backend)).collect(), + backends: paged + .iter() + .map(|backend| proto_backend_from_domain(backend)) + .collect(), })) } @@ -131,7 +135,10 @@ impl AiBackendControlPlaneServiceTrait for SmsServiceImpl { ) -> Result, Status> { let req = request.into_inner(); let service = self.ai_backend_service(); - let backend = service.get_backend(&req.backend_id).await.map_err(Status::from)?; + let backend = service + .get_backend(&req.backend_id) + .await + .map_err(Status::from)?; Ok(Response::new(GetAiBackendResponse { found: backend.is_some(), backend: backend.as_ref().map(proto_backend_from_domain), @@ -171,7 +178,10 @@ impl AiBackendControlPlaneServiceTrait for SmsServiceImpl { ) -> Result, Status> { let req = request.into_inner(); let service = self.ai_backend_service(); - let deleted = service.delete_backend(&req.backend_id).await.map_err(Status::from)?; + let deleted = service + .delete_backend(&req.backend_id) + .await + .map_err(Status::from)?; Ok(Response::new(DeleteAiBackendResponse { deleted })) } @@ -181,7 +191,8 @@ impl AiBackendControlPlaneServiceTrait for SmsServiceImpl { request: Request, ) -> Result, Status> { let req = request.into_inner(); - let desired_state = domain_desired_state_from_proto(req.desired_state).map_err(Status::from)?; + let desired_state = + domain_desired_state_from_proto(req.desired_state).map_err(Status::from)?; let service = self.ai_backend_service(); let backend = service .set_desired_state(&req.backend_id, desired_state) @@ -277,7 +288,10 @@ impl AiBackendControlPlaneServiceTrait for SmsServiceImpl { .map_err(Status::from)?; Ok(Response::new(ListAiBackendAssignmentsResponse { total_count: assignments.len() as i32, - assignments: assignments.iter().map(proto_assignment_from_domain).collect(), + assignments: assignments + .iter() + .map(proto_assignment_from_domain) + .collect(), })) } @@ -351,7 +365,10 @@ impl AiBackendControlPlaneServiceTrait for SmsServiceImpl { let paged = paginate_slice(&filtered, req.limit, req.offset); Ok(Response::new(ListAiModelViewsResponse { total_count, - views: paged.iter().map(|view| proto_ai_model_view_from_domain(view)).collect(), + views: paged + .iter() + .map(|view| proto_ai_model_view_from_domain(view)) + .collect(), })) } } @@ -408,8 +425,12 @@ fn filter_ai_backends<'a>( if !desired_state.is_empty() && normalize_filter(match backend.desired_state { - crate::sms::ai_backends::model::AiBackendDesiredStateModel::Enabled => "enabled", - crate::sms::ai_backends::model::AiBackendDesiredStateModel::Disabled => "disabled", + crate::sms::ai_backends::model::AiBackendDesiredStateModel::Enabled => { + "enabled" + } + crate::sms::ai_backends::model::AiBackendDesiredStateModel::Disabled => { + "disabled" + } }) != desired_state { return false; @@ -427,7 +448,11 @@ fn filter_ai_backends<'a>( } let haystack = format!( "{} {} {} {} {}", - backend.display_name, backend.backend_id, backend.provider, backend.model, backend.backend_kind + backend.display_name, + backend.backend_id, + backend.provider, + backend.model, + backend.backend_kind ); normalize_filter(&haystack).contains(&q) }) @@ -462,7 +487,11 @@ fn filter_ai_model_views<'a>( return false; } if !status.is_empty() { - let computed = if view.ready_nodes > 0 { "available" } else { "unavailable" }; + let computed = if view.ready_nodes > 0 { + "available" + } else { + "unavailable" + }; if computed != status { return false; } @@ -505,7 +534,11 @@ fn proto_ai_model_view_from_domain(view: &AiModelView) -> ProtoAiModelView { enabled_nodes: view.enabled_nodes as i32, ready_nodes: view.ready_nodes as i32, total_nodes: view.total_nodes as i32, - instances: view.instances.iter().map(proto_ai_model_view_instance_from_domain).collect(), + instances: view + .instances + .iter() + .map(proto_ai_model_view_instance_from_domain) + .collect(), } } diff --git a/src/sms/ai_backends/placement_service.rs b/src/sms/ai_backends/placement_service.rs index e80bbf0b..2c258a2c 100644 --- a/src/sms/ai_backends/placement_service.rs +++ b/src/sms/ai_backends/placement_service.rs @@ -40,7 +40,12 @@ impl AiBackendPlacementService { &self, input: UpsertPlacementInput, ) -> Result { - if self.repository.get_backend(&input.backend_id).await?.is_none() { + if self + .repository + .get_backend(&input.backend_id) + .await? + .is_none() + { return Err(SmsError::NotFound(format!( "backend {} not found", input.backend_id diff --git a/src/sms/ai_backends/proto_conv.rs b/src/sms/ai_backends/proto_conv.rs index 4bd55519..92e18b72 100644 --- a/src/sms/ai_backends/proto_conv.rs +++ b/src/sms/ai_backends/proto_conv.rs @@ -4,11 +4,15 @@ use std::collections::BTreeMap; use prost_types::{value::Kind as ProstValueKind, Struct as ProstStruct, Value as ProstValue}; +use crate::ai_backend_types::{ + CanonicalBackendDesiredState, CanonicalBackendHosting, CanonicalBackendManagementMode, +}; use crate::proto::sms::{ AiBackendDesiredState, AiBackendHosting, AiBackendManagementMode, - AiBackendNodeStatus as ProtoAiBackendNodeStatus, AiBackendNodeStatusRecord as ProtoAiBackendNodeStatusRecord, - AiBackendPlacementRecord as ProtoAiBackendPlacementRecord, AiBackendRecord as ProtoAiBackendRecord, - BackendSpec, + AiBackendNodeStatus as ProtoAiBackendNodeStatus, + AiBackendNodeStatusRecord as ProtoAiBackendNodeStatusRecord, + AiBackendPlacementRecord as ProtoAiBackendPlacementRecord, + AiBackendRecord as ProtoAiBackendRecord, BackendSpec, }; use crate::sms::ai_backends::model::{ AiBackendDesiredStateModel, AiBackendHostingModel, AiBackendManagementModeModel, @@ -39,7 +43,9 @@ pub fn proto_backend_from_domain(record: &AiBackendRecordModel) -> ProtoAiBacken } /// Convert a proto backend record into a domain record / 把 proto backend 记录转换为领域记录 -pub fn domain_backend_from_proto(record: &ProtoAiBackendRecord) -> Result { +pub fn domain_backend_from_proto( + record: &ProtoAiBackendRecord, +) -> Result { Ok(AiBackendRecordModel { backend_id: record.backend_id.clone(), display_name: record.display_name.clone(), @@ -56,7 +62,11 @@ pub fn domain_backend_from_proto(record: &ProtoAiBackendRecord) -> Result>(), + labels: record + .labels + .clone() + .into_iter() + .collect::>(), metadata: json_from_proto_struct(record.metadata.as_ref()), generation: record.generation, created_at_ms: record.created_at_ms, @@ -208,7 +218,9 @@ fn proto_value_from_json(value: &serde_json::Value) -> ProstValue { let kind = match value { serde_json::Value::Null => ProstValueKind::NullValue(0), serde_json::Value::Bool(inner) => ProstValueKind::BoolValue(*inner), - serde_json::Value::Number(inner) => ProstValueKind::NumberValue(inner.as_f64().unwrap_or_default()), + serde_json::Value::Number(inner) => { + ProstValueKind::NumberValue(inner.as_f64().unwrap_or_default()) + } serde_json::Value::String(inner) => ProstValueKind::StringValue(inner.clone()), serde_json::Value::Array(inner) => ProstValueKind::ListValue(prost_types::ListValue { values: inner.iter().map(proto_value_from_json).collect(), @@ -256,10 +268,10 @@ fn proto_hosting_from_domain(value: AiBackendHostingModel) -> AiBackendHosting { /// Convert a proto hosting enum into domain / 把 proto hosting 枚举转换为领域 fn domain_hosting_from_proto(value: i32) -> Result { - match AiBackendHosting::try_from(value).unwrap_or(AiBackendHosting::Unspecified) { - AiBackendHosting::Remote => Ok(AiBackendHostingModel::Remote), - AiBackendHosting::Local => Ok(AiBackendHostingModel::Local), - AiBackendHosting::Unspecified => Err(SmsError::InvalidRequest( + match CanonicalBackendHosting::from_proto_i32(value) { + CanonicalBackendHosting::Remote => Ok(AiBackendHostingModel::Remote), + CanonicalBackendHosting::Local => Ok(AiBackendHostingModel::Local), + CanonicalBackendHosting::Unknown(_) => Err(SmsError::InvalidRequest( "invalid ai backend hosting".to_string(), )), } @@ -275,10 +287,10 @@ fn proto_desired_state_from_domain(value: AiBackendDesiredStateModel) -> AiBacke /// Convert a proto desired-state enum into domain / 把 proto 期望状态枚举转换为领域 pub fn domain_desired_state_from_proto(value: i32) -> Result { - match AiBackendDesiredState::try_from(value).unwrap_or(AiBackendDesiredState::Unspecified) { - AiBackendDesiredState::Enabled => Ok(AiBackendDesiredStateModel::Enabled), - AiBackendDesiredState::Disabled => Ok(AiBackendDesiredStateModel::Disabled), - AiBackendDesiredState::Unspecified => Err(SmsError::InvalidRequest( + match CanonicalBackendDesiredState::from_proto_i32(value) { + CanonicalBackendDesiredState::Enabled => Ok(AiBackendDesiredStateModel::Enabled), + CanonicalBackendDesiredState::Disabled => Ok(AiBackendDesiredStateModel::Disabled), + CanonicalBackendDesiredState::Unknown(_) => Err(SmsError::InvalidRequest( "invalid ai backend desired state".to_string(), )), } @@ -295,15 +307,11 @@ fn proto_management_mode_from_domain( } /// Convert a proto management-mode enum into domain / 把 proto 管理模式枚举转换为领域 -fn domain_management_mode_from_proto( - value: i32, -) -> Result { - match AiBackendManagementMode::try_from(value) - .unwrap_or(AiBackendManagementMode::Unspecified) - { - AiBackendManagementMode::SmsRemote => Ok(AiBackendManagementModeModel::SmsRemote), - AiBackendManagementMode::SmsLocal => Ok(AiBackendManagementModeModel::SmsLocal), - AiBackendManagementMode::Unspecified => Err(SmsError::InvalidRequest( +fn domain_management_mode_from_proto(value: i32) -> Result { + match CanonicalBackendManagementMode::from_proto_i32(value) { + CanonicalBackendManagementMode::SmsRemote => Ok(AiBackendManagementModeModel::SmsRemote), + CanonicalBackendManagementMode::SmsLocal => Ok(AiBackendManagementModeModel::SmsLocal), + CanonicalBackendManagementMode::Unknown(_) => Err(SmsError::InvalidRequest( "invalid ai backend management mode".to_string(), )), } @@ -324,11 +332,8 @@ fn proto_node_status_from_domain( } /// Convert a proto node status enum into domain / 把 proto 节点状态枚举转换为领域 -fn domain_node_status_from_proto( - value: i32, -) -> Result { - match ProtoAiBackendNodeStatus::try_from(value) - .unwrap_or(ProtoAiBackendNodeStatus::Unspecified) +fn domain_node_status_from_proto(value: i32) -> Result { + match ProtoAiBackendNodeStatus::try_from(value).unwrap_or(ProtoAiBackendNodeStatus::Unspecified) { ProtoAiBackendNodeStatus::Pending => Ok(AiBackendNodeRuntimeStatusModel::Pending), ProtoAiBackendNodeStatus::Reconciling => Ok(AiBackendNodeRuntimeStatusModel::Reconciling), @@ -390,4 +395,21 @@ mod tests { assert_eq!(roundtrip.labels, domain.labels); assert_eq!(roundtrip.backend_id, domain.backend_id); } + + #[test] + fn domain_conversions_reject_unspecified_proto_enums() { + let hosting_err = domain_hosting_from_proto(AiBackendHosting::Unspecified as i32) + .expect_err("unspecified hosting should fail"); + assert!(matches!(hosting_err, SmsError::InvalidRequest(_))); + + let desired_err = + domain_desired_state_from_proto(AiBackendDesiredState::Unspecified as i32) + .expect_err("unspecified desired_state should fail"); + assert!(matches!(desired_err, SmsError::InvalidRequest(_))); + + let management_err = + domain_management_mode_from_proto(AiBackendManagementMode::Unspecified as i32) + .expect_err("unspecified management_mode should fail"); + assert!(matches!(management_err, SmsError::InvalidRequest(_))); + } } diff --git a/src/sms/ai_backends/read_model.rs b/src/sms/ai_backends/read_model.rs index a19b8e94..1e26d292 100644 --- a/src/sms/ai_backends/read_model.rs +++ b/src/sms/ai_backends/read_model.rs @@ -28,9 +28,13 @@ pub fn build_ai_model_views( .entry(key) .or_insert_with(|| AggregationState::new(backend)); state.backend_ids.insert(backend.backend_id.clone()); - state.operations.extend(backend.spec.operations.iter().cloned()); + state + .operations + .extend(backend.spec.operations.iter().cloned()); state.features.extend(backend.spec.features.iter().cloned()); - state.transports.extend(backend.spec.transports.iter().cloned()); + state + .transports + .extend(backend.spec.transports.iter().cloned()); if let Some(backend_placements) = placements_by_backend.get(&backend.backend_id) { for placement in backend_placements { @@ -66,7 +70,11 @@ pub fn build_ai_model_views( .map(AggregationState::into_view) .collect::>(); result.sort_by(|left, right| { - (&left.provider, &left.model, left.hosting).cmp(&(&right.provider, &right.model, right.hosting)) + (&left.provider, &left.model, left.hosting).cmp(&( + &right.provider, + &right.model, + right.hosting, + )) }); result } diff --git a/src/sms/ai_backends/repository.rs b/src/sms/ai_backends/repository.rs index 4c8f24a6..b185a7f8 100644 --- a/src/sms/ai_backends/repository.rs +++ b/src/sms/ai_backends/repository.rs @@ -17,7 +17,8 @@ pub trait AiBackendRepository: Send + Sync { ) -> Result; /// Fetch one backend by identity / 按身份读取单个 backend - async fn get_backend(&self, backend_id: &str) -> Result, SmsError>; + async fn get_backend(&self, backend_id: &str) + -> Result, SmsError>; /// List all backends / 列出所有 backend async fn list_backends(&self) -> Result, SmsError>; @@ -78,5 +79,6 @@ pub trait AiBackendRepository: Send + Sync { ) -> Result, SmsError>; /// Delete one node status / 删除单条节点状态 - async fn delete_node_status(&self, backend_id: &str, node_uuid: &str) -> Result; + async fn delete_node_status(&self, backend_id: &str, node_uuid: &str) + -> Result; } diff --git a/src/sms/ai_backends/repository_kv.rs b/src/sms/ai_backends/repository_kv.rs index e55a9f9b..ce4b7204 100644 --- a/src/sms/ai_backends/repository_kv.rs +++ b/src/sms/ai_backends/repository_kv.rs @@ -156,7 +156,10 @@ impl AiBackendRepository for KvAiBackendRepository { Ok(record) } - async fn get_backend(&self, backend_id: &str) -> Result, SmsError> { + async fn get_backend( + &self, + backend_id: &str, + ) -> Result, SmsError> { self.load_record(&keys::backend(backend_id)).await } @@ -219,8 +222,11 @@ impl AiBackendRepository for KvAiBackendRepository { &self, backend_id: &str, ) -> Result, SmsError> { - self.load_records_via_index(&keys::placement_by_backend_prefix(backend_id), keys::placement) - .await + self.load_records_via_index( + &keys::placement_by_backend_prefix(backend_id), + keys::placement, + ) + .await } async fn list_placements_by_node( @@ -253,8 +259,11 @@ impl AiBackendRepository for KvAiBackendRepository { &self, record: AiBackendNodeStatusRecordModel, ) -> Result { - self.store_record(&keys::status(&record.backend_id, &record.node_uuid), &record) - .await?; + self.store_record( + &keys::status(&record.backend_id, &record.node_uuid), + &record, + ) + .await?; self.kv .put( &keys::status_by_backend(&record.backend_id, &record.node_uuid), @@ -286,7 +295,11 @@ impl AiBackendRepository for KvAiBackendRepository { .await } - async fn delete_node_status(&self, backend_id: &str, node_uuid: &str) -> Result { + async fn delete_node_status( + &self, + backend_id: &str, + node_uuid: &str, + ) -> Result { self.kv .delete(&keys::status_by_backend(backend_id, node_uuid)) .await?; @@ -355,7 +368,10 @@ mod tests { repo.upsert_placement(placement.clone()).await.unwrap(); - assert_eq!(repo.list_placements().await.unwrap(), vec![placement.clone()]); + assert_eq!( + repo.list_placements().await.unwrap(), + vec![placement.clone()] + ); assert_eq!( repo.list_placements_by_backend("backend-1").await.unwrap(), vec![placement.clone()] @@ -387,7 +403,9 @@ mod tests { repo.upsert_node_status(status.clone()).await.unwrap(); assert_eq!( - repo.list_node_statuses_by_backend("backend-1").await.unwrap(), + repo.list_node_statuses_by_backend("backend-1") + .await + .unwrap(), vec![status.clone()] ); assert_eq!( diff --git a/src/sms/ai_backends/service.rs b/src/sms/ai_backends/service.rs index dbcae93c..ff306341 100644 --- a/src/sms/ai_backends/service.rs +++ b/src/sms/ai_backends/service.rs @@ -119,7 +119,10 @@ impl AiBackendService { } /// Fetch one backend / 读取单个 backend - pub async fn get_backend(&self, backend_id: &str) -> Result, SmsError> { + pub async fn get_backend( + &self, + backend_id: &str, + ) -> Result, SmsError> { self.repository.get_backend(backend_id).await } @@ -132,12 +135,20 @@ impl AiBackendService { /// Delete a backend and its derived state / 删除 backend 及其派生状态 pub async fn delete_backend(&self, backend_id: &str) -> Result { - let placements = self.repository.list_placements_by_backend(backend_id).await?; + let placements = self + .repository + .list_placements_by_backend(backend_id) + .await?; for placement in placements { - self.repository.delete_placement(&placement.placement_id).await?; + self.repository + .delete_placement(&placement.placement_id) + .await?; } - let statuses = self.repository.list_node_statuses_by_backend(backend_id).await?; + let statuses = self + .repository + .list_node_statuses_by_backend(backend_id) + .await?; for status in statuses { self.repository .delete_node_status(&status.backend_id, &status.node_uuid) diff --git a/src/sms/ai_backends/status_service.rs b/src/sms/ai_backends/status_service.rs index 00e5bdf0..fbf9093d 100644 --- a/src/sms/ai_backends/status_service.rs +++ b/src/sms/ai_backends/status_service.rs @@ -34,7 +34,12 @@ impl AiBackendStatusService { record.node_uuid ))); } - if self.repository.get_backend(&record.backend_id).await?.is_none() { + if self + .repository + .get_backend(&record.backend_id) + .await? + .is_none() + { return Err(SmsError::NotFound(format!( "backend {} not found", record.backend_id @@ -53,7 +58,10 @@ impl AiBackendStatusService { &self, backend_id: &str, ) -> Result, SmsError> { - let mut statuses = self.repository.list_node_statuses_by_backend(backend_id).await?; + let mut statuses = self + .repository + .list_node_statuses_by_backend(backend_id) + .await?; statuses.sort_by(|left, right| left.node_uuid.cmp(&right.node_uuid)); Ok(statuses) } diff --git a/src/sms/ai_backends/validator.rs b/src/sms/ai_backends/validator.rs index 24fe87ac..26dbf6f2 100644 --- a/src/sms/ai_backends/validator.rs +++ b/src/sms/ai_backends/validator.rs @@ -1,5 +1,6 @@ //! Validation helpers for unified AI backends / 统一 AI backend 的校验辅助 +use crate::ai_backend_types::{AllowedHosting, CanonicalBackendKind}; use crate::sms::ai_backends::model::{ AiBackendHostingModel, AiBackendManagementModeModel, AiBackendPlacementRecordModel, AiBackendRecordModel, AiBackendSpecModel, @@ -72,8 +73,13 @@ fn validate_non_empty(value: &str, field_name: &str) -> Result<(), SmsError> { fn validate_hosting_management_mode(record: &AiBackendRecordModel) -> Result<(), SmsError> { let matches = matches!( (record.hosting, record.management_mode), - (AiBackendHostingModel::Remote, AiBackendManagementModeModel::SmsRemote) - | (AiBackendHostingModel::Local, AiBackendManagementModeModel::SmsLocal) + ( + AiBackendHostingModel::Remote, + AiBackendManagementModeModel::SmsRemote + ) | ( + AiBackendHostingModel::Local, + AiBackendManagementModeModel::SmsLocal + ) ); if matches { Ok(()) @@ -85,22 +91,21 @@ fn validate_hosting_management_mode(record: &AiBackendRecordModel) -> Result<(), } fn validate_backend_kind_for_hosting(record: &AiBackendRecordModel) -> Result<(), SmsError> { - let kind = record.backend_kind.trim().to_ascii_lowercase(); - let is_local_only = matches!(kind.as_str(), "llamacpp" | "llama_cpp" | "llama.cpp" | "vllm" | "vllm-openai"); - let is_remote_only = matches!( - kind.as_str(), - "openai_chat_completion" | "openai_realtime_ws" | "ollama_chat" - ); + let kind = CanonicalBackendKind::parse(&record.backend_kind); match record.hosting { - AiBackendHostingModel::Remote if is_local_only => Err(SmsError::InvalidRequest(format!( - "backend_kind {} is local-only and cannot be used with remote hosting", - record.backend_kind - ))), - AiBackendHostingModel::Local if is_remote_only => Err(SmsError::InvalidRequest(format!( - "backend_kind {} is remote-only and cannot be used with local hosting", - record.backend_kind - ))), + AiBackendHostingModel::Remote if kind.allowed_hosting() == AllowedHosting::LocalOnly => { + Err(SmsError::InvalidRequest(format!( + "backend_kind {} is local-only and cannot be used with remote hosting", + record.backend_kind + ))) + } + AiBackendHostingModel::Local if kind.allowed_hosting() == AllowedHosting::RemoteOnly => { + Err(SmsError::InvalidRequest(format!( + "backend_kind {} is remote-only and cannot be used with local hosting", + record.backend_kind + ))) + } _ => Ok(()), } } @@ -130,7 +135,9 @@ fn validate_record_spec_consistency(record: &AiBackendRecordModel) -> Result<(), )); } - if matches!(record.hosting, AiBackendHostingModel::Remote) && record.spec.base_url.trim().is_empty() { + if matches!(record.hosting, AiBackendHostingModel::Remote) + && record.spec.base_url.trim().is_empty() + { return Err(SmsError::InvalidRequest( "remote backends require spec.base_url".to_string(), )); @@ -217,7 +224,8 @@ mod tests { updated_at_ms: 1, }; - let err = validate_backend_record(&record).expect_err("mismatched management_mode should fail"); + let err = + validate_backend_record(&record).expect_err("mismatched management_mode should fail"); assert!(matches!(err, SmsError::InvalidRequest(_))); } @@ -249,10 +257,75 @@ mod tests { updated_at_ms: 1, }; - let err = validate_backend_record(&record).expect_err("remote hosting should reject local-only kind"); + let err = validate_backend_record(&record) + .expect_err("remote hosting should reject local-only kind"); + assert!(matches!(err, SmsError::InvalidRequest(_))); + } + + #[test] + fn rejects_remote_with_local_only_backend_kind_alias() { + let record = AiBackendRecordModel { + backend_id: "backend-1".to_string(), + display_name: "Backend 1".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: AiBackendHostingModel::Remote, + backend_kind: "llama.cpp".to_string(), + desired_state: AiBackendDesiredStateModel::Enabled, + management_mode: AiBackendManagementModeModel::SmsRemote, + credential_ref: None, + spec: AiBackendSpecModel { + kind: "llama.cpp".to_string(), + operations: vec!["chat_completions".to_string()], + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + base_url: "https://example.com".to_string(), + credential_ref: String::new(), + ..sample_spec() + }, + labels: BTreeMap::new(), + metadata: serde_json::json!({}), + generation: 1, + created_at_ms: 1, + updated_at_ms: 1, + }; + + let err = validate_backend_record(&record) + .expect_err("remote hosting should reject local-only alias kind"); assert!(matches!(err, SmsError::InvalidRequest(_))); } + #[test] + fn allows_local_ollama_chat_backend_kind() { + let record = AiBackendRecordModel { + backend_id: "backend-1".to_string(), + display_name: "Backend 1".to_string(), + provider: "ollama".to_string(), + model: "llama3.1:8b".to_string(), + hosting: AiBackendHostingModel::Local, + backend_kind: "ollama_chat".to_string(), + desired_state: AiBackendDesiredStateModel::Enabled, + management_mode: AiBackendManagementModeModel::SmsLocal, + credential_ref: None, + spec: AiBackendSpecModel { + kind: "ollama_chat".to_string(), + operations: vec!["chat_completions".to_string()], + provider: "ollama".to_string(), + model: "llama3.1:8b".to_string(), + base_url: "http://127.0.0.1:11434".to_string(), + credential_ref: String::new(), + ..sample_spec() + }, + labels: BTreeMap::new(), + metadata: serde_json::json!({}), + generation: 1, + created_at_ms: 1, + updated_at_ms: 1, + }; + + validate_backend_record(&record).expect("local ollama_chat should be allowed"); + } + #[test] fn rejects_non_empty_legacy_deployment_id() { let mut spec = sample_spec(); diff --git a/src/sms/file_service_test.rs b/src/sms/file_service_test.rs index 9054e58f..bb094a71 100644 --- a/src/sms/file_service_test.rs +++ b/src/sms/file_service_test.rs @@ -4,9 +4,8 @@ use crate::proto::sms::{ execution_index_service_client::ExecutionIndexServiceClient, execution_registry_service_client::ExecutionRegistryServiceClient, instance_registry_service_client::InstanceRegistryServiceClient, - mcp_registry_service_client::McpRegistryServiceClient, - node_service_client::NodeServiceClient, placement_service_client::PlacementServiceClient, - task_service_client::TaskServiceClient, + mcp_registry_service_client::McpRegistryServiceClient, node_service_client::NodeServiceClient, + placement_service_client::PlacementServiceClient, task_service_client::TaskServiceClient, }; use crate::sms::gateway::{create_gateway_router, GatewayState}; use axum::body; diff --git a/src/sms/gateway.rs b/src/sms/gateway.rs index 4142f513..fcc8df0d 100644 --- a/src/sms/gateway.rs +++ b/src/sms/gateway.rs @@ -2,20 +2,19 @@ //! SPEAR元数据服务器gRPC服务的HTTP网关 use axum::Router; -use rust_embed::RustEmbed; use tokio_util::sync::CancellationToken; use tower_http::cors::CorsLayer; use super::routes::create_routes; use crate::proto::sms::{ - ai_backend_control_plane_service_client::AiBackendControlPlaneServiceClient, admin_credential_service_client::AdminCredentialServiceClient, + ai_backend_control_plane_service_client::AiBackendControlPlaneServiceClient, backend_registry_service_client::BackendRegistryServiceClient, execution_index_service_client::ExecutionIndexServiceClient, execution_registry_service_client::ExecutionRegistryServiceClient, instance_registry_service_client::InstanceRegistryServiceClient, - mcp_registry_service_client::McpRegistryServiceClient, - node_service_client::NodeServiceClient, placement_service_client::PlacementServiceClient, + mcp_registry_service_client::McpRegistryServiceClient, node_service_client::NodeServiceClient, + placement_service_client::PlacementServiceClient, task_placement_assignment_service_client::TaskPlacementAssignmentServiceClient, task_service_client::TaskServiceClient, }; @@ -27,12 +26,6 @@ use std::time::{Duration, Instant}; use tokio::sync::OnceCell; use uuid::Uuid; -/// Embedded static files for Swagger UI / Swagger UI的嵌入式静态文件 -#[derive(RustEmbed)] -#[folder = "static/"] -#[allow(dead_code)] -struct StaticFiles; - /// Stream session record / 流会话记录 #[derive(Clone, Debug)] pub struct StreamSession { @@ -340,7 +333,8 @@ pub struct GatewayState { pub execution_index_client: ExecutionIndexServiceClient, pub mcp_registry_client: McpRegistryServiceClient, pub backend_registry_client: BackendRegistryServiceClient, - pub ai_backend_control_plane_client: AiBackendControlPlaneServiceClient, + pub ai_backend_control_plane_client: + AiBackendControlPlaneServiceClient, pub admin_credential_client: AdminCredentialServiceClient, /// Stream sessions for WS proxy / WS 代理的流会话 pub stream_sessions: StreamSessionStore, diff --git a/src/sms/gateway_test.rs b/src/sms/gateway_test.rs index 0d2ad2bb..46cf37ad 100644 --- a/src/sms/gateway_test.rs +++ b/src/sms/gateway_test.rs @@ -11,9 +11,8 @@ use crate::proto::sms::{ execution_index_service_client::ExecutionIndexServiceClient, execution_registry_service_client::ExecutionRegistryServiceClient, instance_registry_service_client::InstanceRegistryServiceClient, - mcp_registry_service_client::McpRegistryServiceClient, - node_service_client::NodeServiceClient, placement_service_client::PlacementServiceClient, - task_service_client::TaskServiceClient, + mcp_registry_service_client::McpRegistryServiceClient, node_service_client::NodeServiceClient, + placement_service_client::PlacementServiceClient, task_service_client::TaskServiceClient, }; use crate::sms::gateway::{create_gateway_router, GatewayState}; use tokio_util::sync::CancellationToken; diff --git a/src/sms/grpc_server.rs b/src/sms/grpc_server.rs index 04313052..5178f4fb 100644 --- a/src/sms/grpc_server.rs +++ b/src/sms/grpc_server.rs @@ -7,16 +7,16 @@ use tonic::transport::Server; use tracing::{error, info}; use crate::proto::sms::{ - ai_backend_control_plane_service_server::AiBackendControlPlaneServiceServer, admin_credential_service_server::AdminCredentialServiceServer, + ai_backend_control_plane_service_server::AiBackendControlPlaneServiceServer, backend_registry_service_server::BackendRegistryServiceServer, events_service_server::EventsServiceServer, execution_index_service_server::ExecutionIndexServiceServer, execution_log_ingest_service_server::ExecutionLogIngestServiceServer, execution_registry_service_server::ExecutionRegistryServiceServer, instance_registry_service_server::InstanceRegistryServiceServer, - mcp_registry_service_server::McpRegistryServiceServer, - node_service_server::NodeServiceServer, placement_service_server::PlacementServiceServer, + mcp_registry_service_server::McpRegistryServiceServer, node_service_server::NodeServiceServer, + placement_service_server::PlacementServiceServer, task_placement_assignment_service_server::TaskPlacementAssignmentServiceServer, task_service_server::TaskServiceServer, }; @@ -53,9 +53,7 @@ impl GrpcServer { )) .add_service(McpRegistryServiceServer::new(sms_service.clone())) .add_service(BackendRegistryServiceServer::new(sms_service.clone())) - .add_service(AiBackendControlPlaneServiceServer::new( - sms_service.clone(), - )) + .add_service(AiBackendControlPlaneServiceServer::new(sms_service.clone())) .add_service(AdminCredentialServiceServer::new(sms_service.clone())) .add_service(RouterFilterServiceServer::new(sms_service.clone())) .add_service(PlacementServiceServer::new(sms_service)) @@ -92,9 +90,7 @@ impl GrpcServer { )) .add_service(McpRegistryServiceServer::new(sms_service.clone())) .add_service(BackendRegistryServiceServer::new(sms_service.clone())) - .add_service(AiBackendControlPlaneServiceServer::new( - sms_service.clone(), - )) + .add_service(AiBackendControlPlaneServiceServer::new(sms_service.clone())) .add_service(AdminCredentialServiceServer::new(sms_service.clone())) .add_service(RouterFilterServiceServer::new(sms_service.clone())) .add_service(PlacementServiceServer::new(sms_service)) diff --git a/src/sms/handlers/node.rs b/src/sms/handlers/node.rs index 6a4cb009..06a77575 100644 --- a/src/sms/handlers/node.rs +++ b/src/sms/handlers/node.rs @@ -7,10 +7,9 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - response::Json, + response::{IntoResponse, Json, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::collections::HashMap; use tracing::{info, warn}; use uuid::Uuid; @@ -20,6 +19,10 @@ use crate::proto::sms::{ RegisterNodeRequest, UpdateNodeRequest, }; use crate::sms::gateway::GatewayState; +use crate::sms::node_api::{ + node_to_response, PublicNodeActionResponse, PublicNodeEnvelope, PublicNodeListResponse, + PublicNodeRegistrationResponse, +}; /// Node registration request for HTTP API / HTTP API的节点注册请求 #[derive(Debug, Serialize, Deserialize)] @@ -52,11 +55,26 @@ pub struct ListNodesQuery { pub status: Option, } +#[derive(Debug, Serialize)] +struct PublicNodeErrorResponse { + success: bool, + error: String, + message: String, +} + +fn node_not_found_response() -> Json { + Json(PublicNodeErrorResponse { + success: false, + error: "Node not found".to_string(), + message: "Node not found".to_string(), + }) +} + /// Register a new node / 注册新节点 pub async fn register_node( State(state): State, Json(req): Json, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result<(StatusCode, Json), StatusCode> { let mut client = state.node_client.clone(); let node = Node { @@ -79,20 +97,21 @@ pub async fn register_node( info!("Node registered successfully via HTTP: {}", resp.node_uuid); Ok(( StatusCode::CREATED, - Json(json!({ - "success": true, - "message": resp.message, - "node_uuid": resp.node_uuid - })), + Json(PublicNodeRegistrationResponse { + success: true, + message: resp.message, + node_uuid: resp.node_uuid, + }), )) } else { warn!("Failed to register node via HTTP: {}", resp.message); Ok(( StatusCode::BAD_REQUEST, - Json(json!({ - "success": false, - "message": resp.message - })), + Json(PublicNodeRegistrationResponse { + success: false, + message: resp.message, + node_uuid: String::new(), + }), )) } } @@ -107,7 +126,7 @@ pub async fn register_node( pub async fn list_nodes( State(state): State, Query(query): Query, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let mut client = state.node_client.clone(); let status_filter = query.status.unwrap_or_default(); @@ -122,27 +141,11 @@ pub async fn list_nodes( Ok(response) => { let resp = response.into_inner(); info!("Listed {} nodes via HTTP", resp.nodes.len()); - // 手动构建节点数组以避免 serde 版本冲突 / Manually build node array to avoid serde version conflicts - let nodes_json: Vec = resp - .nodes - .into_iter() - .map(|node| { - json!({ - "uuid": node.uuid, - "ip_address": node.ip_address, - "port": node.port, - "http_port": node.http_port, - "status": node.status, - "last_heartbeat": node.last_heartbeat, - "registered_at": node.registered_at, - "metadata": node.metadata - }) - }) - .collect(); - Ok(Json(json!({ - "success": true, - "nodes": nodes_json - }))) + let nodes = resp.nodes.into_iter().map(node_to_response).collect(); + Ok(Json(PublicNodeListResponse { + success: true, + nodes, + })) } Err(e) => { warn!("gRPC error during node listing: {}", e); @@ -155,7 +158,7 @@ pub async fn list_nodes( pub async fn get_node( State(state): State, Path(uuid): Path, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result { let mut client = state.node_client.clone(); if Uuid::parse_str(&uuid).is_err() { return Err(StatusCode::INTERNAL_SERVER_ERROR); @@ -167,47 +170,24 @@ pub async fn get_node( Ok(response) => { let resp = response.into_inner(); if resp.found { - // 手动构建节点对象以避免 serde 版本冲突 / Manually build node object to avoid serde version conflicts - let node_json = if let Some(node) = resp.node { - json!({ - "uuid": node.uuid, - "ip_address": node.ip_address, - "port": node.port, - "http_port": node.http_port, - "status": node.status, - "last_heartbeat": node.last_heartbeat, - "registered_at": node.registered_at, - "metadata": node.metadata - }) - } else { - serde_json::Value::Null + let Some(node) = resp.node else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); }; Ok(( StatusCode::OK, - Json(json!({ - "success": true, - "node": node_json - })), - )) + Json(PublicNodeEnvelope { + success: true, + node: node_to_response(node), + }), + ) + .into_response()) } else { - Ok(( - StatusCode::NOT_FOUND, - Json(json!({ - "success": false, - "error": "Node not found" - })), - )) + Ok((StatusCode::NOT_FOUND, node_not_found_response()).into_response()) } } Err(e) => { if e.code() == tonic::Code::NotFound { - Ok(( - StatusCode::NOT_FOUND, - Json(json!({ - "success": false, - "error": "Node not found" - })), - )) + Ok((StatusCode::NOT_FOUND, node_not_found_response()).into_response()) } else { warn!("gRPC error during node retrieval: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -221,7 +201,7 @@ pub async fn update_node( State(state): State, Path(uuid): Path, Json(req): Json, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result { let mut client = state.node_client.clone(); // First get the existing node / 首先获取现有节点 @@ -233,24 +213,12 @@ pub async fn update_node( if resp.found { resp.node.unwrap() } else { - return Ok(( - StatusCode::NOT_FOUND, - Json(json!({ - "success": false, - "error": "Node not found" - })), - )); + return Ok((StatusCode::NOT_FOUND, node_not_found_response()).into_response()); } } Err(e) => { if e.code() == tonic::Code::NotFound { - return Ok(( - StatusCode::NOT_FOUND, - Json(json!({ - "success": false, - "error": "Node not found" - })), - )); + return Ok((StatusCode::NOT_FOUND, node_not_found_response()).into_response()); } else { warn!("gRPC error during node retrieval for update: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); @@ -280,11 +248,12 @@ pub async fn update_node( let resp = response.into_inner(); Ok(( StatusCode::OK, - Json(json!({ - "success": resp.success, - "message": resp.message - })), - )) + Json(PublicNodeActionResponse { + success: resp.success, + message: resp.message, + }), + ) + .into_response()) } Err(e) => { warn!("gRPC error during node update: {}", e); @@ -297,7 +266,7 @@ pub async fn update_node( pub async fn delete_node( State(state): State, Path(uuid): Path, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result { let mut client = state.node_client.clone(); let grpc_req = DeleteNodeRequest { uuid }; @@ -307,23 +276,18 @@ pub async fn delete_node( let resp = response.into_inner(); Ok(( StatusCode::OK, - Json(json!({ - "success": resp.success, - "message": resp.message - })), - )) + Json(PublicNodeActionResponse { + success: resp.success, + message: resp.message, + }), + ) + .into_response()) } Err(e) => { warn!("gRPC error during node deletion: {}", e); // Handle specific gRPC error codes / 处理特定的gRPC错误码 if e.code() == tonic::Code::NotFound { - Ok(( - StatusCode::NOT_FOUND, - Json(json!({ - "success": false, - "error": "Node not found" - })), - )) + Ok((StatusCode::NOT_FOUND, node_not_found_response()).into_response()) } else { Err(StatusCode::INTERNAL_SERVER_ERROR) } @@ -336,7 +300,7 @@ pub async fn heartbeat( State(state): State, Path(uuid): Path, Json(req): Json, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result { let mut client = state.node_client.clone(); let grpc_req = HeartbeatRequest { @@ -350,23 +314,18 @@ pub async fn heartbeat( let resp = response.into_inner(); Ok(( StatusCode::OK, - Json(json!({ - "success": resp.success, - "message": resp.message - })), - )) + Json(PublicNodeActionResponse { + success: resp.success, + message: resp.message, + }), + ) + .into_response()) } Err(e) => { warn!("gRPC error during heartbeat: {}", e); // Handle specific gRPC error codes / 处理特定的gRPC错误码 if e.code() == tonic::Code::NotFound { - Ok(( - StatusCode::NOT_FOUND, - Json(json!({ - "success": false, - "error": "Node not found" - })), - )) + Ok((StatusCode::NOT_FOUND, node_not_found_response()).into_response()) } else { Err(StatusCode::INTERNAL_SERVER_ERROR) } diff --git a/src/sms/handlers/placement.rs b/src/sms/handlers/placement.rs index 3c2a4067..baeda049 100644 --- a/src/sms/handlers/placement.rs +++ b/src/sms/handlers/placement.rs @@ -2,9 +2,7 @@ use axum::{extract::State, http::StatusCode, response::Json}; use serde::{Deserialize, Serialize}; use serde_json::json; -use crate::proto::sms::{ - PlaceInvocationRequest, ReportInvocationOutcomeRequest, -}; +use crate::proto::sms::{PlaceInvocationRequest, ReportInvocationOutcomeRequest}; use crate::sms::gateway::GatewayState; use crate::sms::placement::outcome::parse_http_outcome_class; diff --git a/src/sms/handlers/resource.rs b/src/sms/handlers/resource.rs index dcbdaaae..dc4a22a6 100644 --- a/src/sms/handlers/resource.rs +++ b/src/sms/handlers/resource.rs @@ -10,7 +10,6 @@ use axum::{ response::Json, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::collections::HashMap; use tracing::{info, warn}; @@ -19,6 +18,10 @@ use crate::proto::sms::{ UpdateNodeResourceRequest, }; use crate::sms::gateway::GatewayState; +use crate::sms::node_api::{ + node_resource_to_response, node_with_resource_to_response, NodeResourceResponse, + PublicNodeActionResponse, PublicNodeResourceListResponse, PublicNodeWithResourceResponse, +}; /// Node resource update request for HTTP API / HTTP API的节点资源更新请求 #[derive(Debug, Serialize, Deserialize)] @@ -50,7 +53,7 @@ pub async fn update_node_resource( State(state): State, Path(uuid): Path, Json(req): Json, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let mut client = state.node_client.clone(); // Convert HTTP request to gRPC request / 将HTTP请求转换为gRPC请求 @@ -80,10 +83,10 @@ pub async fn update_node_resource( match client.update_node_resource(grpc_req).await { Ok(response) => { let resp = response.into_inner(); - Ok(Json(json!({ - "success": resp.success, - "message": resp.message - }))) + Ok(Json(PublicNodeActionResponse { + success: resp.success, + message: resp.message, + })) } Err(e) => { warn!("Failed to update node resource: {}", e); @@ -96,7 +99,7 @@ pub async fn update_node_resource( pub async fn get_node_resource( State(state): State, Path(uuid): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let mut client = state.node_client.clone(); let grpc_req = GetNodeResourceRequest { node_uuid: uuid }; @@ -105,24 +108,7 @@ pub async fn get_node_resource( Ok(response) => { let resp = response.into_inner(); if let Some(resource) = resp.resource { - Ok(Json(json!({ - "node_uuid": resource.node_uuid, - "cpu_usage_percent": resource.cpu_usage_percent, - "memory_usage_percent": resource.memory_usage_percent, - "total_memory_bytes": resource.total_memory_bytes, - "used_memory_bytes": resource.used_memory_bytes, - "available_memory_bytes": resource.available_memory_bytes, - "disk_usage_percent": resource.disk_usage_percent, - "total_disk_bytes": resource.total_disk_bytes, - "used_disk_bytes": resource.used_disk_bytes, - "network_rx_bytes_per_sec": resource.network_rx_bytes_per_sec, - "network_tx_bytes_per_sec": resource.network_tx_bytes_per_sec, - "load_average_1m": resource.load_average_1m, - "load_average_5m": resource.load_average_5m, - "load_average_15m": resource.load_average_15m, - "resource_metadata": resource.resource_metadata, - "updated_at": resource.updated_at - }))) + Ok(Json(node_resource_to_response(resource))) } else { Err(StatusCode::NOT_FOUND) } @@ -138,7 +124,7 @@ pub async fn get_node_resource( pub async fn list_node_resources( State(state): State, Query(query): Query, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let mut client = state.node_client.clone(); let node_uuids = if let Some(uuids_str) = &query.node_uuids { @@ -146,10 +132,13 @@ pub async fn list_node_resources( "HTTP list_node_resources called with node_uuids: '{}'", uuids_str ); - uuids_str.split(',').map(|s| s.trim().to_string()).collect() + uuids_str + .split(',') + .map(|s| s.trim().to_string()) + .collect::>() } else { info!("HTTP list_node_resources called with no node_uuids filter"); - vec![] + Vec::::new() }; let grpc_req = ListNodeResourcesRequest { node_uuids }; @@ -157,34 +146,13 @@ pub async fn list_node_resources( match client.list_node_resources(grpc_req).await { Ok(response) => { let resp = response.into_inner(); - let resources: Vec = resp + let resources = resp .resources .into_iter() - .map(|resource| { - json!({ - "node_uuid": resource.node_uuid, - "cpu_usage_percent": resource.cpu_usage_percent, - "memory_usage_percent": resource.memory_usage_percent, - "total_memory_bytes": resource.total_memory_bytes, - "used_memory_bytes": resource.used_memory_bytes, - "available_memory_bytes": resource.available_memory_bytes, - "disk_usage_percent": resource.disk_usage_percent, - "total_disk_bytes": resource.total_disk_bytes, - "used_disk_bytes": resource.used_disk_bytes, - "network_rx_bytes_per_sec": resource.network_rx_bytes_per_sec, - "network_tx_bytes_per_sec": resource.network_tx_bytes_per_sec, - "load_average_1m": resource.load_average_1m, - "load_average_5m": resource.load_average_5m, - "load_average_15m": resource.load_average_15m, - "resource_metadata": resource.resource_metadata, - "updated_at": resource.updated_at - }) - }) + .map(node_resource_to_response) .collect(); - Ok(Json(json!({ - "resources": resources - }))) + Ok(Json(PublicNodeResourceListResponse { resources })) } Err(e) => { warn!("Failed to list node resources: {}", e); @@ -197,7 +165,7 @@ pub async fn list_node_resources( pub async fn get_node_with_resource( State(state): State, Path(uuid): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let mut client = state.node_client.clone(); let grpc_req = GetNodeWithResourceRequest { uuid }; @@ -206,37 +174,7 @@ pub async fn get_node_with_resource( Ok(response) => { let resp = response.into_inner(); if let Some(node) = resp.node { - let mut result = json!({ - "uuid": node.uuid, - "ip_address": node.ip_address, - "port": node.port, - "status": node.status, - "metadata": node.metadata, - "registered_at": node.registered_at, - "last_heartbeat": node.last_heartbeat - }); - - if let Some(resource) = resp.resource { - result["resource"] = json!({ - "cpu_usage_percent": resource.cpu_usage_percent, - "memory_usage_percent": resource.memory_usage_percent, - "total_memory_bytes": resource.total_memory_bytes, - "used_memory_bytes": resource.used_memory_bytes, - "available_memory_bytes": resource.available_memory_bytes, - "disk_usage_percent": resource.disk_usage_percent, - "total_disk_bytes": resource.total_disk_bytes, - "used_disk_bytes": resource.used_disk_bytes, - "network_rx_bytes_per_sec": resource.network_rx_bytes_per_sec, - "network_tx_bytes_per_sec": resource.network_tx_bytes_per_sec, - "load_average_1m": resource.load_average_1m, - "load_average_5m": resource.load_average_5m, - "load_average_15m": resource.load_average_15m, - "resource_metadata": resource.resource_metadata, - "updated_at": resource.updated_at - }); - } - - Ok(Json(result)) + Ok(Json(node_with_resource_to_response(node, resp.resource))) } else { Err(StatusCode::NOT_FOUND) } diff --git a/src/sms/handlers/task.rs b/src/sms/handlers/task.rs index 0fd8e096..9175995b 100644 --- a/src/sms/handlers/task.rs +++ b/src/sms/handlers/task.rs @@ -15,15 +15,13 @@ use tonic::Request; use tracing::{debug, error, info}; use super::common::ErrorResponse; -use crate::proto::sms::{ - DeleteTaskRequest, ExecutableType, GetTaskRequest, ListTasksRequest, RegisterTaskRequest, - TaskExecutable, TaskPriority, TaskSchedulingStrategy, -}; +use crate::proto::sms::{DeleteTaskRequest, GetTaskRequest, ListTasksRequest, TaskPriority}; use crate::sms::gateway::GatewayState; -use crate::sms::{ - parse_task_priority_public_str, parse_task_status_public_str, task_priority_to_public_str, - task_status_to_public_str, FilterState, +use crate::sms::task_api::{ + build_register_task_request, task_to_public_response, PublicTaskResponse, RegisterTaskSpec, + TaskExecutableSpec, }; +use crate::sms::{parse_task_priority_public_str, parse_task_status_public_str, FilterState}; // HTTP request/response types / HTTP请求/响应类型 @@ -66,29 +64,6 @@ pub struct DeleteTaskParams { pub force: Option, } -#[derive(Debug, Serialize)] -pub struct TaskResponse { - pub task_id: String, - pub name: String, - pub description: String, - pub status: String, - pub priority: String, - pub desired_replicas: u32, - pub scheduling_strategy: String, - pub endpoint: String, - pub version: String, - pub capabilities: Vec, - pub registered_at: i64, - pub last_heartbeat: i64, - pub metadata: HashMap, - pub config: HashMap, - pub result_uris: Vec, - pub last_result_uri: String, - pub last_result_status: String, - pub last_completed_at: i64, - pub last_result_metadata: HashMap, -} - #[derive(Debug, Serialize)] pub struct RegisterTaskResponse { pub success: bool, @@ -98,7 +73,7 @@ pub struct RegisterTaskResponse { #[derive(Debug, Serialize)] pub struct ListTasksResponse { - pub tasks: Vec, + pub tasks: Vec, pub total_count: i32, } @@ -108,36 +83,6 @@ pub struct TaskActionResponse { pub message: String, } -// Helper function to convert proto Task to TaskResponse / 转换proto Task为TaskResponse的辅助函数 -fn task_to_response(task: crate::proto::sms::Task) -> TaskResponse { - TaskResponse { - task_id: task.task_id, - name: task.name, - description: task.description, - status: task_status_to_public_str(task.status).to_string(), - priority: task_priority_to_public_str(task.priority).to_string(), - desired_replicas: task.desired_replicas, - scheduling_strategy: match TaskSchedulingStrategy::try_from(task.scheduling_strategy) - .unwrap_or(TaskSchedulingStrategy::Spread) - { - TaskSchedulingStrategy::Spread => "spread".to_string(), - TaskSchedulingStrategy::Unknown => "unknown".to_string(), - }, - endpoint: task.endpoint, - version: task.version, - capabilities: task.capabilities, - registered_at: task.registered_at, - last_heartbeat: task.last_heartbeat, - metadata: task.metadata, - config: task.config, - result_uris: task.result_uris, - last_result_uri: task.last_result_uri, - last_result_status: task.last_result_status, - last_completed_at: task.last_completed_at, - last_result_metadata: task.last_result_metadata, - } -} - // HTTP Handlers / HTTP处理器 /// Register a new task / 注册新任务 @@ -153,40 +98,28 @@ pub async fn register_task( .map(parse_task_priority_public_str) .unwrap_or(TaskPriority::Normal as i32); - let meta = params.metadata.clone().unwrap_or_default(); - let scheduling_strategy = match params.scheduling_strategy.as_deref() { - Some("spread") | None => TaskSchedulingStrategy::Spread as i32, - _ => TaskSchedulingStrategy::Unknown as i32, - }; - let request = Request::new(RegisterTaskRequest { + let request = Request::new(build_register_task_request(RegisterTaskSpec { name: params.name.clone(), description: params .description .unwrap_or_else(|| format!("Task: {}", params.name)), priority, + desired_replicas: params.desired_replicas.unwrap_or(1), + scheduling_strategy: params.scheduling_strategy, endpoint: params.endpoint, version: params.version, capabilities: params.capabilities.unwrap_or_default(), - metadata: meta.clone(), + metadata: params.metadata.unwrap_or_default(), config: params.config.unwrap_or_default(), - executable: params.executable.as_ref().map(|e| TaskExecutable { - r#type: match e.r#type.to_lowercase().as_str() { - "binary" => ExecutableType::Binary as i32, - "script" => ExecutableType::Script as i32, - "container" => ExecutableType::Container as i32, - "wasm" => ExecutableType::Wasm as i32, - "process" => ExecutableType::Process as i32, - _ => ExecutableType::Unknown as i32, - }, - uri: e.uri.clone(), - name: e.name.clone().unwrap_or_default(), - checksum_sha256: e.checksum_sha256.clone().unwrap_or_default(), - args: e.args.clone().unwrap_or_default(), - env: e.env.clone().unwrap_or_default(), + executable: params.executable.map(|executable| TaskExecutableSpec { + executable_type: executable.r#type, + uri: executable.uri, + name: executable.name, + checksum_sha256: executable.checksum_sha256, + args: executable.args, + env: executable.env, }), - desired_replicas: params.desired_replicas.unwrap_or(1), - scheduling_strategy, - }); + })); match gateway_state .task_client @@ -249,7 +182,11 @@ pub async fn list_tasks( match gateway_state.task_client.clone().list_tasks(request).await { Ok(response) => { let resp = response.into_inner(); - let tasks = resp.tasks.into_iter().map(task_to_response).collect(); + let tasks = resp + .tasks + .into_iter() + .map(task_to_public_response) + .collect(); Ok(Json(ListTasksResponse { tasks, @@ -273,7 +210,7 @@ pub async fn list_tasks( pub async fn get_task( State(gateway_state): State, Path(task_id): Path, -) -> Result, (StatusCode, Json)> { +) -> Result, (StatusCode, Json)> { debug!("HTTP: Getting task: {}", task_id); let request = Request::new(GetTaskRequest { task_id }); @@ -282,7 +219,7 @@ pub async fn get_task( Ok(response) => { let resp = response.into_inner(); if let Some(task) = resp.task { - Ok(Json(task_to_response(task))) + Ok(Json(task_to_public_response(task))) } else { Err(( StatusCode::NOT_FOUND, diff --git a/src/sms/handlers_test.rs b/src/sms/handlers_test.rs index f0f1850e..784b4843 100644 --- a/src/sms/handlers_test.rs +++ b/src/sms/handlers_test.rs @@ -5,9 +5,9 @@ use serde_urlencoded; use std::collections::HashMap; use crate::sms::handlers::{ - health_check, HttpHeartbeatRequest, HttpRegisterNodeRequest, HttpUpdateNodeRequest, - HttpUpdateNodeResourceRequest, ListNodeResourcesQuery, ListNodesQuery, ListTasksParams, - DeleteTaskParams, RegisterTaskParams, + health_check, DeleteTaskParams, HttpHeartbeatRequest, HttpRegisterNodeRequest, + HttpUpdateNodeRequest, HttpUpdateNodeResourceRequest, ListNodeResourcesQuery, ListNodesQuery, + ListTasksParams, RegisterTaskParams, }; // Mock tests for handlers that don't require gRPC clients / 不需要gRPC客户端的处理器模拟测试 @@ -223,7 +223,7 @@ fn test_list_tasks_params_deserialization() { } #[test] - fn test_delete_task_params_serialization() { +fn test_delete_task_params_serialization() { // Test delete task params serialization / 测试删除任务参数序列化 let params = DeleteTaskParams { reason: Some("Task completed successfully".to_string()), diff --git a/src/sms/http_gateway.rs b/src/sms/http_gateway.rs index e63058d3..feda7385 100644 --- a/src/sms/http_gateway.rs +++ b/src/sms/http_gateway.rs @@ -14,9 +14,8 @@ use crate::proto::sms::{ execution_index_service_client::ExecutionIndexServiceClient, execution_registry_service_client::ExecutionRegistryServiceClient, instance_registry_service_client::InstanceRegistryServiceClient, - mcp_registry_service_client::McpRegistryServiceClient, - node_service_client::NodeServiceClient, placement_service_client::PlacementServiceClient, - task_service_client::TaskServiceClient, + mcp_registry_service_client::McpRegistryServiceClient, node_service_client::NodeServiceClient, + placement_service_client::PlacementServiceClient, task_service_client::TaskServiceClient, }; use tokio_util::sync::CancellationToken; diff --git a/src/sms/instance_execution_index.rs b/src/sms/instance_execution_index.rs index 5c95b076..ace61950 100644 --- a/src/sms/instance_execution_index.rs +++ b/src/sms/instance_execution_index.rs @@ -142,7 +142,8 @@ impl InstanceExecutionIndex { let key = format!("{}{}", INSTANCE_KEY_PREFIX, instance_id); let _ = self.kv.delete(&key).await; - self.store_instance_tombstone(instance_id, deleted_at_ms).await?; + self.store_instance_tombstone(instance_id, deleted_at_ms) + .await?; self.remove_from_task_active_instances(task_id, instance_id) .await?; Ok((true, deleted_at_ms)) @@ -178,7 +179,11 @@ impl InstanceExecutionIndex { Ok((true, rec.updated_at_ms)) } - pub async fn project_instance_views(&self, inst: &Instance, now_ms: i64) -> Result<(), SmsError> { + pub async fn project_instance_views( + &self, + inst: &Instance, + now_ms: i64, + ) -> Result<(), SmsError> { self.project_task_active_instances_view(inst, now_ms).await } @@ -380,12 +385,13 @@ impl InstanceExecutionIndex { &inst.task_id, inst.updated_at_ms.max(now_ms), ) - .await?; + .await?; return Ok(()); } let (accepted, _) = self.upsert_instance_record(inst.clone()).await?; if accepted { - self.project_task_active_instances_view(&inst, now_ms).await?; + self.project_task_active_instances_view(&inst, now_ms) + .await?; } Ok(()) } @@ -419,7 +425,8 @@ impl InstanceExecutionIndex { }; let (accepted, _) = self.upsert_instance_record(inst.clone()).await?; if accepted { - self.project_task_active_instances_view(&inst, now_ms).await?; + self.project_task_active_instances_view(&inst, now_ms) + .await?; } } Ok(()) @@ -624,7 +631,10 @@ impl InstanceExecutionIndex { .await?; let _ = self .kv - .delete(&execution_history_index_key(Some(rec.task_id.as_str()), rec)) + .delete(&execution_history_index_key( + Some(rec.task_id.as_str()), + rec, + )) .await?; Ok(()) } @@ -944,14 +954,20 @@ mod tests { let (page1, token1) = idx.list_executions(None, None, 2, "").await.unwrap(); assert_eq!( - page1.iter().map(|e| e.execution_id.as_str()).collect::>(), + page1 + .iter() + .map(|e| e.execution_id.as_str()) + .collect::>(), vec!["exe-2", "exe-3"] ); assert!(!token1.is_empty()); let (page2, token2) = idx.list_executions(None, None, 2, &token1).await.unwrap(); assert_eq!( - page2.iter().map(|e| e.execution_id.as_str()).collect::>(), + page2 + .iter() + .map(|e| e.execution_id.as_str()) + .collect::>(), vec!["exe-1"] ); assert!(token2.is_empty()); diff --git a/src/sms/mod.rs b/src/sms/mod.rs index 50ce7751..1a92c572 100644 --- a/src/sms/mod.rs +++ b/src/sms/mod.rs @@ -38,26 +38,31 @@ //! - `http_gateway`: HTTP gateway implementation / HTTP网关实现 //! - `service`: Main SMS service implementation / 主要SMS服务实现 -pub mod config; +pub(crate) mod admin_api; +pub mod admin_credentials; +pub(crate) mod ai_admin_api; +mod ai_backend_rpc; pub mod ai_backends; +pub mod config; pub mod execution_logs; pub mod gateway; pub mod grpc_server; pub mod handlers; pub mod http_gateway; pub mod instance_execution_index; +pub(crate) mod node_api; pub mod placement; pub mod projectors; pub(crate) mod query_support; -pub mod admin_credentials; -pub mod registry_watch; pub mod registry; +pub mod registry_watch; pub mod routes; pub(crate) mod runtime; +pub(crate) mod runtime_api; pub mod service; pub mod services; pub mod stream_mux; -mod ai_backend_rpc; +pub(crate) mod task_api; mod task_assignment_rpc; mod task_rpc; pub(crate) mod task_semantics; diff --git a/src/sms/node_api.rs b/src/sms/node_api.rs new file mode 100644 index 00000000..29413d3b --- /dev/null +++ b/src/sms/node_api.rs @@ -0,0 +1,256 @@ +//! Shared node and resource API mapping helpers for SMS. +//! SMS 的节点与资源 API 共享映射辅助模块。 +//! +//! This module keeps node/resource presentation rules in one place so public +//! handlers and admin endpoints do not duplicate field projection logic. +//! 此模块将节点/资源展示规则集中到一个位置,避免 public handler 与 admin 入口重复维护字段投影逻辑。 + +use serde::Serialize; +use std::collections::HashMap; + +use crate::proto::sms::{Node, NodeResource}; + +#[derive(Debug, Clone, Serialize)] +pub struct NodeResponse { + pub uuid: String, + pub ip_address: String, + pub port: i32, + pub http_port: i32, + pub status: String, + pub last_heartbeat: i64, + pub registered_at: i64, + pub metadata: HashMap, +} + +pub(crate) fn node_to_response(node: Node) -> NodeResponse { + NodeResponse { + uuid: node.uuid, + ip_address: node.ip_address, + port: node.port, + http_port: node.http_port, + status: node.status, + last_heartbeat: node.last_heartbeat, + registered_at: node.registered_at, + metadata: node.metadata, + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct NodeResourceResponse { + pub node_uuid: String, + pub cpu_usage_percent: f64, + pub memory_usage_percent: f64, + pub total_memory_bytes: i64, + pub used_memory_bytes: i64, + pub available_memory_bytes: i64, + pub disk_usage_percent: f64, + pub total_disk_bytes: i64, + pub used_disk_bytes: i64, + pub network_rx_bytes_per_sec: i64, + pub network_tx_bytes_per_sec: i64, + pub load_average_1m: f64, + pub load_average_5m: f64, + pub load_average_15m: f64, + pub resource_metadata: HashMap, + pub updated_at: i64, +} + +pub(crate) fn node_resource_to_response(resource: NodeResource) -> NodeResourceResponse { + NodeResourceResponse { + node_uuid: resource.node_uuid, + cpu_usage_percent: resource.cpu_usage_percent, + memory_usage_percent: resource.memory_usage_percent, + total_memory_bytes: resource.total_memory_bytes, + used_memory_bytes: resource.used_memory_bytes, + available_memory_bytes: resource.available_memory_bytes, + disk_usage_percent: resource.disk_usage_percent, + total_disk_bytes: resource.total_disk_bytes, + used_disk_bytes: resource.used_disk_bytes, + network_rx_bytes_per_sec: resource.network_rx_bytes_per_sec, + network_tx_bytes_per_sec: resource.network_tx_bytes_per_sec, + load_average_1m: resource.load_average_1m, + load_average_5m: resource.load_average_5m, + load_average_15m: resource.load_average_15m, + resource_metadata: resource.resource_metadata, + updated_at: resource.updated_at, + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicNodeRegistrationResponse { + pub success: bool, + pub message: String, + pub node_uuid: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicNodeActionResponse { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicNodeListResponse { + pub success: bool, + pub nodes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicNodeEnvelope { + pub success: bool, + pub node: NodeResponse, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicNodeResourceListResponse { + pub resources: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicNodeWithResourceResponse { + pub uuid: String, + pub ip_address: String, + pub port: i32, + pub status: String, + pub metadata: HashMap, + pub registered_at: i64, + pub last_heartbeat: i64, + pub resource: Option, +} + +pub(crate) fn node_with_resource_to_response( + node: Node, + resource: Option, +) -> PublicNodeWithResourceResponse { + PublicNodeWithResourceResponse { + uuid: node.uuid, + ip_address: node.ip_address, + port: node.port, + status: node.status, + metadata: node.metadata, + registered_at: node.registered_at, + last_heartbeat: node.last_heartbeat, + resource: resource.map(node_resource_to_response), + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminNodeListItem { + pub(crate) uuid: String, + pub(crate) name: String, + pub(crate) ip_address: String, + pub(crate) port: i32, + pub(crate) status: String, + pub(crate) last_heartbeat: i64, + pub(crate) registered_at: i64, + pub(crate) metadata: HashMap, +} + +pub(crate) fn node_to_admin_list_item(node: Node) -> AdminNodeListItem { + let name = node.metadata.get("name").cloned().unwrap_or_default(); + AdminNodeListItem { + uuid: node.uuid, + name, + ip_address: node.ip_address, + port: node.port, + status: node.status, + last_heartbeat: node.last_heartbeat, + registered_at: node.registered_at, + metadata: node.metadata, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminNodeListResponse { + pub(crate) nodes: Vec, + pub(crate) total_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminNodeDetailEnvelope { + pub(crate) found: bool, + pub(crate) node: Option, + pub(crate) resource: Option, +} + +pub(crate) fn admin_node_detail_response( + node: Option, + resource: Option, +) -> AdminNodeDetailEnvelope { + AdminNodeDetailEnvelope { + found: node.is_some(), + node: node.map(node_to_response), + resource: resource.map(node_resource_to_response), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_node() -> Node { + Node { + uuid: "node-1".to_string(), + ip_address: "127.0.0.1".to_string(), + port: 9000, + http_port: 8081, + status: "online".to_string(), + last_heartbeat: 20, + registered_at: 10, + metadata: HashMap::from([("name".to_string(), "alpha".to_string())]), + } + } + + fn sample_resource() -> NodeResource { + NodeResource { + node_uuid: "node-1".to_string(), + cpu_usage_percent: 10.0, + memory_usage_percent: 20.0, + total_memory_bytes: 100, + used_memory_bytes: 50, + available_memory_bytes: 50, + disk_usage_percent: 30.0, + total_disk_bytes: 200, + used_disk_bytes: 60, + network_rx_bytes_per_sec: 1, + network_tx_bytes_per_sec: 2, + load_average_1m: 0.1, + load_average_5m: 0.2, + load_average_15m: 0.3, + resource_metadata: HashMap::from([("gpu".to_string(), "A10".to_string())]), + updated_at: 30, + } + } + + #[test] + fn node_response_keeps_public_fields() { + let response = node_to_response(sample_node()); + assert_eq!(response.uuid, "node-1"); + assert_eq!(response.http_port, 8081); + assert_eq!( + response.metadata.get("name").map(String::as_str), + Some("alpha") + ); + } + + #[test] + fn node_with_resource_response_nests_resource() { + let response = node_with_resource_to_response(sample_node(), Some(sample_resource())); + assert_eq!(response.uuid, "node-1"); + assert_eq!( + response + .resource + .as_ref() + .and_then(|resource| resource.resource_metadata.get("gpu")) + .map(String::as_str), + Some("A10") + ); + } + + #[test] + fn admin_list_item_derives_name_from_metadata() { + let item = node_to_admin_list_item(sample_node()); + assert_eq!(item.name, "alpha"); + assert_eq!(item.ip_address, "127.0.0.1"); + } +} diff --git a/src/sms/placement/mod.rs b/src/sms/placement/mod.rs index bd765d92..9700e456 100644 --- a/src/sms/placement/mod.rs +++ b/src/sms/placement/mod.rs @@ -1,4 +1,4 @@ pub mod outcome; pub mod planner; -pub mod state; pub mod policy; +pub mod state; diff --git a/src/sms/placement/planner.rs b/src/sms/placement/planner.rs index 47cfcb58..0593a9d5 100644 --- a/src/sms/placement/planner.rs +++ b/src/sms/placement/planner.rs @@ -40,11 +40,21 @@ impl SmsServiceImpl { let mut candidates = Vec::new(); for node in nodes { - if !is_candidate_node(&node, now_secs, heartbeat_timeout_secs, &self.placement_state) { + if !is_candidate_node( + &node, + now_secs, + heartbeat_timeout_secs, + &self.placement_state, + ) { continue; } let resource = match uuid::Uuid::parse_str(&node.uuid) { - Ok(uuid) => self.resource_service.get_resource(&uuid).await.ok().flatten(), + Ok(uuid) => self + .resource_service + .get_resource(&uuid) + .await + .ok() + .flatten(), Err(_) => None, }; let score = score_node( @@ -99,7 +109,9 @@ impl SmsServiceImpl { break; }; *assignments.entry(chosen.node_uuid.clone()).or_insert(0) += 1; - *synthetic_counts.entry(chosen.node_uuid.clone()).or_insert(0) += 1; + *synthetic_counts + .entry(chosen.node_uuid.clone()) + .or_insert(0) += 1; } Ok(assignments) } diff --git a/src/sms/query_support.rs b/src/sms/query_support.rs index 875079ac..6226dacb 100644 --- a/src/sms/query_support.rs +++ b/src/sms/query_support.rs @@ -7,7 +7,9 @@ use crate::sms::config::SmsConfig; pub(crate) const MAX_TASK_INSTANCE_SCAN_PAGES: usize = 10; pub(crate) fn instance_projection_stale_after_ms(config: &SmsConfig) -> i64 { - (config.heartbeat_timeout as i64).saturating_mul(2_000).max(1) + (config.heartbeat_timeout as i64) + .saturating_mul(2_000) + .max(1) } pub(crate) fn instance_is_active_and_fresh( diff --git a/src/sms/registry_watch.rs b/src/sms/registry_watch.rs index f9bf8096..17002579 100644 --- a/src/sms/registry_watch.rs +++ b/src/sms/registry_watch.rs @@ -105,14 +105,8 @@ mod tests { #[tokio::test] async fn test_watch_since_too_old_requires_resync() { let hub = RegistryWatchHub::::new(2, 16); - hub.push_event(TestEvent { - revision: 10, - }) - .await; - hub.push_event(TestEvent { - revision: 11, - }) - .await; + hub.push_event(TestEvent { revision: 10 }).await; + hub.push_event(TestEvent { revision: 11 }).await; let err = match hub.watch(9, |e| e.revision).await { Ok(_) => panic!("expected error"), @@ -127,10 +121,7 @@ mod tests { let mut stream = hub.watch(0, |e| e.revision).await.unwrap(); for i in 0..16u64 { - hub.push_event(TestEvent { - revision: 100 + i, - }) - .await; + hub.push_event(TestEvent { revision: 100 + i }).await; } let first = stream.next().await.unwrap(); diff --git a/src/sms/routes.rs b/src/sms/routes.rs index ff088804..1064f84e 100644 --- a/src/sms/routes.rs +++ b/src/sms/routes.rs @@ -12,9 +12,8 @@ use axum::{ use super::gateway::GatewayState; use super::handlers::{ console_index, console_static, create_stream_session, delete_file, delete_node, delete_task, - download_file, - endpoint_ws_proxy, get_execution, get_file_meta, get_instance, get_node, get_node_resource, - get_node_with_resource, get_task, health_check, heartbeat, list_files, + download_file, endpoint_ws_proxy, get_execution, get_file_meta, get_instance, get_node, + get_node_resource, get_node_with_resource, get_task, health_check, heartbeat, list_files, list_instance_executions, list_node_resources, list_nodes, list_task_instances, list_tasks, openapi_spec, place_invocation, presign_upload, register_node, register_task, report_invocation_outcome, stream_ws_proxy, swagger_ui, swagger_ui_assets, update_node, @@ -56,10 +55,7 @@ pub(crate) fn create_routes(state: GatewayState) -> Router { "/api/v1/tasks/{task_id}/instances", get(list_task_instances), ) - .route( - "/api/v1/instances/{instance_id}", - get(get_instance), - ) + .route("/api/v1/instances/{instance_id}", get(get_instance)) .route( "/api/v1/instances/{instance_id}/executions", get(list_instance_executions), diff --git a/src/sms/routes_test.rs b/src/sms/routes_test.rs index 79b3d0d1..966f90bd 100644 --- a/src/sms/routes_test.rs +++ b/src/sms/routes_test.rs @@ -13,9 +13,8 @@ use crate::proto::sms::{ execution_index_service_client::ExecutionIndexServiceClient, execution_registry_service_client::ExecutionRegistryServiceClient, instance_registry_service_client::InstanceRegistryServiceClient, - mcp_registry_service_client::McpRegistryServiceClient, - node_service_client::NodeServiceClient, placement_service_client::PlacementServiceClient, - task_service_client::TaskServiceClient, + mcp_registry_service_client::McpRegistryServiceClient, node_service_client::NodeServiceClient, + placement_service_client::PlacementServiceClient, task_service_client::TaskServiceClient, }; use crate::sms::gateway::{create_gateway_router, GatewayState}; use crate::sms::routes::create_routes; @@ -54,6 +53,48 @@ fn create_mock_gateway_state() -> GatewayState { } } +/// Build a JSON route probe request and optionally force extractor-level +/// failure for methods that only need route matching coverage. +/// 构造 JSON 路由探测请求;对于只需要验证路由命中的方法,可选择故意让请求在 +/// extractor 层失败。 +fn build_json_route_probe_request( + method: Method, + uri: &str, + fast_fail_methods: &[Method], +) -> Request { + let request_body = if fast_fail_methods.iter().any(|candidate| candidate == &method) { + Body::from("{") + } else { + Body::from("{}") + }; + + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(request_body) + .unwrap() +} + +/// Assert that a route was matched, even when the request stops at the JSON +/// extractor instead of reaching downstream handlers. +/// 断言路由已命中;即使请求停在 JSON extractor 阶段、尚未进入下游 handler, +/// 也视为路由存在。 +fn assert_route_matched_status( + method: &Method, + uri: &str, + status: StatusCode, + matched_statuses: &[StatusCode], +) { + assert!( + matched_statuses.contains(&status), + "Route {} {} should be matched, got {}", + method, + uri, + status + ); +} + #[tokio::test] async fn test_routes_creation() { // Test routes creation / 测试路由创建 @@ -122,29 +163,25 @@ async fn test_node_routes_structure() { ]; for (method, uri) in test_cases { - let request = Request::builder() - .method(method.clone()) - .uri(uri) - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); + let request = build_json_route_probe_request(method.clone(), uri, &[Method::POST]); let response = app.clone().oneshot(request).await.unwrap(); // For ID-specific endpoints, 404 indicates entity not found, not missing route // 对于带ID的端点,404表示实体不存在,而不是路由缺失 if uri.starts_with("/api/v1/nodes/") && uri != "/api/v1/nodes" { - assert!( - [ + assert_route_matched_status( + &method, + uri, + response.status(), + &[ StatusCode::OK, StatusCode::NOT_FOUND, StatusCode::INTERNAL_SERVER_ERROR, - StatusCode::CREATED - ] - .contains(&response.status()), - "Route {} {} should be matched", - method, - uri + StatusCode::CREATED, + StatusCode::BAD_REQUEST, + StatusCode::UNPROCESSABLE_ENTITY, + ], ); } else { assert_ne!( @@ -181,28 +218,24 @@ async fn test_resource_routes_structure() { ]; for (method, uri) in test_cases { - let request = Request::builder() - .method(method.clone()) - .uri(uri) - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); + let request = build_json_route_probe_request(method.clone(), uri, &[Method::PUT]); let response = app.clone().oneshot(request).await.unwrap(); // ID-specific endpoints may legitimately return 404 if data is absent // 带ID的端点在数据缺失时返回404是合理的 if uri.contains("/api/v1/nodes/") && !uri.ends_with("/resources") { - assert!( - [ + assert_route_matched_status( + &method, + uri, + response.status(), + &[ StatusCode::OK, StatusCode::NOT_FOUND, - StatusCode::INTERNAL_SERVER_ERROR - ] - .contains(&response.status()), - "Route {} {} should be matched", - method, - uri + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_REQUEST, + StatusCode::UNPROCESSABLE_ENTITY, + ], ); } else { assert_ne!( @@ -229,29 +262,24 @@ async fn test_stream_routes_structure() { ]; for (method, uri) in test_cases { - let request = Request::builder() - .method(method.clone()) - .uri(uri) - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); + let request = build_json_route_probe_request(method.clone(), uri, &[Method::POST]); let response = app.clone().oneshot(request).await.unwrap(); if uri.starts_with("/api/v1/executions/") { - assert!( - [ + assert_route_matched_status( + &method, + uri, + response.status(), + &[ StatusCode::OK, StatusCode::BAD_REQUEST, StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN, StatusCode::NOT_FOUND, StatusCode::BAD_GATEWAY, - StatusCode::INTERNAL_SERVER_ERROR - ] - .contains(&response.status()), - "Route {} {} should be matched", - method, - uri + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::UNPROCESSABLE_ENTITY, + ], ); } else { assert_ne!( @@ -281,28 +309,24 @@ async fn test_task_routes_structure() { ]; for (method, uri) in test_cases { - let request = Request::builder() - .method(method.clone()) - .uri(uri) - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); + let request = build_json_route_probe_request(method.clone(), uri, &[Method::POST]); let response = app.clone().oneshot(request).await.unwrap(); // ID-specific endpoints might return 404 when task doesn't exist // 带ID的端点在任务不存在时可能返回404 if uri.starts_with("/api/v1/tasks/") && uri != "/api/v1/tasks" { - assert!( - [ + assert_route_matched_status( + &method, + uri, + response.status(), + &[ StatusCode::OK, StatusCode::NOT_FOUND, - StatusCode::INTERNAL_SERVER_ERROR - ] - .contains(&response.status()), - "Route {} {} should be matched", - method, - uri + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_REQUEST, + StatusCode::UNPROCESSABLE_ENTITY, + ], ); } else { assert_ne!( @@ -442,18 +466,27 @@ async fn test_content_type_handling() { let state = create_mock_gateway_state(); let app = create_routes(state); + // Use malformed JSON so the request is rejected by the JSON extractor before + // the handler reaches the lazy gRPC client. This keeps the test focused on + // content-type behavior instead of connection timeout behavior. + // 使用非法 JSON,让请求在 JSON extractor 阶段就被拒绝,避免 handler 继续走到 + // lazy gRPC client。这样测试只验证 content-type 行为,而不是被连接超时拖慢。 // Test with correct content type / 测试正确的内容类型 let request = Request::builder() .method(Method::POST) .uri("/api/v1/nodes") .header("content-type", "application/json") - .body(Body::from(r#"{"ip_address": "127.0.0.1", "port": 8080}"#)) + .body(Body::from(r#"{"ip_address":"127.0.0.1","port":}"#)) .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); - // Should not return 415 Unsupported Media Type / 不应返回415不支持的媒体类型 + // Should fail JSON parsing instead of media-type validation / 应因 JSON 解析失败,而不是媒体类型失败 assert_ne!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); // Test with incorrect content type / 测试错误的内容类型 let request = Request::builder() @@ -465,12 +498,12 @@ async fn test_content_type_handling() { let response = app.oneshot(request).await.unwrap(); - // May return 400 Bad Request or 415 Unsupported Media Type / 可能返回400错误请求或415不支持的媒体类型 + // Should be rejected before any handler-side gRPC work / 应在 handler 侧 gRPC 工作之前被拒绝 assert!( response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE - || response.status() == StatusCode::INTERNAL_SERVER_ERROR - ); // Due to gRPC connection / 由于gRPC连接 + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); } #[tokio::test] @@ -479,6 +512,10 @@ async fn test_large_request_handling() { let state = create_mock_gateway_state(); let app = create_routes(state); + // Create a large malformed JSON payload so the request is rejected by the + // JSON extractor instead of reaching the lazy gRPC client. + // 创建一个很大的非法 JSON 负载,让请求在 JSON extractor 阶段被拒绝,而不是继续 + // 走到 lazy gRPC client。 // Create a large JSON payload / 创建大JSON负载 let large_metadata = (0..1000) .map(|i| format!(r#""key{}": "value{}""#, i, i)) @@ -488,7 +525,7 @@ async fn test_large_request_handling() { let large_json = format!( r#"{{ "ip_address": "127.0.0.1", - "port": 8080, + "port": , "metadata": {{{}}} }}"#, large_metadata @@ -503,8 +540,12 @@ async fn test_large_request_handling() { let response = app.oneshot(request).await.unwrap(); - // Should handle large requests (not return 413 Payload Too Large) / 应处理大请求(不返回413负载过大) + // Should handle large requests without tripping payload limits / 应处理大请求而不触发负载限制 assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); } // Integration tests for route behavior / 路由行为的集成测试 diff --git a/src/sms/runtime.rs b/src/sms/runtime.rs index 1ab2b38b..1c93200c 100644 --- a/src/sms/runtime.rs +++ b/src/sms/runtime.rs @@ -5,8 +5,8 @@ use tokio::sync::RwLock; use tracing::warn; use crate::proto::sms::EventOp; -use crate::sms::ai_backends::KvAiBackendRepository; use crate::sms::admin_credentials::AdminCredentialsState; +use crate::sms::ai_backends::KvAiBackendRepository; use crate::sms::config::SmsConfig; use crate::sms::instance_execution_index::InstanceExecutionIndex; use crate::sms::service::SmsServiceImpl; @@ -72,8 +72,12 @@ pub(crate) async fn build_runtime_stores(config: Arc) -> SmsRuntimeSt .await; let unified_events = Arc::new(UnifiedEventBus::new(event_kv.clone())); let stale_after_ms = (config.heartbeat_timeout as i64).saturating_mul(2_000); - let instance_execution_index = - Arc::new(InstanceExecutionIndex::new(event_kv, 256, 1000, stale_after_ms)); + let instance_execution_index = Arc::new(InstanceExecutionIndex::new( + event_kv, + 256, + 1000, + stale_after_ms, + )); let admin_kv_cfg = select_admin_kv_config(&config); let admin_kv_box = match create_kv_store_from_config(&admin_kv_cfg).await { diff --git a/src/sms/runtime_api.rs b/src/sms/runtime_api.rs new file mode 100644 index 00000000..cc983ac5 --- /dev/null +++ b/src/sms/runtime_api.rs @@ -0,0 +1,295 @@ +//! Shared runtime admin API mapping helpers for SMS. +//! SMS 的运行时管理 API 共享映射辅助模块。 +//! +//! This module centralizes instance/execution presentation rules so Web Admin +//! handlers can stay focused on orchestration instead of JSON assembly. +//! 此模块集中管理 instance/execution 的展示规则,让 Web Admin handler 专注于流程编排,而不是手工拼 JSON。 + +use serde::Serialize; +use std::collections::HashMap; + +use crate::proto::sms::{Execution, ExecutionSummary, Instance, InstanceSummary, LogRef}; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminTaskInstanceRow { + pub(crate) instance_id: String, + pub(crate) task_id: String, + pub(crate) node_uuid: String, + pub(crate) status: String, + pub(crate) created_at_ms: i64, + pub(crate) updated_at_ms: i64, + pub(crate) last_seen_ms: i64, + pub(crate) current_execution_id: String, +} + +pub(crate) fn task_instance_row( + summary: InstanceSummary, + detail: Option<&Instance>, +) -> AdminTaskInstanceRow { + let summary_status = crate::sms::instance_status_to_public_str(summary.status).to_string(); + AdminTaskInstanceRow { + instance_id: summary.instance_id, + task_id: detail.map(|inst| inst.task_id.clone()).unwrap_or_default(), + node_uuid: summary.node_uuid, + status: detail + .map(|inst| crate::sms::instance_status_to_public_str(inst.status).to_string()) + .unwrap_or(summary_status), + created_at_ms: detail.map(|inst| inst.created_at_ms).unwrap_or_default(), + updated_at_ms: detail.map(|inst| inst.updated_at_ms).unwrap_or_default(), + last_seen_ms: detail + .map(|inst| inst.last_seen_ms) + .unwrap_or(summary.last_seen_ms), + current_execution_id: detail + .map(|inst| inst.current_execution_id.clone()) + .unwrap_or(summary.current_execution_id), + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminTaskInstanceListResponse { + pub(crate) success: bool, + pub(crate) instances: Vec, + pub(crate) next_page_token: String, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminInstanceDetail { + pub(crate) instance_id: String, + pub(crate) task_id: String, + pub(crate) node_uuid: String, + pub(crate) status: String, + pub(crate) created_at_ms: i64, + pub(crate) updated_at_ms: i64, + pub(crate) last_seen_ms: i64, + pub(crate) current_execution_id: String, + pub(crate) metadata: HashMap, +} + +pub(crate) fn instance_to_detail(instance: Instance) -> AdminInstanceDetail { + AdminInstanceDetail { + instance_id: instance.instance_id, + task_id: instance.task_id, + node_uuid: instance.node_uuid, + status: crate::sms::instance_status_to_public_str(instance.status).to_string(), + created_at_ms: instance.created_at_ms, + updated_at_ms: instance.updated_at_ms, + last_seen_ms: instance.last_seen_ms, + current_execution_id: instance.current_execution_id, + metadata: instance.metadata, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminInstanceDetailEnvelope { + pub(crate) success: bool, + pub(crate) found: bool, + pub(crate) active: bool, + pub(crate) instance: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionSummaryRow { + pub(crate) execution_id: String, + pub(crate) task_id: String, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) completed_at_ms: i64, + pub(crate) function_name: String, +} + +pub(crate) fn execution_summary_to_row(execution: ExecutionSummary) -> AdminExecutionSummaryRow { + AdminExecutionSummaryRow { + execution_id: execution.execution_id, + task_id: execution.task_id, + status: crate::sms::execution_status_to_public_str(execution.status).to_string(), + started_at_ms: execution.started_at_ms, + completed_at_ms: execution.completed_at_ms, + function_name: execution.function_name, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionSummaryListResponse { + pub(crate) success: bool, + pub(crate) executions: Vec, + pub(crate) next_page_token: String, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionHistoryRow { + pub(crate) execution_id: String, + pub(crate) invocation_id: String, + pub(crate) task_id: String, + pub(crate) function_name: String, + pub(crate) node_uuid: String, + pub(crate) instance_id: String, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) completed_at_ms: i64, + pub(crate) updated_at_ms: i64, +} + +pub(crate) fn execution_to_history_row(execution: Execution) -> AdminExecutionHistoryRow { + AdminExecutionHistoryRow { + execution_id: execution.execution_id, + invocation_id: execution.invocation_id, + task_id: execution.task_id, + function_name: execution.function_name, + node_uuid: execution.node_uuid, + instance_id: execution.instance_id, + status: crate::sms::execution_status_to_public_str(execution.status).to_string(), + started_at_ms: execution.started_at_ms, + completed_at_ms: execution.completed_at_ms, + updated_at_ms: execution.updated_at_ms, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionHistoryListResponse { + pub(crate) success: bool, + pub(crate) executions: Vec, + pub(crate) next_page_token: String, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminLogRef { + pub(crate) backend: String, + pub(crate) uri_prefix: String, + pub(crate) content_type: String, + pub(crate) compression: String, +} + +fn log_ref_to_response(log_ref: LogRef) -> AdminLogRef { + AdminLogRef { + backend: log_ref.backend, + uri_prefix: log_ref.uri_prefix, + content_type: log_ref.content_type, + compression: log_ref.compression, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionDetail { + pub(crate) execution_id: String, + pub(crate) invocation_id: String, + pub(crate) task_id: String, + pub(crate) function_name: String, + pub(crate) node_uuid: String, + pub(crate) instance_id: String, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) completed_at_ms: i64, + pub(crate) updated_at_ms: i64, + pub(crate) metadata: HashMap, + pub(crate) log_ref: Option, +} + +pub(crate) fn execution_to_detail(execution: Execution) -> AdminExecutionDetail { + AdminExecutionDetail { + execution_id: execution.execution_id, + invocation_id: execution.invocation_id, + task_id: execution.task_id, + function_name: execution.function_name, + node_uuid: execution.node_uuid, + instance_id: execution.instance_id, + status: crate::sms::execution_status_to_public_str(execution.status).to_string(), + started_at_ms: execution.started_at_ms, + completed_at_ms: execution.completed_at_ms, + updated_at_ms: execution.updated_at_ms, + metadata: execution.metadata, + log_ref: execution.log_ref.map(log_ref_to_response), + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionDetailEnvelope { + pub(crate) success: bool, + pub(crate) found: bool, + pub(crate) execution: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminExecutionMutationResponse { + pub(crate) success: bool, + pub(crate) node_uuid: Option, + pub(crate) execution_id: Option, + pub(crate) final_status: Option, + pub(crate) message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminInstanceMutationResponse { + pub(crate) success: bool, + pub(crate) node_uuid: Option, + pub(crate) instance_id: Option, + pub(crate) message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_instance_row_prefers_detail_fields() { + let summary = InstanceSummary { + instance_id: "inst-1".to_string(), + node_uuid: "node-1".to_string(), + status: 0, + last_seen_ms: 11, + current_execution_id: "from-summary".to_string(), + }; + let detail = Instance { + instance_id: "inst-1".to_string(), + task_id: "task-1".to_string(), + node_uuid: "node-1".to_string(), + status: 2, + created_at_ms: 20, + updated_at_ms: 30, + last_seen_ms: 40, + current_execution_id: "from-detail".to_string(), + metadata: HashMap::from([("owner".to_string(), "team-a".to_string())]), + }; + + let row = task_instance_row(summary, Some(&detail)); + assert_eq!(row.task_id, "task-1"); + assert_eq!(row.created_at_ms, 20); + assert_eq!(row.current_execution_id, "from-detail"); + } + + #[test] + fn execution_detail_keeps_log_reference() { + let detail = execution_to_detail(Execution { + execution_id: "exe-1".to_string(), + invocation_id: "inv-1".to_string(), + task_id: "task-1".to_string(), + function_name: "run".to_string(), + node_uuid: "node-1".to_string(), + instance_id: "inst-1".to_string(), + status: 1, + started_at_ms: 10, + completed_at_ms: 20, + updated_at_ms: 30, + metadata: HashMap::from([("trace".to_string(), "1".to_string())]), + log_ref: Some(LogRef { + backend: "s3".to_string(), + uri_prefix: "s3://bucket/prefix".to_string(), + content_type: "text/plain".to_string(), + compression: "gzip".to_string(), + }), + }); + + assert_eq!(detail.execution_id, "exe-1"); + assert_eq!( + detail + .log_ref + .as_ref() + .map(|log_ref| log_ref.backend.as_str()), + Some("s3") + ); + } +} diff --git a/src/sms/service.rs b/src/sms/service.rs index ff3138b7..533b3e79 100644 --- a/src/sms/service.rs +++ b/src/sms/service.rs @@ -11,11 +11,11 @@ use crate::sms::instance_execution_index::InstanceExecutionIndex; use crate::sms::placement::outcome::normalize_outcome_class; use crate::sms::placement::policy::select_top_candidates; use crate::sms::placement::state::PlacementState; +use crate::sms::registry::mcp::{delete_mcp_record, list_mcp_records, upsert_mcp_record}; +use crate::sms::registry::state::{BackendRegistryState, McpRegistryState}; use crate::sms::runtime::{ build_runtime_stores, start_assignment_reconcile_loop, start_cleanup_loop, }; -use crate::sms::registry::mcp::{delete_mcp_record, list_mcp_records, upsert_mcp_record}; -use crate::sms::registry::state::{BackendRegistryState, McpRegistryState}; use crate::sms::services::{ node_service::NodeService, resource_service::ResourceService, task_assignment_service::TaskAssignmentService as TaskAssignmentServiceImpl, @@ -26,8 +26,8 @@ use anyhow::Context; use futures::{stream::unfold, StreamExt}; use tracing::{debug, warn}; -use crate::sms::ai_backends::KvAiBackendRepository; use crate::sms::admin_credentials::AdminCredentialsState; +use crate::sms::ai_backends::KvAiBackendRepository; // Import proto types / 导入proto类型 use crate::proto::sms::{ @@ -43,6 +43,7 @@ use crate::proto::sms::{ placement_service_server::PlacementService as PlacementServiceTrait, AppendExecutionLogsRequest, AppendExecutionLogsResponse, + BackendSpec, BackendStatus, DeleteCredentialRequest, DeleteCredentialResponse, @@ -57,10 +58,10 @@ use crate::proto::sms::{ Execution, FinalizeExecutionLogsRequest, FinalizeExecutionLogsResponse, - GetInstanceRequest, - GetInstanceResponse, GetExecutionRequest, GetExecutionResponse, + GetInstanceRequest, + GetInstanceResponse, GetNodeBackendsRequest, GetNodeBackendsResponse, GetNodeRequest, @@ -104,16 +105,15 @@ use crate::proto::sms::{ ReportInvocationOutcomeResponse, ReportNodeBackendsRequest, ReportNodeBackendsResponse, - BackendSpec, SubscribeEventsRequest, UpdateNodeRequest, UpdateNodeResourceRequest, UpdateNodeResourceResponse, UpdateNodeResponse, - UpsertMcpServerRequest, - UpsertMcpServerResponse, UpsertCredentialRequest, UpsertCredentialResponse, + UpsertMcpServerRequest, + UpsertMcpServerResponse, WatchCredentialMaterialsRequest, WatchCredentialMaterialsResponse, WatchCredentialsRequest, @@ -469,7 +469,6 @@ impl SmsServiceImpl { pub fn node_service(&self) -> Arc> { self.node_service.clone() } - } #[tonic::async_trait] @@ -522,7 +521,8 @@ impl McpRegistryServiceTrait for SmsServiceImpl { &self, request: Request, ) -> Result, Status> { - let revision = delete_mcp_record(&self.mcp_registry, request.into_inner().server_id).await?; + let revision = + delete_mcp_record(&self.mcp_registry, request.into_inner().server_id).await?; Ok(Response::new(DeleteMcpServerResponse { revision })) } } @@ -661,7 +661,10 @@ impl AdminCredentialServiceTrait for SmsServiceImpl { ) -> Result, Status> { let req = request.into_inner(); let (revision, deleted) = self.admin_credentials.delete(&req.name).await?; - Ok(Response::new(DeleteCredentialResponse { revision, deleted })) + Ok(Response::new(DeleteCredentialResponse { + revision, + deleted, + })) } async fn watch_credentials( @@ -669,7 +672,10 @@ impl AdminCredentialServiceTrait for SmsServiceImpl { request: Request, ) -> Result, Status> { let req = request.into_inner(); - let stream = self.admin_credentials.watch_infos(req.since_revision).await?; + let stream = self + .admin_credentials + .watch_infos(req.since_revision) + .await?; Ok(Response::new(stream)) } @@ -1288,10 +1294,19 @@ impl ExecutionIndexServiceTrait for SmsServiceImpl { request: Request, ) -> Result, Status> { let req = request.into_inner(); - let limit = if req.limit <= 0 { 100 } else { req.limit as usize }; + let limit = if req.limit <= 0 { + 100 + } else { + req.limit as usize + }; let (executions, next_page_token) = self .instance_execution_index - .list_executions(Some(&req.task_id), Some(&req.status), limit, &req.page_token) + .list_executions( + Some(&req.task_id), + Some(&req.status), + limit, + &req.page_token, + ) .await .map_err(|e| Status::internal(e.to_string()))?; Ok(Response::new(ListExecutionsResponse { diff --git a/src/sms/services/resource_service.rs b/src/sms/services/resource_service.rs index ec78bfce..2140494b 100644 --- a/src/sms/services/resource_service.rs +++ b/src/sms/services/resource_service.rs @@ -101,21 +101,6 @@ impl NodeResourceInfo { now.signed_duration_since(self.updated_at) > max_age } - /// Update resource metadata / 更新资源元数据 - pub fn update_metadata(&mut self, key: String, value: String) { - self.resource_metadata.insert(key, value); - } - - /// Get memory usage in bytes / 获取内存使用量(字节) - pub fn get_memory_usage_bytes(&self) -> i64 { - self.used_memory_bytes - } - - /// Get available disk space in bytes / 获取可用磁盘空间(字节) - pub fn get_available_disk_bytes(&self) -> i64 { - self.total_disk_bytes - self.used_disk_bytes - } - /// Check if node is under high load / 检查节点是否处于高负载状态 pub fn is_high_load(&self) -> bool { self.cpu_usage_percent > 80.0 diff --git a/src/sms/services/task_assignment_service.rs b/src/sms/services/task_assignment_service.rs index 84014967..6bcc3c13 100644 --- a/src/sms/services/task_assignment_service.rs +++ b/src/sms/services/task_assignment_service.rs @@ -106,6 +106,7 @@ impl TaskAssignmentService { task_id: &str, _updated_at_ms: i64, ) -> AssignmentReplaceResult { - self.replace_task_assignments(task_id, HashMap::new(), 0).await + self.replace_task_assignments(task_id, HashMap::new(), 0) + .await } } diff --git a/src/sms/services/task_service.rs b/src/sms/services/task_service.rs index d37bf019..5cf4eb3a 100644 --- a/src/sms/services/task_service.rs +++ b/src/sms/services/task_service.rs @@ -79,15 +79,15 @@ impl TaskService { /// Resolve a routable task by endpoint. /// 根据 endpoint 解析当前可路由的 task。 - pub async fn resolve_routable_task_by_endpoint(&self, endpoint: &str) -> SmsResult> { + pub async fn resolve_routable_task_by_endpoint( + &self, + endpoint: &str, + ) -> SmsResult> { let normalized = normalize_gateway_endpoint(endpoint)?; let idx = self.endpoint_index.read().await; if let Some(task_id) = idx.get(&normalized).cloned() { drop(idx); - return Ok(self - .get_task(&task_id) - .await? - .filter(is_routable_task)); + return Ok(self.get_task(&task_id).await?.filter(is_routable_task)); } Ok(None) } @@ -157,7 +157,6 @@ impl TaskService { self.remove_task(task_id).await } - /// List tasks with filters / 使用过滤器列出任务 pub async fn list_tasks_with_filters( &self, @@ -205,7 +204,6 @@ impl TaskService { Ok(filtered_tasks) } - } impl Default for TaskService { @@ -254,11 +252,17 @@ mod tests { let mut svc = TaskService::new(); svc.register_task(make_task("t1", "Echo_01")).await.unwrap(); - let t = svc.resolve_routable_task_by_endpoint("echo_01").await.unwrap(); + let t = svc + .resolve_routable_task_by_endpoint("echo_01") + .await + .unwrap(); assert!(t.is_some()); assert_eq!(t.unwrap().task_id, "t1"); - let t2 = svc.resolve_routable_task_by_endpoint("ECHO_01").await.unwrap(); + let t2 = svc + .resolve_routable_task_by_endpoint("ECHO_01") + .await + .unwrap(); assert!(t2.is_some()); assert_eq!(t2.unwrap().endpoint, "echo_01"); } @@ -282,11 +286,19 @@ mod tests { async fn remove_task_removes_endpoint_index() { let mut svc = TaskService::new(); svc.register_task(make_task("t1", "echo")).await.unwrap(); - assert!(svc.resolve_routable_task_by_endpoint("echo").await.unwrap().is_some()); + assert!(svc + .resolve_routable_task_by_endpoint("echo") + .await + .unwrap() + .is_some()); let removed = svc.remove_task("t1").await.unwrap(); assert!(removed); - assert!(svc.resolve_routable_task_by_endpoint("echo").await.unwrap().is_none()); + assert!(svc + .resolve_routable_task_by_endpoint("echo") + .await + .unwrap() + .is_none()); } #[tokio::test] @@ -303,7 +315,11 @@ mod tests { assert_eq!(deleting.status, TaskStatus::Deleting as i32); assert_eq!(deleting.deletion_reason, "delete requested"); assert_eq!(deleting.deletion_requested_at, ts); - assert!(svc.resolve_routable_task_by_endpoint("echo").await.unwrap().is_none()); + assert!(svc + .resolve_routable_task_by_endpoint("echo") + .await + .unwrap() + .is_none()); assert!(svc.get_task("t1").await.unwrap().is_some()); } @@ -353,7 +369,15 @@ mod tests { svc.register_task(make_task("t1", "echo")).await.unwrap(); svc.register_task(make_task("t1", "echo2")).await.unwrap(); - assert!(svc.resolve_routable_task_by_endpoint("echo").await.unwrap().is_none()); - assert!(svc.resolve_routable_task_by_endpoint("echo2").await.unwrap().is_some()); + assert!(svc + .resolve_routable_task_by_endpoint("echo") + .await + .unwrap() + .is_none()); + assert!(svc + .resolve_routable_task_by_endpoint("echo2") + .await + .unwrap() + .is_some()); } } diff --git a/src/sms/stream_mux.rs b/src/sms/stream_mux.rs index 85a881db..78bf8a93 100644 --- a/src/sms/stream_mux.rs +++ b/src/sms/stream_mux.rs @@ -82,7 +82,19 @@ impl ExecutionStreamRouter { let up_stream_id = match inner.client_to_up.get(&key).copied() { Some(v) => v, None => { - let v = self.alloc_up_stream_id(&inner); + // Prefer the original client stream id when it is free so single-client + // user-stream workloads keep their expected stream semantics through the + // endpoint gateway. When there is a collision, fall back to remapping. + // 优先保留原始 client stream id,这样单客户端 user-stream 任务在 + // endpoint gateway 下仍能保持预期的流语义;只有冲突时才回退到重写。 + let preferred_stream_id = if client_stream_id == 0 { + None + } else if inner.up_to_client.contains_key(&client_stream_id) { + None + } else { + Some(client_stream_id) + }; + let v = self.alloc_up_stream_id(&inner, preferred_stream_id); if v == 0 { return Err("no available upstream stream_id".to_string()); } @@ -134,7 +146,12 @@ impl ExecutionStreamRouter { inner.client_to_up.is_empty() && inner.up_to_client.is_empty() } - fn alloc_up_stream_id(&self, inner: &RouterInner) -> u32 { + fn alloc_up_stream_id(&self, inner: &RouterInner, preferred_stream_id: Option) -> u32 { + if let Some(preferred_stream_id) = preferred_stream_id { + if !inner.up_to_client.contains_key(&preferred_stream_id) { + return preferred_stream_id; + } + } for _ in 0..1024 { let id = self.next_up_stream_id.fetch_add(1, Ordering::Relaxed); let id = if id == 0 { 1 } else { id }; @@ -213,6 +230,7 @@ mod tests { let b = r.route_client_to_upstream("c2", &f).await.unwrap(); let (sa, _) = parse_ssf_v1_header(&a).unwrap(); let (sb, _) = parse_ssf_v1_header(&b).unwrap(); + assert_eq!(sa, 1); assert_ne!(sa, sb); let back_a = r.route_upstream_to_client(&a).await.unwrap().unwrap(); @@ -225,6 +243,15 @@ mod tests { assert_eq!(csb, 1); } + #[tokio::test] + async fn first_client_keeps_original_stream_id_when_available() { + let r = ExecutionStreamRouter::new(); + let f = build_frame(2, 2, b"{}", b"voice"); + let upstream = r.route_client_to_upstream("c1", &f).await.unwrap(); + let (stream_id, _) = parse_ssf_v1_header(&upstream).unwrap(); + assert_eq!(stream_id, 2); + } + #[tokio::test] async fn disconnect_cleans_mappings() { let r = ExecutionStreamRouter::new(); diff --git a/src/sms/task_api.rs b/src/sms/task_api.rs new file mode 100644 index 00000000..d1fa1e84 --- /dev/null +++ b/src/sms/task_api.rs @@ -0,0 +1,483 @@ +//! Shared task API mapping helpers for SMS. +//! SMS 的任务 API 共享映射辅助模块。 +//! +//! This module centralizes task request construction and task presentation +//! mapping so HTTP handlers and admin endpoints can stay focused on transport. +//! 此模块集中处理任务请求构造与任务展示映射,让 HTTP 处理器和管理端接口专注于传输层职责。 + +use serde::Serialize; +use std::collections::HashMap; + +use crate::proto::sms::{ + ExecutableType, RegisterTaskRequest, Task, TaskExecutable, TaskSchedulingStrategy, +}; +use crate::sms::{task_priority_to_public_str, task_status_to_public_str}; + +fn executable_type_from_public_str(value: &str) -> i32 { + match value.trim().to_ascii_lowercase().as_str() { + "binary" => ExecutableType::Binary as i32, + "script" => ExecutableType::Script as i32, + "container" => ExecutableType::Container as i32, + "wasm" => ExecutableType::Wasm as i32, + "process" => ExecutableType::Process as i32, + _ => ExecutableType::Unknown as i32, + } +} + +fn executable_type_to_public_str(value: i32) -> &'static str { + match ExecutableType::try_from(value).unwrap_or(ExecutableType::Unknown) { + ExecutableType::Binary => "binary", + ExecutableType::Script => "script", + ExecutableType::Container => "container", + ExecutableType::Wasm => "wasm", + ExecutableType::Process => "process", + _ => "unknown", + } +} + +fn scheduling_strategy_from_public_str(value: Option<&str>) -> i32 { + match value.map(|v| v.trim().to_ascii_lowercase()) { + Some(v) if v == "spread" => TaskSchedulingStrategy::Spread as i32, + None => TaskSchedulingStrategy::Spread as i32, + _ => TaskSchedulingStrategy::Unknown as i32, + } +} + +fn scheduling_strategy_to_public_str(value: i32) -> &'static str { + match TaskSchedulingStrategy::try_from(value).unwrap_or(TaskSchedulingStrategy::Spread) { + TaskSchedulingStrategy::Spread => "spread", + TaskSchedulingStrategy::Unknown => "unknown", + } +} + +#[derive(Debug, Clone)] +pub(crate) struct TaskExecutableSpec { + pub(crate) executable_type: String, + pub(crate) uri: String, + pub(crate) name: Option, + pub(crate) checksum_sha256: Option, + pub(crate) args: Option>, + pub(crate) env: Option>, +} + +#[derive(Debug, Clone)] +pub(crate) struct RegisterTaskSpec { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) priority: i32, + pub(crate) desired_replicas: u32, + pub(crate) scheduling_strategy: Option, + pub(crate) endpoint: String, + pub(crate) version: String, + pub(crate) capabilities: Vec, + pub(crate) metadata: HashMap, + pub(crate) config: HashMap, + pub(crate) executable: Option, +} + +pub(crate) fn build_register_task_request(spec: RegisterTaskSpec) -> RegisterTaskRequest { + RegisterTaskRequest { + name: spec.name, + description: spec.description, + priority: spec.priority, + endpoint: spec.endpoint, + version: spec.version, + capabilities: spec.capabilities, + metadata: spec.metadata, + config: spec.config, + executable: spec.executable.map(|exec| TaskExecutable { + r#type: executable_type_from_public_str(&exec.executable_type), + uri: exec.uri, + name: exec.name.unwrap_or_default(), + checksum_sha256: exec.checksum_sha256.unwrap_or_default(), + args: exec.args.unwrap_or_default(), + env: exec.env.unwrap_or_default(), + }), + desired_replicas: spec.desired_replicas, + scheduling_strategy: scheduling_strategy_from_public_str( + spec.scheduling_strategy.as_deref(), + ), + } +} + +#[derive(Debug, Clone, Default)] +struct TaskExecutableView { + executable_type: String, + executable_uri: String, + executable_name: String, + executable_checksum: String, + executable_args: Vec, + executable_env: HashMap, +} + +impl From> for TaskExecutableView { + fn from(executable: Option) -> Self { + let Some(exec) = executable else { + return Self::default(); + }; + + Self { + executable_type: executable_type_to_public_str(exec.r#type).to_string(), + executable_uri: exec.uri, + executable_name: exec.name, + executable_checksum: exec.checksum_sha256, + executable_args: exec.args, + executable_env: exec.env, + } + } +} + +#[derive(Debug, Clone)] +struct TaskPresentation { + task_id: String, + name: String, + description: String, + status: String, + priority: String, + desired_replicas: u32, + scheduling_strategy: String, + endpoint: String, + version: String, + capabilities: Vec, + registered_at: i64, + last_heartbeat: i64, + metadata: HashMap, + config: HashMap, + result_uris: Vec, + last_result_uri: String, + last_result_status: String, + last_completed_at: i64, + last_result_metadata: HashMap, + deletion_requested_at: i64, + deletion_reason: String, + executable: TaskExecutableView, +} + +impl From for TaskPresentation { + fn from(task: Task) -> Self { + Self { + task_id: task.task_id, + name: task.name, + description: task.description, + status: task_status_to_public_str(task.status).to_string(), + priority: task_priority_to_public_str(task.priority).to_string(), + desired_replicas: task.desired_replicas, + scheduling_strategy: scheduling_strategy_to_public_str(task.scheduling_strategy) + .to_string(), + endpoint: task.endpoint, + version: task.version, + capabilities: task.capabilities, + registered_at: task.registered_at, + last_heartbeat: task.last_heartbeat, + metadata: task.metadata, + config: task.config, + result_uris: task.result_uris, + last_result_uri: task.last_result_uri, + last_result_status: task.last_result_status, + last_completed_at: task.last_completed_at, + last_result_metadata: task.last_result_metadata, + deletion_requested_at: task.deletion_requested_at, + deletion_reason: task.deletion_reason, + executable: task.executable.into(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicTaskResponse { + pub task_id: String, + pub name: String, + pub description: String, + pub status: String, + pub priority: String, + pub desired_replicas: u32, + pub scheduling_strategy: String, + pub endpoint: String, + pub version: String, + pub capabilities: Vec, + pub registered_at: i64, + pub last_heartbeat: i64, + pub metadata: HashMap, + pub config: HashMap, + pub result_uris: Vec, + pub last_result_uri: String, + pub last_result_status: String, + pub last_completed_at: i64, + pub last_result_metadata: HashMap, +} + +pub(crate) fn task_to_public_response(task: Task) -> PublicTaskResponse { + let presentation = TaskPresentation::from(task); + PublicTaskResponse { + task_id: presentation.task_id, + name: presentation.name, + description: presentation.description, + status: presentation.status, + priority: presentation.priority, + desired_replicas: presentation.desired_replicas, + scheduling_strategy: presentation.scheduling_strategy, + endpoint: presentation.endpoint, + version: presentation.version, + capabilities: presentation.capabilities, + registered_at: presentation.registered_at, + last_heartbeat: presentation.last_heartbeat, + metadata: presentation.metadata, + config: presentation.config, + result_uris: presentation.result_uris, + last_result_uri: presentation.last_result_uri, + last_result_status: presentation.last_result_status, + last_completed_at: presentation.last_completed_at, + last_result_metadata: presentation.last_result_metadata, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminTaskSummaryResponse { + pub(crate) task_id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) status: String, + pub(crate) priority: String, + pub(crate) desired_replicas: u32, + pub(crate) active_instances: usize, + pub(crate) ready_instances: usize, + pub(crate) underprovisioned: bool, + pub(crate) reconciling: bool, + pub(crate) scheduling_strategy: String, + pub(crate) endpoint: String, + pub(crate) version: String, + pub(crate) capabilities: Vec, + pub(crate) registered_at: i64, + pub(crate) last_heartbeat: i64, + pub(crate) metadata: HashMap, + pub(crate) config: HashMap, + pub(crate) executable_type: String, + pub(crate) executable_uri: String, + pub(crate) executable_name: String, + pub(crate) result_uris: Vec, + pub(crate) last_result_uri: String, + pub(crate) last_result_status: String, + pub(crate) last_completed_at: i64, + pub(crate) last_result_metadata: HashMap, + pub(crate) deletion_requested_at: i64, + pub(crate) deletion_reason: String, +} + +pub(crate) fn task_to_admin_summary_response( + task: Task, + active_instances: usize, + ready_instances: usize, +) -> AdminTaskSummaryResponse { + let presentation = TaskPresentation::from(task); + let underprovisioned = active_instances < presentation.desired_replicas as usize; + AdminTaskSummaryResponse { + task_id: presentation.task_id, + name: presentation.name, + description: presentation.description, + status: presentation.status, + priority: presentation.priority, + desired_replicas: presentation.desired_replicas, + active_instances, + ready_instances, + underprovisioned, + reconciling: underprovisioned, + scheduling_strategy: presentation.scheduling_strategy, + endpoint: presentation.endpoint, + version: presentation.version, + capabilities: presentation.capabilities, + registered_at: presentation.registered_at, + last_heartbeat: presentation.last_heartbeat, + metadata: presentation.metadata, + config: presentation.config, + executable_type: presentation.executable.executable_type, + executable_uri: presentation.executable.executable_uri, + executable_name: presentation.executable.executable_name, + result_uris: presentation.result_uris, + last_result_uri: presentation.last_result_uri, + last_result_status: presentation.last_result_status, + last_completed_at: presentation.last_completed_at, + last_result_metadata: presentation.last_result_metadata, + deletion_requested_at: presentation.deletion_requested_at, + deletion_reason: presentation.deletion_reason, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminTaskDetailResponse { + pub(crate) task_id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) status: String, + pub(crate) priority: String, + pub(crate) desired_replicas: u32, + pub(crate) scheduling_strategy: String, + pub(crate) endpoint: String, + pub(crate) version: String, + pub(crate) capabilities: Vec, + pub(crate) registered_at: i64, + pub(crate) last_heartbeat: i64, + pub(crate) metadata: HashMap, + pub(crate) config: HashMap, + pub(crate) executable_type: String, + pub(crate) executable_uri: String, + pub(crate) executable_name: String, + pub(crate) executable_checksum: String, + pub(crate) executable_args: Vec, + pub(crate) executable_env: HashMap, + pub(crate) result_uris: Vec, + pub(crate) last_result_uri: String, + pub(crate) last_result_status: String, + pub(crate) last_completed_at: i64, + pub(crate) last_result_metadata: HashMap, + pub(crate) deletion_requested_at: i64, + pub(crate) deletion_reason: String, +} + +pub(crate) fn task_to_admin_detail_response(task: Task) -> AdminTaskDetailResponse { + let presentation = TaskPresentation::from(task); + AdminTaskDetailResponse { + task_id: presentation.task_id, + name: presentation.name, + description: presentation.description, + status: presentation.status, + priority: presentation.priority, + desired_replicas: presentation.desired_replicas, + scheduling_strategy: presentation.scheduling_strategy, + endpoint: presentation.endpoint, + version: presentation.version, + capabilities: presentation.capabilities, + registered_at: presentation.registered_at, + last_heartbeat: presentation.last_heartbeat, + metadata: presentation.metadata, + config: presentation.config, + executable_type: presentation.executable.executable_type, + executable_uri: presentation.executable.executable_uri, + executable_name: presentation.executable.executable_name, + executable_checksum: presentation.executable.executable_checksum, + executable_args: presentation.executable.executable_args, + executable_env: presentation.executable.executable_env, + result_uris: presentation.result_uris, + last_result_uri: presentation.last_result_uri, + last_result_status: presentation.last_result_status, + last_completed_at: presentation.last_completed_at, + last_result_metadata: presentation.last_result_metadata, + deletion_requested_at: presentation.deletion_requested_at, + deletion_reason: presentation.deletion_reason, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AdminDeletedTaskSnapshot { + pub(crate) task_id: String, + pub(crate) status: String, + pub(crate) deletion_requested_at: i64, + pub(crate) deletion_reason: String, +} + +pub(crate) fn task_to_deleted_snapshot(task: Task) -> AdminDeletedTaskSnapshot { + let presentation = TaskPresentation::from(task); + AdminDeletedTaskSnapshot { + task_id: presentation.task_id, + status: presentation.status, + deletion_requested_at: presentation.deletion_requested_at, + deletion_reason: presentation.deletion_reason, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::sms::{TaskPriority, TaskStatus}; + + fn sample_task() -> Task { + Task { + task_id: "task-1".to_string(), + name: "demo".to_string(), + description: "demo task".to_string(), + status: TaskStatus::Active as i32, + priority: TaskPriority::High as i32, + endpoint: "http://localhost:8080".to_string(), + version: "1.0.0".to_string(), + capabilities: vec!["cpu".to_string()], + registered_at: 10, + last_heartbeat: 20, + metadata: HashMap::from([("owner".to_string(), "team-a".to_string())]), + config: HashMap::from([("timeout".to_string(), "30".to_string())]), + executable: Some(TaskExecutable { + r#type: ExecutableType::Wasm as i32, + uri: "file:///tmp/demo.wasm".to_string(), + name: "demo.wasm".to_string(), + checksum_sha256: "abc".to_string(), + args: vec!["--verbose".to_string()], + env: HashMap::from([("RUST_LOG".to_string(), "debug".to_string())]), + }), + result_uris: vec!["s3://bucket/result".to_string()], + last_result_uri: "s3://bucket/result".to_string(), + last_result_status: "completed".to_string(), + last_completed_at: 30, + last_result_metadata: HashMap::from([("size".to_string(), "1".to_string())]), + deletion_requested_at: 40, + deletion_reason: "cleanup".to_string(), + desired_replicas: 2, + scheduling_strategy: TaskSchedulingStrategy::Spread as i32, + } + } + + #[test] + fn build_register_task_request_maps_public_fields() { + let request = build_register_task_request(RegisterTaskSpec { + name: "demo".to_string(), + description: "demo task".to_string(), + priority: TaskPriority::Urgent as i32, + desired_replicas: 3, + scheduling_strategy: Some("spread".to_string()), + endpoint: "http://localhost:8080".to_string(), + version: "1.0.0".to_string(), + capabilities: vec!["cpu".to_string()], + metadata: HashMap::from([("owner".to_string(), "team-a".to_string())]), + config: HashMap::new(), + executable: Some(TaskExecutableSpec { + executable_type: "wasm".to_string(), + uri: "file:///tmp/demo.wasm".to_string(), + name: Some("demo.wasm".to_string()), + checksum_sha256: Some("abc".to_string()), + args: Some(vec!["--verbose".to_string()]), + env: Some(HashMap::from([( + "RUST_LOG".to_string(), + "debug".to_string(), + )])), + }), + }); + + assert_eq!(request.priority, TaskPriority::Urgent as i32); + assert_eq!( + request.scheduling_strategy, + TaskSchedulingStrategy::Spread as i32 + ); + let executable = request.executable.expect("executable should exist"); + assert_eq!(executable.r#type, ExecutableType::Wasm as i32); + assert_eq!(executable.uri, "file:///tmp/demo.wasm"); + } + + #[test] + fn task_public_response_uses_public_strings() { + let response = task_to_public_response(sample_task()); + assert_eq!(response.status, "active"); + assert_eq!(response.priority, "high"); + assert_eq!(response.scheduling_strategy, "spread"); + assert_eq!(response.last_result_uri, "s3://bucket/result"); + } + + #[test] + fn admin_detail_response_keeps_executable_fields() { + let response = task_to_admin_detail_response(sample_task()); + assert_eq!(response.executable_type, "wasm"); + assert_eq!(response.executable_name, "demo.wasm"); + assert_eq!(response.executable_checksum, "abc"); + assert_eq!(response.executable_args, vec!["--verbose".to_string()]); + assert_eq!( + response.executable_env.get("RUST_LOG").map(String::as_str), + Some("debug") + ); + } +} diff --git a/src/sms/task_assignment_rpc.rs b/src/sms/task_assignment_rpc.rs index 6b8d0ff5..dc592afb 100644 --- a/src/sms/task_assignment_rpc.rs +++ b/src/sms/task_assignment_rpc.rs @@ -97,7 +97,8 @@ impl SmsServiceImpl { .task_assignment_service .remove_task_assignments(task_id, updated_at_ms) .await; - self.apply_assignment_change_result(task_id, result, false).await; + self.apply_assignment_change_result(task_id, result, false) + .await; } async fn trigger_assignment_fast_path(&self, task_id: &str, nodes: HashSet) { @@ -160,8 +161,13 @@ impl TaskPlacementAssignmentServiceTrait for SmsServiceImpl { if node_uuid.trim().is_empty() { return Err(Status::invalid_argument("node_uuid is required")); } - let assignments = self.task_assignment_service.list_node_assignments(&node_uuid).await; - Ok(Response::new(ListNodeTaskAssignmentsResponse { assignments })) + let assignments = self + .task_assignment_service + .list_node_assignments(&node_uuid) + .await; + Ok(Response::new(ListNodeTaskAssignmentsResponse { + assignments, + })) } async fn list_task_assignments( @@ -172,7 +178,10 @@ impl TaskPlacementAssignmentServiceTrait for SmsServiceImpl { if task_id.trim().is_empty() { return Err(Status::invalid_argument("task_id is required")); } - let assignments = self.task_assignment_service.list_task_assignments(&task_id).await; + let assignments = self + .task_assignment_service + .list_task_assignments(&task_id) + .await; Ok(Response::new(ListTaskAssignmentsResponse { assignments })) } } diff --git a/src/sms/task_rpc.rs b/src/sms/task_rpc.rs index 5e7cd9cf..9975ecd3 100644 --- a/src/sms/task_rpc.rs +++ b/src/sms/task_rpc.rs @@ -5,9 +5,9 @@ use crate::proto::sms::{ task_service_server::TaskService as TaskServiceTrait, CompleteTaskDeletionRequest, CompleteTaskDeletionResponse, DeleteTaskRequest, DeleteTaskResponse, GetTaskRequest, GetTaskResponse, ListTasksRequest, ListTasksResponse, RegisterTaskRequest, - RegisterTaskResponse, ResolveEndpointRequest, ResolveEndpointResponse, - Task, TaskEventKind, UnregisterTaskRequest, UnregisterTaskResponse, UpdateTaskResultRequest, UpdateTaskResultResponse, - UpdateTaskStatusRequest, UpdateTaskStatusResponse, + RegisterTaskResponse, ResolveEndpointRequest, ResolveEndpointResponse, Task, TaskEventKind, + UnregisterTaskRequest, UnregisterTaskResponse, UpdateTaskResultRequest, + UpdateTaskResultResponse, UpdateTaskStatusRequest, UpdateTaskStatusResponse, }; use crate::sms::service::SmsServiceImpl; @@ -175,7 +175,10 @@ impl TaskServiceTrait for SmsServiceImpl { ) -> Result, Status> { let req = request.into_inner(); let task_service = self.task_service.read().await; - match task_service.resolve_routable_task_by_endpoint(&req.endpoint).await { + match task_service + .resolve_routable_task_by_endpoint(&req.endpoint) + .await + { Ok(Some(task)) => Ok(Response::new(ResolveEndpointResponse { found: true, task: Some(task), @@ -204,7 +207,7 @@ impl TaskServiceTrait for SmsServiceImpl { &req.task_id, chrono::Utc::now().timestamp_millis(), ) - .await; + .await; Ok(Response::new(UnregisterTaskResponse { success: true, message: "Task unregistered successfully".to_string(), diff --git a/src/sms/task_status_test.rs b/src/sms/task_status_test.rs index ab31d1d5..5eb3dcca 100644 --- a/src/sms/task_status_test.rs +++ b/src/sms/task_status_test.rs @@ -5,12 +5,12 @@ use crate::config::base::StorageConfig; use crate::proto::sms::{ execution_index_service_server::ExecutionIndexService as ExecutionIndexServiceTrait, instance_registry_service_server::InstanceRegistryService as InstanceRegistryServiceTrait, - Instance, InstanceStatus, ListTaskInstancesRequest, node_service_server::NodeService as NodeServiceTrait, - task_service_server::TaskService as TaskServiceTrait, CompleteTaskDeletionRequest, task_placement_assignment_service_server::TaskPlacementAssignmentService as TaskPlacementAssignmentServiceTrait, - DeleteTaskRequest, GetTaskRequest, ListNodeTaskAssignmentsRequest, Node, RegisterNodeRequest, - RegisterTaskRequest, ResolveEndpointRequest, TaskPriority, TaskStatus, UpdateTaskStatusRequest, + task_service_server::TaskService as TaskServiceTrait, CompleteTaskDeletionRequest, + DeleteTaskRequest, GetTaskRequest, Instance, InstanceStatus, ListNodeTaskAssignmentsRequest, + ListTaskInstancesRequest, Node, RegisterNodeRequest, RegisterTaskRequest, + ResolveEndpointRequest, TaskPriority, TaskStatus, UpdateTaskStatusRequest, }; use crate::sms::service::SmsServiceImpl; use uuid::Uuid; @@ -322,7 +322,13 @@ async fn complete_task_deletion_waits_for_active_instances_to_disappear() { ) .await .unwrap(); - let task_id = register_resp.get_ref().task.as_ref().unwrap().task_id.clone(); + let task_id = register_resp + .get_ref() + .task + .as_ref() + .unwrap() + .task_id + .clone(); let now_ms = chrono::Utc::now().timestamp_millis(); InstanceRegistryServiceTrait::report_instance( @@ -394,9 +400,7 @@ async fn complete_task_deletion_waits_for_active_instances_to_disappear() { let complete = TaskServiceTrait::complete_task_deletion( &sms_service, - Request::new(CompleteTaskDeletionRequest { - task_id: task_id, - }), + Request::new(CompleteTaskDeletionRequest { task_id: task_id }), ) .await .unwrap() diff --git a/src/sms/web_admin.rs b/src/sms/web_admin.rs index 58b4e7f7..98ad0e29 100644 --- a/src/sms/web_admin.rs +++ b/src/sms/web_admin.rs @@ -10,7 +10,6 @@ use axum::{ Router, }; use futures::StreamExt; -use serde::Deserialize; use serde_json::json; use std::convert::Infallible; use std::net::SocketAddr; @@ -22,25 +21,26 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::proto::sms::{ - ai_backend_control_plane_service_client::AiBackendControlPlaneServiceClient, admin_credential_service_client::AdminCredentialServiceClient, + ai_backend_control_plane_service_client::AiBackendControlPlaneServiceClient, backend_registry_service_client::BackendRegistryServiceClient, - mcp_registry_service_client::McpRegistryServiceClient, - node_service_client::NodeServiceClient, placement_service_client::PlacementServiceClient, - DeleteCredentialRequest, ListCredentialsRequest, ListNodesRequest, UpsertCredentialRequest, + mcp_registry_service_client::McpRegistryServiceClient, node_service_client::NodeServiceClient, + placement_service_client::PlacementServiceClient, ListNodesRequest, }; use crate::sms::gateway::GatewayState; -use crate::sms::query_support::{collect_task_instances_bounded, instance_is_active_and_fresh}; -pub use router::create_admin_router; -use presenter::{ - credential_info_json, execution_detail_json, execution_history_row_json, - instance_execution_summary_json, instance_json, mcp_server_json, node_backends_snapshot_json, - node_detail_json, task_instance_row_json, +use crate::sms::node_api::{ + admin_node_detail_response, node_to_admin_list_item, AdminNodeListResponse, }; -use types::{ - AiBackendsQuery, AiModelViewsQuery, ExecutionHistoryQuery, ListQuery, PageTokenQuery, - StreamQuery, +use crate::sms::query_support::{collect_task_instances_bounded, instance_is_active_and_fresh}; +use crate::sms::runtime_api::{ + execution_summary_to_row, execution_to_detail, execution_to_history_row, instance_to_detail, + task_instance_row, AdminExecutionDetailEnvelope, AdminExecutionHistoryListResponse, + AdminExecutionMutationResponse, AdminExecutionSummaryListResponse, AdminInstanceDetailEnvelope, + AdminInstanceMutationResponse, AdminTaskInstanceListResponse, }; +use presenter::node_backends_snapshot_json; +pub use router::create_admin_router; +use types::{ExecutionHistoryQuery, ListQuery, PageTokenQuery, StreamQuery}; use crate::proto::spearlet::{ execution_service_client::ExecutionServiceClient, @@ -49,13 +49,28 @@ use crate::proto::spearlet::{ ReconcileTaskAssignmentsNowRequest, TerminateExecutionRequest, }; +mod ai_backend_admin; +mod credential_mcp_admin; mod invocation_flow; mod node_rpc; +mod presenter; mod router; mod task_admin; -mod presenter; mod types; +pub(crate) use ai_backend_admin::{ + create_ai_backend_admin, delete_ai_backend_admin, delete_ai_backend_placement_admin, + get_ai_backend_admin, list_ai_backend_assignments_admin, list_ai_backend_node_statuses_admin, + list_ai_backend_placements_admin, list_ai_backends_admin, list_ai_model_views_admin, + preflight_ai_backend_admin, set_ai_backend_desired_state_admin, update_ai_backend_admin, + upsert_ai_backend_placement_admin, AiBackendPlacementWriteBody, AiBackendPlacementsQuery, + AiBackendPreflightBody, AiBackendWriteBody, SetAiBackendDesiredStateBody, +}; +pub(crate) use credential_mcp_admin::{ + delete_credential_admin, delete_mcp_server, get_mcp_server, list_credentials_admin, + list_mcp_servers, upsert_credential_admin, upsert_mcp_server, McpServerUpsertBody, + UpsertCredentialBody, +}; pub(crate) use task_admin::{ create_task, delete_task_admin, get_task_detail, list_tasks, CreateTaskBody, DeleteTaskBody, }; @@ -179,101 +194,6 @@ impl WebAdminServer { } } -#[derive(Deserialize)] -struct McpServerUpsertBody { - server_id: String, - display_name: Option, - transport: String, - stdio: Option, - http: Option, - tool_namespace: Option, - allowed_tools: Option>, - budgets: Option, - approval_policy: Option, -} - -#[derive(Deserialize)] -struct McpStdioBody { - command: String, - args: Option>, - env: Option>, - cwd: Option, -} - -#[derive(Deserialize)] -struct McpHttpBody { - url: String, - headers: Option>, - auth_ref: Option, -} - -#[derive(Deserialize)] -pub(crate) struct UpsertCredentialBody { - name: String, - secret: Option, - description: Option, - disabled: Option, -} - -#[derive(Deserialize)] -pub(crate) struct AiBackendWriteBody { - display_name: String, - provider: String, - model: String, - hosting: String, - backend_kind: String, - management_mode: Option, - credential_ref: Option, - desired_state: Option, - spec: AiBackendSpecBody, - labels: Option>, - metadata: Option, -} - -#[derive(Deserialize)] -pub(crate) struct AiBackendSpecBody { - base_url: Option, - operations: Vec, - features: Option>, - transports: Option>, - weight: Option, - priority: Option, -} - -#[derive(Deserialize)] -pub(crate) struct SetAiBackendDesiredStateBody { - desired_state: String, -} - -#[derive(Deserialize)] -pub(crate) struct AiBackendPlacementWriteBody { - placement_id: Option, - backend_id: String, - node_uuid: String, - desired_state: Option, - weight_override: Option, - priority_override: Option, -} - -#[derive(Deserialize)] -pub(crate) struct AiBackendPlacementsQuery { - backend_id: Option, - node_uuid: Option, -} - -#[derive(Deserialize)] -struct McpBudgetsBody { - tool_timeout_ms: Option, - max_concurrency: Option, - max_tool_output_bytes: Option, -} - -#[derive(Deserialize)] -struct McpApprovalPolicyBody { - default_policy: Option, - per_tool: Option>, -} - async fn get_node_backends(state: GatewayState, p: Path) -> Json { use crate::proto::sms::GetNodeBackendsRequest; @@ -302,952 +222,6 @@ async fn get_node_backends(state: GatewayState, p: Path) -> Json, -) -> Json { - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .list_ai_backends(crate::proto::sms::ListAiBackendsRequest { - limit: q.limit.unwrap_or(0), - offset: q.offset.unwrap_or(0), - q: q.q.unwrap_or_default(), - hosting: q.hosting.unwrap_or_default(), - desired_state: q.desired_state.unwrap_or_default(), - provider: q.provider.unwrap_or_default(), - model: q.model.unwrap_or_default(), - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - let backends = inner - .backends - .into_iter() - .map(ai_backend_record_json) - .collect::>(); - Json(json!({"success": true, "backends": backends, "total_count": inner.total_count})) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn get_ai_backend_admin( - state: GatewayState, - p: Path, -) -> Json { - let backend_id = p.0; - if backend_id.trim().is_empty() { - return Json(json!({"success": false, "message": "backend_id is required"})); - } - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .get_ai_backend(crate::proto::sms::GetAiBackendRequest { backend_id }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - Json(json!({ - "success": true, - "found": inner.found, - "backend": inner.backend.map(ai_backend_record_json), - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn create_ai_backend_admin( - state: GatewayState, - body: Json, -) -> Json { - let backend = match ai_backend_proto_from_body(body.0, String::new()) { - Ok(value) => value, - Err(message) => return Json(json!({"success": false, "message": message})), - }; - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .create_ai_backend(crate::proto::sms::CreateAiBackendRequest { - backend: Some(backend), - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - Json(json!({ - "success": true, - "backend": inner.backend.map(ai_backend_record_json), - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn update_ai_backend_admin( - state: GatewayState, - p: Path, - body: Json, -) -> Json { - let backend_id = p.0; - if backend_id.trim().is_empty() { - return Json(json!({"success": false, "message": "backend_id is required"})); - } - - let backend = match ai_backend_proto_from_body(body.0, backend_id) { - Ok(value) => value, - Err(message) => return Json(json!({"success": false, "message": message})), - }; - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .update_ai_backend(crate::proto::sms::UpdateAiBackendRequest { - backend: Some(backend), - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - Json(json!({ - "success": true, - "backend": inner.backend.map(ai_backend_record_json), - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn delete_ai_backend_admin( - state: GatewayState, - p: Path, -) -> Json { - let backend_id = p.0; - if backend_id.trim().is_empty() { - return Json(json!({"success": false, "message": "backend_id is required"})); - } - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .delete_ai_backend(crate::proto::sms::DeleteAiBackendRequest { backend_id }) - .await - { - Ok(resp) => Json(json!({"success": true, "deleted": resp.into_inner().deleted})), - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn set_ai_backend_desired_state_admin( - state: GatewayState, - p: Path, - body: Json, -) -> Json { - let backend_id = p.0; - if backend_id.trim().is_empty() { - return Json(json!({"success": false, "message": "backend_id is required"})); - } - - let desired_state = match parse_ai_backend_desired_state(&body.0.desired_state) { - Ok(value) => value, - Err(message) => return Json(json!({"success": false, "message": message})), - }; - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .set_ai_backend_desired_state(crate::proto::sms::SetAiBackendDesiredStateRequest { - backend_id, - desired_state: desired_state as i32, - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - Json(json!({ - "success": true, - "backend": inner.backend.map(ai_backend_record_json), - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn list_ai_backend_placements_admin( - state: GatewayState, - Query(q): Query, -) -> Json { - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .list_ai_backend_placements(crate::proto::sms::ListAiBackendPlacementsRequest { - backend_id: q.backend_id.unwrap_or_default(), - node_uuid: q.node_uuid.unwrap_or_default(), - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - let placements = inner - .placements - .into_iter() - .map(ai_backend_placement_json) - .collect::>(); - Json(json!({ - "success": true, - "placements": placements, - "total_count": inner.total_count, - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn upsert_ai_backend_placement_admin( - state: GatewayState, - body: Json, -) -> Json { - let placement = match ai_backend_placement_proto_from_body(body.0) { - Ok(value) => value, - Err(message) => return Json(json!({"success": false, "message": message})), - }; - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .upsert_ai_backend_placement(crate::proto::sms::UpsertAiBackendPlacementRequest { - placement: Some(placement), - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - Json(json!({ - "success": true, - "placement": inner.placement.map(ai_backend_placement_json), - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn delete_ai_backend_placement_admin( - state: GatewayState, - p: Path, -) -> Json { - let placement_id = p.0; - if placement_id.trim().is_empty() { - return Json(json!({"success": false, "message": "placement_id is required"})); - } - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .delete_ai_backend_placement(crate::proto::sms::DeleteAiBackendPlacementRequest { - placement_id, - }) - .await - { - Ok(resp) => Json(json!({"success": true, "deleted": resp.into_inner().deleted})), - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn list_ai_backend_assignments_admin( - state: GatewayState, - p: Path, -) -> Json { - let node_uuid = p.0; - if node_uuid.trim().is_empty() { - return Json(json!({"success": false, "message": "node_uuid is required"})); - } - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .list_ai_backend_assignments(crate::proto::sms::ListAiBackendAssignmentsRequest { - node_uuid, - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - let assignments = inner - .assignments - .into_iter() - .map(ai_backend_assignment_json) - .collect::>(); - Json(json!({ - "success": true, - "assignments": assignments, - "total_count": inner.total_count, - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn list_ai_backend_node_statuses_admin( - state: GatewayState, - p: Path, -) -> Json { - let backend_id = p.0; - if backend_id.trim().is_empty() { - return Json(json!({"success": false, "message": "backend_id is required"})); - } - - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .list_ai_backend_node_statuses(crate::proto::sms::ListAiBackendNodeStatusesRequest { - backend_id, - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - let statuses = inner - .statuses - .into_iter() - .map(ai_backend_node_status_json) - .collect::>(); - Json(json!({ - "success": true, - "statuses": statuses, - "total_count": inner.total_count, - })) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn list_ai_model_views_admin( - state: GatewayState, - Query(q): Query, -) -> Json { - let mut client = state.ai_backend_control_plane_client.clone(); - match client - .list_ai_model_views(crate::proto::sms::ListAiModelViewsRequest { - limit: q.limit.unwrap_or(0), - offset: q.offset.unwrap_or(0), - q: q.q.unwrap_or_default(), - hosting: q.hosting.unwrap_or_default(), - status: q.status.unwrap_or_default(), - provider: q.provider.unwrap_or_default(), - model: q.model.unwrap_or_default(), - }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - let views = inner - .views - .into_iter() - .map(ai_model_view_json) - .collect::>(); - Json(json!({"success": true, "views": views, "total_count": inner.total_count})) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -/// Build a proto backend record from HTTP JSON input / 从 HTTP JSON 输入构建 proto backend 记录 -fn ai_backend_proto_from_body( - body: AiBackendWriteBody, - backend_id: String, -) -> Result { - if body.display_name.trim().is_empty() { - return Err("display_name is required".to_string()); - } - if body.provider.trim().is_empty() { - return Err("provider is required".to_string()); - } - if body.model.trim().is_empty() { - return Err("model is required".to_string()); - } - if body.backend_kind.trim().is_empty() { - return Err("backend_kind is required".to_string()); - } - if body.spec.operations.is_empty() { - return Err("spec.operations is required".to_string()); - } - - let hosting = parse_ai_backend_hosting(&body.hosting)?; - let management_mode = match body.management_mode.as_deref() { - Some(value) => parse_ai_backend_management_mode(value)?, - None => default_ai_backend_management_mode(hosting), - }; - let desired_state = match body.desired_state.as_deref() { - Some(value) => parse_ai_backend_desired_state(value)?, - None => crate::proto::sms::AiBackendDesiredState::Enabled, - }; - let credential_ref = trim_to_option(body.credential_ref.clone()); - - let record = crate::sms::ai_backends::model::AiBackendRecordModel { - backend_id, - display_name: body.display_name.trim().to_string(), - provider: body.provider.trim().to_string(), - model: body.model.trim().to_string(), - hosting: match hosting { - crate::proto::sms::AiBackendHosting::Remote => { - crate::sms::ai_backends::model::AiBackendHostingModel::Remote - } - crate::proto::sms::AiBackendHosting::Local => { - crate::sms::ai_backends::model::AiBackendHostingModel::Local - } - crate::proto::sms::AiBackendHosting::Unspecified => { - return Err("hosting is required".to_string()) - } - }, - backend_kind: body.backend_kind.trim().to_string(), - desired_state: match desired_state { - crate::proto::sms::AiBackendDesiredState::Enabled => { - crate::sms::ai_backends::model::AiBackendDesiredStateModel::Enabled - } - crate::proto::sms::AiBackendDesiredState::Disabled => { - crate::sms::ai_backends::model::AiBackendDesiredStateModel::Disabled - } - crate::proto::sms::AiBackendDesiredState::Unspecified => { - return Err("desired_state is required".to_string()) - } - }, - management_mode: match management_mode { - crate::proto::sms::AiBackendManagementMode::SmsRemote => { - crate::sms::ai_backends::model::AiBackendManagementModeModel::SmsRemote - } - crate::proto::sms::AiBackendManagementMode::SmsLocal => { - crate::sms::ai_backends::model::AiBackendManagementModeModel::SmsLocal - } - crate::proto::sms::AiBackendManagementMode::Unspecified => { - return Err("management_mode is required".to_string()) - } - }, - credential_ref: credential_ref.clone(), - spec: crate::sms::ai_backends::model::AiBackendSpecModel { - name: String::new(), - kind: body.backend_kind.trim().to_string(), - operations: body.spec.operations, - features: body.spec.features.unwrap_or_default(), - transports: body - .spec - .transports - .unwrap_or_else(|| vec!["http".to_string()]), - weight: body.spec.weight.unwrap_or(100), - priority: body.spec.priority.unwrap_or(0), - base_url: body.spec.base_url.unwrap_or_default(), - provider: body.provider.trim().to_string(), - model: body.model.trim().to_string(), - credential_ref: credential_ref.unwrap_or_default(), - origin: 0, - deployment_id: String::new(), - }, - labels: body.labels.unwrap_or_default().into_iter().collect(), - metadata: body.metadata.unwrap_or_else(|| json!({})), - generation: 0, - created_at_ms: 0, - updated_at_ms: 0, - }; - Ok(crate::sms::ai_backends::proto_conv::proto_backend_from_domain(&record)) -} - -/// Build a proto placement record from HTTP JSON input / 从 HTTP JSON 输入构建 proto placement 记录 -fn ai_backend_placement_proto_from_body( - body: AiBackendPlacementWriteBody, -) -> Result { - if body.backend_id.trim().is_empty() { - return Err("backend_id is required".to_string()); - } - if body.node_uuid.trim().is_empty() { - return Err("node_uuid is required".to_string()); - } - - Ok(crate::proto::sms::AiBackendPlacementRecord { - placement_id: body.placement_id.unwrap_or_default(), - backend_id: body.backend_id, - node_uuid: body.node_uuid, - desired_state: match body.desired_state.as_deref() { - Some(value) => parse_ai_backend_desired_state(value)? as i32, - None => crate::proto::sms::AiBackendDesiredState::Enabled as i32, - }, - weight_override: body.weight_override, - priority_override: body.priority_override, - generation: 0, - created_at_ms: 0, - updated_at_ms: 0, - }) -} - -/// Convert one backend record into admin JSON / 把单个 backend 记录转换为管理端 JSON -fn ai_backend_record_json(record: crate::proto::sms::AiBackendRecord) -> serde_json::Value { - json!({ - "backend_id": record.backend_id, - "display_name": record.display_name, - "provider": record.provider, - "model": record.model, - "hosting": ai_backend_hosting_label(record.hosting), - "backend_kind": record.backend_kind, - "desired_state": ai_backend_desired_state_label(record.desired_state), - "management_mode": ai_backend_management_mode_label(record.management_mode), - "credential_ref": trim_to_option(record.credential_ref), - "spec": record.spec.map(ai_backend_spec_json), - "labels": record.labels, - "metadata": proto_struct_to_json(record.metadata.as_ref()), - "generation": record.generation, - "created_at_ms": record.created_at_ms, - "updated_at_ms": record.updated_at_ms, - }) -} - -/// Convert one backend spec into admin JSON / 把单个 backend spec 转换为管理端 JSON -fn ai_backend_spec_json(spec: crate::proto::sms::BackendSpec) -> serde_json::Value { - json!({ - "name": spec.name, - "kind": spec.kind, - "operations": spec.operations, - "features": spec.features, - "transports": spec.transports, - "weight": spec.weight, - "priority": spec.priority, - "base_url": spec.base_url, - "provider": spec.provider, - "model": spec.model, - "credential_ref": trim_to_option(Some(spec.credential_ref)), - "origin": spec.origin, - "deployment_id": spec.deployment_id, - }) -} - -/// Convert one placement record into admin JSON / 把单个 placement 记录转换为管理端 JSON -fn ai_backend_placement_json( - record: crate::proto::sms::AiBackendPlacementRecord, -) -> serde_json::Value { - json!({ - "placement_id": record.placement_id, - "backend_id": record.backend_id, - "node_uuid": record.node_uuid, - "desired_state": ai_backend_desired_state_label(record.desired_state), - "weight_override": record.weight_override, - "priority_override": record.priority_override, - "generation": record.generation, - "created_at_ms": record.created_at_ms, - "updated_at_ms": record.updated_at_ms, - }) -} - -/// Convert one node status record into admin JSON / 把单个节点状态记录转换为管理端 JSON -fn ai_backend_node_status_json( - record: crate::proto::sms::AiBackendNodeStatusRecord, -) -> serde_json::Value { - json!({ - "backend_id": record.backend_id, - "node_uuid": record.node_uuid, - "observed_generation": record.observed_generation, - "status": ai_backend_node_status_label(record.status), - "status_reason": record.status_reason, - "runtime_backend_name": trim_to_option(Some(record.runtime_backend_name)), - "endpoint": trim_to_option(Some(record.endpoint)), - "available": record.available, - "operations": record.operations, - "features": record.features, - "transports": record.transports, - "last_heartbeat_at_ms": record.last_heartbeat_at_ms, - }) -} - -/// Convert one resolved assignment into admin JSON / 把单个解析后的 assignment 转换为管理端 JSON -fn ai_backend_assignment_json( - assignment: crate::proto::sms::ResolvedAiBackendAssignment, -) -> serde_json::Value { - json!({ - "backend": assignment.backend.map(ai_backend_record_json), - "placement": assignment.placement.map(ai_backend_placement_json), - }) -} - -/// Convert one read-model row into admin JSON / 把单个只读模型行转换为管理端 JSON -fn ai_model_view_json(view: crate::proto::sms::AiModelView) -> serde_json::Value { - json!({ - "provider": view.provider, - "model": view.model, - "hosting": ai_backend_hosting_label(view.hosting), - "backend_ids": view.backend_ids, - "operations": view.operations, - "features": view.features, - "transports": view.transports, - "enabled_nodes": view.enabled_nodes, - "ready_nodes": view.ready_nodes, - "total_nodes": view.total_nodes, - "instances": view.instances.into_iter().map(ai_model_view_instance_json).collect::>(), - }) -} - -/// Convert one read-model instance into admin JSON / 把单个只读模型实例转换为管理端 JSON -fn ai_model_view_instance_json( - instance: crate::proto::sms::AiModelViewInstance, -) -> serde_json::Value { - json!({ - "backend_id": instance.backend_id, - "node_uuid": instance.node_uuid, - "placement_state": ai_backend_desired_state_label(instance.placement_state), - "backend_state": ai_backend_desired_state_label(instance.backend_state), - "runtime_status": instance.runtime_status.map(ai_backend_node_status_label), - "runtime_backend_name": trim_to_option(instance.runtime_backend_name), - "endpoint": trim_to_option(instance.endpoint), - "available": instance.available, - }) -} - -/// Convert protobuf Struct into JSON / 把 protobuf Struct 转换为 JSON -fn proto_struct_to_json(value: Option<&prost_types::Struct>) -> serde_json::Value { - let fields = value - .map(|inner| { - inner - .fields - .iter() - .map(|(key, value)| (key.clone(), proto_value_to_json(value))) - .collect() - }) - .unwrap_or_default(); - serde_json::Value::Object(fields) -} - -/// Convert protobuf Value into JSON / 把 protobuf Value 转换为 JSON -fn proto_value_to_json(value: &prost_types::Value) -> serde_json::Value { - match value.kind.as_ref() { - Some(prost_types::value::Kind::NullValue(_)) | None => serde_json::Value::Null, - Some(prost_types::value::Kind::BoolValue(inner)) => serde_json::Value::Bool(*inner), - Some(prost_types::value::Kind::NumberValue(inner)) => serde_json::json!(inner), - Some(prost_types::value::Kind::StringValue(inner)) => serde_json::Value::String(inner.clone()), - Some(prost_types::value::Kind::StructValue(inner)) => proto_struct_to_json(Some(inner)), - Some(prost_types::value::Kind::ListValue(inner)) => { - serde_json::Value::Array(inner.values.iter().map(proto_value_to_json).collect()) - } - } -} - -/// Parse backend hosting from HTTP text / 从 HTTP 文本解析 backend hosting -fn parse_ai_backend_hosting( - value: &str, -) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "remote" => Ok(crate::proto::sms::AiBackendHosting::Remote), - "local" => Ok(crate::proto::sms::AiBackendHosting::Local), - _ => Err("hosting must be remote or local".to_string()), - } -} - -/// Parse desired state from HTTP text / 从 HTTP 文本解析期望状态 -fn parse_ai_backend_desired_state( - value: &str, -) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "enabled" => Ok(crate::proto::sms::AiBackendDesiredState::Enabled), - "disabled" => Ok(crate::proto::sms::AiBackendDesiredState::Disabled), - _ => Err("desired_state must be enabled or disabled".to_string()), - } -} - -/// Parse management mode from HTTP text / 从 HTTP 文本解析管理模式 -fn parse_ai_backend_management_mode( - value: &str, -) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "sms_remote" | "remote" => Ok(crate::proto::sms::AiBackendManagementMode::SmsRemote), - "sms_local" | "local" => Ok(crate::proto::sms::AiBackendManagementMode::SmsLocal), - _ => Err("management_mode must be sms_remote or sms_local".to_string()), - } -} - -/// Choose the default management mode from hosting / 根据 hosting 选择默认管理模式 -fn default_ai_backend_management_mode( - hosting: crate::proto::sms::AiBackendHosting, -) -> crate::proto::sms::AiBackendManagementMode { - match hosting { - crate::proto::sms::AiBackendHosting::Remote => { - crate::proto::sms::AiBackendManagementMode::SmsRemote - } - crate::proto::sms::AiBackendHosting::Local => { - crate::proto::sms::AiBackendManagementMode::SmsLocal - } - crate::proto::sms::AiBackendHosting::Unspecified => { - crate::proto::sms::AiBackendManagementMode::Unspecified - } - } -} - -/// Normalize an optional string by trimming empties / 通过裁剪空字符串归一化可选字符串 -fn trim_to_option(value: Option) -> Option { - value.and_then(|inner| { - let trimmed = inner.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -/// Render hosting enum as stable text / 把 hosting 枚举渲染为稳定文本 -fn ai_backend_hosting_label(value: i32) -> &'static str { - match crate::proto::sms::AiBackendHosting::try_from(value) - .unwrap_or(crate::proto::sms::AiBackendHosting::Unspecified) - { - crate::proto::sms::AiBackendHosting::Remote => "remote", - crate::proto::sms::AiBackendHosting::Local => "local", - crate::proto::sms::AiBackendHosting::Unspecified => "unspecified", - } -} - -/// Render desired-state enum as stable text / 把期望状态枚举渲染为稳定文本 -fn ai_backend_desired_state_label(value: i32) -> &'static str { - match crate::proto::sms::AiBackendDesiredState::try_from(value) - .unwrap_or(crate::proto::sms::AiBackendDesiredState::Unspecified) - { - crate::proto::sms::AiBackendDesiredState::Enabled => "enabled", - crate::proto::sms::AiBackendDesiredState::Disabled => "disabled", - crate::proto::sms::AiBackendDesiredState::Unspecified => "unspecified", - } -} - -/// Render management-mode enum as stable text / 把管理模式枚举渲染为稳定文本 -fn ai_backend_management_mode_label(value: i32) -> &'static str { - match crate::proto::sms::AiBackendManagementMode::try_from(value) - .unwrap_or(crate::proto::sms::AiBackendManagementMode::Unspecified) - { - crate::proto::sms::AiBackendManagementMode::SmsRemote => "sms_remote", - crate::proto::sms::AiBackendManagementMode::SmsLocal => "sms_local", - crate::proto::sms::AiBackendManagementMode::Unspecified => "unspecified", - } -} - -/// Render node-status enum as stable text / 把节点状态枚举渲染为稳定文本 -fn ai_backend_node_status_label(value: i32) -> &'static str { - match crate::proto::sms::AiBackendNodeStatus::try_from(value) - .unwrap_or(crate::proto::sms::AiBackendNodeStatus::Unspecified) - { - crate::proto::sms::AiBackendNodeStatus::Pending => "pending", - crate::proto::sms::AiBackendNodeStatus::Reconciling => "reconciling", - crate::proto::sms::AiBackendNodeStatus::Ready => "ready", - crate::proto::sms::AiBackendNodeStatus::Degraded => "degraded", - crate::proto::sms::AiBackendNodeStatus::Error => "error", - crate::proto::sms::AiBackendNodeStatus::Disabled => "disabled", - crate::proto::sms::AiBackendNodeStatus::Unspecified => "unspecified", - } -} - -async fn list_credentials_admin(state: GatewayState) -> Json { - let mut client = state.admin_credential_client.clone(); - match client.list_credentials(ListCredentialsRequest {}).await { - Ok(resp) => { - let inner = resp.into_inner(); - let mut backend_client = state.ai_backend_control_plane_client.clone(); - let referenced_by_count = match backend_client - .list_ai_backends(crate::proto::sms::ListAiBackendsRequest { - limit: 0, - offset: 0, - q: String::new(), - hosting: String::new(), - desired_state: String::new(), - provider: String::new(), - model: String::new(), - }) - .await - { - Ok(resp) => { - // Count credential references from the unified AI backend records. - // 从统一 AI backend 记录中统计 credential 引用数。 - let mut counts = std::collections::HashMap::::new(); - for backend in resp.into_inner().backends { - let credential_ref = backend.credential_ref.as_deref().unwrap_or("").trim(); - if credential_ref.is_empty() { - continue; - } - *counts.entry(credential_ref.to_string()).or_insert(0) += 1; - } - counts - } - Err(_) => std::collections::HashMap::new(), - }; - let credentials = inner - .credentials - .into_iter() - .map(|c| { - let referenced = referenced_by_count.get(&c.name).copied().unwrap_or(0); - credential_info_json(c, referenced) - }) - .collect::>(); - Json(json!({"success": true, "revision": inner.revision, "credentials": credentials})) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn upsert_credential_admin( - state: GatewayState, - body: Json, -) -> Json { - let b = body.0; - if b.name.trim().is_empty() { - return Json(json!({"success": false, "message": "name is required"})); - } - let mut client = state.admin_credential_client.clone(); - let resp = match client - .upsert_credential(UpsertCredentialRequest { - name: b.name, - secret: b.secret.unwrap_or_default(), - description: b.description.unwrap_or_default(), - disabled: b.disabled.unwrap_or(false), - }) - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Json(json!({"success": false, "message": e.to_string()})), - }; - Json(json!({"success": true, "revision": resp.revision})) -} - -async fn delete_credential_admin( - state: GatewayState, - p: Path, -) -> Json { - let name = p.0; - if name.trim().is_empty() { - return Json(json!({"success": false, "message": "name is required"})); - } - let mut client = state.admin_credential_client.clone(); - let resp = match client - .delete_credential(DeleteCredentialRequest { name }) - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Json(json!({"success": false, "message": e.to_string()})), - }; - Json(json!({"success": true, "revision": resp.revision, "deleted": resp.deleted})) -} - -async fn list_mcp_servers(state: GatewayState) -> Json { - let mut client = state.mcp_registry_client.clone(); - match client - .list_mcp_servers(crate::proto::sms::ListMcpServersRequest { since_revision: 0 }) - .await - { - Ok(resp) => { - let inner = resp.into_inner(); - let servers = inner - .servers - .into_iter() - .map(mcp_server_json) - .collect::>(); - Json(json!({"success": true, "revision": inner.revision, "servers": servers})) - } - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn get_mcp_server(state: GatewayState, p: Path) -> Json { - let server_id = p.0; - if server_id.trim().is_empty() { - return Json(json!({"success": true, "found": false})); - } - let mut client = state.mcp_registry_client.clone(); - let resp = match client - .list_mcp_servers(crate::proto::sms::ListMcpServersRequest { since_revision: 0 }) - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Json(json!({"success": false, "message": e.to_string()})), - }; - - for s in resp.servers.into_iter() { - if s.server_id != server_id { - continue; - } - let server = mcp_server_json(s); - return Json(json!({"success": true, "found": true, "server": server})); - } - - Json(json!({"success": true, "found": false})) -} - -async fn upsert_mcp_server( - state: GatewayState, - body: Json, -) -> Json { - use crate::proto::sms::{ - McpApprovalPolicy, McpBudgets, McpHttpConfig, McpServerRecord, McpStdioConfig, McpTransport, - }; - - let body = body.0; - if body.server_id.trim().is_empty() { - return Json(json!({"success": false, "message": "server_id is required"})); - } - - let transport = match body.transport.as_str() { - "stdio" => McpTransport::Stdio as i32, - "streamable_http" => McpTransport::StreamableHttp as i32, - _ => { - return Json(json!({"success": false, "message": "invalid transport"})); - } - }; - - let stdio = body.stdio.map(|s| McpStdioConfig { - command: s.command, - args: s.args.unwrap_or_default(), - env: s.env.unwrap_or_default(), - cwd: s.cwd.unwrap_or_default(), - }); - let http = body.http.map(|h| McpHttpConfig { - url: h.url, - headers: h.headers.unwrap_or_default(), - auth_ref: h.auth_ref.unwrap_or_default(), - }); - - let budgets = body.budgets.map(|b| McpBudgets { - tool_timeout_ms: b.tool_timeout_ms.unwrap_or(0), - max_concurrency: b.max_concurrency.unwrap_or(0), - max_tool_output_bytes: b.max_tool_output_bytes.unwrap_or(0), - }); - - let approval_policy = body.approval_policy.map(|p| McpApprovalPolicy { - default_policy: p.default_policy.unwrap_or_default(), - per_tool: p.per_tool.unwrap_or_default(), - }); - - let record = McpServerRecord { - server_id: body.server_id.trim().to_string(), - display_name: body.display_name.unwrap_or_default(), - transport, - stdio, - http, - tool_namespace: body.tool_namespace.unwrap_or_default(), - allowed_tools: body.allowed_tools.unwrap_or_default(), - approval_policy, - budgets, - updated_at_ms: 0, - }; - - let mut client = state.mcp_registry_client.clone(); - match client - .upsert_mcp_server(crate::proto::sms::UpsertMcpServerRequest { - record: Some(record), - }) - .await - { - Ok(resp) => Json(json!({"success": true, "revision": resp.into_inner().revision})), - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - -async fn delete_mcp_server(state: GatewayState, p: Path) -> Json { - let server_id = p.0; - if server_id.trim().is_empty() { - return Json(json!({"success": false, "message": "server_id is required"})); - } - let mut client = state.mcp_registry_client.clone(); - match client - .delete_mcp_server(crate::proto::sms::DeleteMcpServerRequest { - server_id: server_id.trim().to_string(), - }) - .await - { - Ok(resp) => Json(json!({"success": true, "revision": resp.into_inner().revision})), - Err(e) => Json(json!({"success": false, "message": e.to_string()})), - } -} - #[derive(serde::Deserialize)] struct CreateExecutionBody { task_id: String, @@ -1340,11 +314,17 @@ pub(super) async fn terminate_execution_admin( state: GatewayState, p: Path, axum::extract::Json(body): axum::extract::Json, -) -> Json { +) -> Json { use crate::proto::sms::GetExecutionRequest; let execution_id = p.0; if execution_id.trim().is_empty() { - return Json(json!({"success": false, "message": "execution_id is required"})); + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: None, + execution_id: None, + final_status: None, + message: "execution_id is required".to_string(), + }); } let mut exe_client = state.execution_index_client.clone(); @@ -1355,21 +335,61 @@ pub(super) async fn terminate_execution_admin( .await { Ok(r) => r.into_inner(), - Err(e) => return Json(json!({"success": false, "message": e.to_string()})), + Err(e) => { + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: None, + execution_id: Some(execution_id.clone()), + final_status: None, + message: e.to_string(), + }) + } }; if !exe.found { - return Json(json!({"success": false, "message": "execution not found"})); + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: None, + execution_id: Some(execution_id.clone()), + final_status: None, + message: "execution not found".to_string(), + }); } let Some(exe) = exe.execution else { - return Json(json!({"success": false, "message": "execution not found"})); + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: None, + execution_id: Some(execution_id.clone()), + final_status: None, + message: "execution not found".to_string(), + }); }; if exe.node_uuid.trim().is_empty() { - return Json(json!({"success": false, "message": "node_uuid is missing for execution"})); + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: None, + execution_id: Some(execution_id.clone()), + final_status: None, + message: "node_uuid is missing for execution".to_string(), + }); } let target = match node_rpc::resolve_node_channel(&state, &exe.node_uuid).await { Ok(t) => t, - Err(resp) => return resp, + Err(resp) => { + let message = resp + .0 + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("failed to resolve node") + .to_string(); + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: Some(exe.node_uuid), + execution_id: Some(execution_id.clone()), + final_status: None, + message, + }); + } }; let mut client = ExecutionServiceClient::new(target.channel); @@ -1382,34 +402,67 @@ pub(super) async fn terminate_execution_admin( .await { Ok(r) => r.into_inner(), - Err(e) => return Json(json!({"success": false, "message": e.to_string()})), + Err(e) => { + return Json(AdminExecutionMutationResponse { + success: false, + node_uuid: Some(target.node_uuid), + execution_id: Some(execution_id), + final_status: None, + message: e.to_string(), + }) + } }; - Json(json!({ - "success": resp.success, - "node_uuid": target.node_uuid, - "execution_id": execution_id, - "final_status": resp.final_status, - "message": resp.message, - })) + Json(AdminExecutionMutationResponse { + success: resp.success, + node_uuid: Some(target.node_uuid), + execution_id: Some(execution_id), + final_status: Some( + crate::sms::execution_status_to_public_str(resp.final_status).to_string(), + ), + message: resp.message, + }) } pub(super) async fn destroy_instance_admin( state: GatewayState, p: Path, axum::extract::Json(body): axum::extract::Json, -) -> Json { +) -> Json { let instance_id = p.0; if instance_id.trim().is_empty() { - return Json(json!({"success": false, "message": "instance_id is required"})); + return Json(AdminInstanceMutationResponse { + success: false, + node_uuid: None, + instance_id: None, + message: "instance_id is required".to_string(), + }); } if body.node_uuid.trim().is_empty() { - return Json(json!({"success": false, "message": "node_uuid is required"})); + return Json(AdminInstanceMutationResponse { + success: false, + node_uuid: None, + instance_id: Some(instance_id), + message: "node_uuid is required".to_string(), + }); } let target = match node_rpc::resolve_node_channel(&state, &body.node_uuid).await { Ok(t) => t, - Err(resp) => return resp, + Err(resp) => { + let message = resp + .0 + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("failed to resolve node") + .to_string(); + return Json(AdminInstanceMutationResponse { + success: false, + node_uuid: Some(body.node_uuid), + instance_id: Some(instance_id), + message, + }); + } }; let mut client = InstanceServiceClient::new(target.channel); @@ -1423,22 +476,29 @@ pub(super) async fn destroy_instance_admin( { Ok(r) => r.into_inner(), Err(e) if e.code() == tonic::Code::NotFound => { - return Json(json!({ - "success": true, - "node_uuid": target.node_uuid, - "instance_id": instance_id, - "message": "instance already absent", - })); + return Json(AdminInstanceMutationResponse { + success: true, + node_uuid: Some(target.node_uuid), + instance_id: Some(instance_id), + message: "instance already absent".to_string(), + }); + } + Err(e) => { + return Json(AdminInstanceMutationResponse { + success: false, + node_uuid: Some(target.node_uuid), + instance_id: Some(instance_id), + message: e.to_string(), + }) } - Err(e) => return Json(json!({"success": false, "message": e.to_string()})), }; - Json(json!({ - "success": resp.success, - "node_uuid": target.node_uuid, - "instance_id": instance_id, - "message": resp.message, - })) + Json(AdminInstanceMutationResponse { + success: resp.success, + node_uuid: Some(target.node_uuid), + instance_id: Some(instance_id), + message: resp.message, + }) } async fn create_invocation( @@ -1536,7 +596,10 @@ async fn create_invocation( Json(json!({ "success": false, "message": "all ready-instance nodes failed" })) } -async fn list_nodes(state: GatewayState, Query(q): Query) -> Json { +async fn list_nodes( + state: GatewayState, + Query(q): Query, +) -> Json { let mut client = state.node_client.clone(); let req = ListNodesRequest { status_filter: q.status.unwrap_or_default(), @@ -1546,34 +609,17 @@ async fn list_nodes(state: GatewayState, Query(q): Query) -> Json>(); if let Some(q) = q.q.as_ref().map(|s| s.to_lowercase()) { list.retain(|item| { - let uuid = item["uuid"].as_str().unwrap_or("").to_lowercase(); - let ip = item["ip_address"].as_str().unwrap_or("").to_lowercase(); - let meta = item["metadata"].as_object(); - let meta_hit = meta - .map(|m| { - m.iter().any(|(k, v)| { - k.to_lowercase().contains(&q) - || v.as_str().unwrap_or("").to_lowercase().contains(&q) - }) - }) - .unwrap_or(false); - uuid.contains(&q) || ip.contains(&q) || meta_hit + let meta_hit = item + .metadata + .iter() + .any(|(k, v)| k.to_lowercase().contains(&q) || v.to_lowercase().contains(&q)); + item.uuid.to_lowercase().contains(&q) + || item.ip_address.to_lowercase().contains(&q) + || meta_hit }); } let (field, asc) = if let Some(sort) = &q.sort { @@ -1590,8 +636,8 @@ async fn list_nodes(state: GatewayState, Query(q): Query) -> Json list.sort_by_key(|i| i["last_heartbeat"].as_i64().unwrap_or(0)), - "registered_at" => list.sort_by_key(|i| i["registered_at"].as_i64().unwrap_or(0)), + "last_heartbeat" => list.sort_by_key(|item| item.last_heartbeat), + "registered_at" => list.sort_by_key(|item| item.registered_at), _ => {} } if !asc { @@ -1610,14 +656,17 @@ async fn list_nodes(state: GatewayState, Query(q): Query) -> Json, Query(q): Query, -) -> Json { +) -> Json { use crate::proto::sms::{GetInstanceRequest, ListTaskInstancesRequest}; let mut client = state.execution_index_client.clone(); let limit = q.limit.unwrap_or(50).max(1); @@ -1642,22 +691,28 @@ async fn list_task_instances_admin( .ok() .map(|resp| resp.into_inner().instance) .flatten(); - instances.push(task_instance_row_json(i, detail.as_ref())); + instances.push(task_instance_row(i, detail.as_ref())); } - Json(json!({ - "success": true, - "instances": instances, - "next_page_token": inner.next_page_token, - })) + Json(AdminTaskInstanceListResponse { + success: true, + instances, + next_page_token: inner.next_page_token, + message: None, + }) } - Err(e) => Json(json!({ "success": false, "message": e.to_string() })), + Err(e) => Json(AdminTaskInstanceListResponse { + success: false, + instances: Vec::new(), + next_page_token: String::new(), + message: Some(e.to_string()), + }), } } async fn get_instance_admin( state: GatewayState, Path(instance_id): Path, -) -> Json { +) -> Json { use crate::proto::sms::GetInstanceRequest; let mut client = state.execution_index_client.clone(); let resp = client @@ -1666,15 +721,21 @@ async fn get_instance_admin( match resp { Ok(r) => { let inner = r.into_inner(); - let instance = inner.instance.map(instance_json); - Json(json!({ - "success": true, - "found": inner.found, - "active": inner.active, - "instance": instance, - })) + Json(AdminInstanceDetailEnvelope { + success: true, + found: inner.found, + active: inner.active, + instance: inner.instance.map(instance_to_detail), + message: None, + }) } - Err(e) => Json(json!({ "success": false, "message": e.to_string() })), + Err(e) => Json(AdminInstanceDetailEnvelope { + success: false, + found: false, + active: false, + instance: None, + message: Some(e.to_string()), + }), } } @@ -1682,7 +743,7 @@ async fn list_instance_executions_admin( state: GatewayState, Path(instance_id): Path, Query(q): Query, -) -> Json { +) -> Json { use crate::proto::sms::ListInstanceExecutionsRequest; let mut client = state.execution_index_client.clone(); let limit = q.limit.unwrap_or(50).max(1); @@ -1700,22 +761,28 @@ async fn list_instance_executions_admin( let executions = inner .executions .into_iter() - .map(instance_execution_summary_json) + .map(execution_summary_to_row) .collect::>(); - Json(json!({ - "success": true, - "executions": executions, - "next_page_token": inner.next_page_token, - })) + Json(AdminExecutionSummaryListResponse { + success: true, + executions, + next_page_token: inner.next_page_token, + message: None, + }) } - Err(e) => Json(json!({ "success": false, "message": e.to_string() })), + Err(e) => Json(AdminExecutionSummaryListResponse { + success: false, + executions: Vec::new(), + next_page_token: String::new(), + message: Some(e.to_string()), + }), } } async fn list_execution_history_admin( state: GatewayState, Query(q): Query, -) -> Json { +) -> Json { use crate::proto::sms::ListExecutionsRequest; // This endpoint serves the durable execution archive, not the task/runtime view. @@ -1736,22 +803,28 @@ async fn list_execution_history_admin( let executions = inner .executions .into_iter() - .map(execution_history_row_json) + .map(execution_to_history_row) .collect::>(); - Json(json!({ - "success": true, - "executions": executions, - "next_page_token": inner.next_page_token, - })) + Json(AdminExecutionHistoryListResponse { + success: true, + executions, + next_page_token: inner.next_page_token, + message: None, + }) } - Err(e) => Json(json!({ "success": false, "message": e.to_string() })), + Err(e) => Json(AdminExecutionHistoryListResponse { + success: false, + executions: Vec::new(), + next_page_token: String::new(), + message: Some(e.to_string()), + }), } } async fn get_execution_admin( state: GatewayState, Path(execution_id): Path, -) -> Json { +) -> Json { use crate::proto::sms::GetExecutionRequest; let mut client = state.execution_index_client.clone(); let resp = client @@ -1761,19 +834,33 @@ async fn get_execution_admin( Ok(r) => { let inner = r.into_inner(); if !inner.found { - return Json(json!({ "success": true, "found": false })); + return Json(AdminExecutionDetailEnvelope { + success: true, + found: false, + execution: None, + message: None, + }); } - Json(json!({ - "success": true, - "found": true, - "execution": execution_detail_json(inner.execution.unwrap()) - })) + Json(AdminExecutionDetailEnvelope { + success: true, + found: true, + execution: inner.execution.map(execution_to_detail), + message: None, + }) } - Err(e) => Json(json!({ "success": false, "message": e.to_string() })), + Err(e) => Json(AdminExecutionDetailEnvelope { + success: false, + found: false, + execution: None, + message: Some(e.to_string()), + }), } } -async fn get_node_detail(state: GatewayState, Path(uuid): Path) -> Json { +async fn get_node_detail( + state: GatewayState, + Path(uuid): Path, +) -> Json { use crate::proto::sms::GetNodeWithResourceRequest; let mut client = state.node_client.clone(); let resp = client @@ -1781,7 +868,7 @@ async fn get_node_detail(state: GatewayState, Path(uuid): Path) -> Json< .await .unwrap() .into_inner(); - Json(node_detail_json(resp.node, resp.resource)) + Json(admin_node_detail_response(resp.node, resp.resource)) } async fn get_node_ai_credential_sync( diff --git a/src/sms/web_admin/ai_backend_admin.rs b/src/sms/web_admin/ai_backend_admin.rs new file mode 100644 index 00000000..8685faa1 --- /dev/null +++ b/src/sms/web_admin/ai_backend_admin.rs @@ -0,0 +1,2829 @@ +use axum::{ + extract::{Path, Query}, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::ai_backend_types::{ + CanonicalBackendDesiredState, CanonicalBackendHosting, CanonicalBackendKind, + CanonicalBackendManagementMode, CanonicalBackendProvider, +}; +use crate::sms::ai_admin_api::{ + backend_assignment_to_response, backend_node_status_to_response, backend_placement_to_response, + backend_record_to_response, model_view_to_response, AdminAiBackendAssignmentListResponse, + AdminAiBackendDetailResponse, AdminAiBackendListResponse, + AdminAiBackendLocalModelPreflightSourceResponse, AdminAiBackendMutationResponse, + AdminAiBackendNodeStatusListResponse, AdminAiBackendPlacementListResponse, + AdminAiBackendOllamaPreflightOutcomeResponse, + AdminAiBackendPlacementMutationResponse, AdminAiBackendPreflightCheckResponse, + AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse, + AdminAiBackendPreflightDetailsResponse, AdminAiBackendPreflightNodeResponse, + AdminAiBackendPreflightResponse, AdminAiBackendRemoteProviderPreflightOutcomeResponse, + AdminAiBackendRemoteProviderPreflightResultResponse, + AdminAiModelViewListResponse, +}; +use crate::sms::gateway::GatewayState; + +use super::types::{AiBackendsQuery, AiModelViewsQuery}; + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AiBackendWriteBody { + pub(crate) display_name: String, + pub(crate) provider: String, + pub(crate) model: String, + pub(crate) hosting: String, + pub(crate) backend_kind: String, + pub(crate) management_mode: Option, + pub(crate) credential_ref: Option, + pub(crate) desired_state: Option, + pub(crate) spec: AiBackendSpecBody, + pub(crate) labels: Option>, + pub(crate) local: Option, + pub(crate) remote: Option, + pub(crate) metadata: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AiBackendSpecBody { + pub(crate) base_url: Option, + pub(crate) operations: Vec, + pub(crate) features: Option>, + pub(crate) transports: Option>, + pub(crate) weight: Option, + pub(crate) priority: Option, +} + +/// Provider-specific local write body accepted at the admin API boundary. +/// 管理端 API 边界接受的 provider-specific local 写入体。 +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "provider_family", content = "config", rename_all = "snake_case")] +pub(crate) enum AiBackendLocalProviderWriteBody { + /// Structured local llama.cpp input body / 结构化 local llama.cpp 输入体 + LlamaCpp(AiBackendLlamaCppLocalWriteBody), + /// Structured local vLLM input body / 结构化 local vLLM 输入体 + Vllm(AiBackendVllmLocalWriteBody), + /// Local Ollama input body with no extra structured fields yet / 暂无额外结构化字段的 local Ollama 输入体 + Ollama, + /// Internal local provider body / 内部 local provider 输入体 + Internal, +} + +/// Structured local llama.cpp write body accepted at the admin API boundary. +/// 管理端 API 边界接受的结构化 local llama.cpp 写入体。 +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AiBackendLlamaCppLocalWriteBody { + pub(crate) model_url: Option, + pub(crate) model_path: Option, + pub(crate) skip_download: Option, + pub(crate) download_timeout_s: Option, + pub(crate) server_mode: Option, + pub(crate) server_cmd: Option, + pub(crate) server_cmd_args: Option, + pub(crate) threads: Option, + pub(crate) ctx_size: Option, + pub(crate) ready_probe: Option, + pub(crate) start_timeout_s: Option, +} + +/// Structured local vLLM write body accepted at the admin API boundary. +/// 管理端 API 边界接受的结构化 local vLLM 写入体。 +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AiBackendVllmLocalWriteBody { + pub(crate) mode: Option, + pub(crate) managed_externally: Option, +} + +/// Provider-specific remote write body accepted at the admin API boundary. +/// 管理端 API 边界接受的 provider-specific remote 写入体。 +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "provider_family", content = "config", rename_all = "snake_case")] +pub(crate) enum AiBackendRemoteProviderWriteBody { + /// Structured OpenAI-compatible remote input body / 结构化 OpenAI-compatible remote 输入体 + OpenAiCompatible(AiBackendOpenAiCompatibleRemoteWriteBody), + /// Structured Ollama remote input body / 结构化 Ollama remote 输入体 + Ollama(AiBackendOllamaRemoteWriteBody), + /// Internal remote provider body / 内部 remote provider 输入体 + Internal, +} + +/// Structured OpenAI-compatible remote write body accepted at the admin API boundary. +/// 管理端 API 边界接受的结构化 OpenAI-compatible remote 写入体。 +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AiBackendOpenAiCompatibleRemoteWriteBody { + pub(crate) base_url: Option, + pub(crate) credential_ref: Option, + pub(crate) operations: Option>, + pub(crate) features: Option>, + pub(crate) transports: Option>, +} + +/// Structured Ollama remote write body accepted at the admin API boundary. +/// 管理端 API 边界接受的结构化 Ollama remote 写入体。 +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AiBackendOllamaRemoteWriteBody { + pub(crate) base_url: Option, + pub(crate) operations: Option>, + pub(crate) features: Option>, + pub(crate) transports: Option>, +} + +/// Typed llama.cpp metadata accepted at the admin write boundary. +/// 管理端写入边界接受的强类型 llama.cpp metadata。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct LlamaCppAdminMetadataInput { + model_url: Option, + model_path: Option, + skip_download: Option, + download_timeout_s: Option, + server_mode: Option, + server_cmd: Option, + server_cmd_args: Option, + threads: Option, + ctx_size: Option, + ready_probe: Option, + start_timeout_s: Option, +} + +/// Typed vLLM metadata accepted at the admin write boundary. +/// 管理端写入边界接受的强类型 vLLM metadata。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct VllmAdminMetadataInput { + mode: Option, + managed_externally: Option, +} + +/// Typed local/remote write view derived from the admin backend write body. +/// 从管理端 backend 写入体派生出的 local/remote 强类型写入视图。 +#[derive(Debug, Clone)] +enum AiBackendWriteTypedView { + Local(LocalAiBackendWriteView), + Remote(RemoteAiBackendWriteView), +} + +/// Typed local backend write view. +/// 强类型 local backend 写入视图。 +#[derive(Debug, Clone)] +struct LocalAiBackendWriteView { + body: AiBackendWriteBody, + kind: CanonicalBackendKind, + provider: CanonicalBackendProvider, +} + +/// Typed remote backend write view. +/// 强类型 remote backend 写入视图。 +#[derive(Debug, Clone)] +struct RemoteAiBackendWriteView { + body: AiBackendWriteBody, + kind: CanonicalBackendKind, + provider: CanonicalBackendProvider, +} + +/// Provider-specific local backend write view. +/// provider-specific 的 local backend 写入视图。 +#[derive(Debug, Clone)] +enum LocalAiBackendWriteProviderView<'a> { + Ollama(&'a LocalAiBackendWriteView), + LlamaCpp(LlamaCppLocalAiBackendWriteView<'a>), + Vllm(VllmLocalAiBackendWriteView<'a>), + Internal(&'a LocalAiBackendWriteView), +} + +/// Provider-specific remote backend write view. +/// provider-specific 的 remote backend 写入视图。 +#[derive(Debug, Clone, Copy)] +enum RemoteAiBackendWriteProviderView<'a> { + OpenAi(OpenAiCompatibleRemoteAiBackendWriteView<'a>), + Ollama(OllamaRemoteAiBackendWriteView<'a>), + Internal(&'a RemoteAiBackendWriteView), +} + +/// Local llama.cpp write view with parsed provider-specific metadata. +/// 带有 provider-specific metadata 的 local llama.cpp 写入视图。 +#[derive(Debug, Clone)] +struct LlamaCppLocalAiBackendWriteView<'a> { + local: &'a LocalAiBackendWriteView, + metadata: LlamaCppAdminMetadataInput, +} + +/// Local vLLM write view with parsed provider-specific metadata. +/// 带有 provider-specific metadata 的 local vLLM 写入视图。 +#[derive(Debug, Clone)] +struct VllmLocalAiBackendWriteView<'a> { + local: &'a LocalAiBackendWriteView, + metadata: VllmAdminMetadataInput, +} + +/// Remote OpenAI-compatible write view with resolved provider-specific config. +/// 带有 provider-specific 配置的 remote OpenAI-compatible 写入视图。 +#[derive(Debug, Clone, Copy)] +struct OpenAiCompatibleRemoteAiBackendWriteView<'a> { + remote: &'a RemoteAiBackendWriteView, + config: Option<&'a AiBackendOpenAiCompatibleRemoteWriteBody>, +} + +/// Remote Ollama write view with resolved provider-specific config. +/// 带有 provider-specific 配置的 remote Ollama 写入视图。 +#[derive(Debug, Clone, Copy)] +struct OllamaRemoteAiBackendWriteView<'a> { + remote: &'a RemoteAiBackendWriteView, + config: Option<&'a AiBackendOllamaRemoteWriteBody>, +} + +/// Resolved write-time backend spec used by create/update/preflight. +/// create/update/preflight 共享的写入时 backend 规范视图。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedAdminWriteSpec { + base_url: Option, + credential_ref: Option, + operations: Vec, + features: Vec, + transports: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct AiBackendPreflightBody { + pub(crate) backend: AiBackendWriteBody, + pub(crate) node_uuids: Vec, + pub(crate) verification_policy: Option, + pub(crate) requested_checks: Option>, +} + +#[derive(Debug, Clone, Serialize)] +struct SpearletLocalModelPreflightRequest { + provider: String, + model: String, + model_url: Option, + model_path: Option, + skip_download: Option, + download_timeout_s: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "source_kind", rename_all = "snake_case")] +enum SpearletLocalModelPreflightDetailsResponse { + Unavailable, + ModelPath { + effective_model_path: String, + }, + ModelUrl { + effective_model_path: String, + final_url: String, + http_status: u16, + content_length: Option, + }, +} + +#[derive(Debug, Deserialize)] +struct SpearletLocalModelPreflightResponse { + success: bool, + details: SpearletLocalModelPreflightDetailsResponse, + message: String, +} + +#[derive(Debug, Clone, Serialize)] +struct SpearletRemoteBackendPreflightRequest { + backend: SpearletRemoteBackendDraft, + requested_checks: Option>, +} + +#[derive(Debug, Clone, Serialize)] +struct SpearletRemoteBackendDraft { + provider: String, + model: String, + hosting: String, + backend_kind: String, + base_url: Option, + credential_ref: Option, + operations: Option>, + features: Option>, + transports: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +enum SpearletRemoteBackendPreflightGenericOutcomeResponse { + Success { + phase: String, + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + }, + Failure { + phase: String, + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +enum SpearletOpenAiCompatiblePreflightOutcomeResponse { + ConnectivityVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + }, + AuthVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: bool, + }, + ModelAccessVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: bool, + model_accessible: bool, + }, + ConnectivityFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + }, + AuthFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: bool, + }, + ModelAccessFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: bool, + model_accessible: bool, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +enum SpearletOllamaPreflightOutcomeResponse { + ConnectivityVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + }, + ModelAccessVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + model_accessible: bool, + }, + ConnectivityFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + }, + ModelAccessFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + model_accessible: bool, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "provider_family", rename_all = "snake_case")] +enum SpearletRemoteBackendPreflightResultResponse { + OpenAiCompatible { + outcome: SpearletOpenAiCompatiblePreflightOutcomeResponse, + }, + Ollama { + outcome: SpearletOllamaPreflightOutcomeResponse, + }, + Unknown { + provider: String, + outcome: SpearletRemoteBackendPreflightGenericOutcomeResponse, + }, +} + +#[derive(Debug, Deserialize)] +struct SpearletRemoteBackendPreflightResponse { + success: bool, + result: SpearletRemoteBackendPreflightResultResponse, + message: String, + latency_ms: Option, +} + +#[derive(Debug, Deserialize)] +struct SpearletRemoteBackendPreflightCheck { + name: String, + ok: bool, +} + +#[derive(Deserialize)] +pub(crate) struct SetAiBackendDesiredStateBody { + pub(crate) desired_state: String, +} + +#[derive(Deserialize)] +pub(crate) struct AiBackendPlacementWriteBody { + pub(crate) placement_id: Option, + pub(crate) backend_id: String, + pub(crate) node_uuid: String, + pub(crate) desired_state: Option, + pub(crate) weight_override: Option, + pub(crate) priority_override: Option, +} + +#[derive(Deserialize)] +pub(crate) struct AiBackendPlacementsQuery { + pub(crate) backend_id: Option, + pub(crate) node_uuid: Option, +} + +pub(crate) async fn list_ai_backends_admin( + state: GatewayState, + Query(q): Query, +) -> Json { + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .list_ai_backends(crate::proto::sms::ListAiBackendsRequest { + limit: q.limit.unwrap_or(0), + offset: q.offset.unwrap_or(0), + q: q.q.unwrap_or_default(), + hosting: q.hosting.unwrap_or_default(), + desired_state: q.desired_state.unwrap_or_default(), + provider: q.provider.unwrap_or_default(), + model: q.model.unwrap_or_default(), + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + let backends = inner + .backends + .into_iter() + .map(backend_record_to_response) + .collect::>(); + Json(AdminAiBackendListResponse { + success: true, + backends, + total_count: inner.total_count, + message: None, + }) + } + Err(e) => Json(AdminAiBackendListResponse { + success: false, + backends: Vec::new(), + total_count: 0, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn get_ai_backend_admin( + state: GatewayState, + p: Path, +) -> Json { + let backend_id = p.0; + if backend_id.trim().is_empty() { + return Json(AdminAiBackendDetailResponse { + success: false, + found: false, + backend: None, + message: Some("backend_id is required".to_string()), + }); + } + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .get_ai_backend(crate::proto::sms::GetAiBackendRequest { backend_id }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + Json(AdminAiBackendDetailResponse { + success: true, + found: inner.found, + backend: inner.backend.map(backend_record_to_response), + message: None, + }) + } + Err(e) => Json(AdminAiBackendDetailResponse { + success: false, + found: false, + backend: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn create_ai_backend_admin( + state: GatewayState, + body: Json, +) -> Json { + let backend = match ai_backend_proto_from_body(body.0, String::new()) { + Ok(value) => value, + Err(message) => { + return Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(message), + }) + } + }; + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .create_ai_backend(crate::proto::sms::CreateAiBackendRequest { + backend: Some(backend), + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + Json(AdminAiBackendMutationResponse { + success: true, + backend: inner.backend.map(backend_record_to_response), + deleted: None, + message: None, + }) + } + Err(e) => Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn preflight_ai_backend_admin( + state: GatewayState, + body: Json, +) -> Json { + if body.node_uuids.is_empty() { + return Json(AdminAiBackendPreflightResponse { + success: false, + results: Vec::new(), + message: Some("node_uuids is required".to_string()), + }); + } + + if let Err(message) = ai_backend_proto_from_body(body.backend.clone(), String::new()) { + return Json(AdminAiBackendPreflightResponse { + success: false, + results: Vec::new(), + message: Some(message), + }); + } + + let backend = &body.backend; + let verification_policy = + determine_preflight_policy(body.verification_policy.as_deref(), body.node_uuids.len()); + let target_node_uuids = select_preflight_nodes(&body.node_uuids, &verification_policy); + if target_node_uuids.is_empty() { + return Json(AdminAiBackendPreflightResponse { + success: false, + results: Vec::new(), + message: Some("no target nodes remain after applying verification policy".to_string()), + }); + } + + let http_client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(client) => client, + Err(error) => { + return Json(AdminAiBackendPreflightResponse { + success: false, + results: Vec::new(), + message: Some(error.to_string()), + }) + } + }; + + let mut results = Vec::new(); + let mut had_failures = false; + for node_uuid in &target_node_uuids { + let target = match super::node_rpc::resolve_node_http_target(&state, node_uuid).await { + Ok(target) => target, + Err(resp) => { + let message = resp.0["message"] + .as_str() + .unwrap_or("failed to resolve node http target") + .to_string(); + return Json(AdminAiBackendPreflightResponse { + success: false, + results, + message: Some(format!("{}: {}", node_uuid, message)), + }); + } + }; + + let node_result = match backend.hosting.trim().to_ascii_lowercase().as_str() { + "local" => preflight_local_backend_node(&http_client, backend, &target.base_url).await, + "remote" => { + preflight_remote_backend_node( + &http_client, + backend, + &target.base_url, + body.requested_checks.as_ref(), + ) + .await + } + _ => Err("preflight only supports local or remote backends".to_string()), + }; + + let result = match node_result { + Ok(value) => value, + Err(message) => { + return Json(AdminAiBackendPreflightResponse { + success: false, + results, + message: Some(format!("{}: {}", target.node_uuid, message)), + }); + } + }; + let success = result.success; + let message = result.message.clone(); + results.push(AdminAiBackendPreflightNodeResponse { + node_uuid: target.node_uuid.clone(), + ..result + }); + if !success { + had_failures = true; + if verification_policy == "best_effort" { + continue; + } + return Json(AdminAiBackendPreflightResponse { + success: false, + results, + message: Some(format!("{}: {}", target.node_uuid, message)), + }); + } + } + + Json(AdminAiBackendPreflightResponse { + success: !had_failures || verification_policy == "best_effort", + results, + message: if verification_policy == "best_effort" && had_failures { + Some( + "preflight completed with warnings because verification_policy=best_effort" + .to_string(), + ) + } else if verification_policy == "sampled_strict" + && body.node_uuids.len() > target_node_uuids.len() + { + Some(format!( + "sampled {} of {} nodes before create; full verification continues after placement reconciliation", + target_node_uuids.len(), + body.node_uuids.len() + )) + } else { + None + }, + }) +} + +pub(crate) async fn update_ai_backend_admin( + state: GatewayState, + p: Path, + body: Json, +) -> Json { + let backend_id = p.0; + if backend_id.trim().is_empty() { + return Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some("backend_id is required".to_string()), + }); + } + + let backend = match ai_backend_proto_from_body(body.0, backend_id) { + Ok(value) => value, + Err(message) => { + return Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(message), + }) + } + }; + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .update_ai_backend(crate::proto::sms::UpdateAiBackendRequest { + backend: Some(backend), + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + Json(AdminAiBackendMutationResponse { + success: true, + backend: inner.backend.map(backend_record_to_response), + deleted: None, + message: None, + }) + } + Err(e) => Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn delete_ai_backend_admin( + state: GatewayState, + p: Path, +) -> Json { + let backend_id = p.0; + if backend_id.trim().is_empty() { + return Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some("backend_id is required".to_string()), + }); + } + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .delete_ai_backend(crate::proto::sms::DeleteAiBackendRequest { backend_id }) + .await + { + Ok(resp) => Json(AdminAiBackendMutationResponse { + success: true, + backend: None, + deleted: Some(resp.into_inner().deleted), + message: None, + }), + Err(e) => Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn set_ai_backend_desired_state_admin( + state: GatewayState, + p: Path, + body: Json, +) -> Json { + let backend_id = p.0; + if backend_id.trim().is_empty() { + return Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some("backend_id is required".to_string()), + }); + } + + let desired_state = match parse_ai_backend_desired_state(&body.0.desired_state) { + Ok(value) => value, + Err(message) => { + return Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(message), + }) + } + }; + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .set_ai_backend_desired_state(crate::proto::sms::SetAiBackendDesiredStateRequest { + backend_id, + desired_state: desired_state as i32, + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + Json(AdminAiBackendMutationResponse { + success: true, + backend: inner.backend.map(backend_record_to_response), + deleted: None, + message: None, + }) + } + Err(e) => Json(AdminAiBackendMutationResponse { + success: false, + backend: None, + deleted: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn list_ai_backend_placements_admin( + state: GatewayState, + Query(q): Query, +) -> Json { + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .list_ai_backend_placements(crate::proto::sms::ListAiBackendPlacementsRequest { + backend_id: q.backend_id.unwrap_or_default(), + node_uuid: q.node_uuid.unwrap_or_default(), + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + let placements = inner + .placements + .into_iter() + .map(backend_placement_to_response) + .collect::>(); + Json(AdminAiBackendPlacementListResponse { + success: true, + placements, + total_count: inner.total_count, + message: None, + }) + } + Err(e) => Json(AdminAiBackendPlacementListResponse { + success: false, + placements: Vec::new(), + total_count: 0, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn upsert_ai_backend_placement_admin( + state: GatewayState, + body: Json, +) -> Json { + let placement = match ai_backend_placement_proto_from_body(body.0) { + Ok(value) => value, + Err(message) => { + return Json(AdminAiBackendPlacementMutationResponse { + success: false, + placement: None, + deleted: None, + message: Some(message), + }) + } + }; + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .upsert_ai_backend_placement(crate::proto::sms::UpsertAiBackendPlacementRequest { + placement: Some(placement), + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + Json(AdminAiBackendPlacementMutationResponse { + success: true, + placement: inner.placement.map(backend_placement_to_response), + deleted: None, + message: None, + }) + } + Err(e) => Json(AdminAiBackendPlacementMutationResponse { + success: false, + placement: None, + deleted: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn delete_ai_backend_placement_admin( + state: GatewayState, + p: Path, +) -> Json { + let placement_id = p.0; + if placement_id.trim().is_empty() { + return Json(AdminAiBackendPlacementMutationResponse { + success: false, + placement: None, + deleted: None, + message: Some("placement_id is required".to_string()), + }); + } + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .delete_ai_backend_placement(crate::proto::sms::DeleteAiBackendPlacementRequest { + placement_id, + }) + .await + { + Ok(resp) => Json(AdminAiBackendPlacementMutationResponse { + success: true, + placement: None, + deleted: Some(resp.into_inner().deleted), + message: None, + }), + Err(e) => Json(AdminAiBackendPlacementMutationResponse { + success: false, + placement: None, + deleted: None, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn list_ai_backend_assignments_admin( + state: GatewayState, + p: Path, +) -> Json { + let node_uuid = p.0; + if node_uuid.trim().is_empty() { + return Json(AdminAiBackendAssignmentListResponse { + success: false, + assignments: Vec::new(), + total_count: 0, + message: Some("node_uuid is required".to_string()), + }); + } + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .list_ai_backend_assignments(crate::proto::sms::ListAiBackendAssignmentsRequest { + node_uuid, + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + let assignments = inner + .assignments + .into_iter() + .map(backend_assignment_to_response) + .collect::>(); + Json(AdminAiBackendAssignmentListResponse { + success: true, + assignments, + total_count: inner.total_count, + message: None, + }) + } + Err(e) => Json(AdminAiBackendAssignmentListResponse { + success: false, + assignments: Vec::new(), + total_count: 0, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn list_ai_backend_node_statuses_admin( + state: GatewayState, + p: Path, +) -> Json { + let backend_id = p.0; + if backend_id.trim().is_empty() { + return Json(AdminAiBackendNodeStatusListResponse { + success: false, + statuses: Vec::new(), + total_count: 0, + message: Some("backend_id is required".to_string()), + }); + } + + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .list_ai_backend_node_statuses(crate::proto::sms::ListAiBackendNodeStatusesRequest { + backend_id, + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + let statuses = inner + .statuses + .into_iter() + .map(backend_node_status_to_response) + .collect::>(); + Json(AdminAiBackendNodeStatusListResponse { + success: true, + statuses, + total_count: inner.total_count, + message: None, + }) + } + Err(e) => Json(AdminAiBackendNodeStatusListResponse { + success: false, + statuses: Vec::new(), + total_count: 0, + message: Some(e.to_string()), + }), + } +} + +pub(crate) async fn list_ai_model_views_admin( + state: GatewayState, + Query(q): Query, +) -> Json { + let mut client = state.ai_backend_control_plane_client.clone(); + match client + .list_ai_model_views(crate::proto::sms::ListAiModelViewsRequest { + limit: q.limit.unwrap_or(0), + offset: q.offset.unwrap_or(0), + q: q.q.unwrap_or_default(), + hosting: q.hosting.unwrap_or_default(), + status: q.status.unwrap_or_default(), + provider: q.provider.unwrap_or_default(), + model: q.model.unwrap_or_default(), + }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + let views = inner + .views + .into_iter() + .map(model_view_to_response) + .collect::>(); + Json(AdminAiModelViewListResponse { + success: true, + views, + total_count: inner.total_count, + message: None, + }) + } + Err(e) => Json(AdminAiModelViewListResponse { + success: false, + views: Vec::new(), + total_count: 0, + message: Some(e.to_string()), + }), + } +} + +fn ai_backend_proto_from_body( + body: AiBackendWriteBody, + backend_id: String, +) -> Result { + if body.display_name.trim().is_empty() { + return Err("display_name is required".to_string()); + } + if body.provider.trim().is_empty() { + return Err("provider is required".to_string()); + } + if body.model.trim().is_empty() { + return Err("model is required".to_string()); + } + if body.backend_kind.trim().is_empty() { + return Err("backend_kind is required".to_string()); + } + let canonical_kind = CanonicalBackendKind::parse(&body.backend_kind); + let canonical_provider = parse_supported_ai_backend_provider(&body.provider)?; + let hosting = parse_ai_backend_hosting(&body.hosting)?; + let typed = AiBackendWriteTypedView::parse(body.clone())?; + let resolved_spec = typed.resolved_spec()?; + if resolved_spec.operations.is_empty() { + return Err("spec.operations is required".to_string()); + } + let management_mode = match body.management_mode.as_deref() { + Some(value) => parse_ai_backend_management_mode(value)?, + None => default_ai_backend_management_mode(hosting), + }; + let desired_state = match body.desired_state.as_deref() { + Some(value) => parse_ai_backend_desired_state(value)?, + None => crate::proto::sms::AiBackendDesiredState::Enabled, + }; + let credential_ref = resolved_spec.credential_ref.clone(); + let normalized_metadata = typed.normalized_metadata()?; + + let record = crate::sms::ai_backends::model::AiBackendRecordModel { + backend_id, + display_name: body.display_name.trim().to_string(), + provider: canonical_provider.as_str().to_string(), + model: body.model.trim().to_string(), + hosting: match hosting { + crate::proto::sms::AiBackendHosting::Remote => { + crate::sms::ai_backends::model::AiBackendHostingModel::Remote + } + crate::proto::sms::AiBackendHosting::Local => { + crate::sms::ai_backends::model::AiBackendHostingModel::Local + } + crate::proto::sms::AiBackendHosting::Unspecified => { + return Err("hosting is required".to_string()) + } + }, + backend_kind: canonical_kind.as_str().to_string(), + desired_state: match desired_state { + crate::proto::sms::AiBackendDesiredState::Enabled => { + crate::sms::ai_backends::model::AiBackendDesiredStateModel::Enabled + } + crate::proto::sms::AiBackendDesiredState::Disabled => { + crate::sms::ai_backends::model::AiBackendDesiredStateModel::Disabled + } + crate::proto::sms::AiBackendDesiredState::Unspecified => { + return Err("desired_state is required".to_string()) + } + }, + management_mode: match management_mode { + crate::proto::sms::AiBackendManagementMode::SmsRemote => { + crate::sms::ai_backends::model::AiBackendManagementModeModel::SmsRemote + } + crate::proto::sms::AiBackendManagementMode::SmsLocal => { + crate::sms::ai_backends::model::AiBackendManagementModeModel::SmsLocal + } + crate::proto::sms::AiBackendManagementMode::Unspecified => { + return Err("management_mode is required".to_string()) + } + }, + credential_ref: credential_ref.clone(), + spec: crate::sms::ai_backends::model::AiBackendSpecModel { + name: String::new(), + kind: canonical_kind.as_str().to_string(), + operations: resolved_spec.operations, + features: resolved_spec.features, + transports: resolved_spec.transports, + weight: body.spec.weight.unwrap_or(100), + priority: body.spec.priority.unwrap_or(0), + base_url: resolved_spec.base_url.unwrap_or_default(), + provider: canonical_provider.as_str().to_string(), + model: body.model.trim().to_string(), + credential_ref: credential_ref.unwrap_or_default(), + origin: 0, + deployment_id: String::new(), + }, + labels: body.labels.unwrap_or_default().into_iter().collect(), + metadata: normalized_metadata, + generation: 0, + created_at_ms: 0, + updated_at_ms: 0, + }; + Ok(crate::sms::ai_backends::proto_conv::proto_backend_from_domain(&record)) +} + +fn determine_preflight_policy(policy: Option<&str>, node_count: usize) -> String { + let normalized = policy + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()); + match normalized.as_deref() { + Some("single_node_strict") => "single_node_strict".to_string(), + Some("sampled_strict") => "sampled_strict".to_string(), + Some("strict_all_nodes") => "strict_all_nodes".to_string(), + Some("best_effort") => "best_effort".to_string(), + _ if node_count <= 1 => "single_node_strict".to_string(), + _ => "sampled_strict".to_string(), + } +} + +impl AiBackendWriteTypedView { + /// Parse one admin write body into a typed local/remote write view. + /// 将管理端写入体解析成强类型 local/remote 写入视图。 + fn parse(mut body: AiBackendWriteBody) -> Result { + let hosting = CanonicalBackendHosting::parse(&body.hosting); + let kind = CanonicalBackendKind::parse(&body.backend_kind); + let provider = parse_supported_ai_backend_provider(&body.provider)?; + if body.metadata.is_none() { + body.metadata = Some(json!({})); + } + match hosting { + CanonicalBackendHosting::Local => Ok(Self::Local(LocalAiBackendWriteView { + body, + kind, + provider, + })), + CanonicalBackendHosting::Remote => Ok(Self::Remote(RemoteAiBackendWriteView { + body, + kind, + provider, + })), + CanonicalBackendHosting::Unknown(_) => { + Err("hosting must be remote or local".to_string()) + } + } + } + + /// Render one stable normalized metadata JSON shape. + /// 渲染稳定的规范化 metadata JSON 形态。 + fn normalized_metadata(&self) -> Result { + match self { + Self::Local(local) => local.normalized_metadata(), + Self::Remote(remote) => Ok(remote.metadata_object().clone().into()), + } + } + + /// Resolve the effective write-time spec after applying provider-specific input views. + /// 应用 provider-specific 输入视图后,解析有效的写入时 spec。 + fn resolved_spec(&self) -> Result { + match self { + Self::Local(local) => Ok(local.default_resolved_spec()), + Self::Remote(remote) => remote.resolved_spec(), + } + } +} + +impl LocalAiBackendWriteView { + /// Resolve the default local write-time spec from the legacy top-level fields. + /// 从旧的顶层字段解析默认 local 写入时 spec。 + fn default_resolved_spec(&self) -> ResolvedAdminWriteSpec { + ResolvedAdminWriteSpec { + base_url: trim_to_option(self.body.spec.base_url.clone()), + credential_ref: trim_to_option(self.body.credential_ref.clone()), + operations: self.body.spec.operations.clone(), + features: self.body.spec.features.clone().unwrap_or_default(), + transports: self + .body + .spec + .transports + .clone() + .unwrap_or_else(|| vec!["http".to_string()]), + } + } + + /// Return the provider-specific local request body when present. + /// 返回存在时的 provider-specific local 请求体。 + fn local_provider_body(&self) -> Option<&AiBackendLocalProviderWriteBody> { + self.body.local.as_ref() + } + + /// Reject duplicated legacy metadata keys once structured local input is present. + /// 当结构化 local 输入存在时,拒绝重复出现的 legacy metadata 字段。 + fn ensure_no_legacy_metadata_overlap(&self, keys: &[&str], provider_label: &str) -> Result<(), String> { + let duplicates = keys + .iter() + .copied() + .filter(|key| self.metadata_object().contains_key(*key)) + .collect::>(); + if duplicates.is_empty() { + return Ok(()); + } + Err(format!( + "local {provider_label} structured input cannot be combined with metadata keys: {}", + duplicates.join(", ") + )) + } + + /// Project the local write view into one provider-specific subview. + /// 将 local 写入视图投影为 provider-specific 子视图。 + fn provider_view(&self) -> Result, String> { + match self.provider { + CanonicalBackendProvider::Ollama => { + if matches!( + self.local_provider_body(), + Some(AiBackendLocalProviderWriteBody::LlamaCpp(_)) + | Some(AiBackendLocalProviderWriteBody::Vllm(_)) + | Some(AiBackendLocalProviderWriteBody::Internal) + ) { + return Err( + "local.provider_family must match backend provider ollama".to_string(), + ); + } + Ok(LocalAiBackendWriteProviderView::Ollama(self)) + } + CanonicalBackendProvider::LlamaCpp => Ok(LocalAiBackendWriteProviderView::LlamaCpp( + LlamaCppLocalAiBackendWriteView { + local: self, + metadata: match self.local_provider_body() { + Some(AiBackendLocalProviderWriteBody::LlamaCpp(body)) => { + self.ensure_no_legacy_metadata_overlap( + &[ + "model_url", + "model_path", + "skip_download", + "download_timeout_s", + "server_mode", + "server_cmd", + "server_cmd_args", + "threads", + "ctx_size", + "ready_probe", + "start_timeout_s", + ], + "llamacpp", + )?; + LlamaCppAdminMetadataInput::from_local_write_body(body)? + } + Some(_) => { + return Err( + "local.provider_family must match backend provider llamacpp" + .to_string(), + ) + } + None => { + return Err( + "local provider-specific input is required for backend provider llamacpp" + .to_string(), + ) + } + }, + }, + )), + CanonicalBackendProvider::Vllm => Ok(LocalAiBackendWriteProviderView::Vllm( + VllmLocalAiBackendWriteView { + local: self, + metadata: match self.local_provider_body() { + Some(AiBackendLocalProviderWriteBody::Vllm(body)) => { + self.ensure_no_legacy_metadata_overlap( + &["mode", "managed_externally"], + "vllm", + )?; + VllmAdminMetadataInput::from_local_write_body(body)? + } + Some(_) => { + return Err( + "local.provider_family must match backend provider vllm" + .to_string(), + ) + } + None => { + return Err( + "local provider-specific input is required for backend provider vllm" + .to_string(), + ) + } + }, + }, + )), + CanonicalBackendProvider::Internal => { + if matches!( + self.local_provider_body(), + Some(AiBackendLocalProviderWriteBody::LlamaCpp(_)) + | Some(AiBackendLocalProviderWriteBody::Vllm(_)) + | Some(AiBackendLocalProviderWriteBody::Ollama) + ) { + return Err( + "local.provider_family must match backend provider internal".to_string(), + ); + } + Ok(LocalAiBackendWriteProviderView::Internal(self)) + } + CanonicalBackendProvider::OpenAi => { + if self.local_provider_body().is_some() { + return Err( + "local provider-specific input is only supported for known local providers" + .to_string(), + ); + } + return Err("openai does not support local hosting".to_string()); + } + CanonicalBackendProvider::Unknown(_) => { + Err("unsupported backend provider should have been rejected earlier".to_string()) + } + } + } + + /// Return the write-body metadata object for local providers. + /// 返回 local provider 使用的写入体 metadata 对象。 + fn metadata_object(&self) -> &Map { + self.body + .metadata + .as_ref() + .and_then(|value| value.as_object()) + .expect("typed write view should always carry object metadata") + } + + /// Render one normalized local metadata JSON shape. + /// 渲染规范化后的 local metadata JSON 形态。 + fn normalized_metadata(&self) -> Result { + match self.provider_view()? { + LocalAiBackendWriteProviderView::LlamaCpp(llamacpp) => { + Ok(llamacpp.normalized_metadata()) + } + LocalAiBackendWriteProviderView::Vllm(vllm) => Ok(vllm.normalized_metadata()), + LocalAiBackendWriteProviderView::Ollama(local) + | LocalAiBackendWriteProviderView::Internal(local) => { + Ok(local.metadata_object().clone().into()) + } + } + } +} + +impl RemoteAiBackendWriteView { + /// Return the provider-specific remote request body when present. + /// 返回存在时的 provider-specific remote 请求体。 + fn remote_provider_body(&self) -> Option<&AiBackendRemoteProviderWriteBody> { + self.body.remote.as_ref() + } + + /// Reject duplicated legacy top-level fields once structured remote input is present. + /// 当结构化 remote 输入存在时,拒绝重复出现的 legacy 顶层字段。 + fn ensure_no_legacy_remote_overlap(&self, provider_label: &str) -> Result<(), String> { + let mut duplicates = Vec::new(); + if trim_to_option(self.body.spec.base_url.clone()).is_some() { + duplicates.push("spec.base_url"); + } + if trim_to_option(self.body.credential_ref.clone()).is_some() { + duplicates.push("credential_ref"); + } + if !self.body.spec.operations.is_empty() { + duplicates.push("spec.operations"); + } + if self + .body + .spec + .features + .as_ref() + .is_some_and(|features| !features.is_empty()) + { + duplicates.push("spec.features"); + } + if self + .body + .spec + .transports + .as_ref() + .is_some_and(|transports| !transports.is_empty()) + { + duplicates.push("spec.transports"); + } + if duplicates.is_empty() { + return Ok(()); + } + Err(format!( + "remote {provider_label} structured input cannot be combined with legacy fields: {}", + duplicates.join(", ") + )) + } + + /// Project the remote write view into one provider-specific subview. + /// 将 remote 写入视图投影为 provider-specific 子视图。 + fn provider_view(&self) -> Result, String> { + match self.provider { + CanonicalBackendProvider::OpenAi => match self.remote_provider_body() { + Some(AiBackendRemoteProviderWriteBody::OpenAiCompatible(config)) => { + self.ensure_no_legacy_remote_overlap("openai")?; + Ok(RemoteAiBackendWriteProviderView::OpenAi( + OpenAiCompatibleRemoteAiBackendWriteView { + remote: self, + config: Some(config), + }, + )) + } + Some(_) => { + return Err( + "remote.provider_family must match backend provider openai".to_string(), + ) + } + None => { + return Err( + "remote provider-specific input is required for backend provider openai" + .to_string(), + ) + } + }, + CanonicalBackendProvider::Ollama => match self.remote_provider_body() { + Some(AiBackendRemoteProviderWriteBody::Ollama(config)) => { + self.ensure_no_legacy_remote_overlap("ollama")?; + Ok(RemoteAiBackendWriteProviderView::Ollama( + OllamaRemoteAiBackendWriteView { + remote: self, + config: Some(config), + }, + )) + } + Some(_) => { + return Err( + "remote.provider_family must match backend provider ollama".to_string(), + ) + } + None => { + return Err( + "remote provider-specific input is required for backend provider ollama" + .to_string(), + ) + } + }, + CanonicalBackendProvider::Internal => { + if matches!( + self.remote_provider_body(), + Some(AiBackendRemoteProviderWriteBody::OpenAiCompatible(_)) + | Some(AiBackendRemoteProviderWriteBody::Ollama(_)) + ) { + return Err( + "remote.provider_family must match backend provider internal".to_string(), + ); + } + Ok(RemoteAiBackendWriteProviderView::Internal(self)) + } + CanonicalBackendProvider::LlamaCpp + | CanonicalBackendProvider::Vllm => { + if self.remote_provider_body().is_some() { + return Err( + "remote provider-specific input is only supported for known remote providers" + .to_string(), + ); + } + return Err(format!( + "{} does not support remote hosting", + self.provider.as_str() + )); + } + CanonicalBackendProvider::Unknown(_) => { + Err("unsupported backend provider should have been rejected earlier".to_string()) + } + } + } + + /// Return the write-body metadata object for remote providers. + /// 返回 remote provider 使用的写入体 metadata 对象。 + fn metadata_object(&self) -> &Map { + self.body + .metadata + .as_ref() + .and_then(|value| value.as_object()) + .expect("typed write view should always carry object metadata") + } + + /// Resolve the default remote write-time spec from the legacy top-level fields. + /// 从旧的顶层字段解析默认 remote 写入时 spec。 + fn default_resolved_spec(&self) -> ResolvedAdminWriteSpec { + ResolvedAdminWriteSpec { + base_url: trim_to_option(self.body.spec.base_url.clone()), + credential_ref: trim_to_option(self.body.credential_ref.clone()), + operations: self.body.spec.operations.clone(), + features: self.body.spec.features.clone().unwrap_or_default(), + transports: self + .body + .spec + .transports + .clone() + .unwrap_or_else(|| vec!["http".to_string()]), + } + } + + /// Resolve the effective remote write-time spec after provider-specific overrides. + /// 应用 provider-specific 覆盖后解析有效的 remote 写入时 spec。 + fn resolved_spec(&self) -> Result { + Ok(match self.provider_view()? { + RemoteAiBackendWriteProviderView::OpenAi(openai) => openai.resolved_spec(), + RemoteAiBackendWriteProviderView::Ollama(ollama) => ollama.resolved_spec(), + RemoteAiBackendWriteProviderView::Internal(remote) => remote.default_resolved_spec(), + }) + } +} + +impl<'a> LlamaCppLocalAiBackendWriteView<'a> { + /// Render one normalized llama.cpp metadata JSON shape. + /// 渲染规范化后的 llama.cpp metadata JSON 形态。 + fn normalized_metadata(&self) -> Value { + let mut normalized = self.local.metadata_object().clone(); + self.metadata.apply_normalized(&mut normalized); + Value::Object(normalized) + } + + /// Build the node-side local-model preflight request. + /// 构建节点侧 local-model preflight 请求。 + fn to_preflight_request(&self) -> SpearletLocalModelPreflightRequest { + SpearletLocalModelPreflightRequest { + provider: self.local.body.provider.trim().to_string(), + model: self.local.body.model.trim().to_string(), + model_url: self.metadata.model_url.clone(), + model_path: self.metadata.model_path.clone(), + skip_download: self.metadata.skip_download, + download_timeout_s: self.metadata.download_timeout_s, + } + } +} + +impl<'a> VllmLocalAiBackendWriteView<'a> { + /// Render one normalized vLLM metadata JSON shape. + /// 渲染规范化后的 vLLM metadata JSON 形态。 + fn normalized_metadata(&self) -> Value { + let mut normalized = self.local.metadata_object().clone(); + self.metadata.apply_normalized(&mut normalized); + Value::Object(normalized) + } +} + +impl<'a> OpenAiCompatibleRemoteAiBackendWriteView<'a> { + /// Resolve the effective OpenAI-compatible remote spec. + /// 解析有效的 OpenAI-compatible remote spec。 + fn resolved_spec(&self) -> ResolvedAdminWriteSpec { + let fallback = self.remote.default_resolved_spec(); + ResolvedAdminWriteSpec { + base_url: self + .config + .and_then(|config| trim_to_option(config.base_url.clone())) + .or(fallback.base_url), + credential_ref: self + .config + .and_then(|config| trim_to_option(config.credential_ref.clone())) + .or(fallback.credential_ref), + operations: self + .config + .and_then(|config| config.operations.clone()) + .unwrap_or(fallback.operations), + features: self + .config + .and_then(|config| config.features.clone()) + .unwrap_or(fallback.features), + transports: self + .config + .and_then(|config| config.transports.clone()) + .unwrap_or(fallback.transports), + } + } +} + +impl<'a> OllamaRemoteAiBackendWriteView<'a> { + /// Resolve the effective Ollama remote spec. + /// 解析有效的 Ollama remote spec。 + fn resolved_spec(&self) -> ResolvedAdminWriteSpec { + let fallback = self.remote.default_resolved_spec(); + ResolvedAdminWriteSpec { + base_url: self + .config + .and_then(|config| trim_to_option(config.base_url.clone())) + .or(fallback.base_url), + credential_ref: fallback.credential_ref, + operations: self + .config + .and_then(|config| config.operations.clone()) + .unwrap_or(fallback.operations), + features: self + .config + .and_then(|config| config.features.clone()) + .unwrap_or(fallback.features), + transports: self + .config + .and_then(|config| config.transports.clone()) + .unwrap_or(fallback.transports), + } + } +} + +impl LlamaCppAdminMetadataInput { + /// Parse typed llama.cpp metadata from one structured local write body. + /// 从结构化 local 写入体解析强类型 llama.cpp metadata。 + fn from_local_write_body(body: &AiBackendLlamaCppLocalWriteBody) -> Result { + let server_mode = trim_to_option(body.server_mode.clone()).map(|value| value.to_ascii_lowercase()); + let ready_probe = trim_to_option(body.ready_probe.clone()).map(|value| value.to_ascii_lowercase()); + let input = Self { + model_url: trim_to_option(body.model_url.clone()), + model_path: trim_to_option(body.model_path.clone()), + skip_download: body.skip_download, + download_timeout_s: body.download_timeout_s, + server_mode: server_mode.clone(), + server_cmd: trim_to_option(body.server_cmd.clone()), + server_cmd_args: trim_to_option(body.server_cmd_args.clone()), + threads: body.threads.map(|value| value.to_string()), + ctx_size: body.ctx_size.map(|value| value.to_string()), + ready_probe, + start_timeout_s: body.start_timeout_s, + }; + if matches!(server_mode.as_deref(), Some("raw")) && input.server_cmd_args.is_none() { + return Err("llamacpp metadata server_mode=raw requires server_cmd_args".to_string()); + } + Ok(input) + } + + /// Apply the canonical llama.cpp metadata rendering to one JSON object. + /// 将规范化后的 llama.cpp metadata 渲染回 JSON 对象。 + fn apply_normalized(&self, metadata: &mut Map) { + set_optional_string(metadata, "model_url", self.model_url.clone()); + set_optional_string(metadata, "model_path", self.model_path.clone()); + set_optional_bool_string(metadata, "skip_download", self.skip_download); + set_optional_u64_string( + metadata, + "download_timeout_s", + self.download_timeout_s, + ); + set_optional_string(metadata, "server_mode", self.server_mode.clone()); + set_optional_string(metadata, "server_cmd", self.server_cmd.clone()); + set_optional_string(metadata, "server_cmd_args", self.server_cmd_args.clone()); + set_optional_string(metadata, "threads", self.threads.clone()); + set_optional_string(metadata, "ctx_size", self.ctx_size.clone()); + set_optional_string(metadata, "ready_probe", self.ready_probe.clone()); + set_optional_u64_string(metadata, "start_timeout_s", self.start_timeout_s); + } +} + +impl VllmAdminMetadataInput { + /// Parse typed vLLM metadata from one structured local write body. + /// 从结构化 local 写入体解析强类型 vLLM metadata。 + fn from_local_write_body(body: &AiBackendVllmLocalWriteBody) -> Result { + Ok(Self { + mode: trim_to_option(body.mode.clone()).map(|value| value.to_ascii_lowercase()), + managed_externally: body.managed_externally, + }) + } + + /// Apply the canonical vLLM metadata rendering to one JSON object. + /// 将规范化后的 vLLM metadata 渲染回 JSON 对象。 + fn apply_normalized(&self, metadata: &mut Map) { + set_optional_string(metadata, "mode", self.mode.clone()); + set_optional_bool_string( + metadata, + "managed_externally", + self.managed_externally, + ); + } +} + +fn set_optional_string(metadata: &mut Map, key: &str, value: Option) { + match value { + Some(value) => { + metadata.insert(key.to_string(), Value::String(value)); + } + None => { + metadata.remove(key); + } + } +} + +fn set_optional_bool_string(metadata: &mut Map, key: &str, value: Option) { + set_optional_string(metadata, key, value.map(|value| value.to_string())); +} + +fn set_optional_u64_string(metadata: &mut Map, key: &str, value: Option) { + set_optional_string(metadata, key, value.map(|value| value.to_string())); +} + +fn llamacpp_preflight_request_from_backend( + backend: &AiBackendWriteBody, +) -> Result { + let typed = AiBackendWriteTypedView::parse(backend.clone())?; + match typed { + AiBackendWriteTypedView::Local(local) => match local.provider_view()? { + LocalAiBackendWriteProviderView::LlamaCpp(llamacpp) => { + Ok(llamacpp.to_preflight_request()) + } + _ => Err("preflight only supports local llamacpp backends".to_string()), + }, + AiBackendWriteTypedView::Remote(_) => { + Err("preflight only supports local llamacpp backends".to_string()) + } + } +} + +fn select_preflight_nodes(node_uuids: &[String], verification_policy: &str) -> Vec { + let mut nodes = node_uuids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>(); + nodes.sort(); + nodes.dedup(); + match verification_policy { + "sampled_strict" if nodes.len() > 1 => { + let sample_size = nodes.len().min(3); + nodes.into_iter().take(sample_size).collect() + } + _ => nodes, + } +} + +async fn preflight_local_backend_node( + http_client: &reqwest::Client, + backend: &AiBackendWriteBody, + base_url: &str, +) -> Result { + let typed = AiBackendWriteTypedView::parse(backend.clone())?; + let local = match typed { + AiBackendWriteTypedView::Local(local) => local, + AiBackendWriteTypedView::Remote(_) => { + return Err("preflight only supports local llamacpp backends".to_string()) + } + }; + if !local.kind.supports_local_model_preflight() { + return Err("preflight only supports local llamacpp backends".to_string()); + } + let request = llamacpp_preflight_request_from_backend(&local.body)?; + let url = format!("{}/internal/ai/local-models/preflight", base_url); + let response = http_client + .post(&url) + .json(&request) + .send() + .await + .map_err(|error| error.to_string())?; + let status = response.status(); + let payload = response + .json::() + .await + .map_err(|error| error.to_string())?; + let details = match payload.details { + SpearletLocalModelPreflightDetailsResponse::Unavailable => { + AdminAiBackendLocalModelPreflightSourceResponse::Unavailable + } + SpearletLocalModelPreflightDetailsResponse::ModelPath { + effective_model_path, + } => AdminAiBackendLocalModelPreflightSourceResponse::ModelPath { + effective_model_path, + }, + SpearletLocalModelPreflightDetailsResponse::ModelUrl { + effective_model_path, + final_url, + http_status, + content_length, + } => AdminAiBackendLocalModelPreflightSourceResponse::ModelUrl { + effective_model_path, + final_url, + http_status, + content_length, + }, + }; + Ok(AdminAiBackendPreflightNodeResponse { + node_uuid: String::new(), + success: status.is_success() && payload.success, + details: AdminAiBackendPreflightDetailsResponse::LocalModel { + source: details, + }, + latency_ms: None, + message: payload.message, + }) +} + +async fn preflight_remote_backend_node( + http_client: &reqwest::Client, + backend: &AiBackendWriteBody, + base_url: &str, + requested_checks: Option<&Vec>, +) -> Result { + let typed = AiBackendWriteTypedView::parse(backend.clone())?; + let remote = match typed { + AiBackendWriteTypedView::Remote(remote) => remote, + AiBackendWriteTypedView::Local(_) => { + return Err( + "preflight only supports remote OpenAI-compatible or Ollama backends".to_string(), + ) + } + }; + if !remote.kind.supports_remote_preflight() { + return Err( + "preflight only supports remote OpenAI-compatible or Ollama backends".to_string(), + ); + } + match remote.provider_view()? { + RemoteAiBackendWriteProviderView::OpenAi(_) + | RemoteAiBackendWriteProviderView::Ollama(_) => {} + RemoteAiBackendWriteProviderView::Internal(_) => { + return Err( + "preflight only supports remote OpenAI-compatible or Ollama backends".to_string(), + ) + } + } + let resolved_spec = remote.resolved_spec()?; + let request = SpearletRemoteBackendPreflightRequest { + backend: SpearletRemoteBackendDraft { + provider: remote.provider.as_str().to_string(), + model: remote.body.model.trim().to_string(), + hosting: CanonicalBackendHosting::Remote.as_str().to_string(), + backend_kind: remote.kind.as_str().to_string(), + base_url: resolved_spec.base_url, + credential_ref: resolved_spec.credential_ref, + operations: Some(resolved_spec.operations), + features: Some(resolved_spec.features), + transports: Some(resolved_spec.transports), + }, + requested_checks: requested_checks.cloned(), + }; + let url = format!("{}/internal/ai/backends/preflight", base_url); + let response = http_client + .post(&url) + .json(&request) + .send() + .await + .map_err(|error| error.to_string())?; + let status = response.status(); + let payload = response + .json::() + .await + .map_err(|error| error.to_string())?; + let to_admin_checks = |checks: Vec| { + checks + .into_iter() + .map(|check| AdminAiBackendPreflightCheckResponse { + name: check.name, + ok: check.ok, + }) + .collect() + }; + let to_unknown_outcome = + |outcome: SpearletRemoteBackendPreflightGenericOutcomeResponse| match outcome { + SpearletRemoteBackendPreflightGenericOutcomeResponse::Success { + phase, + resolved_endpoint, + http_status, + checks, + auth_valid, + model_accessible, + } => AdminAiBackendRemoteProviderPreflightOutcomeResponse::Success { + phase, + resolved_endpoint, + http_status, + checks: to_admin_checks(checks), + auth_valid, + model_accessible, + }, + SpearletRemoteBackendPreflightGenericOutcomeResponse::Failure { + phase, + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + auth_valid, + model_accessible, + } => AdminAiBackendRemoteProviderPreflightOutcomeResponse::Failure { + phase, + error_code, + resolved_endpoint, + http_status, + provider_code, + checks: to_admin_checks(checks), + auth_valid, + model_accessible, + }, + }; + let result = match payload.result { + SpearletRemoteBackendPreflightResultResponse::OpenAiCompatible { outcome } => { + AdminAiBackendRemoteProviderPreflightResultResponse::OpenAiCompatible { + outcome: match outcome { + SpearletOpenAiCompatiblePreflightOutcomeResponse::ConnectivityVerified { + resolved_endpoint, + http_status, + checks, + } => AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse::ConnectivityVerified { + resolved_endpoint, + http_status, + checks: to_admin_checks(checks), + }, + SpearletOpenAiCompatiblePreflightOutcomeResponse::AuthVerified { + resolved_endpoint, + http_status, + checks, + auth_valid, + } => AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse::AuthVerified { + resolved_endpoint, + http_status, + checks: to_admin_checks(checks), + auth_valid, + }, + SpearletOpenAiCompatiblePreflightOutcomeResponse::ModelAccessVerified { + resolved_endpoint, + http_status, + checks, + auth_valid, + model_accessible, + } => { + AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse::ModelAccessVerified { + resolved_endpoint, + http_status, + checks: to_admin_checks(checks), + auth_valid, + model_accessible, + } + } + SpearletOpenAiCompatiblePreflightOutcomeResponse::ConnectivityFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + } => AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse::ConnectivityFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks: to_admin_checks(checks), + }, + SpearletOpenAiCompatiblePreflightOutcomeResponse::AuthFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + auth_valid, + } => AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse::AuthFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks: to_admin_checks(checks), + auth_valid, + }, + SpearletOpenAiCompatiblePreflightOutcomeResponse::ModelAccessFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + auth_valid, + model_accessible, + } => AdminAiBackendOpenAiCompatiblePreflightOutcomeResponse::ModelAccessFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks: to_admin_checks(checks), + auth_valid, + model_accessible, + }, + }, + } + } + SpearletRemoteBackendPreflightResultResponse::Ollama { outcome } => { + AdminAiBackendRemoteProviderPreflightResultResponse::Ollama { + outcome: match outcome { + SpearletOllamaPreflightOutcomeResponse::ConnectivityVerified { + resolved_endpoint, + http_status, + checks, + } => AdminAiBackendOllamaPreflightOutcomeResponse::ConnectivityVerified { + resolved_endpoint, + http_status, + checks: to_admin_checks(checks), + }, + SpearletOllamaPreflightOutcomeResponse::ModelAccessVerified { + resolved_endpoint, + http_status, + checks, + model_accessible, + } => AdminAiBackendOllamaPreflightOutcomeResponse::ModelAccessVerified { + resolved_endpoint, + http_status, + checks: to_admin_checks(checks), + model_accessible, + }, + SpearletOllamaPreflightOutcomeResponse::ConnectivityFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + } => AdminAiBackendOllamaPreflightOutcomeResponse::ConnectivityFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks: to_admin_checks(checks), + }, + SpearletOllamaPreflightOutcomeResponse::ModelAccessFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + model_accessible, + } => AdminAiBackendOllamaPreflightOutcomeResponse::ModelAccessFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks: to_admin_checks(checks), + model_accessible, + }, + }, + } + } + SpearletRemoteBackendPreflightResultResponse::Unknown { provider, outcome } => { + AdminAiBackendRemoteProviderPreflightResultResponse::Unknown { + provider, + outcome: to_unknown_outcome(outcome), + } + } + }; + Ok(AdminAiBackendPreflightNodeResponse { + node_uuid: String::new(), + success: status.is_success() && payload.success, + details: AdminAiBackendPreflightDetailsResponse::RemoteProvider { result }, + latency_ms: payload.latency_ms, + message: payload.message, + }) +} + +fn ai_backend_placement_proto_from_body( + body: AiBackendPlacementWriteBody, +) -> Result { + if body.backend_id.trim().is_empty() { + return Err("backend_id is required".to_string()); + } + if body.node_uuid.trim().is_empty() { + return Err("node_uuid is required".to_string()); + } + + Ok(crate::proto::sms::AiBackendPlacementRecord { + placement_id: body.placement_id.unwrap_or_default(), + backend_id: body.backend_id, + node_uuid: body.node_uuid, + desired_state: match body.desired_state.as_deref() { + Some(value) => parse_ai_backend_desired_state(value)? as i32, + None => crate::proto::sms::AiBackendDesiredState::Enabled as i32, + }, + weight_override: body.weight_override, + priority_override: body.priority_override, + generation: 0, + created_at_ms: 0, + updated_at_ms: 0, + }) +} + +fn parse_ai_backend_hosting(value: &str) -> Result { + match CanonicalBackendHosting::parse(value) { + CanonicalBackendHosting::Remote => Ok(CanonicalBackendHosting::Remote.to_proto_enum()), + CanonicalBackendHosting::Local => Ok(CanonicalBackendHosting::Local.to_proto_enum()), + CanonicalBackendHosting::Unknown(_) => Err("hosting must be remote or local".to_string()), + } +} + +/// Parse one admin backend provider and reject unsupported compatibility aliases. +/// 解析管理端 backend provider,并拒绝仍停留在兼容层的未建模 provider。 +fn parse_supported_ai_backend_provider(value: &str) -> Result { + match CanonicalBackendProvider::parse(value) { + CanonicalBackendProvider::Unknown(_) => { + Err(format!("unsupported backend provider: {}", value.trim())) + } + provider => Ok(provider), + } +} + +fn parse_ai_backend_desired_state( + value: &str, +) -> Result { + match CanonicalBackendDesiredState::parse(value) { + CanonicalBackendDesiredState::Enabled => { + Ok(CanonicalBackendDesiredState::Enabled.to_proto_enum()) + } + CanonicalBackendDesiredState::Disabled => { + Ok(CanonicalBackendDesiredState::Disabled.to_proto_enum()) + } + CanonicalBackendDesiredState::Unknown(_) => { + Err("desired_state must be enabled or disabled".to_string()) + } + } +} + +fn parse_ai_backend_management_mode( + value: &str, +) -> Result { + match CanonicalBackendManagementMode::parse(value) { + CanonicalBackendManagementMode::SmsRemote => { + Ok(CanonicalBackendManagementMode::SmsRemote.to_proto_enum()) + } + CanonicalBackendManagementMode::SmsLocal => { + Ok(CanonicalBackendManagementMode::SmsLocal.to_proto_enum()) + } + CanonicalBackendManagementMode::Unknown(_) => { + Err("management_mode must be sms_remote or sms_local".to_string()) + } + } +} + +fn default_ai_backend_management_mode( + hosting: crate::proto::sms::AiBackendHosting, +) -> crate::proto::sms::AiBackendManagementMode { + match CanonicalBackendManagementMode::default_for_hosting( + &CanonicalBackendHosting::from_proto_i32(hosting as i32), + ) { + CanonicalBackendManagementMode::SmsRemote => { + CanonicalBackendManagementMode::SmsRemote.to_proto_enum() + } + CanonicalBackendManagementMode::SmsLocal => { + CanonicalBackendManagementMode::SmsLocal.to_proto_enum() + } + CanonicalBackendManagementMode::Unknown(_) => { + crate::proto::sms::AiBackendManagementMode::Unspecified + } + } +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|inner| { + let trimmed = inner.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +#[cfg(test)] +mod tests { + use super::{ + ai_backend_proto_from_body, default_ai_backend_management_mode, + llamacpp_preflight_request_from_backend, parse_ai_backend_desired_state, + parse_ai_backend_management_mode, AiBackendLlamaCppLocalWriteBody, + AiBackendLocalProviderWriteBody, AiBackendOllamaRemoteWriteBody, + AiBackendOpenAiCompatibleRemoteWriteBody, AiBackendRemoteProviderWriteBody, + AiBackendSpecBody, AiBackendVllmLocalWriteBody, AiBackendWriteBody, + }; + + fn proto_struct_string_field<'a>( + value: Option<&'a prost_types::Struct>, + field_name: &str, + ) -> Option<&'a str> { + value + .and_then(|inner| inner.fields.get(field_name)) + .and_then(|field| field.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.as_str()), + _ => None, + }) + } + + #[test] + fn ai_backend_proto_from_body_normalizes_provider_and_kind_aliases() { + let record = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llama_cpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llama.cpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::LlamaCpp( + AiBackendLlamaCppLocalWriteBody { + model_url: None, + model_path: Some("/models/qwen.gguf".to_string()), + skip_download: None, + download_timeout_s: None, + server_mode: None, + server_cmd: None, + server_cmd_args: None, + threads: None, + ctx_size: None, + ready_probe: None, + start_timeout_s: None, + }, + )), + remote: None, + metadata: Some(serde_json::json!({})), + }, + "backend-1".to_string(), + ) + .expect("body should normalize aliases"); + + assert_eq!(record.provider, "llamacpp"); + assert_eq!(record.backend_kind, "llamacpp"); + assert_eq!( + record.spec.as_ref().expect("spec should exist").provider, + "llamacpp" + ); + assert_eq!( + record.spec.as_ref().expect("spec should exist").kind, + "llamacpp" + ); + } + + #[test] + fn desired_state_and_management_mode_use_canonical_parsers() { + assert_eq!( + parse_ai_backend_desired_state("enabled").expect("enabled should parse") as i32, + crate::proto::sms::AiBackendDesiredState::Enabled as i32 + ); + assert_eq!( + parse_ai_backend_management_mode("remote").expect("remote should parse") as i32, + crate::proto::sms::AiBackendManagementMode::SmsRemote as i32 + ); + assert_eq!( + default_ai_backend_management_mode(crate::proto::sms::AiBackendHosting::Local) as i32, + crate::proto::sms::AiBackendManagementMode::SmsLocal as i32 + ); + } + + #[test] + fn ai_backend_proto_from_body_normalizes_llamacpp_metadata_scalars() { + let record = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llamacpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::LlamaCpp( + AiBackendLlamaCppLocalWriteBody { + model_url: Some(" https://models.example.com/qwen.gguf ".to_string()), + model_path: None, + skip_download: Some(true), + download_timeout_s: Some(45), + server_mode: Some(" RAW ".to_string()), + server_cmd: None, + server_cmd_args: Some("--port 8080".to_string()), + threads: Some(8), + ctx_size: Some(4096), + ready_probe: Some(" HTTP ".to_string()), + start_timeout_s: None, + }, + )), + remote: None, + metadata: Some(serde_json::json!({ + "extra_key": "preserve-me" + })), + }, + "backend-1".to_string(), + ) + .expect("body should normalize metadata"); + + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "model_url"), + Some("https://models.example.com/qwen.gguf") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "skip_download"), + Some("true") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "download_timeout_s"), + Some("45") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "server_mode"), + Some("raw") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "threads"), + Some("8") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "ctx_size"), + Some("4096") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "ready_probe"), + Some("http") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "extra_key"), + Some("preserve-me") + ); + } + + #[test] + fn ai_backend_proto_from_body_rejects_raw_llamacpp_mode_without_args() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llamacpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::LlamaCpp( + AiBackendLlamaCppLocalWriteBody { + model_url: None, + model_path: None, + skip_download: None, + download_timeout_s: None, + server_mode: Some("raw".to_string()), + server_cmd: None, + server_cmd_args: None, + threads: None, + ctx_size: None, + ready_probe: None, + start_timeout_s: None, + }, + )), + remote: None, + metadata: Some(serde_json::json!({})), + }, + "backend-1".to_string(), + ) + .expect_err("raw mode without args should fail"); + + assert_eq!( + err, + "llamacpp metadata server_mode=raw requires server_cmd_args" + ); + } + + #[test] + fn llamacpp_preflight_request_accepts_typed_metadata_scalars() { + let request = llamacpp_preflight_request_from_backend(&AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llamacpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::LlamaCpp( + AiBackendLlamaCppLocalWriteBody { + model_url: Some("https://models.example.com/qwen.gguf".to_string()), + model_path: None, + skip_download: Some(false), + download_timeout_s: Some(30), + server_mode: None, + server_cmd: None, + server_cmd_args: None, + threads: None, + ctx_size: None, + ready_probe: None, + start_timeout_s: None, + }, + )), + remote: None, + metadata: Some(serde_json::json!({})), + }) + .expect("preflight request should parse typed metadata"); + + assert_eq!(request.provider, "llamacpp"); + assert_eq!(request.model, "qwen"); + assert_eq!( + request.model_url.as_deref(), + Some("https://models.example.com/qwen.gguf") + ); + assert_eq!(request.skip_download, Some(false)); + assert_eq!(request.download_timeout_s, Some(30)); + } + + #[test] + fn ai_backend_proto_from_body_normalizes_vllm_local_body() { + let record = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local vLLM".to_string(), + provider: "vllm".to_string(), + model: "qwen2.5".to_string(), + hosting: "local".to_string(), + backend_kind: "vllm".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some("http://127.0.0.1:8000".to_string()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::Vllm( + AiBackendVllmLocalWriteBody { + mode: Some(" OpenAI ".to_string()), + managed_externally: Some(false), + }, + )), + remote: None, + metadata: Some(serde_json::json!({ + "extra_key": "preserve-me" + })), + }, + "backend-2".to_string(), + ) + .expect("body should normalize vllm local input"); + + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "mode"), + Some("openai") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "managed_externally"), + Some("false") + ); + assert_eq!( + proto_struct_string_field(record.metadata.as_ref(), "extra_key"), + Some("preserve-me") + ); + } + + #[test] + fn ai_backend_proto_from_body_rejects_mismatched_local_provider_body() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llamacpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::Vllm( + AiBackendVllmLocalWriteBody { + mode: Some("openai".to_string()), + managed_externally: Some(false), + }, + )), + remote: None, + metadata: Some(serde_json::json!({})), + }, + "backend-3".to_string(), + ) + .expect_err("mismatched local provider body should fail"); + + assert_eq!( + err, + "local.provider_family must match backend provider llamacpp" + ); + } + + #[test] + fn ai_backend_proto_from_body_rejects_duplicate_local_llamacpp_sources() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llamacpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: Some(AiBackendLocalProviderWriteBody::LlamaCpp( + AiBackendLlamaCppLocalWriteBody { + model_url: Some("https://models.example.com/qwen.gguf".to_string()), + model_path: None, + skip_download: Some(false), + download_timeout_s: None, + server_mode: None, + server_cmd: None, + server_cmd_args: None, + threads: None, + ctx_size: None, + ready_probe: None, + start_timeout_s: None, + }, + )), + remote: None, + metadata: Some(serde_json::json!({ + "model_url": "https://duplicate.example.com/qwen.gguf" + })), + }, + "backend-local-dup".to_string(), + ) + .expect_err("duplicate local structured and metadata fields should fail"); + + assert_eq!( + err, + "local llamacpp structured input cannot be combined with metadata keys: model_url" + ); + } + + #[test] + fn ai_backend_proto_from_body_requires_local_structured_input_for_llamacpp() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Local llama.cpp".to_string(), + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + hosting: "local".to_string(), + backend_kind: "llamacpp".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some("http://127.0.0.1:8080/v1".to_string()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: None, + remote: None, + metadata: Some(serde_json::json!({ + "model_path": "/models/qwen.gguf" + })), + }, + "backend-local-required".to_string(), + ) + .expect_err("llamacpp should require structured local input"); + + assert_eq!( + err, + "local provider-specific input is required for backend provider llamacpp" + ); + } + + #[test] + fn ai_backend_proto_from_body_resolves_remote_openai_body() { + let record = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Remote OpenAI".to_string(), + provider: "openai".to_string(), + model: "gpt-4o-mini".to_string(), + hosting: "remote".to_string(), + backend_kind: "openai_chat_completion".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec![], + features: Some(vec![]), + transports: Some(vec![]), + weight: None, + priority: None, + }, + labels: None, + local: None, + remote: Some(AiBackendRemoteProviderWriteBody::OpenAiCompatible( + AiBackendOpenAiCompatibleRemoteWriteBody { + base_url: Some(" https://api.openai.com/v1 ".to_string()), + credential_ref: Some("openai-prod".to_string()), + operations: Some(vec!["chat_completions".to_string()]), + features: Some(vec!["streaming".to_string()]), + transports: Some(vec!["http".to_string()]), + }, + )), + metadata: Some(serde_json::json!({})), + }, + "backend-4".to_string(), + ) + .expect("body should resolve remote openai input"); + + let spec = record.spec.as_ref().expect("spec should exist"); + assert_eq!(spec.base_url, "https://api.openai.com/v1"); + assert_eq!(spec.operations, vec!["chat_completions"]); + assert_eq!(spec.features, vec!["streaming"]); + assert_eq!(spec.transports, vec!["http"]); + assert_eq!(record.credential_ref.as_deref(), Some("openai-prod")); + } + + #[test] + fn ai_backend_proto_from_body_rejects_mismatched_remote_provider_body() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Remote OpenAI".to_string(), + provider: "openai".to_string(), + model: "gpt-4o-mini".to_string(), + hosting: "remote".to_string(), + backend_kind: "openai_chat_completion".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some(String::new()), + operations: vec!["chat_completions".to_string()], + features: None, + transports: None, + weight: None, + priority: None, + }, + labels: None, + local: None, + remote: Some(AiBackendRemoteProviderWriteBody::Ollama( + AiBackendOllamaRemoteWriteBody { + base_url: Some("http://ollama.local:11434".to_string()), + operations: Some(vec!["chat_completions".to_string()]), + features: None, + transports: Some(vec!["http".to_string()]), + }, + )), + metadata: Some(serde_json::json!({})), + }, + "backend-5".to_string(), + ) + .expect_err("mismatched remote provider body should fail"); + + assert_eq!(err, "remote.provider_family must match backend provider openai"); + } + + #[test] + fn ai_backend_proto_from_body_rejects_duplicate_remote_openai_sources() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Remote OpenAI".to_string(), + provider: "openai".to_string(), + model: "gpt-4o-mini".to_string(), + hosting: "remote".to_string(), + backend_kind: "openai_chat_completion".to_string(), + management_mode: None, + credential_ref: Some("legacy-openai".to_string()), + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some("https://legacy.example.com/v1".to_string()), + operations: vec!["chat_completions".to_string()], + features: Some(vec!["streaming".to_string()]), + transports: Some(vec!["http".to_string()]), + weight: None, + priority: None, + }, + labels: None, + local: None, + remote: Some(AiBackendRemoteProviderWriteBody::OpenAiCompatible( + AiBackendOpenAiCompatibleRemoteWriteBody { + base_url: Some("https://api.openai.com/v1".to_string()), + credential_ref: Some("openai-prod".to_string()), + operations: Some(vec!["chat_completions".to_string()]), + features: Some(vec!["streaming".to_string()]), + transports: Some(vec!["http".to_string()]), + }, + )), + metadata: Some(serde_json::json!({})), + }, + "backend-remote-dup".to_string(), + ) + .expect_err("duplicate remote structured and legacy fields should fail"); + + assert_eq!( + err, + "remote openai structured input cannot be combined with legacy fields: spec.base_url, credential_ref, spec.operations, spec.features, spec.transports" + ); + } + + #[test] + fn ai_backend_proto_from_body_requires_remote_structured_input_for_openai() { + let err = ai_backend_proto_from_body( + AiBackendWriteBody { + display_name: "Remote OpenAI".to_string(), + provider: "openai".to_string(), + model: "gpt-4o-mini".to_string(), + hosting: "remote".to_string(), + backend_kind: "openai_chat_completion".to_string(), + management_mode: None, + credential_ref: None, + desired_state: None, + spec: AiBackendSpecBody { + base_url: Some("https://api.openai.com/v1".to_string()), + operations: vec!["chat_completions".to_string()], + features: Some(vec!["streaming".to_string()]), + transports: Some(vec!["http".to_string()]), + weight: None, + priority: None, + }, + labels: None, + local: None, + remote: None, + metadata: Some(serde_json::json!({})), + }, + "backend-remote-required".to_string(), + ) + .expect_err("openai should require structured remote input"); + + assert_eq!( + err, + "remote provider-specific input is required for backend provider openai" + ); + } +} diff --git a/src/sms/web_admin/credential_mcp_admin.rs b/src/sms/web_admin/credential_mcp_admin.rs new file mode 100644 index 00000000..c299b6ca --- /dev/null +++ b/src/sms/web_admin/credential_mcp_admin.rs @@ -0,0 +1,389 @@ +use axum::{extract::Path, Json}; +use serde::Deserialize; + +use crate::proto::sms::{DeleteCredentialRequest, ListCredentialsRequest, UpsertCredentialRequest}; +use crate::sms::admin_api::{ + credential_info_to_response, mcp_server_to_response, AdminCredentialListResponse, + AdminCredentialMutationResponse, AdminMcpServerDetailResponse, AdminMcpServerListResponse, + AdminRevisionMutationResponse, +}; +use crate::sms::gateway::GatewayState; + +#[derive(Deserialize)] +pub(crate) struct McpServerUpsertBody { + pub(crate) server_id: String, + pub(crate) display_name: Option, + pub(crate) transport: String, + pub(crate) stdio: Option, + pub(crate) http: Option, + pub(crate) tool_namespace: Option, + pub(crate) allowed_tools: Option>, + pub(crate) budgets: Option, + pub(crate) approval_policy: Option, +} + +#[derive(Deserialize)] +pub(crate) struct McpStdioBody { + command: String, + args: Option>, + env: Option>, + cwd: Option, +} + +#[derive(Deserialize)] +pub(crate) struct McpHttpBody { + url: String, + headers: Option>, + auth_ref: Option, +} + +#[derive(Deserialize)] +pub(crate) struct UpsertCredentialBody { + pub(crate) name: String, + pub(crate) secret: Option, + pub(crate) description: Option, + pub(crate) disabled: Option, +} + +#[derive(Deserialize)] +pub(crate) struct McpBudgetsBody { + tool_timeout_ms: Option, + max_concurrency: Option, + max_tool_output_bytes: Option, +} + +#[derive(Deserialize)] +pub(crate) struct McpApprovalPolicyBody { + default_policy: Option, + per_tool: Option>, +} + +pub(crate) async fn list_credentials_admin( + state: GatewayState, +) -> Json { + let mut client = state.admin_credential_client.clone(); + match client.list_credentials(ListCredentialsRequest {}).await { + Ok(resp) => { + let inner = resp.into_inner(); + let mut backend_client = state.ai_backend_control_plane_client.clone(); + let referenced_by_count = match backend_client + .list_ai_backends(crate::proto::sms::ListAiBackendsRequest { + limit: 0, + offset: 0, + q: String::new(), + hosting: String::new(), + desired_state: String::new(), + provider: String::new(), + model: String::new(), + }) + .await + { + Ok(resp) => { + let mut counts = std::collections::HashMap::::new(); + for backend in resp.into_inner().backends { + let credential_ref = backend.credential_ref.as_deref().unwrap_or("").trim(); + if credential_ref.is_empty() { + continue; + } + *counts.entry(credential_ref.to_string()).or_insert(0) += 1; + } + counts + } + Err(_) => std::collections::HashMap::new(), + }; + let credentials = inner + .credentials + .into_iter() + .map(|credential| { + let referenced = referenced_by_count + .get(&credential.name) + .copied() + .unwrap_or(0); + credential_info_to_response(credential, referenced) + }) + .collect::>(); + Json(AdminCredentialListResponse { + success: true, + revision: inner.revision, + credentials, + message: None, + }) + } + Err(error) => Json(AdminCredentialListResponse { + success: false, + revision: 0, + credentials: Vec::new(), + message: Some(error.to_string()), + }), + } +} + +pub(crate) async fn upsert_credential_admin( + state: GatewayState, + body: Json, +) -> Json { + let body = body.0; + if body.name.trim().is_empty() { + return Json(AdminCredentialMutationResponse { + success: false, + revision: 0, + deleted: None, + message: Some("name is required".to_string()), + }); + } + let mut client = state.admin_credential_client.clone(); + let response = match client + .upsert_credential(UpsertCredentialRequest { + name: body.name, + secret: body.secret.unwrap_or_default(), + description: body.description.unwrap_or_default(), + disabled: body.disabled.unwrap_or(false), + }) + .await + { + Ok(response) => response.into_inner(), + Err(error) => { + return Json(AdminCredentialMutationResponse { + success: false, + revision: 0, + deleted: None, + message: Some(error.to_string()), + }) + } + }; + Json(AdminCredentialMutationResponse { + success: true, + revision: response.revision, + deleted: None, + message: None, + }) +} + +pub(crate) async fn delete_credential_admin( + state: GatewayState, + path: Path, +) -> Json { + let name = path.0; + if name.trim().is_empty() { + return Json(AdminCredentialMutationResponse { + success: false, + revision: 0, + deleted: None, + message: Some("name is required".to_string()), + }); + } + let mut client = state.admin_credential_client.clone(); + let response = match client + .delete_credential(DeleteCredentialRequest { name }) + .await + { + Ok(response) => response.into_inner(), + Err(error) => { + return Json(AdminCredentialMutationResponse { + success: false, + revision: 0, + deleted: None, + message: Some(error.to_string()), + }) + } + }; + Json(AdminCredentialMutationResponse { + success: true, + revision: response.revision, + deleted: Some(response.deleted), + message: None, + }) +} + +pub(crate) async fn list_mcp_servers(state: GatewayState) -> Json { + let mut client = state.mcp_registry_client.clone(); + match client + .list_mcp_servers(crate::proto::sms::ListMcpServersRequest { since_revision: 0 }) + .await + { + Ok(response) => { + let inner = response.into_inner(); + let servers = inner + .servers + .into_iter() + .map(mcp_server_to_response) + .collect::>(); + Json(AdminMcpServerListResponse { + success: true, + revision: inner.revision, + servers, + message: None, + }) + } + Err(error) => Json(AdminMcpServerListResponse { + success: false, + revision: 0, + servers: Vec::new(), + message: Some(error.to_string()), + }), + } +} + +pub(crate) async fn get_mcp_server( + state: GatewayState, + path: Path, +) -> Json { + let server_id = path.0; + if server_id.trim().is_empty() { + return Json(AdminMcpServerDetailResponse { + success: true, + found: false, + server: None, + message: None, + }); + } + let mut client = state.mcp_registry_client.clone(); + let response = match client + .list_mcp_servers(crate::proto::sms::ListMcpServersRequest { since_revision: 0 }) + .await + { + Ok(response) => response.into_inner(), + Err(error) => { + return Json(AdminMcpServerDetailResponse { + success: false, + found: false, + server: None, + message: Some(error.to_string()), + }) + } + }; + + for server in response.servers { + if server.server_id != server_id { + continue; + } + return Json(AdminMcpServerDetailResponse { + success: true, + found: true, + server: Some(mcp_server_to_response(server)), + message: None, + }); + } + + Json(AdminMcpServerDetailResponse { + success: true, + found: false, + server: None, + message: None, + }) +} + +pub(crate) async fn upsert_mcp_server( + state: GatewayState, + body: Json, +) -> Json { + use crate::proto::sms::{ + McpApprovalPolicy, McpBudgets, McpHttpConfig, McpServerRecord, McpStdioConfig, McpTransport, + }; + + let body = body.0; + if body.server_id.trim().is_empty() { + return Json(AdminRevisionMutationResponse { + success: false, + revision: 0, + message: Some("server_id is required".to_string()), + }); + } + + let transport = match body.transport.as_str() { + "stdio" => McpTransport::Stdio as i32, + "streamable_http" => McpTransport::StreamableHttp as i32, + _ => { + return Json(AdminRevisionMutationResponse { + success: false, + revision: 0, + message: Some("invalid transport".to_string()), + }); + } + }; + + let stdio = body.stdio.map(|stdio| McpStdioConfig { + command: stdio.command, + args: stdio.args.unwrap_or_default(), + env: stdio.env.unwrap_or_default(), + cwd: stdio.cwd.unwrap_or_default(), + }); + let http = body.http.map(|http| McpHttpConfig { + url: http.url, + headers: http.headers.unwrap_or_default(), + auth_ref: http.auth_ref.unwrap_or_default(), + }); + let budgets = body.budgets.map(|budgets| McpBudgets { + tool_timeout_ms: budgets.tool_timeout_ms.unwrap_or(0), + max_concurrency: budgets.max_concurrency.unwrap_or(0), + max_tool_output_bytes: budgets.max_tool_output_bytes.unwrap_or(0), + }); + let approval_policy = body.approval_policy.map(|policy| McpApprovalPolicy { + default_policy: policy.default_policy.unwrap_or_default(), + per_tool: policy.per_tool.unwrap_or_default(), + }); + + let record = McpServerRecord { + server_id: body.server_id.trim().to_string(), + display_name: body.display_name.unwrap_or_default(), + transport, + stdio, + http, + tool_namespace: body.tool_namespace.unwrap_or_default(), + allowed_tools: body.allowed_tools.unwrap_or_default(), + approval_policy, + budgets, + updated_at_ms: 0, + }; + + let mut client = state.mcp_registry_client.clone(); + match client + .upsert_mcp_server(crate::proto::sms::UpsertMcpServerRequest { + record: Some(record), + }) + .await + { + Ok(response) => Json(AdminRevisionMutationResponse { + success: true, + revision: response.into_inner().revision, + message: None, + }), + Err(error) => Json(AdminRevisionMutationResponse { + success: false, + revision: 0, + message: Some(error.to_string()), + }), + } +} + +pub(crate) async fn delete_mcp_server( + state: GatewayState, + path: Path, +) -> Json { + let server_id = path.0; + if server_id.trim().is_empty() { + return Json(AdminRevisionMutationResponse { + success: false, + revision: 0, + message: Some("server_id is required".to_string()), + }); + } + let mut client = state.mcp_registry_client.clone(); + match client + .delete_mcp_server(crate::proto::sms::DeleteMcpServerRequest { + server_id: server_id.trim().to_string(), + }) + .await + { + Ok(response) => Json(AdminRevisionMutationResponse { + success: true, + revision: response.into_inner().revision, + message: None, + }), + Err(error) => Json(AdminRevisionMutationResponse { + success: false, + revision: 0, + message: Some(error.to_string()), + }), + } +} diff --git a/src/sms/web_admin/node_rpc.rs b/src/sms/web_admin/node_rpc.rs index d2691f20..ad0ad549 100644 --- a/src/sms/web_admin/node_rpc.rs +++ b/src/sms/web_admin/node_rpc.rs @@ -40,7 +40,9 @@ pub(super) async fn resolve_node_channel( }; let Some(channel) = connect_lazy_node_channel(&node.ip_address, node.port) else { - return Err(Json(json!({"success": false, "message": "invalid node url"}))); + return Err(Json( + json!({"success": false, "message": "invalid node url"}), + )); }; Ok(ResolvedNodeTarget { diff --git a/src/sms/web_admin/presenter.rs b/src/sms/web_admin/presenter.rs index d9380300..b67d4846 100644 --- a/src/sms/web_admin/presenter.rs +++ b/src/sms/web_admin/presenter.rs @@ -1,102 +1,6 @@ use serde_json::{json, Value}; -use crate::proto::sms::{ - CredentialInfo, Execution, ExecutionSummary, Instance, InstanceSummary, LogRef, - McpServerRecord, Node, NodeBackendSnapshot, NodeResource, -}; - -fn empty_string_as_optional(value: String) -> Option { - if value.is_empty() { - None - } else { - Some(value) - } -} - -pub(super) fn task_instance_row_json(summary: InstanceSummary, detail: Option<&Instance>) -> Value { - let summary_status = crate::sms::instance_status_to_public_str(summary.status).to_string(); - json!({ - "instance_id": summary.instance_id, - "task_id": detail.map(|inst| inst.task_id.clone()).unwrap_or_default(), - "node_uuid": summary.node_uuid, - "status": detail - .map(|inst| crate::sms::instance_status_to_public_str(inst.status).to_string()) - .unwrap_or(summary_status), - "created_at_ms": detail.map(|inst| inst.created_at_ms).unwrap_or_default(), - "updated_at_ms": detail.map(|inst| inst.updated_at_ms).unwrap_or_default(), - "last_seen_ms": detail.map(|inst| inst.last_seen_ms).unwrap_or(summary.last_seen_ms), - "current_execution_id": detail - .map(|inst| inst.current_execution_id.clone()) - .unwrap_or(summary.current_execution_id), - }) -} - -pub(super) fn instance_json(instance: Instance) -> Value { - json!({ - "instance_id": instance.instance_id, - "task_id": instance.task_id, - "node_uuid": instance.node_uuid, - "status": crate::sms::instance_status_to_public_str(instance.status), - "created_at_ms": instance.created_at_ms, - "updated_at_ms": instance.updated_at_ms, - "last_seen_ms": instance.last_seen_ms, - "current_execution_id": instance.current_execution_id, - "metadata": instance.metadata, - }) -} - -pub(super) fn instance_execution_summary_json(execution: ExecutionSummary) -> Value { - json!({ - "execution_id": execution.execution_id, - "task_id": execution.task_id, - "status": crate::sms::execution_status_to_public_str(execution.status), - "started_at_ms": execution.started_at_ms, - "completed_at_ms": execution.completed_at_ms, - "function_name": execution.function_name, - }) -} - -pub(super) fn execution_history_row_json(execution: Execution) -> Value { - json!({ - "execution_id": execution.execution_id, - "invocation_id": execution.invocation_id, - "task_id": execution.task_id, - "function_name": execution.function_name, - "node_uuid": execution.node_uuid, - "instance_id": execution.instance_id, - "status": crate::sms::execution_status_to_public_str(execution.status), - "started_at_ms": execution.started_at_ms, - "completed_at_ms": execution.completed_at_ms, - "updated_at_ms": execution.updated_at_ms, - }) -} - -fn log_ref_json(log_ref: LogRef) -> Value { - json!({ - "backend": log_ref.backend, - "uri_prefix": log_ref.uri_prefix, - "content_type": log_ref.content_type, - "compression": log_ref.compression, - }) -} - -pub(super) fn execution_detail_json(execution: Execution) -> Value { - let log_ref = execution.log_ref.map(log_ref_json); - json!({ - "execution_id": execution.execution_id, - "invocation_id": execution.invocation_id, - "task_id": execution.task_id, - "function_name": execution.function_name, - "node_uuid": execution.node_uuid, - "instance_id": execution.instance_id, - "status": crate::sms::execution_status_to_public_str(execution.status), - "started_at_ms": execution.started_at_ms, - "completed_at_ms": execution.completed_at_ms, - "updated_at_ms": execution.updated_at_ms, - "metadata": execution.metadata, - "log_ref": log_ref, - }) -} +use crate::proto::sms::NodeBackendSnapshot; pub(super) fn node_backends_snapshot_json(snapshot: Option<&NodeBackendSnapshot>) -> Value { let meta = snapshot.map(|s| { @@ -136,105 +40,3 @@ pub(super) fn node_backends_snapshot_json(snapshot: Option<&NodeBackendSnapshot> "snapshot": meta, }) } - -fn node_json(node: Node) -> Value { - json!({ - "uuid": node.uuid, - "ip_address": node.ip_address, - "port": node.port, - "status": node.status, - "last_heartbeat": node.last_heartbeat, - "registered_at": node.registered_at, - "metadata": node.metadata, - }) -} - -fn node_resource_json(resource: NodeResource) -> Value { - json!({ - "cpu_usage_percent": resource.cpu_usage_percent, - "memory_usage_percent": resource.memory_usage_percent, - "disk_usage_percent": resource.disk_usage_percent, - "total_memory_bytes": resource.total_memory_bytes, - "used_memory_bytes": resource.used_memory_bytes, - "available_memory_bytes": resource.available_memory_bytes, - }) -} - -pub(super) fn node_detail_json(node: Option, resource: Option) -> Value { - json!({ - "found": node.is_some(), - "node": node.map(node_json), - "resource": resource.map(node_resource_json), - }) -} - -pub(super) fn credential_info_json( - credential: CredentialInfo, - referenced_by_count: usize, -) -> Value { - json!({ - "name": credential.name, - "provider_kind": credential.provider_kind, - "version": credential.version, - "description": empty_string_as_optional(credential.description), - "disabled": credential.disabled, - "created_at_ms": credential.created_at_ms, - "updated_at_ms": credential.updated_at_ms, - "referenced_by_count": referenced_by_count, - }) -} - -fn mcp_stdio_json(record: &McpServerRecord) -> Option { - record.stdio.as_ref().map(|x| { - json!({ - "command": x.command, - "args": x.args, - "env": x.env, - "cwd": x.cwd, - }) - }) -} - -fn mcp_http_json(record: &McpServerRecord) -> Option { - record.http.as_ref().map(|x| { - json!({ - "url": x.url, - "headers": x.headers, - "auth_ref": x.auth_ref, - }) - }) -} - -fn mcp_approval_policy_json(record: &McpServerRecord) -> Option { - record.approval_policy.as_ref().map(|x| { - json!({ - "default_policy": x.default_policy, - "per_tool": x.per_tool, - }) - }) -} - -fn mcp_budgets_json(record: &McpServerRecord) -> Option { - record.budgets.as_ref().map(|x| { - json!({ - "tool_timeout_ms": x.tool_timeout_ms, - "max_concurrency": x.max_concurrency, - "max_tool_output_bytes": x.max_tool_output_bytes, - }) - }) -} - -pub(super) fn mcp_server_json(record: McpServerRecord) -> Value { - json!({ - "server_id": record.server_id, - "display_name": record.display_name, - "transport": record.transport, - "stdio": mcp_stdio_json(&record), - "http": mcp_http_json(&record), - "tool_namespace": record.tool_namespace, - "allowed_tools": record.allowed_tools, - "approval_policy": mcp_approval_policy_json(&record), - "budgets": mcp_budgets_json(&record), - "updated_at_ms": record.updated_at_ms, - }) -} diff --git a/src/sms/web_admin/router.rs b/src/sms/web_admin/router.rs index 2c8cef1f..44a51b2b 100644 --- a/src/sms/web_admin/router.rs +++ b/src/sms/web_admin/router.rs @@ -78,6 +78,15 @@ pub fn create_admin_router(state: GatewayState) -> Router { } }), ) + .route( + "/admin/api/ai-backends/preflight", + post({ + let state = state.clone(); + move |body: Json| { + super::preflight_ai_backend_admin(state.clone(), body) + } + }), + ) .route( "/admin/api/ai-backends/{backend_id}", get({ diff --git a/src/sms/web_admin/task_admin.rs b/src/sms/web_admin/task_admin.rs index 488d1387..9d59d026 100644 --- a/src/sms/web_admin/task_admin.rs +++ b/src/sms/web_admin/task_admin.rs @@ -2,10 +2,14 @@ use axum::{ extract::{Path, Query}, Json, }; -use serde_json::json; use crate::sms::gateway::GatewayState; use crate::sms::query_support::{collect_task_instances_bounded, instance_is_active_and_fresh}; +use crate::sms::task_api::{ + build_register_task_request, task_to_admin_detail_response, task_to_admin_summary_response, + task_to_deleted_snapshot, AdminDeletedTaskSnapshot, AdminTaskDetailResponse, + AdminTaskSummaryResponse, RegisterTaskSpec, TaskExecutableSpec, +}; use super::types::ListQuery; @@ -40,45 +44,6 @@ pub(crate) struct DeleteTaskBody { pub(crate) force: Option, } -fn task_execution_fields( - executable: Option, -) -> ( - String, - String, - String, - String, - Vec, - std::collections::HashMap, -) { - if let Some(exec) = executable { - let executable_type = match exec.r#type { - 1 => "binary", - 2 => "script", - 3 => "container", - 4 => "wasm", - 5 => "process", - _ => "unknown", - }; - ( - executable_type.to_string(), - exec.uri, - exec.name, - exec.checksum_sha256, - exec.args, - exec.env, - ) - } else { - ( - String::new(), - String::new(), - String::new(), - String::new(), - vec![], - std::collections::HashMap::new(), - ) - } -} - async fn task_replica_counts( state: &GatewayState, task_id: &str, @@ -89,8 +54,12 @@ async fn task_replica_counts( let now_ms = chrono::Utc::now().timestamp_millis(); for instance in collect_task_instances_bounded(&mut client, task_id, 100).await? { - if !instance_is_active_and_fresh(&state.config, instance.status, instance.last_seen_ms, now_ms) - { + if !instance_is_active_and_fresh( + &state.config, + instance.status, + instance.last_seen_ms, + now_ms, + ) { continue; } active_instances += 1; @@ -102,89 +71,30 @@ async fn task_replica_counts( Ok((active_instances, ready_instances)) } -fn task_summary_json( - task: crate::proto::sms::Task, - active_instances: usize, - ready_instances: usize, -) -> serde_json::Value { - let status = crate::sms::task_status_to_public_str(task.status); - let priority = crate::sms::task_priority_to_public_str(task.priority); - let (exec_type, exec_uri, exec_name, _, _, _) = task_execution_fields(task.executable); - let desired_replicas = task.desired_replicas; - - json!({ - "task_id": task.task_id, - "name": task.name, - "description": task.description, - "status": status, - "priority": priority, - "desired_replicas": desired_replicas, - "active_instances": active_instances, - "ready_instances": ready_instances, - "underprovisioned": active_instances < desired_replicas as usize, - "reconciling": active_instances < desired_replicas as usize, - "scheduling_strategy": task.scheduling_strategy, - "endpoint": task.endpoint, - "version": task.version, - "capabilities": task.capabilities, - "registered_at": task.registered_at, - "last_heartbeat": task.last_heartbeat, - "metadata": task.metadata, - "config": task.config, - "executable_type": exec_type, - "executable_uri": exec_uri, - "executable_name": exec_name, - "result_uris": task.result_uris, - "last_result_uri": task.last_result_uri, - "last_result_status": task.last_result_status, - "last_completed_at": task.last_completed_at, - "last_result_metadata": task.last_result_metadata, - "deletion_requested_at": task.deletion_requested_at, - "deletion_reason": task.deletion_reason, - }) +#[derive(serde::Serialize)] +pub(crate) struct AdminTaskListResponse { + pub(crate) tasks: Vec, + pub(crate) total_count: usize, } -fn task_detail_json(task: crate::proto::sms::Task) -> serde_json::Value { - let status = crate::sms::task_status_to_public_str(task.status); - let priority = crate::sms::task_priority_to_public_str(task.priority); - let (exec_type, exec_uri, exec_name, exec_sum, exec_args, exec_env) = - task_execution_fields(task.executable); +#[derive(serde::Serialize)] +pub(crate) struct AdminTaskDetailEnvelope { + pub(crate) found: bool, + pub(crate) task: Option, +} - json!({ - "task_id": task.task_id, - "name": task.name, - "description": task.description, - "status": status, - "priority": priority, - "desired_replicas": task.desired_replicas, - "scheduling_strategy": task.scheduling_strategy, - "endpoint": task.endpoint, - "version": task.version, - "capabilities": task.capabilities, - "registered_at": task.registered_at, - "last_heartbeat": task.last_heartbeat, - "metadata": task.metadata, - "config": task.config, - "executable_type": exec_type, - "executable_uri": exec_uri, - "executable_name": exec_name, - "executable_checksum": exec_sum, - "executable_args": exec_args, - "executable_env": exec_env, - "result_uris": task.result_uris, - "last_result_uri": task.last_result_uri, - "last_result_status": task.last_result_status, - "last_completed_at": task.last_completed_at, - "last_result_metadata": task.last_result_metadata, - "deletion_requested_at": task.deletion_requested_at, - "deletion_reason": task.deletion_reason, - }) +#[derive(serde::Serialize)] +pub(crate) struct AdminTaskMutationResponse { + pub(crate) success: bool, + pub(crate) message: String, + pub(crate) task_id: Option, + pub(crate) task: Option, } pub(crate) async fn list_tasks( state: GatewayState, Query(q): Query, -) -> Json { +) -> Json { use crate::proto::sms::ListTasksRequest; let mut client = state.task_client.clone(); @@ -205,26 +115,18 @@ pub(crate) async fn list_tasks( let counts = task_replica_counts(&state, &task.task_id) .await .unwrap_or((0, 0)); - list.push(task_summary_json(task, counts.0, counts.1)); + list.push(task_to_admin_summary_response(task, counts.0, counts.1)); } if let Some(qs) = q.q.as_ref().map(|s| s.to_lowercase()) { list.retain(|item| { - let id = item["task_id"].as_str().unwrap_or("").to_lowercase(); - let name = item["name"].as_str().unwrap_or("").to_lowercase(); - let endpoint = item["endpoint"].as_str().unwrap_or("").to_lowercase(); - let meta = item["metadata"].as_object(); - let meta_hit = meta - .map(|m| { - m.iter().any(|(k, v)| { - k.to_lowercase().contains(&qs) - || v.as_str().unwrap_or("").to_lowercase().contains(&qs) - }) - }) - .unwrap_or(false); - id.contains(&qs) - || name.contains(&qs) - || endpoint.contains(&qs) + let meta_hit = item + .metadata + .iter() + .any(|(k, v)| k.to_lowercase().contains(&qs) || v.to_lowercase().contains(&qs)); + item.task_id.to_lowercase().contains(&qs) + || item.name.to_lowercase().contains(&qs) + || item.endpoint.to_lowercase().contains(&qs) || meta_hit }); } @@ -243,8 +145,8 @@ pub(crate) async fn list_tasks( }; if !field.is_empty() { match field.as_str() { - "last_heartbeat" => list.sort_by_key(|i| i["last_heartbeat"].as_i64().unwrap_or(0)), - "registered_at" => list.sort_by_key(|i| i["registered_at"].as_i64().unwrap_or(0)), + "last_heartbeat" => list.sort_by_key(|item| item.last_heartbeat), + "registered_at" => list.sort_by_key(|item| item.registered_at), _ => {} } if !asc { @@ -264,14 +166,17 @@ pub(crate) async fn list_tasks( list.truncate(limit); } } - Json(json!({ "tasks": list, "total_count": total })) + Json(AdminTaskListResponse { + tasks: list, + total_count: total, + }) } pub(crate) async fn create_task( state: GatewayState, Json(body): Json, ) -> Json { - use crate::proto::sms::{ExecutableType, RegisterTaskRequest, TaskExecutable, TaskPriority}; + use crate::proto::sms::TaskPriority; let priority = match body.priority.as_ref().map(|s| s.to_ascii_lowercase()) { Some(s) if s == "low" => TaskPriority::Low as i32, @@ -280,58 +185,44 @@ pub(crate) async fn create_task( Some(s) if s == "unknown" => TaskPriority::Unknown as i32, _ => TaskPriority::Normal as i32, }; - let executable = body.executable.as_ref().map(|e| { - let executable_type = match e.r#type.as_ref().map(|s| s.to_ascii_lowercase()) { - Some(s) if s == "binary" => ExecutableType::Binary as i32, - Some(s) if s == "script" => ExecutableType::Script as i32, - Some(s) if s == "container" => ExecutableType::Container as i32, - Some(s) if s == "wasm" => ExecutableType::Wasm as i32, - Some(s) if s == "process" => ExecutableType::Process as i32, - _ => ExecutableType::Unknown as i32, - }; - TaskExecutable { - r#type: executable_type, - uri: e.uri.clone().unwrap_or_default(), - name: e.name.clone().unwrap_or_default(), - checksum_sha256: e.checksum_sha256.clone().unwrap_or_default(), - args: e.args.clone().unwrap_or_default(), - env: e.env.clone().unwrap_or_default(), - } - }); - let metadata = body.metadata.clone().unwrap_or_default(); - let scheduling_strategy = match body.scheduling_strategy.as_deref() { - Some("spread") | None => crate::proto::sms::TaskSchedulingStrategy::Spread as i32, - _ => crate::proto::sms::TaskSchedulingStrategy::Unknown as i32, - }; - let request = RegisterTaskRequest { + let request = build_register_task_request(RegisterTaskSpec { name: body.name, description: body.description.unwrap_or_default(), priority, + desired_replicas: body.desired_replicas.unwrap_or(1), + scheduling_strategy: body.scheduling_strategy, endpoint: body.endpoint, version: body.version, capabilities: body.capabilities.unwrap_or_default(), - metadata: metadata.clone(), + metadata: body.metadata.unwrap_or_default(), config: body.config.unwrap_or_default(), - executable, - desired_replicas: body.desired_replicas.unwrap_or(1), - scheduling_strategy, - }; + executable: body.executable.map(|executable| TaskExecutableSpec { + executable_type: executable.r#type.unwrap_or_default(), + uri: executable.uri.unwrap_or_default(), + name: executable.name, + checksum_sha256: executable.checksum_sha256, + args: executable.args, + env: executable.env, + }), + }); let mut client = state.task_client.clone(); match client.register_task(tonic::Request::new(request)).await { Ok(response) => { let inner = response.into_inner(); - Json( - json!({ "success": inner.success, "task_id": inner.task_id, "message": inner.message }), - ) + Json(serde_json::json!({ + "success": inner.success, + "task_id": inner.task_id, + "message": inner.message + })) } - Err(error) => Json(json!({ "success": false, "message": error.to_string() })), + Err(error) => Json(serde_json::json!({ "success": false, "message": error.to_string() })), } } pub(crate) async fn get_task_detail( state: GatewayState, Path(task_id): Path, -) -> Json { +) -> Json { use crate::proto::sms::GetTaskRequest; let mut client = state.task_client.clone(); @@ -339,15 +230,21 @@ pub(crate) async fn get_task_detail( Ok(response) => { let inner = response.into_inner(); if let Some(task) = inner.task { - Json(json!({ - "found": true, - "task": task_detail_json(task), - })) + Json(AdminTaskDetailEnvelope { + found: true, + task: Some(task_to_admin_detail_response(task)), + }) } else { - Json(json!({"found": false})) + Json(AdminTaskDetailEnvelope { + found: false, + task: None, + }) } } - Err(_) => Json(json!({"found": false})), + Err(_) => Json(AdminTaskDetailEnvelope { + found: false, + task: None, + }), } } @@ -355,7 +252,7 @@ pub(crate) async fn delete_task_admin( state: GatewayState, Path(task_id): Path, Json(body): Json, -) -> Json { +) -> Json { use crate::proto::sms::DeleteTaskRequest; let mut client = state.task_client.clone(); @@ -369,21 +266,18 @@ pub(crate) async fn delete_task_admin( { Ok(response) => { let inner = response.into_inner(); - let task = inner.task.map(|task| { - json!({ - "task_id": task.task_id, - "status": crate::sms::task_status_to_public_str(task.status), - "deletion_requested_at": task.deletion_requested_at, - "deletion_reason": task.deletion_reason, - }) - }); - Json(json!({ - "success": inner.success, - "message": inner.message, - "task_id": inner.task_id, - "task": task, - })) + Json(AdminTaskMutationResponse { + success: inner.success, + message: inner.message, + task_id: Some(inner.task_id), + task: inner.task.map(task_to_deleted_snapshot), + }) } - Err(error) => Json(json!({ "success": false, "message": error.to_string() })), + Err(error) => Json(AdminTaskMutationResponse { + success: false, + message: error.to_string(), + task_id: None, + task: None, + }), } } diff --git a/src/spearlet/ai/backend_assembly.rs b/src/spearlet/ai/backend_assembly.rs index a7b3b599..fdd731b0 100644 --- a/src/spearlet/ai/backend_assembly.rs +++ b/src/spearlet/ai/backend_assembly.rs @@ -1,16 +1,19 @@ use std::sync::Arc; +use crate::ai_backend_types::{ + CanonicalBackendHosting, CanonicalBackendKind, CanonicalBackendOrigin, CanonicalBackendProvider, +}; use crate::proto::sms::{BackendHosting, BackendInfo, BackendOrigin, BackendSpec, BackendStatus}; use crate::spearlet::ai::credential_resolver::CredentialResolver; -use crate::spearlet::config::AiBackendConfig; +use crate::spearlet::config::{ + AiBackendConfig, AiBackendProviderView, AiBackendTypedView, LocalBackendConfigView, + LocalBackendProviderView, RemoteBackendConfigView, RemoteBackendProviderView, +}; use crate::spearlet::execution::ai::backends::ollama_chat::OllamaChatBackendAdapter; use crate::spearlet::execution::ai::backends::openai_chat_completion::OpenAIChatCompletionBackendAdapter; use crate::spearlet::execution::ai::backends::openai_realtime_ws::OpenAIRealtimeWsBackendAdapter; use crate::spearlet::execution::ai::backends::stub::StubBackendAdapter; -use crate::spearlet::execution::ai::backends::{ - BackendAdapter, KIND_OLLAMA_CHAT, KIND_OPENAI_CHAT_COMPLETION, KIND_OPENAI_REALTIME_WS, - KIND_STUB, -}; +use crate::spearlet::execution::ai::backends::BackendAdapter; use crate::spearlet::execution::ai::ir::Operation; use crate::spearlet::execution::ai::router::capabilities::Capabilities; use crate::spearlet::execution::ai::router::registry::{BackendInstance, Hosting}; @@ -34,33 +37,21 @@ pub struct BackendSpecParts { } pub fn infer_provider(kind: &str) -> String { - let k = kind.trim(); - if k.starts_with("openai_") { - "openai".to_string() - } else if k == KIND_OLLAMA_CHAT { - "ollama".to_string() - } else if k == KIND_STUB { - "internal".to_string() - } else { - "unknown".to_string() - } + CanonicalBackendKind::parse(kind) + .inferred_provider() + .as_str() + .to_string() } pub fn parse_origin(v: Option<&str>) -> BackendOrigin { - match v.map(|s| s.trim().to_ascii_lowercase()) { - Some(s) if s == "sms" => BackendOrigin::Sms, - Some(s) if s == "static_config" => BackendOrigin::StaticConfig, - Some(s) if s == "local_controller" => BackendOrigin::LocalController, - _ => BackendOrigin::StaticConfig, - } + CanonicalBackendOrigin::parse_optional(v).to_proto_enum() } pub fn parse_hosting(s: &str) -> Hosting { - let v = s.trim().to_ascii_lowercase(); - match v.as_str() { - "local" => Hosting::Local, - "remote" => Hosting::Remote, - _ => Hosting::Unknown, + match CanonicalBackendHosting::parse(s) { + CanonicalBackendHosting::Local => Hosting::Local, + CanonicalBackendHosting::Remote => Hosting::Remote, + CanonicalBackendHosting::Unknown(_) => Hosting::Unknown, } } @@ -106,15 +97,56 @@ fn optional_string(v: &str) -> Option { } } -pub fn origin_to_config_value(origin: i32) -> Option { - match BackendOrigin::try_from(origin).ok() { - Some(BackendOrigin::Sms) => Some("sms".to_string()), - Some(BackendOrigin::StaticConfig) => Some("static_config".to_string()), - Some(BackendOrigin::LocalController) => Some("local_controller".to_string()), - _ => None, +/// Runtime backend family used to centralize capability and adapter selection. +/// 用于集中 capability 与 adapter 选择的运行时 backend 家族。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum RuntimeBackendFamily { + /// OpenAI chat-completions adapter family / OpenAI chat-completions 适配器家族 + OpenAiChatCompletion, + /// OpenAI realtime websocket adapter family / OpenAI realtime websocket 适配器家族 + OpenAiRealtimeWs, + /// Ollama chat adapter family / Ollama chat 适配器家族 + OllamaChat, + /// Internal stub adapter family / 内部 stub 适配器家族 + Stub, + /// Unsupported runtime backend family / 不支持的运行时 backend 家族 + Unknown(String), +} + +impl RuntimeBackendFamily { + /// Classify one runtime backend kind into a canonical adapter family. + /// 将运行时 backend kind 分类为规范 adapter 家族。 + fn from_kind(kind: &str) -> Self { + match CanonicalBackendKind::parse(kind) { + CanonicalBackendKind::OpenAiChatCompletion => Self::OpenAiChatCompletion, + CanonicalBackendKind::OpenAiRealtimeWs => Self::OpenAiRealtimeWs, + CanonicalBackendKind::OllamaChat => Self::OllamaChat, + CanonicalBackendKind::Stub => Self::Stub, + other => Self::Unknown(other.as_str().to_string()), + } + } + + /// Return the supported operation set for one runtime family when it is fixed. + /// 返回一个运行时家族在固定情况下支持的 operation 集合。 + fn supported_operations(&self) -> Option<&'static [Operation]> { + match self { + Self::OpenAiChatCompletion | Self::OllamaChat | Self::Stub => { + Some(&[Operation::ChatCompletions]) + } + Self::OpenAiRealtimeWs => Some(&[Operation::SpeechToText]), + Self::Unknown(_) => None, + } } } +pub fn origin_to_config_value(origin: i32) -> Option { + Some( + CanonicalBackendOrigin::from_proto_i32(origin) + .as_str() + .to_string(), + ) +} + pub fn hosting_to_config_value(hosting: i32) -> Option { match hosting { x if x == BackendHosting::Remote as i32 => Some("remote".to_string()), @@ -130,13 +162,15 @@ pub fn backend_spec_from_parts(parts: BackendSpecParts) -> BackendSpec { let provider = parts .provider .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(str::to_string) + .map(CanonicalBackendProvider::parse) + .filter(|provider| !matches!(provider, CanonicalBackendProvider::Unknown(_))) + .map(|provider| provider.as_str().to_string()) .unwrap_or_else(|| infer_provider(&parts.kind)); BackendSpec { name: parts.name, - kind: parts.kind, + kind: CanonicalBackendKind::parse(&parts.kind) + .as_str() + .to_string(), operations: parts.operations, features: parts.features, transports: parts.transports, @@ -180,6 +214,9 @@ pub fn available_backend_info(spec: BackendSpec) -> BackendInfo { /// Convert config-layer backend definition into the shared `BackendSpec`. /// 将配置层 backend 定义转换为统一的 `BackendSpec`。 pub fn backend_spec_from_config(b: &AiBackendConfig) -> BackendSpec { + if let Ok(view) = b.typed_view() { + return backend_spec_from_typed_config(&view); + } let hosting = resolve_hosting(b.hosting.as_deref()); let origin = parse_origin(b.origin.as_deref()); backend_spec_from_parts(BackendSpecParts { @@ -200,6 +237,130 @@ pub fn backend_spec_from_config(b: &AiBackendConfig) -> BackendSpec { }) } +/// Convert typed config view into the shared `BackendSpec`. +/// 将强类型配置视图转换为统一的 `BackendSpec`。 +pub fn backend_spec_from_typed_config(view: &AiBackendTypedView) -> BackendSpec { + match view.provider_view() { + AiBackendProviderView::Local(local) => backend_spec_from_local_provider_typed_config(local), + AiBackendProviderView::Remote(remote) => { + backend_spec_from_remote_provider_typed_config(remote) + } + } +} + +fn backend_spec_from_local_provider_typed_config( + view: LocalBackendProviderView<'_>, +) -> BackendSpec { + match view { + LocalBackendProviderView::Ollama(local) => { + backend_spec_from_local_ollama_typed_config(local) + } + LocalBackendProviderView::LlamaCpp(local) => { + backend_spec_from_local_llamacpp_typed_config(local) + } + LocalBackendProviderView::Vllm(local) => backend_spec_from_local_vllm_typed_config(local), + LocalBackendProviderView::Internal(local) => { + backend_spec_from_local_internal_typed_config(local) + } + LocalBackendProviderView::Unknown(local) => { + backend_spec_from_local_unknown_typed_config(local) + } + } +} + +fn backend_spec_from_local_typed_config(view: &LocalBackendConfigView) -> BackendSpec { + backend_spec_from_parts(BackendSpecParts { + name: view.common.name.clone(), + kind: view.common.kind.as_str().to_string(), + operations: view.common.ops.clone(), + features: view.common.features.clone(), + transports: view.common.transports.clone(), + weight: view.common.weight, + priority: view.common.priority, + base_url: view.base_url.clone(), + provider: Some(view.common.provider.as_str().to_string()), + model: view.model.clone(), + hosting: parse_hosting("local"), + credential_ref: view.credential_ref.clone(), + origin: view.common.origin.to_proto_enum(), + deployment_id: view.common.deployment_id.clone(), + }) +} + +fn backend_spec_from_local_ollama_typed_config(view: &LocalBackendConfigView) -> BackendSpec { + backend_spec_from_local_typed_config(view) +} + +fn backend_spec_from_local_llamacpp_typed_config(view: &LocalBackendConfigView) -> BackendSpec { + backend_spec_from_local_typed_config(view) +} + +fn backend_spec_from_local_vllm_typed_config(view: &LocalBackendConfigView) -> BackendSpec { + backend_spec_from_local_typed_config(view) +} + +fn backend_spec_from_local_internal_typed_config(view: &LocalBackendConfigView) -> BackendSpec { + backend_spec_from_local_typed_config(view) +} + +fn backend_spec_from_local_unknown_typed_config(view: &LocalBackendConfigView) -> BackendSpec { + backend_spec_from_local_typed_config(view) +} + +fn backend_spec_from_remote_provider_typed_config( + view: RemoteBackendProviderView<'_>, +) -> BackendSpec { + match view { + RemoteBackendProviderView::OpenAi(remote) => { + backend_spec_from_remote_openai_typed_config(remote) + } + RemoteBackendProviderView::Ollama(remote) => { + backend_spec_from_remote_ollama_typed_config(remote) + } + RemoteBackendProviderView::Internal(remote) => { + backend_spec_from_remote_internal_typed_config(remote) + } + RemoteBackendProviderView::Unknown(remote) => { + backend_spec_from_remote_unknown_typed_config(remote) + } + } +} + +fn backend_spec_from_remote_typed_config(view: &RemoteBackendConfigView) -> BackendSpec { + backend_spec_from_parts(BackendSpecParts { + name: view.common.name.clone(), + kind: view.common.kind.as_str().to_string(), + operations: view.common.ops.clone(), + features: view.common.features.clone(), + transports: view.common.transports.clone(), + weight: view.common.weight, + priority: view.common.priority, + base_url: view.base_url.clone(), + provider: Some(view.common.provider.as_str().to_string()), + model: view.model.clone(), + hosting: parse_hosting("remote"), + credential_ref: view.credential_ref.clone(), + origin: view.common.origin.to_proto_enum(), + deployment_id: view.common.deployment_id.clone(), + }) +} + +fn backend_spec_from_remote_openai_typed_config(view: &RemoteBackendConfigView) -> BackendSpec { + backend_spec_from_remote_typed_config(view) +} + +fn backend_spec_from_remote_ollama_typed_config(view: &RemoteBackendConfigView) -> BackendSpec { + backend_spec_from_remote_typed_config(view) +} + +fn backend_spec_from_remote_internal_typed_config(view: &RemoteBackendConfigView) -> BackendSpec { + backend_spec_from_remote_typed_config(view) +} + +fn backend_spec_from_remote_unknown_typed_config(view: &RemoteBackendConfigView) -> BackendSpec { + backend_spec_from_remote_typed_config(view) +} + /// Convert the shared `BackendSpec` into config-layer backend definition. /// 将统一的 `BackendSpec` 转回配置层 backend 定义。 pub fn ai_backend_config_from_spec(spec: BackendSpec) -> AiBackendConfig { @@ -233,13 +394,7 @@ pub fn parse_operation(s: &str) -> Option { } fn supported_operations_for_kind(kind: &str) -> Option<&'static [Operation]> { - match kind { - KIND_OPENAI_CHAT_COMPLETION | KIND_OLLAMA_CHAT | KIND_STUB => { - Some(&[Operation::ChatCompletions]) - } - KIND_OPENAI_REALTIME_WS => Some(&[Operation::SpeechToText]), - _ => None, - } + RuntimeBackendFamily::from_kind(kind).supported_operations() } fn kind_supports_operation(kind: &str, operation: &Operation) -> bool { @@ -323,8 +478,8 @@ pub fn build_adapter_from_spec( } else { Some(spec.credential_ref.as_str()) }; - match spec.kind.as_str() { - KIND_OPENAI_CHAT_COMPLETION => { + match RuntimeBackendFamily::from_kind(&spec.kind) { + RuntimeBackendFamily::OpenAiChatCompletion => { let mut a = OpenAIChatCompletionBackendAdapter::new( spec.name.clone(), spec.base_url.clone(), @@ -336,7 +491,7 @@ pub fn build_adapter_from_spec( } Some(Arc::new(a)) } - KIND_OPENAI_REALTIME_WS => { + RuntimeBackendFamily::OpenAiRealtimeWs => { Some(Arc::new(OpenAIRealtimeWsBackendAdapter::new( spec.name.clone(), spec.base_url.clone(), @@ -344,7 +499,7 @@ pub fn build_adapter_from_spec( credential_resolver.cloned(), ))) } - KIND_OLLAMA_CHAT => Some(Arc::new(OllamaChatBackendAdapter::new( + RuntimeBackendFamily::OllamaChat => Some(Arc::new(OllamaChatBackendAdapter::new( spec.name.clone(), spec.base_url.clone(), if spec.model.trim().is_empty() { @@ -353,13 +508,13 @@ pub fn build_adapter_from_spec( Some(spec.model.clone()) }, ))), - KIND_STUB => { + RuntimeBackendFamily::Stub => { if !allow_stub { return None; } Some(Arc::new(StubBackendAdapter::new(&spec.name))) } - _ => None, + RuntimeBackendFamily::Unknown(_) => None, } } @@ -367,6 +522,52 @@ pub fn build_adapter_from_spec( mod tests { use super::*; use crate::proto::sms::BackendOrigin; + use crate::spearlet::config::{ + AiBackendCommonView, AiBackendTypedView, LocalBackendConfigView, RemoteBackendConfigView, + }; + + #[test] + fn runtime_backend_family_classifies_supported_runtime_kinds() { + assert_eq!( + RuntimeBackendFamily::from_kind("OPENAI_CHAT_COMPLETION"), + RuntimeBackendFamily::OpenAiChatCompletion + ); + assert_eq!( + RuntimeBackendFamily::from_kind("openai_realtime_ws"), + RuntimeBackendFamily::OpenAiRealtimeWs + ); + assert_eq!( + RuntimeBackendFamily::from_kind("ollama_chat"), + RuntimeBackendFamily::OllamaChat + ); + assert_eq!( + RuntimeBackendFamily::from_kind("stub"), + RuntimeBackendFamily::Stub + ); + } + + #[test] + fn capabilities_from_spec_uses_runtime_family_selector() { + let spec = backend_spec_from_parts(BackendSpecParts { + name: "rt".to_string(), + kind: "openai_realtime_ws".to_string(), + operations: vec!["speech_to_text".to_string()], + features: vec![], + transports: vec!["websocket".to_string()], + weight: 100, + priority: 0, + base_url: "https://api.openai.com/v1".to_string(), + provider: None, + model: None, + hosting: Hosting::Remote, + credential_ref: None, + origin: BackendOrigin::Sms, + deployment_id: None, + }); + + let capabilities = capabilities_from_spec(&spec).expect("capabilities should exist"); + assert_eq!(capabilities.ops, vec![Operation::SpeechToText]); + } #[test] fn build_instance_from_spec_infers_provider_and_hosting() { @@ -397,6 +598,94 @@ mod tests { assert_eq!(inst.spec.kind, "openai_realtime_ws"); } + #[test] + fn backend_spec_from_parts_normalizes_provider_and_kind_aliases() { + let spec = backend_spec_from_parts(BackendSpecParts { + name: "local-llama".to_string(), + kind: "llama.cpp".to_string(), + operations: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + weight: 100, + priority: 0, + base_url: String::new(), + provider: Some("llama_cpp".to_string()), + model: Some("qwen".to_string()), + hosting: Hosting::Local, + credential_ref: None, + origin: BackendOrigin::StaticConfig, + deployment_id: None, + }); + + assert_eq!(spec.kind, "llamacpp"); + assert_eq!(spec.provider, "llamacpp"); + } + + #[test] + fn parse_origin_and_config_value_use_canonical_origin_helper() { + assert_eq!( + parse_origin(Some("local_controller")), + BackendOrigin::LocalController + ); + assert_eq!( + origin_to_config_value(BackendOrigin::Sms as i32), + Some("sms".to_string()) + ); + } + + #[test] + fn backend_spec_from_typed_config_supports_local_variant() { + let spec = + backend_spec_from_typed_config(&AiBackendTypedView::Local(LocalBackendConfigView { + common: AiBackendCommonView { + name: "ollama-local".to_string(), + kind: CanonicalBackendKind::parse("ollama_chat"), + provider: CanonicalBackendProvider::parse("ollama"), + origin: CanonicalBackendOrigin::parse_optional(Some("local_controller")), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }, + model: Some("llama3.1:8b".to_string()), + credential_ref: None, + base_url: "http://127.0.0.1:11434".to_string(), + })); + + assert_eq!(spec.kind, "ollama_chat"); + assert_eq!(spec.provider, "ollama"); + assert_eq!(spec.hosting, BackendHosting::NodeLocal as i32); + } + + #[test] + fn backend_spec_from_typed_config_supports_remote_variant() { + let spec = + backend_spec_from_typed_config(&AiBackendTypedView::Remote(RemoteBackendConfigView { + common: AiBackendCommonView { + name: "openai-remote".to_string(), + kind: CanonicalBackendKind::parse("openai_chat_completion"), + provider: CanonicalBackendProvider::parse("openai"), + origin: CanonicalBackendOrigin::parse_optional(Some("sms")), + deployment_id: Some("deploy-1".to_string()), + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }, + model: Some("gpt-4o-mini".to_string()), + credential_ref: Some("openai-key".to_string()), + base_url: "https://api.openai.com/v1".to_string(), + })); + + assert_eq!(spec.kind, "openai_chat_completion"); + assert_eq!(spec.provider, "openai"); + assert_eq!(spec.hosting, BackendHosting::Remote as i32); + assert_eq!(spec.credential_ref, "openai-key"); + } + #[test] fn parse_operation_rejects_unimplemented_realtime_voice() { assert_eq!(parse_operation("realtime_voice"), None); @@ -428,4 +717,26 @@ mod tests { assert!(inst.is_none()); } + #[test] + fn build_adapter_from_spec_respects_stub_family_gate() { + let spec = backend_spec_from_parts(BackendSpecParts { + name: "stub-backend".to_string(), + kind: "stub".to_string(), + operations: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["memory".to_string()], + weight: 100, + priority: 0, + base_url: String::new(), + provider: Some("internal".to_string()), + model: None, + hosting: Hosting::Local, + credential_ref: None, + origin: BackendOrigin::StaticConfig, + deployment_id: None, + }); + + assert!(build_adapter_from_spec(&spec, None, false).is_none()); + assert!(build_adapter_from_spec(&spec, None, true).is_some()); + } } diff --git a/src/spearlet/ai/backend_assignment_controller.rs b/src/spearlet/ai/backend_assignment_controller.rs index 316687af..08076041 100644 --- a/src/spearlet/ai/backend_assignment_controller.rs +++ b/src/spearlet/ai/backend_assignment_controller.rs @@ -15,20 +15,26 @@ use tonic::transport::Channel; use crate::proto::sms::ai_backend_control_plane_service_client::AiBackendControlPlaneServiceClient; use crate::proto::sms::{ - AiBackendHosting, AiBackendNodeStatus, AiBackendNodeStatusRecord, - BackendInfo, ListAiBackendAssignmentsRequest, - ReportAiBackendNodeStatusesRequest, ResolvedAiBackendAssignment, + AiBackendHosting, AiBackendNodeStatus, AiBackendNodeStatusRecord, BackendInfo, + ListAiBackendAssignmentsRequest, ReportAiBackendNodeStatusesRequest, + ResolvedAiBackendAssignment, }; use crate::spearlet::ai::backend_assembly::{apply_runtime_overrides, available_backend_info}; +use crate::spearlet::ai::credential_resolver::CredentialResolver; use crate::spearlet::ai::dynamic_backend_registry::{ global_dynamic_backends, DynamicBackendSource, }; +use crate::spearlet::ai::remote_preflight::{default_requested_checks, preflight_remote_backend}; use crate::spearlet::backend_reporter::BackendReportTrigger; use crate::spearlet::config::SpearletConfig; use crate::spearlet::controller::Controller; use crate::spearlet::local_models::llamacpp::LlamaCppSupervisor; -use crate::spearlet::local_models::provider::{resolve_local_provider_kind, LocalProviderKind}; -use crate::spearlet::local_models::vllm::{VllmPreparedAssignment, VllmSupervisor}; +use crate::spearlet::local_models::provider::{ + resolve_local_provider_kind_for_assignment, LocalProviderKind, +}; +use crate::spearlet::local_models::vllm::{ + VllmPreparedAssignment, VllmSupervisor, +}; /// Controller that materializes control-plane assignments into runtime backends. /// 将控制面 assignment 落地为运行时 backend 的控制器。 @@ -45,6 +51,92 @@ pub struct BackendAssignmentController { self_weak: Weak, } +/// Typed local runtime family derived from one local assignment. +/// 从单个 local assignment 推导出的强类型运行时家族。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum LocalAssignmentRuntimeFamily { + /// llama.cpp managed local runtime / llama.cpp 托管本地运行时 + LlamaCpp, + /// vLLM local runtime family / vLLM 本地运行时家族 + Vllm, + /// Unsupported local runtime family / 不支持的本地运行时家族 + Unsupported(String), +} + +impl LocalAssignmentRuntimeFamily { + /// Classify one local assignment into a runtime family. + /// 将一个 local assignment 分类为运行时家族。 + fn from_assignment( + backend: &crate::proto::sms::AiBackendRecord, + spec: &crate::proto::sms::BackendSpec, + ) -> Self { + match resolve_local_provider_kind_for_assignment( + &backend.backend_kind, + &spec.kind, + &spec.provider, + ) { + LocalProviderKind::LlamaCpp => Self::LlamaCpp, + LocalProviderKind::Vllm => Self::Vllm, + LocalProviderKind::Unsupported(kind) => Self::Unsupported(kind), + } + } +} + +/// Typed llama.cpp assignment params derived from backend metadata. +/// 从 backend metadata 派生的强类型 llama.cpp assignment 参数。 +#[derive(Debug, Clone)] +struct LlamaCppAssignmentParams { + raw: std::collections::HashMap, + should_pull: bool, +} + +impl LlamaCppAssignmentParams { + /// Build typed llama.cpp params from optional backend metadata. + /// 从可选 backend metadata 构建强类型 llama.cpp 参数。 + fn from_metadata(metadata: Option<&prost_types::Struct>) -> Self { + let raw = metadata_to_string_map(metadata); + let should_pull = compute_llamacpp_should_pull(&raw); + Self { raw, should_pull } + } + + /// Borrow the raw metadata map for downstream supervisor calls. + /// 为下游 supervisor 调用借出原始 metadata 映射。 + fn raw(&self) -> &std::collections::HashMap { + &self.raw + } + + /// Whether controller reconcile expects a pull/download stage. + /// 控制器收敛是否预期会经过拉取/下载阶段。 + fn should_pull(&self) -> bool { + self.should_pull + } +} + +/// Typed vLLM assignment params derived from backend metadata. +/// 从 backend metadata 派生的强类型 vLLM assignment 参数。 +#[derive(Debug, Clone)] +struct VllmAssignmentParams { + raw: std::collections::HashMap, +} + +impl VllmAssignmentParams { + /// Build typed vLLM params from spec and optional backend metadata. + /// 基于 spec 与可选 backend metadata 构建强类型 vLLM 参数。 + fn from_spec_and_metadata( + _spec: &crate::proto::sms::BackendSpec, + metadata: Option<&prost_types::Struct>, + ) -> Self { + let raw = metadata_to_string_map(metadata); + Self { raw } + } + + /// Borrow the raw metadata map for downstream supervisor calls. + /// 为下游 supervisor 调用借出原始 metadata 映射。 + fn raw(&self) -> &std::collections::HashMap { + &self.raw + } +} + impl BackendAssignmentController { /// Create a new assignment controller. /// 创建新的 assignment 控制器。 @@ -171,7 +263,8 @@ impl BackendAssignmentController { } self.llamacpp.stop_removed(&live_local_ids).await; - global_dynamic_backends().set_backends(DynamicBackendSource::AiControlPlane, runtime_backends); + global_dynamic_backends() + .set_backends(DynamicBackendSource::AiControlPlane, runtime_backends); self.backend_report_trigger.trigger(); client @@ -199,8 +292,8 @@ impl BackendAssignmentController { let spec = backend.spec.as_ref()?; let now_ms = Utc::now().timestamp_millis(); - let hosting = AiBackendHosting::try_from(backend.hosting) - .unwrap_or(AiBackendHosting::Unspecified); + let hosting = + AiBackendHosting::try_from(backend.hosting).unwrap_or(AiBackendHosting::Unspecified); if hosting == AiBackendHosting::Remote { let mut runtime_spec = spec.clone(); @@ -209,34 +302,71 @@ impl BackendAssignmentController { placement.weight_override, placement.priority_override, ); - - runtime_backends.push(available_backend_info(runtime_spec.clone())); - - return Some(AiBackendNodeStatusRecord { - backend_id: backend.backend_id.clone(), - node_uuid: node_uuid.to_string(), - observed_generation: backend.generation, - status: AiBackendNodeStatus::Ready as i32, - status_reason: String::new(), - runtime_backend_name: runtime_spec.name.clone(), - endpoint: runtime_spec.base_url.clone(), - available: true, - operations: runtime_spec.operations.clone(), - features: runtime_spec.features.clone(), - transports: runtime_spec.transports.clone(), - last_heartbeat_at_ms: now_ms, - }); + let resolver = crate::spearlet::ai::credential_resolver::global() + .unwrap_or_else(|| CredentialResolver::from_config(self.config.as_ref())); + let requested_checks = default_requested_checks(&runtime_spec); + let report = + preflight_remote_backend(&self.http, &resolver, &runtime_spec, &requested_checks) + .await; + if report.success { + runtime_backends.push(available_backend_info(runtime_spec.clone())); + return Some(ready_status_for_runtime_spec( + backend, + &runtime_spec, + node_uuid, + now_ms, + )); + } + let report_message = report.message; + let failed_status = if report + .error_code + .as_deref() + .is_some_and(|code| code == "credential_not_synced") + { + pending_status_for_backend( + backend, + &runtime_spec, + node_uuid, + now_ms, + report_message.clone(), + ) + } else { + error_status_for_backend(backend, &runtime_spec, node_uuid, now_ms, report_message) + }; + return Some(failed_status); } let deployment_id = backend.backend_id.clone(); live_local_ids.insert(deployment_id.clone()); - match resolve_local_provider_kind([ - backend.backend_kind.as_str(), - spec.kind.as_str(), - spec.provider.as_str(), - ]) { - LocalProviderKind::LlamaCpp => { + self.reconcile_local_assignment( + LocalAssignmentRuntimeFamily::from_assignment(backend, spec), + client, + backend, + placement, + spec, + runtime_backends, + node_uuid, + now_ms, + ) + .await + } + + /// Reconcile one local assignment through the typed runtime family selector. + /// 通过强类型运行时家族选择器收敛单个 local assignment。 + async fn reconcile_local_assignment( + &self, + family: LocalAssignmentRuntimeFamily, + client: &mut AiBackendControlPlaneServiceClient, + backend: &crate::proto::sms::AiBackendRecord, + placement: &crate::proto::sms::AiBackendPlacementRecord, + spec: &crate::proto::sms::BackendSpec, + runtime_backends: &mut Vec, + node_uuid: &str, + now_ms: i64, + ) -> Option { + match family { + LocalAssignmentRuntimeFamily::LlamaCpp => { self.reconcile_llamacpp_assignment( client, backend, @@ -248,10 +378,15 @@ impl BackendAssignmentController { ) .await } - LocalProviderKind::Vllm => { - self.reconcile_vllm_assignment(backend, placement, spec, runtime_backends, node_uuid, now_ms) - } - LocalProviderKind::Unsupported(kind) => Some(pending_status_for_backend( + LocalAssignmentRuntimeFamily::Vllm => self.reconcile_vllm_assignment( + backend, + placement, + spec, + runtime_backends, + node_uuid, + now_ms, + ), + LocalAssignmentRuntimeFamily::Unsupported(kind) => Some(pending_status_for_backend( backend, spec, node_uuid, @@ -288,9 +423,9 @@ impl BackendAssignmentController { } let deployment_id = backend.backend_id.clone(); - let params = metadata_to_string_map(backend.metadata.as_ref()); - let should_pull = llamacpp_should_pull(¶ms); - let spec_key = local_assignment_spec_key(backend, placement, ¶ms); + let params = LlamaCppAssignmentParams::from_metadata(backend.metadata.as_ref()); + let should_pull = params.should_pull(); + let spec_key = local_assignment_spec_key(backend, placement, params.raw()); let initial_reason = if should_pull { "pulling" } else { "starting" }.to_string(); self.report_single_status( client, @@ -307,7 +442,7 @@ impl BackendAssignmentController { &deployment_id, &spec_key, model, - ¶ms + params.raw() )); loop { tokio::select! { @@ -374,21 +509,11 @@ impl BackendAssignmentController { node_uuid: &str, now_ms: i64, ) -> Option { - let params = metadata_to_string_map(backend.metadata.as_ref()); - match self.vllm.prepare_assignment(spec, ¶ms) { - VllmPreparedAssignment::ExternalEndpoint => { - let endpoint = spec.base_url.trim(); - if endpoint.is_empty() { - return Some(error_status_for_backend( - backend, - spec, - node_uuid, - now_ms, - "vLLM external_endpoint mode requires spec.base_url".to_string(), - )); - } - + let params = VllmAssignmentParams::from_spec_and_metadata(spec, backend.metadata.as_ref()); + match self.vllm.prepare_assignment(spec, params.raw()) { + VllmPreparedAssignment::ExternalEndpoint(endpoint) => { let mut runtime_spec = spec.clone(); + runtime_spec.base_url = endpoint.base_url; apply_runtime_overrides( &mut runtime_spec, placement.weight_override, @@ -402,13 +527,9 @@ impl BackendAssignmentController { now_ms, )) } - VllmPreparedAssignment::Placeholder { reason } => Some(pending_status_for_backend( - backend, - spec, - node_uuid, - now_ms, - reason, - )), + VllmPreparedAssignment::Placeholder { reason } => { + Some(pending_status_for_backend(backend, spec, node_uuid, now_ms, reason)) + } } } @@ -446,19 +567,28 @@ impl Controller for BackendAssignmentController { } } -/// Decide whether llama.cpp reconcile is expected to perform a pull step. -/// 判断 llama.cpp 收敛过程中是否预期会执行拉取阶段。 -fn llamacpp_should_pull(params: &std::collections::HashMap) -> bool { +/// Compute whether llama.cpp reconcile is expected to perform a pull step. +/// 计算 llama.cpp 收敛是否预期会执行拉取阶段。 +fn compute_llamacpp_should_pull(params: &std::collections::HashMap) -> bool { !params .get("server_mode") .map(|value| value.trim().eq_ignore_ascii_case("raw")) .unwrap_or(false) && !params .get("skip_download") - .map(|value| value.trim() == "1") + .map(|value| parse_metadata_truthy(value)) .unwrap_or(false) } +/// Parse a truthy metadata value used by local assignment helpers. +/// 解析 local assignment helper 使用的 metadata 真值字符串。 +fn parse_metadata_truthy(value: &str) -> bool { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + _ => false, + } +} + /// Build a ready status from a runtime spec. /// 基于运行时 spec 构建 ready 状态。 fn ready_status_for_runtime_spec( @@ -560,7 +690,9 @@ fn error_status_for_backend( /// Convert metadata Struct into string params for local drivers. /// 把 metadata Struct 转成 local driver 使用的字符串参数表。 -fn metadata_to_string_map(value: Option<&prost_types::Struct>) -> std::collections::HashMap { +fn metadata_to_string_map( + value: Option<&prost_types::Struct>, +) -> std::collections::HashMap { let mut out = std::collections::HashMap::new(); let Some(value) = value else { return out; @@ -617,9 +749,15 @@ mod tests { AiBackendDesiredState, AiBackendManagementMode, AiBackendPlacementRecord, AiBackendRecord, BackendOrigin, BackendSpec, }; + use axum::{routing::get, Json, Router}; + use serde_json::{json, Value}; + use tokio::net::TcpListener; use tonic::transport::Endpoint; - fn sample_assignment(hosting: AiBackendHosting, backend_kind: &str) -> ResolvedAiBackendAssignment { + fn sample_assignment( + hosting: AiBackendHosting, + backend_kind: &str, + ) -> ResolvedAiBackendAssignment { ResolvedAiBackendAssignment { backend: Some(AiBackendRecord { backend_id: "backend-1".to_string(), @@ -677,7 +815,9 @@ mod tests { assignment } - fn clear_assignment_base_url(mut assignment: ResolvedAiBackendAssignment) -> ResolvedAiBackendAssignment { + fn clear_assignment_base_url( + mut assignment: ResolvedAiBackendAssignment, + ) -> ResolvedAiBackendAssignment { if let Some(backend) = assignment.backend.as_mut() { if let Some(spec) = backend.spec.as_mut() { spec.base_url.clear(); @@ -701,16 +841,51 @@ mod tests { ) } + async fn start_json_server( + path: &'static str, + payload: Value, + ) -> (tokio::task::JoinHandle<()>, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let app = Router::new().route( + path, + get(move || { + let payload = payload.clone(); + async move { Json(payload) } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + (handle, format!("http://{}", addr)) + } + #[tokio::test] async fn materializes_remote_assignment_into_runtime_backend() { + let (handle, base_url) = start_json_server( + "/api/tags", + json!({ + "models": [{"name": "gpt-4.1"}] + }), + ) + .await; let controller = test_controller(); let mut client = test_client(); let mut runtime_backends = Vec::new(); let mut live_local_ids = std::collections::HashSet::new(); + let mut assignment = sample_assignment(AiBackendHosting::Remote, "ollama_chat"); + let backend = assignment.backend.as_mut().expect("backend"); + backend.provider = "ollama".to_string(); + backend.credential_ref = None; + let spec = backend.spec.as_mut().expect("spec"); + spec.kind = "ollama_chat".to_string(); + spec.provider = "ollama".to_string(); + spec.base_url = base_url; + spec.credential_ref.clear(); let status = controller .materialize_assignment( &mut client, - &sample_assignment(AiBackendHosting::Remote, "openai-compatible"), + &assignment, &mut runtime_backends, &mut live_local_ids, "node-1", @@ -723,6 +898,7 @@ mod tests { assert_eq!(runtime_backends[0].spec.as_ref().unwrap().priority, 7); assert_eq!(status.status, AiBackendNodeStatus::Ready as i32); assert!(status.available); + handle.abort(); } #[tokio::test] @@ -785,10 +961,18 @@ mod tests { .into_iter() .collect(), }; + let mut assignment = + sample_assignment_with_metadata(AiBackendHosting::Local, "vllm", metadata); + assignment + .backend + .as_mut() + .and_then(|backend| backend.spec.as_mut()) + .expect("backend spec") + .base_url = "http://127.0.0.1:8000/v1".to_string(); let status = controller .materialize_assignment( &mut client, - &sample_assignment_with_metadata(AiBackendHosting::Local, "vllm", metadata), + &assignment, &mut runtime_backends, &mut live_local_ids, "node-1", @@ -797,6 +981,14 @@ mod tests { .expect("status"); assert_eq!(runtime_backends.len(), 1); + assert_eq!( + runtime_backends[0] + .spec + .as_ref() + .expect("runtime spec") + .base_url, + "http://127.0.0.1:8000/v1" + ); assert_eq!(status.status, AiBackendNodeStatus::Ready as i32); assert!(status.available); } @@ -823,11 +1015,35 @@ mod tests { let params = metadata_to_string_map(Some(&metadata)); assert_eq!(params.get("threads").map(String::as_str), Some("8")); - assert_eq!(params.get("skip_download").map(String::as_str), Some("true")); + assert_eq!( + params.get("skip_download").map(String::as_str), + Some("true") + ); + } + + #[test] + fn llamacpp_assignment_params_capture_should_pull() { + let mut fields = std::collections::BTreeMap::new(); + fields.insert( + "skip_download".to_string(), + prost_types::Value { + kind: Some(ProstValueKind::BoolValue(true)), + }, + ); + let metadata = prost_types::Struct { + fields: fields.into_iter().collect(), + }; + + let params = LlamaCppAssignmentParams::from_metadata(Some(&metadata)); + assert_eq!( + params.raw().get("skip_download").map(String::as_str), + Some("true") + ); + assert!(!params.should_pull()); } #[test] - fn resolves_local_assignment_driver_from_backend_kind_and_spec_fields() { + fn vllm_assignment_params_preserve_metadata_scalars() { let spec = BackendSpec { name: String::new(), kind: "vllm-openai".to_string(), @@ -844,28 +1060,83 @@ mod tests { origin: BackendOrigin::Sms as i32, deployment_id: String::new(), }; + let mut fields = std::collections::BTreeMap::new(); + fields.insert( + "mode".to_string(), + prost_types::Value { + kind: Some(ProstValueKind::StringValue("external_endpoint".to_string())), + }, + ); + let metadata = prost_types::Struct { + fields: fields.into_iter().collect(), + }; + let params = VllmAssignmentParams::from_spec_and_metadata(&spec, Some(&metadata)); assert_eq!( - resolve_local_provider_kind(["llama.cpp", spec.kind.as_str(), spec.provider.as_str()]), - LocalProviderKind::LlamaCpp + params.raw().get("mode").map(String::as_str), + Some("external_endpoint") + ); + } + + #[test] + fn resolves_local_assignment_runtime_family_from_backend_kind_and_spec_fields() { + let backend = AiBackendRecord { + backend_id: "backend-1".to_string(), + display_name: "Backend 1".to_string(), + provider: "vllm".to_string(), + model: "foo".to_string(), + hosting: AiBackendHosting::Local as i32, + backend_kind: "custom".to_string(), + desired_state: AiBackendDesiredState::Enabled as i32, + management_mode: AiBackendManagementMode::SmsLocal as i32, + credential_ref: None, + spec: None, + labels: Default::default(), + metadata: None, + generation: 1, + created_at_ms: 1, + updated_at_ms: 1, + }; + let spec = BackendSpec { + name: String::new(), + kind: "vllm-openai".to_string(), + operations: vec!["chat".to_string()], + features: vec![], + transports: vec!["http".to_string()], + weight: 1, + priority: 0, + base_url: String::new(), + provider: "vllm".to_string(), + model: "foo".to_string(), + hosting: crate::proto::sms::BackendHosting::NodeLocal as i32, + credential_ref: String::new(), + origin: BackendOrigin::Sms as i32, + deployment_id: String::new(), + }; + + let mut llamacpp_backend = backend.clone(); + llamacpp_backend.backend_kind = "llama.cpp".to_string(); + assert_eq!( + LocalAssignmentRuntimeFamily::from_assignment(&llamacpp_backend, &spec), + LocalAssignmentRuntimeFamily::LlamaCpp ); assert_eq!( - resolve_local_provider_kind(["custom", spec.kind.as_str(), spec.provider.as_str()]), - LocalProviderKind::Vllm + LocalAssignmentRuntimeFamily::from_assignment(&backend, &spec), + LocalAssignmentRuntimeFamily::Vllm ); } #[test] - fn llamacpp_should_pull_respects_server_mode_and_skip_download() { + fn compute_llamacpp_should_pull_respects_server_mode_and_skip_download() { let mut params = std::collections::HashMap::new(); - assert!(llamacpp_should_pull(¶ms)); + assert!(compute_llamacpp_should_pull(¶ms)); params.insert("server_mode".to_string(), "raw".to_string()); - assert!(!llamacpp_should_pull(¶ms)); + assert!(!compute_llamacpp_should_pull(¶ms)); params.remove("server_mode"); params.insert("skip_download".to_string(), "1".to_string()); - assert!(!llamacpp_should_pull(¶ms)); + assert!(!compute_llamacpp_should_pull(¶ms)); } #[test] @@ -874,17 +1145,11 @@ mod tests { let backend = assignment.backend.as_ref().unwrap(); let spec = backend.spec.as_ref().unwrap(); - let status = reconciling_status_for_backend( - backend, - spec, - "node-1", - 123, - "starting".to_string(), - ); + let status = + reconciling_status_for_backend(backend, spec, "node-1", 123, "starting".to_string()); assert_eq!(status.status, AiBackendNodeStatus::Reconciling as i32); assert_eq!(status.status_reason, "starting"); assert!(!status.available); } - } diff --git a/src/spearlet/ai/credential_resolver.rs b/src/spearlet/ai/credential_resolver.rs index 6bede58d..74f3f70f 100644 --- a/src/spearlet/ai/credential_resolver.rs +++ b/src/spearlet/ai/credential_resolver.rs @@ -31,7 +31,10 @@ impl CredentialResolution { format!("credential_ref '{}' is available", credential_ref) } CredentialResolution::Disabled => { - format!("credential_ref '{}' is disabled on this node", credential_ref) + format!( + "credential_ref '{}' is disabled on this node", + credential_ref + ) } CredentialResolution::NotSynced => format!( "credential_ref '{}' has not been synced to this node yet", @@ -149,9 +152,10 @@ mod tests { #[test] fn resolve_api_key_state_reports_disabled_dynamic_credential() { - let _guard = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() - .lock() - .expect("lock"); + let _guard = + crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() + .lock() + .expect("lock"); let store = global_dynamic_credentials(); store.clear(); store.set_credentials(vec![CredentialMaterial { @@ -173,9 +177,10 @@ mod tests { #[test] fn resolve_api_key_state_reports_env_missing_for_config_credential() { - let _guard = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() - .lock() - .expect("lock"); + let _guard = + crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() + .lock() + .expect("lock"); let store = global_dynamic_credentials(); store.clear(); let mut cfg = SpearletConfig::default(); diff --git a/src/spearlet/ai/credential_sync.rs b/src/spearlet/ai/credential_sync.rs index e783961e..6ef3f603 100644 --- a/src/spearlet/ai/credential_sync.rs +++ b/src/spearlet/ai/credential_sync.rs @@ -11,9 +11,7 @@ use tokio_util::sync::CancellationToken; use tonic::transport::Channel; use crate::proto::sms::admin_credential_service_client::AdminCredentialServiceClient; -use crate::proto::sms::{ - ListCredentialMaterialsRequest, WatchCredentialMaterialsRequest, -}; +use crate::proto::sms::{ListCredentialMaterialsRequest, WatchCredentialMaterialsRequest}; use crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials; use crate::spearlet::controller::Controller; @@ -292,7 +290,11 @@ impl CredentialSyncService { } } - fn apply_snapshot(&self, revision: u64, credentials: Vec) { + fn apply_snapshot( + &self, + revision: u64, + credentials: Vec, + ) { if revision == 0 { return; } @@ -329,9 +331,10 @@ mod tests { #[test] fn credential_sync_status_snapshot_reports_ready_state() { - let _guard = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() - .lock() - .expect("lock"); + let _guard = + crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() + .lock() + .expect("lock"); let store = global_dynamic_credentials(); store.clear(); store.set_credentials(vec![crate::proto::sms::CredentialMaterial { diff --git a/src/spearlet/ai/mod.rs b/src/spearlet/ai/mod.rs index 878e833b..0bbb3e5f 100644 --- a/src/spearlet/ai/mod.rs +++ b/src/spearlet/ai/mod.rs @@ -2,12 +2,13 @@ use std::collections::{HashMap, HashSet}; use crate::spearlet::config::SpearletConfig; -pub mod dynamic_backend_registry; -pub mod dynamic_credential_store; -pub mod credential_sync; -pub mod credential_resolver; pub mod backend_assembly; pub mod backend_assignment_controller; +pub mod credential_resolver; +pub mod credential_sync; +pub mod dynamic_backend_registry; +pub mod dynamic_credential_store; +pub mod remote_preflight; pub fn collect_ai_global_environment(cfg: &SpearletConfig) -> HashMap { let mut cred_env: HashMap = HashMap::new(); diff --git a/src/spearlet/ai/remote_preflight.rs b/src/spearlet/ai/remote_preflight.rs new file mode 100644 index 00000000..865c1914 --- /dev/null +++ b/src/spearlet/ai/remote_preflight.rs @@ -0,0 +1,748 @@ +use std::time::Instant; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::ai_backend_types::{CanonicalBackendKind, CanonicalBackendProvider}; +use crate::proto::sms::BackendSpec; +use crate::spearlet::ai::credential_resolver::{CredentialResolution, CredentialResolver}; +use crate::spearlet::execution::ai::backends::http_json::join_url; + +/// Supported remote preflight provider families. +/// 当前支持的 remote preflight provider 家族。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum RemotePreflightProviderFamily { + /// OpenAI-compatible HTTP family / OpenAI-compatible HTTP 家族 + OpenAiCompatible, + /// Ollama-compatible HTTP family / Ollama-compatible HTTP 家族 + Ollama, + /// Unsupported provider family / 不支持的 provider 家族 + Unknown(String), +} + +impl RemotePreflightProviderFamily { + /// Classify one backend spec into a remote preflight provider family. + /// 将一个 backend spec 分类到 remote preflight provider 家族。 + fn from_spec(spec: &BackendSpec) -> Self { + let raw_provider = spec.provider.trim().to_ascii_lowercase(); + let raw_kind = spec.kind.trim().to_ascii_lowercase(); + let provider = CanonicalBackendProvider::parse(&spec.provider); + let kind = CanonicalBackendKind::parse(&spec.kind); + + if provider == CanonicalBackendProvider::Ollama || kind == CanonicalBackendKind::OllamaChat + { + return Self::Ollama; + } + if provider == CanonicalBackendProvider::OpenAi + || matches!( + kind, + CanonicalBackendKind::OpenAiChatCompletion | CanonicalBackendKind::OpenAiRealtimeWs + ) + || raw_kind.starts_with("openai_") + { + return Self::OpenAiCompatible; + } + if !raw_provider.is_empty() { + Self::Unknown(raw_provider) + } else { + Self::Unknown(raw_kind) + } + } + + /// Return the stable serialized family name. + /// 返回稳定序列化使用的家族名称。 + fn as_str(&self) -> &str { + match self { + Self::OpenAiCompatible => "openai_compatible", + Self::Ollama => "ollama", + Self::Unknown(value) => value.as_str(), + } + } + + /// Return the default requested checks for one provider family. + /// 返回一个 provider 家族的默认检查项。 + fn default_requested_checks(&self) -> Vec { + match self { + Self::Ollama => vec!["connectivity".to_string(), "model_access".to_string()], + Self::OpenAiCompatible | Self::Unknown(_) => vec![ + "connectivity".to_string(), + "auth".to_string(), + "model_access".to_string(), + ], + } + } +} + +/// One named preflight check outcome. +/// 单个具名预检检查项结果。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoteBackendPreflightCheck { + pub name: String, + pub ok: bool, +} + +/// Node-side remote backend preflight report. +/// 节点侧 remote backend 预检报告。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteBackendPreflightReport { + pub success: bool, + pub provider: String, + pub phase: String, + pub error_code: Option, + pub message: String, + pub resolved_endpoint: Option, + pub http_status: Option, + pub provider_code: Option, + pub latency_ms: Option, + pub checks: Vec, + pub auth_valid: Option, + pub model_accessible: Option, +} + +impl RemoteBackendPreflightReport { + /// Create a successful report with consistent defaults. + /// 构造一个具有一致默认值的成功报告。 + fn success( + provider: &str, + phase: &str, + message: String, + resolved_endpoint: Option, + http_status: Option, + latency_ms: u64, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + ) -> Self { + Self { + success: true, + provider: provider.to_string(), + phase: phase.to_string(), + error_code: None, + message, + resolved_endpoint, + http_status, + provider_code: None, + latency_ms: Some(latency_ms), + checks, + auth_valid, + model_accessible, + } + } + + /// Create a failed report with consistent defaults. + /// 构造一个具有一致默认值的失败报告。 + fn failure( + provider: &str, + phase: &str, + error_code: &str, + message: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + latency_ms: u64, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + ) -> Self { + Self { + success: false, + provider: provider.to_string(), + phase: phase.to_string(), + error_code: Some(error_code.to_string()), + message, + resolved_endpoint, + http_status, + provider_code, + latency_ms: Some(latency_ms), + checks, + auth_valid, + model_accessible, + } + } +} + +/// Probe a remote backend from the real node-side network path. +/// 从真实节点侧网络路径探测 remote backend。 +pub async fn preflight_remote_backend( + http: &Client, + resolver: &CredentialResolver, + spec: &BackendSpec, + requested_checks: &[String], +) -> RemoteBackendPreflightReport { + let started = Instant::now(); + let provider_family = RemotePreflightProviderFamily::from_spec(spec); + let requested = normalized_requested_checks(requested_checks); + let base_url = spec.base_url.trim().to_string(); + + if base_url.is_empty() { + return RemoteBackendPreflightReport::failure( + provider_family.as_str(), + "input", + "invalid_input", + "remote backends require spec.base_url".to_string(), + None, + None, + None, + started.elapsed().as_millis() as u64, + vec![], + None, + None, + ); + } + + match provider_family { + RemotePreflightProviderFamily::OpenAiCompatible => { + preflight_openai_compatible(http, resolver, spec, &requested, started).await + } + RemotePreflightProviderFamily::Ollama => { + preflight_ollama(http, spec, &requested, started).await + } + RemotePreflightProviderFamily::Unknown(provider) => RemoteBackendPreflightReport::failure( + &provider, + "input", + "unsupported_provider", + format!( + "remote preflight does not support provider '{}' with kind '{}'", + spec.provider, spec.kind + ), + Some(base_url), + None, + None, + started.elapsed().as_millis() as u64, + vec![], + None, + None, + ), + } +} + +/// Build the default check list for one backend spec. +/// 为一个 backend spec 构建默认检查项列表。 +pub fn default_requested_checks(spec: &BackendSpec) -> Vec { + RemotePreflightProviderFamily::from_spec(spec).default_requested_checks() +} + +fn normalized_requested_checks(requested_checks: &[String]) -> Vec { + let mut out = requested_checks + .iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + out.sort(); + out.dedup(); + if !out.is_empty() { + return out; + } + vec![ + "connectivity".to_string(), + "auth".to_string(), + "model_access".to_string(), + ] +} + +fn check_enabled(requested: &[String], check: &str) -> bool { + requested.iter().any(|value| value == check) +} + +fn checks_with_default_values(requested: &[String]) -> Vec { + requested + .iter() + .map(|name| RemoteBackendPreflightCheck { + name: name.clone(), + ok: false, + }) + .collect() +} + +fn mark_check(checks: &mut [RemoteBackendPreflightCheck], name: &str, ok: bool) { + if let Some(check) = checks.iter_mut().find(|check| check.name == name) { + check.ok = ok; + } +} + +fn credential_error_code(state: &CredentialResolution) -> &'static str { + match state { + CredentialResolution::Ready(_) => "ok", + CredentialResolution::Disabled => "credential_disabled", + CredentialResolution::NotSynced => "credential_not_synced", + CredentialResolution::Missing => "auth_missing", + } +} + +fn extract_provider_code(payload: &Value) -> Option { + payload + .get("error") + .and_then(|error| error.get("code")) + .and_then(|value| { + value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| value.as_i64().map(|number| number.to_string())) + }) +} + +fn map_http_failure( + _provider: &str, + phase: &str, + status: u16, + payload: Option<&Value>, +) -> (&'static str, Option, String) { + let provider_code = payload.and_then(extract_provider_code); + let provider_message = payload + .and_then(|body| body.get("error")) + .and_then(|error| error.get("message")) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + let message = provider_message.unwrap_or_else(|| format!("upstream status: {}", status)); + let error_code = match status { + 401 => "auth_invalid", + 403 => "permission_denied", + 404 => "http_404", + 429 => "provider_rate_limited", + 500..=599 => "provider_unavailable", + _ if phase == "auth" => "auth_invalid", + _ => "provider_unavailable", + }; + (error_code, provider_code, message) +} + +fn map_reqwest_error_code(error: &reqwest::Error) -> &'static str { + let message = error.to_string().to_ascii_lowercase(); + if error.is_timeout() { + "connect_timeout" + } else if message.contains("dns") || message.contains("lookup address") { + "dns_resolve_failed" + } else if message.contains("certificate") || message.contains("tls") || message.contains("ssl") + { + "tls_handshake_failed" + } else if error.is_connect() { + "node_unreachable" + } else { + "provider_unavailable" + } +} + +async fn preflight_openai_compatible( + http: &Client, + resolver: &CredentialResolver, + spec: &BackendSpec, + requested: &[String], + started: Instant, +) -> RemoteBackendPreflightReport { + let endpoint = if spec.base_url.contains("/v1") { + join_url(&spec.base_url, "models") + } else { + join_url(&spec.base_url, "v1/models") + }; + let mut checks = checks_with_default_values(requested); + let credential_state = resolver.resolve_api_key_state(Some(&spec.credential_ref)); + let api_key = match credential_state.clone() { + CredentialResolution::Ready(secret) => Some(secret), + state if check_enabled(requested, "auth") => { + return RemoteBackendPreflightReport::failure( + "openai_compatible", + "auth", + credential_error_code(&state), + state.message(spec.credential_ref.as_str()), + Some(endpoint), + None, + None, + started.elapsed().as_millis() as u64, + checks, + Some(false), + None, + ); + } + _ => None, + }; + + let mut request = http + .get(&endpoint) + .timeout(std::time::Duration::from_secs(10)); + if let Some(api_key) = api_key.as_deref() { + request = request.bearer_auth(api_key); + } + + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + return RemoteBackendPreflightReport::failure( + "openai_compatible", + "connectivity", + map_reqwest_error_code(&error), + error.to_string(), + Some(endpoint), + None, + None, + started.elapsed().as_millis() as u64, + checks, + None, + None, + ); + } + }; + + mark_check(&mut checks, "connectivity", true); + let status = response.status().as_u16(); + let payload = response.json::().await.ok(); + if !(200..300).contains(&status) { + let phase = if matches!(status, 401 | 403) { + "auth" + } else { + "connectivity" + }; + let (error_code, provider_code, message) = + map_http_failure("openai_compatible", phase, status, payload.as_ref()); + return RemoteBackendPreflightReport::failure( + "openai_compatible", + phase, + error_code, + message, + Some(endpoint), + Some(status), + provider_code, + started.elapsed().as_millis() as u64, + checks, + Some(false), + None, + ); + } + + if check_enabled(requested, "auth") { + mark_check(&mut checks, "auth", true); + } + + let model = spec.model.trim(); + if check_enabled(requested, "model_access") && !model.is_empty() { + let found = payload + .as_ref() + .and_then(|body| body.get("data")) + .and_then(|value| value.as_array()) + .map(|items| { + items.iter().any(|item| { + item.get("id") + .and_then(|value| value.as_str()) + .map(|value| value == model) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !found { + return RemoteBackendPreflightReport::failure( + "openai_compatible", + "model_access", + "model_not_found", + format!("model '{}' is not visible from this node", model), + Some(endpoint), + Some(status), + None, + started.elapsed().as_millis() as u64, + checks, + Some(true), + Some(false), + ); + } + mark_check(&mut checks, "model_access", true); + } + + RemoteBackendPreflightReport::success( + "openai_compatible", + if check_enabled(requested, "model_access") && !model.is_empty() { + "model_access" + } else if check_enabled(requested, "auth") { + "auth" + } else { + "connectivity" + }, + "authentication and model access verified".to_string(), + Some(endpoint), + Some(status), + started.elapsed().as_millis() as u64, + checks, + Some(true), + if check_enabled(requested, "model_access") && !model.is_empty() { + Some(true) + } else { + None + }, + ) +} + +async fn preflight_ollama( + http: &Client, + spec: &BackendSpec, + requested: &[String], + started: Instant, +) -> RemoteBackendPreflightReport { + let endpoint = if spec.base_url.contains("/api/") || spec.base_url.ends_with("/api") { + join_url(&spec.base_url, "tags") + } else { + join_url(&spec.base_url, "api/tags") + }; + let mut checks = checks_with_default_values(requested); + let response = match http + .get(&endpoint) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return RemoteBackendPreflightReport::failure( + "ollama", + "connectivity", + map_reqwest_error_code(&error), + error.to_string(), + Some(endpoint), + None, + None, + started.elapsed().as_millis() as u64, + checks, + None, + None, + ); + } + }; + + mark_check(&mut checks, "connectivity", true); + let status = response.status().as_u16(); + let payload = response.json::().await.ok(); + if !(200..300).contains(&status) { + let (error_code, provider_code, message) = + map_http_failure("ollama", "connectivity", status, payload.as_ref()); + return RemoteBackendPreflightReport::failure( + "ollama", + "connectivity", + error_code, + message, + Some(endpoint), + Some(status), + provider_code, + started.elapsed().as_millis() as u64, + checks, + None, + None, + ); + } + + let model = spec.model.trim(); + if check_enabled(requested, "model_access") && !model.is_empty() { + let found = payload + .as_ref() + .and_then(|body| body.get("models")) + .and_then(|value| value.as_array()) + .map(|items| { + items.iter().any(|item| { + item.get("name") + .and_then(|value| value.as_str()) + .map(|value| value == model || value.starts_with(&format!("{model}:"))) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !found { + return RemoteBackendPreflightReport::failure( + "ollama", + "model_access", + "model_not_found", + format!("model '{}' is not available from this node", model), + Some(endpoint), + Some(status), + None, + started.elapsed().as_millis() as u64, + checks, + None, + Some(false), + ); + } + mark_check(&mut checks, "model_access", true); + } + + RemoteBackendPreflightReport::success( + "ollama", + if check_enabled(requested, "model_access") && !model.is_empty() { + "model_access" + } else { + "connectivity" + }, + "endpoint reachability and model access verified".to_string(), + Some(endpoint), + Some(status), + started.elapsed().as_millis() as u64, + checks, + None, + if check_enabled(requested, "model_access") && !model.is_empty() { + Some(true) + } else { + None + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::get, Json, Router}; + use serde_json::json; + use tokio::net::TcpListener; + + async fn start_json_server( + path: &'static str, + payload: Value, + ) -> (tokio::task::JoinHandle<()>, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let app = Router::new().route( + path, + get(move || { + let payload = payload.clone(); + async move { Json(payload) } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + (handle, format!("http://{}", addr)) + } + + fn test_backend_spec(kind: &str, provider: &str) -> BackendSpec { + BackendSpec { + name: "backend".to_string(), + kind: kind.to_string(), + operations: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + weight: 100, + priority: 0, + base_url: "https://example.invalid".to_string(), + provider: provider.to_string(), + model: "demo".to_string(), + hosting: crate::proto::sms::BackendHosting::Remote as i32, + credential_ref: String::new(), + origin: crate::proto::sms::BackendOrigin::Sms as i32, + deployment_id: String::new(), + } + } + + #[test] + fn remote_preflight_provider_family_normalizes_openai_aliases() { + let spec = test_backend_spec("openai_realtime_ws", "openai_compatible"); + assert_eq!( + RemotePreflightProviderFamily::from_spec(&spec), + RemotePreflightProviderFamily::OpenAiCompatible + ); + } + + #[test] + fn remote_preflight_provider_family_infers_ollama_from_kind() { + let spec = test_backend_spec("ollama_chat", ""); + assert_eq!( + RemotePreflightProviderFamily::from_spec(&spec), + RemotePreflightProviderFamily::Ollama + ); + } + + #[test] + fn default_requested_checks_follow_provider_family() { + let openai_spec = test_backend_spec("openai_chat_completion", ""); + let ollama_spec = test_backend_spec("ollama_chat", ""); + + assert_eq!( + default_requested_checks(&openai_spec), + vec![ + "connectivity".to_string(), + "auth".to_string(), + "model_access".to_string(), + ] + ); + assert_eq!( + default_requested_checks(&ollama_spec), + vec!["connectivity".to_string(), "model_access".to_string()] + ); + } + + #[tokio::test] + async fn openai_preflight_verifies_model_access() { + let (handle, base_url) = start_json_server( + "/v1/models", + json!({ + "data": [{"id": "gpt-4o-mini"}] + }), + ) + .await; + let resolver = + CredentialResolver::from_config(&crate::spearlet::config::SpearletConfig::default()); + let spec = BackendSpec { + name: "backend-1".to_string(), + kind: "openai_chat_completion".to_string(), + operations: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + weight: 100, + priority: 0, + base_url, + provider: "openai".to_string(), + model: "gpt-4o-mini".to_string(), + hosting: crate::proto::sms::BackendHosting::Remote as i32, + credential_ref: String::new(), + origin: crate::proto::sms::BackendOrigin::Sms as i32, + deployment_id: String::new(), + }; + + let report = preflight_remote_backend( + &Client::new(), + &resolver, + &spec, + &["connectivity".to_string(), "model_access".to_string()], + ) + .await; + + assert!(report.success); + assert_eq!(report.phase, "model_access"); + assert_eq!(report.model_accessible, Some(true)); + handle.abort(); + } + + #[tokio::test] + async fn ollama_preflight_reports_missing_model() { + let (handle, base_url) = start_json_server( + "/api/tags", + json!({ + "models": [{"name": "llama3:8b"}] + }), + ) + .await; + let resolver = + CredentialResolver::from_config(&crate::spearlet::config::SpearletConfig::default()); + let spec = BackendSpec { + name: "backend-2".to_string(), + kind: "ollama_chat".to_string(), + operations: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + weight: 100, + priority: 0, + base_url, + provider: "ollama".to_string(), + model: "qwen2.5".to_string(), + hosting: crate::proto::sms::BackendHosting::Remote as i32, + credential_ref: String::new(), + origin: crate::proto::sms::BackendOrigin::Sms as i32, + deployment_id: String::new(), + }; + + let report = preflight_remote_backend( + &Client::new(), + &resolver, + &spec, + &["connectivity".to_string(), "model_access".to_string()], + ) + .await; + + assert!(!report.success); + assert_eq!(report.error_code.as_deref(), Some("model_not_found")); + assert_eq!(report.model_accessible, Some(false)); + handle.abort(); + } +} diff --git a/src/spearlet/backend_reporter.rs b/src/spearlet/backend_reporter.rs index 3de14d83..5df268b8 100644 --- a/src/spearlet/backend_reporter.rs +++ b/src/spearlet/backend_reporter.rs @@ -15,8 +15,8 @@ use crate::proto::sms::{ use crate::spearlet::ai::backend_assembly::backend_spec_from_config; use crate::spearlet::ai::credential_resolver::{CredentialResolution, CredentialResolver}; use crate::spearlet::ai::dynamic_backend_registry::global_dynamic_backends; -use crate::spearlet::config::SpearletConfig; use crate::spearlet::ai::dynamic_backend_registry::DynamicBackendSource; +use crate::spearlet::config::SpearletConfig; use crate::spearlet::controller::Controller; #[derive(Clone, Debug, Default)] @@ -288,7 +288,11 @@ mod tests { let global = crate::spearlet::ai::dynamic_backend_registry::global_dynamic_backends(); global.set_backends( DynamicBackendSource::AiControlPlane, - vec![mk_backend("cp-remote", "openai_chat_completion", BackendOrigin::Sms)], + vec![mk_backend( + "cp-remote", + "openai_chat_completion", + BackendOrigin::Sms, + )], ); let reported = collect_reportable_backends(&cfg); @@ -300,18 +304,16 @@ mod tests { assert_eq!( names, - vec![ - "cp-remote".to_string(), - "static-openai".to_string() - ] + vec!["cp-remote".to_string(), "static-openai".to_string()] ); } #[test] fn build_backend_info_list_reports_disabled_dynamic_credential_as_unavailable() { - let _guard = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() - .lock() - .expect("lock"); + let _guard = + crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() + .lock() + .expect("lock"); let store = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials(); store.clear(); store.set_credentials(vec![CredentialMaterial { diff --git a/src/spearlet/config.rs b/src/spearlet/config.rs index 39f56867..2a4f4415 100644 --- a/src/spearlet/config.rs +++ b/src/spearlet/config.rs @@ -3,6 +3,11 @@ use clap::Parser; use serde::{Deserialize, Serialize}; +use crate::ai_backend_types::{ + AllowedHosting, CanonicalBackendHosting, CanonicalBackendKind, CanonicalBackendOrigin, + CanonicalBackendProvider, +}; + use crate::config::base::{LogConfig, ServerConfig}; /// SPEARlet command line arguments / SPEARlet命令行参数 @@ -272,8 +277,7 @@ impl AppConfig { || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_MAX_CANDIDATES_SENT").is_ok() || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_MAX_DEBUG_KV").is_ok() || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_MAX_INFLIGHT_TOTAL").is_ok() - || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_PER_AGENT_MAX_INFLIGHT") - .is_ok() + || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_PER_AGENT_MAX_INFLIGHT").is_ok() || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_CONTENT_FETCH_ENABLED").is_ok() || std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_CONTENT_FETCH_MAX_BYTES") .is_ok() @@ -333,21 +337,25 @@ impl AppConfig { } } } - if let Ok(v) = std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_PER_AGENT_MAX_INFLIGHT") { + if let Ok(v) = std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_PER_AGENT_MAX_INFLIGHT") + { if let Ok(n) = v.parse::() { if let Some(cfg) = config.spearlet.ai.router_grpc_filter_stream.as_mut() { cfg.per_agent_max_inflight = n; } } } - if let Ok(v) = std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_CONTENT_FETCH_ENABLED") { + if let Ok(v) = std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_CONTENT_FETCH_ENABLED") + { if let Ok(b) = v.parse::() { if let Some(cfg) = config.spearlet.ai.router_grpc_filter_stream.as_mut() { cfg.content_fetch_enabled = b; } } } - if let Ok(v) = std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_CONTENT_FETCH_MAX_BYTES") { + if let Ok(v) = + std::env::var("SPEARLET_AI_ROUTER_GRPC_FILTER_STREAM_CONTENT_FETCH_MAX_BYTES") + { if let Ok(n) = v.parse::() { if let Some(cfg) = config.spearlet.ai.router_grpc_filter_stream.as_mut() { cfg.content_fetch_max_bytes = n; @@ -361,7 +369,11 @@ impl AppConfig { } if let Ok(v) = std::env::var("SPEARLET_AI_BACKEND_CONTROL_PLANE_POLL_INTERVAL_MS") { if let Ok(n) = v.parse::() { - config.spearlet.ai.backend_control_plane_sync.poll_interval_ms = n; + config + .spearlet + .ai + .backend_control_plane_sync + .poll_interval_ms = n; } } if let Ok(v) = std::env::var("SPEARLET_AI_BACKEND_REPORT_INTERVAL_MS") { @@ -486,25 +498,12 @@ fn validate_spearlet_config( cfg: &SpearletConfig, ) -> Result<(), Box> { for b in cfg.ai.backends.iter() { - let hosting = b.hosting.as_deref().map(|s| s.trim()).unwrap_or(""); - if hosting.is_empty() { - return Err(std::io::Error::new( + b.typed_view().map_err(|message| { + std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("ai backend hosting is required: {}", b.name), + format!("{message}: {}", b.name), ) - .into()); - } - let hosting = hosting.to_ascii_lowercase(); - if hosting != "local" && hosting != "remote" { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "invalid ai backend hosting (expected local|remote): {}", - b.name - ), - ) - .into()); - } + })?; } Ok(()) } @@ -608,13 +607,236 @@ impl Default for BackendControlPlaneSyncConfig { #[cfg(test)] mod tests { - use super::AiConfig; + use super::{ + validate_spearlet_config, AiBackendConfig, AiBackendProviderView, AiBackendTypedView, + AiConfig, LocalBackendProviderView, RemoteBackendProviderView, SpearletConfig, + }; #[test] fn ai_config_uses_expected_backend_report_interval_by_default() { let config = AiConfig::default(); assert_eq!(config.backend_report_interval_ms, 60_000); } + + #[test] + fn ai_backend_config_typed_view_normalizes_aliases() { + let config = AiBackendConfig { + name: "local-llama".to_string(), + kind: "llama.cpp".to_string(), + base_url: String::new(), + hosting: Some("local".to_string()), + model: Some("qwen".to_string()), + credential_ref: None, + provider: Some("llama_cpp".to_string()), + origin: Some("local_controller".to_string()), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let typed = config.typed_view().expect("typed view should be built"); + let local = match typed { + AiBackendTypedView::Local(local) => local, + AiBackendTypedView::Remote(_) => panic!("local config should produce local typed view"), + }; + assert_eq!(local.common.kind.as_str(), "llamacpp"); + assert_eq!(local.common.provider.as_str(), "llamacpp"); + assert_eq!(local.common.origin.as_str(), "local_controller"); + } + + #[test] + fn validate_spearlet_config_allows_local_ollama_chat_backend() { + let mut config = SpearletConfig::default(); + config.ai.backends.push(AiBackendConfig { + name: "ollama-local".to_string(), + kind: "ollama_chat".to_string(), + base_url: "http://127.0.0.1:11434".to_string(), + hosting: Some("local".to_string()), + model: Some("llama3.1:8b".to_string()), + credential_ref: None, + provider: Some("ollama".to_string()), + origin: Some("local_controller".to_string()), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }); + + validate_spearlet_config(&config).expect("local ollama_chat should be accepted"); + } + + #[test] + fn ai_backend_config_typed_view_builds_remote_variant() { + let config = AiBackendConfig { + name: "remote-openai".to_string(), + kind: "openai_chat_completion".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + hosting: Some("remote".to_string()), + model: Some("gpt-4o-mini".to_string()), + credential_ref: Some("openai-key".to_string()), + provider: None, + origin: Some("sms".to_string()), + deployment_id: Some("deploy-1".to_string()), + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let typed = config.typed_view().expect("typed view should be built"); + let remote = match typed { + AiBackendTypedView::Remote(remote) => remote, + AiBackendTypedView::Local(_) => { + panic!("remote config should produce remote typed view") + } + }; + assert_eq!(remote.common.kind.as_str(), "openai_chat_completion"); + assert_eq!(remote.common.provider.as_str(), "openai"); + assert_eq!(remote.base_url, "https://api.openai.com/v1"); + assert_eq!(remote.credential_ref.as_deref(), Some("openai-key")); + } + + #[test] + fn ai_backend_config_typed_view_rejects_remote_backend_without_base_url() { + let config = AiBackendConfig { + name: "remote-openai".to_string(), + kind: "openai_chat_completion".to_string(), + base_url: String::new(), + hosting: Some("remote".to_string()), + model: Some("gpt-4o-mini".to_string()), + credential_ref: None, + provider: None, + origin: Some("sms".to_string()), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let err = config + .typed_view() + .expect_err("remote backend without base_url should fail"); + assert!(err.contains("requires base_url")); + } + + #[test] + fn ai_backend_config_typed_view_allows_local_llamacpp_without_base_url() { + let config = AiBackendConfig { + name: "local-llama".to_string(), + kind: "llamacpp".to_string(), + base_url: String::new(), + hosting: Some("local".to_string()), + model: Some("qwen".to_string()), + credential_ref: None, + provider: Some("llamacpp".to_string()), + origin: Some("local_controller".to_string()), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let typed = config + .typed_view() + .expect("local llamacpp should not require base_url"); + match typed { + AiBackendTypedView::Local(local) => { + assert!(local.base_url.is_empty()); + } + AiBackendTypedView::Remote(_) => panic!("llamacpp should stay local"), + } + } + + #[test] + fn ai_backend_config_typed_view_exposes_provider_specific_local_view() { + let config = AiBackendConfig { + name: "ollama-local".to_string(), + kind: "ollama_chat".to_string(), + base_url: "http://127.0.0.1:11434".to_string(), + hosting: Some("local".to_string()), + model: Some("llama3.1:8b".to_string()), + credential_ref: None, + provider: Some("ollama".to_string()), + origin: Some("local_controller".to_string()), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let typed = config.typed_view().expect("typed view should be built"); + match typed.provider_view() { + AiBackendProviderView::Local(LocalBackendProviderView::Ollama(local)) => { + assert_eq!(local.common.provider.as_str(), "ollama"); + } + other => panic!("expected local ollama provider view, got {other:?}"), + } + } + + #[test] + fn ai_backend_config_typed_view_exposes_provider_specific_remote_view() { + let config = AiBackendConfig { + name: "remote-openai".to_string(), + kind: "openai_chat_completion".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + hosting: Some("remote".to_string()), + model: Some("gpt-4o-mini".to_string()), + credential_ref: Some("openai-key".to_string()), + provider: Some("openai".to_string()), + origin: Some("sms".to_string()), + deployment_id: Some("deploy-1".to_string()), + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let typed = config.typed_view().expect("typed view should be built"); + match typed.provider_view() { + AiBackendProviderView::Remote(RemoteBackendProviderView::OpenAi(remote)) => { + assert_eq!(remote.common.provider.as_str(), "openai"); + } + other => panic!("expected remote openai provider view, got {other:?}"), + } + } + + #[test] + fn ai_backend_config_typed_view_rejects_provider_kind_mismatch() { + let config = AiBackendConfig { + name: "bad-remote".to_string(), + kind: "ollama_chat".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + hosting: Some("remote".to_string()), + model: Some("llama3.1:8b".to_string()), + credential_ref: None, + provider: Some("openai".to_string()), + origin: Some("sms".to_string()), + deployment_id: None, + weight: 100, + priority: 0, + ops: vec!["chat_completions".to_string()], + features: vec![], + transports: vec!["http".to_string()], + }; + + let err = config + .typed_view() + .expect_err("provider/kind mismatch should be rejected"); + assert!(err.contains("does not match kind")); + } } /// Router gRPC filter stream configuration / Router gRPC 过滤 stream 配置 @@ -676,7 +898,6 @@ impl Default for AiDiscoveryConfig { } } - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct OllamaDiscoveryConfig { @@ -737,7 +958,6 @@ impl Default for AiCredentialConfig { } } - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AiBackendConfig { @@ -757,6 +977,220 @@ pub struct AiBackendConfig { pub transports: Vec, } +/// Shared typed fields reused by local/remote backend config variants. +/// local/remote backend 配置变体复用的共享强类型字段。 +#[derive(Debug, Clone)] +pub struct AiBackendCommonView { + pub name: String, + pub kind: CanonicalBackendKind, + pub provider: CanonicalBackendProvider, + pub origin: CanonicalBackendOrigin, + pub deployment_id: Option, + pub weight: u32, + pub priority: i32, + pub ops: Vec, + pub features: Vec, + pub transports: Vec, +} + +/// Typed local backend config view. +/// 强类型 local backend 配置视图。 +#[derive(Debug, Clone)] +pub struct LocalBackendConfigView { + pub common: AiBackendCommonView, + pub model: Option, + pub credential_ref: Option, + pub base_url: String, +} + +/// Typed remote backend config view. +/// 强类型 remote backend 配置视图。 +#[derive(Debug, Clone)] +pub struct RemoteBackendConfigView { + pub common: AiBackendCommonView, + pub model: Option, + pub credential_ref: Option, + pub base_url: String, +} + +/// Typed internal view derived from the sparse config bag. +/// 从稀疏配置包派生出的内部强类型视图。 +#[derive(Debug, Clone)] +pub enum AiBackendTypedView { + /// Local backend variant / local backend 变体 + Local(LocalBackendConfigView), + /// Remote backend variant / remote backend 变体 + Remote(RemoteBackendConfigView), +} + +/// Provider-specific typed view layered on top of local/remote variants. +/// 构建在 local/remote 变体之上的 provider-specific 强类型视图。 +#[derive(Debug, Clone, Copy)] +pub enum AiBackendProviderView<'a> { + /// Local provider-specific variant / local provider-specific 变体 + Local(LocalBackendProviderView<'a>), + /// Remote provider-specific variant / remote provider-specific 变体 + Remote(RemoteBackendProviderView<'a>), +} + +/// Provider-specific local backend config view. +/// provider-specific 的 local backend 配置视图。 +#[derive(Debug, Clone, Copy)] +pub enum LocalBackendProviderView<'a> { + /// Local Ollama-compatible endpoint / 本地 Ollama-compatible endpoint + Ollama(&'a LocalBackendConfigView), + /// Node-local llama.cpp managed runtime / 节点本地 llama.cpp 托管运行时 + LlamaCpp(&'a LocalBackendConfigView), + /// Local vLLM family / 本地 vLLM 家族 + Vllm(&'a LocalBackendConfigView), + /// Internal testing provider / 内部测试 provider + Internal(&'a LocalBackendConfigView), + /// Unsupported or still-weakly-typed local provider / 未支持或仍为弱类型的 local provider + Unknown(&'a LocalBackendConfigView), +} + +/// Provider-specific remote backend config view. +/// provider-specific 的 remote backend 配置视图。 +#[derive(Debug, Clone, Copy)] +pub enum RemoteBackendProviderView<'a> { + /// OpenAI or OpenAI-compatible remote provider / OpenAI 或 OpenAI-compatible 远端 provider + OpenAi(&'a RemoteBackendConfigView), + /// Remote Ollama-compatible endpoint / 远端 Ollama-compatible endpoint + Ollama(&'a RemoteBackendConfigView), + /// Internal testing provider / 内部测试 provider + Internal(&'a RemoteBackendConfigView), + /// Unsupported or still-weakly-typed remote provider / 未支持或仍为弱类型的 remote provider + Unknown(&'a RemoteBackendConfigView), +} + +impl AiBackendTypedView { + /// Validate variant-specific minimal config requirements. + /// 校验变体级别的最小配置要求。 + fn validate(self) -> Result { + match &self { + Self::Local(local) => local.validate()?, + Self::Remote(remote) => remote.validate()?, + } + Ok(self) + } + + /// Project the local/remote typed view into a provider-specific subview. + /// 将 local/remote 强类型视图投影为 provider-specific 子视图。 + pub fn provider_view(&self) -> AiBackendProviderView<'_> { + match self { + Self::Local(local) => AiBackendProviderView::Local(local.provider_view()), + Self::Remote(remote) => AiBackendProviderView::Remote(remote.provider_view()), + } + } +} + +impl<'a> LocalBackendProviderView<'a> { + /// Return the underlying local config view regardless of provider family. + /// 无论 provider 家族如何,都返回底层 local 配置视图。 + pub fn config(self) -> &'a LocalBackendConfigView { + match self { + Self::Ollama(config) + | Self::LlamaCpp(config) + | Self::Vllm(config) + | Self::Internal(config) + | Self::Unknown(config) => config, + } + } +} + +impl<'a> RemoteBackendProviderView<'a> { + /// Return the underlying remote config view regardless of provider family. + /// 无论 provider 家族如何,都返回底层 remote 配置视图。 + pub fn config(self) -> &'a RemoteBackendConfigView { + match self { + Self::OpenAi(config) + | Self::Ollama(config) + | Self::Internal(config) + | Self::Unknown(config) => config, + } + } +} + +impl LocalBackendConfigView { + /// Validate local backend minimal constraints. + /// 校验 local backend 的最小约束。 + fn validate(&self) -> Result<(), String> { + match self.common.kind.allowed_hosting() { + AllowedHosting::RemoteOnly => { + return Err(format!( + "backend kind {} does not support local hosting", + self.common.kind.as_str() + )); + } + AllowedHosting::LocalOnly | AllowedHosting::Any => {} + } + match self.provider_view() { + LocalBackendProviderView::Ollama(local) | LocalBackendProviderView::Vllm(local) => { + if local.base_url.trim().is_empty() { + return Err(format!( + "local backend kind {} requires base_url", + local.common.kind.as_str() + )); + } + } + LocalBackendProviderView::LlamaCpp(_) + | LocalBackendProviderView::Internal(_) + | LocalBackendProviderView::Unknown(_) => {} + } + Ok(()) + } + + /// Project the local variant into a provider-specific subview. + /// 将 local 变体投影为 provider-specific 子视图。 + pub fn provider_view(&self) -> LocalBackendProviderView<'_> { + match self.common.provider { + CanonicalBackendProvider::Ollama => LocalBackendProviderView::Ollama(self), + CanonicalBackendProvider::LlamaCpp => LocalBackendProviderView::LlamaCpp(self), + CanonicalBackendProvider::Vllm => LocalBackendProviderView::Vllm(self), + CanonicalBackendProvider::Internal => LocalBackendProviderView::Internal(self), + CanonicalBackendProvider::OpenAi | CanonicalBackendProvider::Unknown(_) => { + LocalBackendProviderView::Unknown(self) + } + } + } +} + +impl RemoteBackendConfigView { + /// Validate remote backend minimal constraints. + /// 校验 remote backend 的最小约束。 + fn validate(&self) -> Result<(), String> { + match self.common.kind.allowed_hosting() { + AllowedHosting::LocalOnly => { + return Err(format!( + "backend kind {} does not support remote hosting", + self.common.kind.as_str() + )); + } + AllowedHosting::RemoteOnly | AllowedHosting::Any => {} + } + if self.base_url.trim().is_empty() { + return Err(format!( + "remote backend kind {} requires base_url", + self.common.kind.as_str() + )); + } + Ok(()) + } + + /// Project the remote variant into a provider-specific subview. + /// 将 remote 变体投影为 provider-specific 子视图。 + pub fn provider_view(&self) -> RemoteBackendProviderView<'_> { + match self.common.provider { + CanonicalBackendProvider::OpenAi => RemoteBackendProviderView::OpenAi(self), + CanonicalBackendProvider::Ollama => RemoteBackendProviderView::Ollama(self), + CanonicalBackendProvider::Internal => RemoteBackendProviderView::Internal(self), + CanonicalBackendProvider::LlamaCpp + | CanonicalBackendProvider::Vllm + | CanonicalBackendProvider::Unknown(_) => RemoteBackendProviderView::Unknown(self), + } + } +} + impl Default for AiBackendConfig { fn default() -> Self { Self { @@ -778,6 +1212,89 @@ impl Default for AiBackendConfig { } } +impl AiBackendConfig { + /// Build a typed view from the weak config bag without changing external config shape. + /// 在不改变外部配置结构的前提下,从弱类型配置包构建强类型视图。 + pub fn typed_view(&self) -> Result { + if self.name.trim().is_empty() { + return Err("ai backend name is required".to_string()); + } + let hosting = match self.hosting.as_deref() { + Some(value) if !value.trim().is_empty() => CanonicalBackendHosting::parse(value), + _ => return Err("ai backend hosting is required".to_string()), + }; + let kind = CanonicalBackendKind::parse(&self.kind); + let provider = canonical_provider_for_kind(&kind, self.provider.as_deref())?; + let common = AiBackendCommonView { + name: self.name.trim().to_string(), + kind, + provider, + origin: CanonicalBackendOrigin::parse_optional(self.origin.as_deref()), + deployment_id: self.deployment_id.as_deref().and_then(trimmed_option), + weight: self.weight, + priority: self.priority, + ops: self.ops.clone(), + features: self.features.clone(), + transports: self.transports.clone(), + }; + let model = self.model.as_deref().and_then(trimmed_option); + let credential_ref = self.credential_ref.as_deref().and_then(trimmed_option); + let base_url = self.base_url.trim().to_string(); + + match hosting { + CanonicalBackendHosting::Local => AiBackendTypedView::Local(LocalBackendConfigView { + common, + model, + credential_ref, + base_url, + }) + .validate(), + CanonicalBackendHosting::Remote => { + AiBackendTypedView::Remote(RemoteBackendConfigView { + common, + model, + credential_ref, + base_url, + }) + .validate() + } + CanonicalBackendHosting::Unknown(_) => { + Err("invalid ai backend hosting (expected local|remote)".to_string()) + } + } + } +} + +fn trimmed_option(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn canonical_provider_for_kind( + kind: &CanonicalBackendKind, + raw_provider: Option<&str>, +) -> Result { + let inferred = kind.inferred_provider(); + let Some(provider) = raw_provider.and_then(trimmed_option) else { + return Ok(inferred); + }; + let parsed = CanonicalBackendProvider::parse(&provider); + if matches!(parsed, CanonicalBackendProvider::Unknown(_)) { + return Err(format!("invalid ai backend provider {provider}")); + } + if matches!(inferred, CanonicalBackendProvider::Unknown(_)) || parsed == inferred { + return Ok(parsed); + } + Err(format!( + "backend provider {} does not match kind {}", + parsed.as_str(), + kind.as_str() + )) +} // gRPC server configuration / gRPC服务器配置 // gRPC uses base ServerConfig / gRPC使用基础ServerConfig diff --git a/src/spearlet/controller.rs b/src/spearlet/controller.rs index 006b6a35..ad3ec060 100644 --- a/src/spearlet/controller.rs +++ b/src/spearlet/controller.rs @@ -32,4 +32,3 @@ impl ControllerGroup { } } } - diff --git a/src/spearlet/debug_reporter.rs b/src/spearlet/debug_reporter.rs index 8c0464b8..888fc71d 100644 --- a/src/spearlet/debug_reporter.rs +++ b/src/spearlet/debug_reporter.rs @@ -6,11 +6,15 @@ mod imp { use super::Value; use std::sync::OnceLock; - #[derive(Clone)] + const LOG_HYPOTHESIS_ID: &str = "LOG"; + + #[derive(Clone, Debug)] struct DebugReporterConfig { url: String, session_id: String, run_id: String, + client_name: String, + stream_name: String, } static CFG: OnceLock> = OnceLock::new(); @@ -21,22 +25,55 @@ mod imp { if url.trim().is_empty() { return None; } - let session_id = std::env::var("DEBUG_SESSION_ID") - .unwrap_or_else(|_| "debug-session".to_string()); + let session_id = read_debug_name("DEBUG_SESSION_ID", "default-session"); let run_id = std::env::var("DEBUG_RUN_ID").unwrap_or_else(|_| "runtime".to_string()); + let client_name = read_debug_name("DEBUG_CLIENT_NAME", "default-client"); + let stream_name = read_debug_name("DEBUG_STREAM_NAME", "default-stream"); Some(DebugReporterConfig { url, session_id, run_id, + client_name, + stream_name, }) }) } - fn report_event( + fn read_debug_name(env_name: &str, default: &str) -> String { + std::env::var(env_name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default.to_string()) + } + + fn build_event_payload( + cfg: &DebugReporterConfig, + hypothesis_id: &'static str, + location: &'static str, + msg: String, + data: Value, + stream_name: Option<&str>, + ) -> Value { + serde_json::json!({ + "sessionId": cfg.session_id, + "runId": cfg.run_id, + "clientName": cfg.client_name, + "streamName": stream_name.unwrap_or(&cfg.stream_name), + "hypothesisId": hypothesis_id, + "location": location, + "msg": msg, + "data": data, + "ts": chrono::Utc::now().timestamp_millis(), + }) + } + + fn report_event_with_stream( hypothesis_id: &'static str, location: &'static str, msg: String, data: Value, + stream_name: Option<&str>, ) { let Some(cfg) = cfg().as_ref() else { return; @@ -46,23 +83,10 @@ mod imp { }; let url = cfg.url.clone(); - let session_id = cfg.session_id.clone(); - let run_id = cfg.run_id.clone(); + let payload = build_event_payload(cfg, hypothesis_id, location, msg, data, stream_name); handle.spawn(async move { - let _ = reqwest::Client::new() - .post(url) - .json(&serde_json::json!({ - "sessionId": session_id, - "runId": run_id, - "hypothesisId": hypothesis_id, - "location": location, - "msg": msg, - "data": data, - "ts": chrono::Utc::now().timestamp_millis(), - })) - .send() - .await; + let _ = reqwest::Client::new().post(url).json(&payload).send().await; }); } @@ -71,7 +95,7 @@ mod imp { snapshot: &crate::spearlet::execution::host_api::termination::TerminationSnapshot, msg: String, ) { - report_event( + report_event_with_stream( "A", location, msg, @@ -79,9 +103,77 @@ mod imp { "scope": format!("{:?}", snapshot.scope), "message": snapshot.message.clone().unwrap_or_default(), }), + None, ); } + /// Mirror runtime logs to the debug server while preserving instance/execution context. + /// 在保留 instance/execution 上下文的前提下,把运行时日志镜像到 debug server。 + pub fn report_log( + location: &'static str, + source: &'static str, + level: &str, + message: &str, + task_id: Option<&str>, + execution_id: Option<&str>, + instance_id: Option<&str>, + ) { + let stream_name = match source { + "wasm" => "wasm-log", + _ => "runtime-log", + }; + report_event_with_stream( + LOG_HYPOTHESIS_ID, + location, + message.to_string(), + serde_json::json!({ + "eventType": "log", + "source": source, + "level": level, + "taskId": task_id, + "executionId": execution_id, + "instanceId": instance_id, + }), + Some(stream_name), + ); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn build_event_payload_uses_override_stream_for_logs() { + let cfg = DebugReporterConfig { + url: "http://127.0.0.1:7777/event".to_string(), + session_id: "default-session".to_string(), + run_id: "runtime".to_string(), + client_name: "default-client".to_string(), + stream_name: "default-stream".to_string(), + }; + + let payload = build_event_payload( + &cfg, + LOG_HYPOTHESIS_ID, + "wasm_log_write", + "hello".to_string(), + serde_json::json!({ + "eventType": "log", + "source": "wasm", + "level": "info", + "executionId": "exec-1", + "instanceId": "inst-1", + }), + Some("wasm-log"), + ); + + assert_eq!(payload["clientName"], "default-client"); + assert_eq!(payload["streamName"], "wasm-log"); + assert_eq!(payload["data"]["eventType"], "log"); + assert_eq!(payload["data"]["executionId"], "exec-1"); + assert_eq!(payload["data"]["instanceId"], "inst-1"); + } + } } #[cfg(not(feature = "debug-reporter"))] @@ -93,6 +185,17 @@ mod imp { ) { } + pub fn report_log( + _: &'static str, + _: &'static str, + _: &str, + _: &str, + _: Option<&str>, + _: Option<&str>, + _: Option<&str>, + ) { + } } -pub use imp::report_termination; +#[allow(unused_imports)] +pub use imp::{report_log, report_termination}; diff --git a/src/spearlet/execution/ai/backends/http_json.rs b/src/spearlet/execution/ai/backends/http_json.rs index 28f70fc0..ead24956 100644 --- a/src/spearlet/execution/ai/backends/http_json.rs +++ b/src/spearlet/execution/ai/backends/http_json.rs @@ -151,23 +151,23 @@ where Ok(_) => { let op_for_spawn = operation.clone(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().map_err(|e| CanonicalError { - code: "runtime_error".to_string(), - message: e.to_string(), - retryable: false, - operation: Some(op_for_spawn.clone()), - })?; - rt.block_on(fut) - }) - .join() - .unwrap_or_else(|_| { - Err(CanonicalError { - code: "runtime_error".to_string(), - message: "thread join failed".to_string(), - retryable: false, - operation: Some(operation), + let rt = tokio::runtime::Runtime::new().map_err(|e| CanonicalError { + code: "runtime_error".to_string(), + message: e.to_string(), + retryable: false, + operation: Some(op_for_spawn.clone()), + })?; + rt.block_on(fut) + }) + .join() + .unwrap_or_else(|_| { + Err(CanonicalError { + code: "runtime_error".to_string(), + message: "thread join failed".to_string(), + retryable: false, + operation: Some(operation), + }) }) - }) } Err(_) => { let rt = tokio::runtime::Runtime::new().map_err(|e| CanonicalError { diff --git a/src/spearlet/execution/ai/backends/ollama_chat.rs b/src/spearlet/execution/ai/backends/ollama_chat.rs index 3f8b9f3e..473d408d 100644 --- a/src/spearlet/execution/ai/backends/ollama_chat.rs +++ b/src/spearlet/execution/ai/backends/ollama_chat.rs @@ -1,12 +1,12 @@ use serde_json::{json, Value}; use std::time::Duration; -use crate::spearlet::execution::ai::backends::BackendAdapter; use crate::spearlet::execution::ai::backends::http_json::{ - build_json_payload_response, ensure_chat_operation, filtered_chat_params, - insert_tools_if_any, join_url, parse_json_response_body, post_json_blocking, - require_non_empty_field, upstream_status_error, + build_json_payload_response, ensure_chat_operation, filtered_chat_params, insert_tools_if_any, + join_url, parse_json_response_body, post_json_blocking, require_non_empty_field, + upstream_status_error, }; +use crate::spearlet::execution::ai::backends::BackendAdapter; use crate::spearlet::execution::ai::ir::{ CanonicalError, CanonicalRequestEnvelope, CanonicalResponseEnvelope, Operation, Payload, }; @@ -138,18 +138,11 @@ impl BackendAdapter for OllamaChatBackendAdapter { let url = join_url(&self.base_url, "api/chat"); let timeout = req.timeout_ms.map(Duration::from_millis); - let resp = post_json_blocking( - Operation::ChatCompletions, - url, - body_bytes, - timeout, - None, - )?; + let resp = post_json_blocking(Operation::ChatCompletions, url, body_bytes, timeout, None)?; let status_u16 = resp.status as u16; let ok = (200..300).contains(&status_u16); - let parsed = - parse_json_response_body(req.operation.clone(), resp.status, &resp.body)?; + let parsed = parse_json_response_body(req.operation.clone(), resp.status, &resp.body)?; if !ok { let extra = Self::extract_error_message(&parsed); @@ -180,11 +173,11 @@ impl BackendAdapter for OllamaChatBackendAdapter { #[cfg(test)] mod tests { use super::*; + use crate::spearlet::execution::ai::ir::ResultPayload; use axum::{routing::post, Json, Router}; use serde_json::json; use std::collections::HashMap; use tokio::net::TcpListener; - use crate::spearlet::execution::ai::ir::ResultPayload; fn chat_req(model: &str) -> CanonicalRequestEnvelope { use crate::spearlet::execution::ai::ir::{ diff --git a/src/spearlet/execution/ai/backends/openai_chat_completion.rs b/src/spearlet/execution/ai/backends/openai_chat_completion.rs index f3a069da..f8bb0a02 100644 --- a/src/spearlet/execution/ai/backends/openai_chat_completion.rs +++ b/src/spearlet/execution/ai/backends/openai_chat_completion.rs @@ -2,12 +2,12 @@ use serde_json::{json, Value}; use std::time::Duration; use crate::spearlet::ai::credential_resolver::{CredentialResolution, CredentialResolver}; -use crate::spearlet::execution::ai::backends::BackendAdapter; use crate::spearlet::execution::ai::backends::http_json::{ - build_json_payload_response, ensure_chat_operation, filtered_chat_params, - insert_tools_if_any, join_url, parse_json_response_body, post_json_blocking, - require_non_empty_field, upstream_status_error, + build_json_payload_response, ensure_chat_operation, filtered_chat_params, insert_tools_if_any, + join_url, parse_json_response_body, post_json_blocking, require_non_empty_field, + upstream_status_error, }; +use crate::spearlet::execution::ai::backends::BackendAdapter; use crate::spearlet::execution::ai::ir::{ CanonicalError, CanonicalRequestEnvelope, CanonicalResponseEnvelope, Operation, Payload, }; @@ -182,7 +182,9 @@ impl BackendAdapter for OpenAIChatCompletionBackendAdapter { CredentialResolution::Ready(secret) => Some(secret), CredentialResolution::Disabled | CredentialResolution::NotSynced - | CredentialResolution::Missing if self.credential_ref.is_some() => { + | CredentialResolution::Missing + if self.credential_ref.is_some() => + { return Err(CanonicalError { code: credential_state.code().to_string(), message: credential_state @@ -205,8 +207,7 @@ impl BackendAdapter for OpenAIChatCompletionBackendAdapter { let status_u16 = resp.status as u16; let ok = (200..300).contains(&status_u16); - let parsed = - parse_json_response_body(req.operation.clone(), resp.status, &resp.body)?; + let parsed = parse_json_response_body(req.operation.clone(), resp.status, &resp.body)?; if !ok { let extra = Self::extract_openai_error_message(&parsed); @@ -218,10 +219,7 @@ impl BackendAdapter for OpenAIChatCompletionBackendAdapter { } Ok(build_json_payload_response( - req, - &self.name, - parsed, - resp.body, + req, &self.name, parsed, resp.body, )) } } @@ -237,13 +235,12 @@ mod tests { #[test] fn test_build_body_does_not_require_api_key() { - let adapter = - OpenAIChatCompletionBackendAdapter::new( - "openai", - "https://api.openai.com/v1", - None, - None, - ); + let adapter = OpenAIChatCompletionBackendAdapter::new( + "openai", + "https://api.openai.com/v1", + None, + None, + ); let req = CanonicalRequestEnvelope { version: 1, request_id: "r1".to_string(), @@ -353,9 +350,10 @@ mod tests { #[test] fn test_invoke_returns_credential_disabled_when_dynamic_credential_is_disabled() { - let _guard = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() - .lock() - .expect("lock"); + let _guard = + crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials_test_lock() + .lock() + .expect("lock"); let store = crate::spearlet::ai::dynamic_credential_store::global_dynamic_credentials(); store.clear(); store.set_credentials(vec![CredentialMaterial { diff --git a/src/spearlet/execution/ai/backends/openai_realtime_ws.rs b/src/spearlet/execution/ai/backends/openai_realtime_ws.rs index 502a122f..0636acb4 100644 --- a/src/spearlet/execution/ai/backends/openai_realtime_ws.rs +++ b/src/spearlet/execution/ai/backends/openai_realtime_ws.rs @@ -93,7 +93,9 @@ impl BackendAdapter for OpenAIRealtimeWsBackendAdapter { CredentialResolution::Ready(secret) => Some(secret), CredentialResolution::Disabled | CredentialResolution::NotSynced - | CredentialResolution::Missing if self.credential_ref.is_some() => { + | CredentialResolution::Missing + if self.credential_ref.is_some() => + { return Err(CanonicalError { code: credential_state.code().to_string(), message: credential_state @@ -107,10 +109,7 @@ impl BackendAdapter for OpenAIRealtimeWsBackendAdapter { | CredentialResolution::Missing => None, }; if let Some(api_key) = api_key.as_deref() { - headers.push(( - "authorization".to_string(), - format!("Bearer {}", api_key), - )); + headers.push(("authorization".to_string(), format!("Bearer {}", api_key))); } Ok(StreamingPlan::Websocket(StreamingWebsocketPlan { @@ -195,7 +194,9 @@ fn normalize_transcription_ws_url(ws_url: &str) -> Result (BackendRegistry, SelectionPolicy) { let Some(cfg) = runtime_config.spearlet_config.as_ref() else { - return (BackendRegistry::new(Vec::new()), SelectionPolicy::WeightedRandom); + return ( + BackendRegistry::new(Vec::new()), + SelectionPolicy::WeightedRandom, + ); }; RegistryBuilder::new(runtime_config, cfg).build() } @@ -81,10 +82,6 @@ impl<'a> RegistryBuilder<'a> { return None; } - build_instance_from_config( - b, - Some(&self.credential_resolver), - self.stub_enabled(), - ) + build_instance_from_config(b, Some(&self.credential_resolver), self.stub_enabled()) } } diff --git a/src/spearlet/execution/ai/router/filter_decision.rs b/src/spearlet/execution/ai/router/filter_decision.rs index 5ce27eac..cd62aa5e 100644 --- a/src/spearlet/execution/ai/router/filter_decision.rs +++ b/src/spearlet/execution/ai/router/filter_decision.rs @@ -69,7 +69,9 @@ pub fn apply_filter_response<'a>( outcome.dropped_names.push(name.to_string()); } if let Some(w) = d.weight_override { - outcome.weight_overrides.insert(name.to_string(), w.min(10_000)); + outcome + .weight_overrides + .insert(name.to_string(), w.min(10_000)); } } outcome.dropped_names.sort(); diff --git a/src/spearlet/execution/ai/router/filter_protocol.rs b/src/spearlet/execution/ai/router/filter_protocol.rs index af46336d..210be79f 100644 --- a/src/spearlet/execution/ai/router/filter_protocol.rs +++ b/src/spearlet/execution/ai/router/filter_protocol.rs @@ -72,10 +72,7 @@ fn build_signals(req: &CanonicalRequestEnvelope) -> RequestSignals { } } -fn build_candidates( - candidates: &[&BackendInstance], - max_candidates_sent: usize, -) -> Vec { +fn build_candidates(candidates: &[&BackendInstance], max_candidates_sent: usize) -> Vec { let mut proto_candidates: Vec = Vec::new(); for c in candidates.iter().take(max_candidates_sent.max(1)) { let ops = c diff --git a/src/spearlet/execution/ai/router/filter_worker.rs b/src/spearlet/execution/ai/router/filter_worker.rs index 030ed075..ffdc16a9 100644 --- a/src/spearlet/execution/ai/router/filter_worker.rs +++ b/src/spearlet/execution/ai/router/filter_worker.rs @@ -68,8 +68,9 @@ impl FilterWorker { } if client.is_none() { - let connect_timeout = Duration::from_millis(job.req.decision_timeout_ms.max(1) as u64) - .min(Duration::from_millis(200)); + let connect_timeout = + Duration::from_millis(job.req.decision_timeout_ms.max(1) as u64) + .min(Duration::from_millis(200)); client = self.build_client(connect_timeout).await.ok(); } let Some(mut c) = client.clone() else { diff --git a/src/spearlet/execution/ai/router/grpc_filter_stream.rs b/src/spearlet/execution/ai/router/grpc_filter_stream.rs index 1d3eb931..3a961375 100644 --- a/src/spearlet/execution/ai/router/grpc_filter_stream.rs +++ b/src/spearlet/execution/ai/router/grpc_filter_stream.rs @@ -17,19 +17,17 @@ use std::time::Duration; use crate::proto::spearlet::FilterResponse; use tokio::sync::{mpsc, Semaphore}; +use crate::spearlet::config::RouterGrpcFilterStreamConfig; +use crate::spearlet::execution::ai::ir::{CanonicalError, CanonicalRequestEnvelope}; use crate::spearlet::execution::ai::router::filter_inflight::InflightRegistry; use crate::spearlet::execution::ai::router::filter_protocol::{ build_filter_request, trace_from_response, }; -use crate::spearlet::execution::ai::router::filter_worker::{ - start_background_worker, FilterJob, -}; -use crate::spearlet::config::RouterGrpcFilterStreamConfig; -use crate::spearlet::execution::ai::ir::{CanonicalError, CanonicalRequestEnvelope}; +use crate::spearlet::execution::ai::router::filter_worker::{start_background_worker, FilterJob}; use crate::spearlet::execution::ai::router::registry::BackendInstance; pub use crate::spearlet::execution::ai::router::filter_protocol::{ - requested_model, FinalActionTrace, FilterTrace, + requested_model, FilterTrace, FinalActionTrace, }; static GLOBAL_HUB: OnceLock> = OnceLock::new(); diff --git a/src/spearlet/execution/ai/router/mod.rs b/src/spearlet/execution/ai/router/mod.rs index 3b03a170..8d4f022e 100644 --- a/src/spearlet/execution/ai/router/mod.rs +++ b/src/spearlet/execution/ai/router/mod.rs @@ -1,6 +1,6 @@ +pub mod builder; pub mod candidate_explainer; pub mod capabilities; -pub mod builder; pub mod debug; pub mod filter_decision; pub mod filter_inflight; @@ -14,9 +14,10 @@ pub mod selection; use std::collections::HashMap; use std::sync::Arc; -use crate::spearlet::ai::backend_assembly::{ - build_instance_from_spec, -}; +use crate::spearlet::ai::backend_assembly::build_instance_from_spec; +#[cfg(not(test))] +use crate::spearlet::ai::dynamic_backend_registry::global_dynamic_backends; +use crate::spearlet::ai::dynamic_backend_registry::{DynamicBackendRegistry, DynamicBackendSource}; use crate::spearlet::execution::ai::ir::{CanonicalError, CanonicalRequestEnvelope}; use crate::spearlet::execution::ai::router::candidate_explainer::build_no_candidate_error; use crate::spearlet::execution::ai::router::filter_decision::{ @@ -25,11 +26,6 @@ use crate::spearlet::execution::ai::router::filter_decision::{ use crate::spearlet::execution::ai::router::policy::SelectionPolicy; use crate::spearlet::execution::ai::router::registry::{BackendInstance, BackendRegistry}; use crate::spearlet::execution::ai::router::selection::{select_backend, snapshot_candidates}; -#[cfg(not(test))] -use crate::spearlet::ai::dynamic_backend_registry::global_dynamic_backends; -use crate::spearlet::ai::dynamic_backend_registry::{ - DynamicBackendRegistry, DynamicBackendSource, -}; use parking_lot::RwLock; use tracing::debug; @@ -117,9 +113,9 @@ impl Router { } } - let merged = self.dynamic_backends.list_merged_sorted(&[ - DynamicBackendSource::AiControlPlane, - ]); + let merged = self + .dynamic_backends + .list_merged_sorted(&[DynamicBackendSource::AiControlPlane]); let mut out: Vec = Vec::new(); for b in merged.into_iter() { if let Some(inst) = managed_backend_info_to_instance(b) { @@ -156,7 +152,10 @@ impl Router { /// Collect one merged view of static and dynamic backends. /// 收集静态与动态 backend 的合并视图。 - fn collect_instances<'a>(&'a self, dyns: &'a Arc>) -> Vec<&'a BackendInstance> { + fn collect_instances<'a>( + &'a self, + dyns: &'a Arc>, + ) -> Vec<&'a BackendInstance> { let mut by_name: HashMap = HashMap::new(); for inst in self.registry.instances().iter() { by_name.insert(inst.spec.name.clone(), inst); @@ -332,11 +331,11 @@ mod tests { use crate::spearlet::execution::ai::ir::{ ChatCompletionsPayload, ChatMessage, Operation, Payload, RoutingHints, SpeechToTextPayload, }; - use crate::spearlet::execution::ai::streaming::StreamingPlan; use crate::spearlet::execution::ai::router::capabilities::Capabilities; use crate::spearlet::execution::ai::router::grpc_filter_stream::RouterFilterStreamHub; use crate::spearlet::execution::ai::router::policy::SelectionPolicy; use crate::spearlet::execution::ai::router::registry::Hosting; + use crate::spearlet::execution::ai::streaming::StreamingPlan; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; diff --git a/src/spearlet/execution/ai/router/registry.rs b/src/spearlet/execution/ai/router/registry.rs index 5a4e4de0..0f6c7f4e 100644 --- a/src/spearlet/execution/ai/router/registry.rs +++ b/src/spearlet/execution/ai/router/registry.rs @@ -1,9 +1,9 @@ use std::sync::Arc; +use crate::proto::sms::BackendSpec; use crate::spearlet::execution::ai::backends::BackendAdapter; use crate::spearlet::execution::ai::ir::CanonicalRequestEnvelope; use crate::spearlet::execution::ai::router::capabilities::Capabilities; -use crate::proto::sms::BackendSpec; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Hosting { diff --git a/src/spearlet/execution/ai/router/selection.rs b/src/spearlet/execution/ai/router/selection.rs index befc4327..8c4e5e46 100644 --- a/src/spearlet/execution/ai/router/selection.rs +++ b/src/spearlet/execution/ai/router/selection.rs @@ -167,13 +167,8 @@ mod tests { let a = mk_inst("a", 1); let mut overrides = HashMap::new(); overrides.insert("a".to_string(), 999); - let selected = select_backend( - SelectionPolicy::WeightedRandom, - &req, - vec![&a], - &overrides, - ) - .expect("selection should succeed"); + let selected = select_backend(SelectionPolicy::WeightedRandom, &req, vec![&a], &overrides) + .expect("selection should succeed"); assert_eq!(selected.spec.name, "a"); } } diff --git a/src/spearlet/execution/execution_finalize.rs b/src/spearlet/execution/execution_finalize.rs index 682d7471..d75f24f2 100644 --- a/src/spearlet/execution/execution_finalize.rs +++ b/src/spearlet/execution/execution_finalize.rs @@ -24,7 +24,10 @@ impl FinalExecutionState { /// Build a final state from runtime success/failure flags. /// 根据运行时成功/失败标记构建最终状态。 pub(super) fn from_result_flags(is_successful: bool, has_failed: bool) -> Self { - Self::from_sms_status(result_flags_to_sms_execution_status(is_successful, has_failed)) + Self::from_sms_status(result_flags_to_sms_execution_status( + is_successful, + has_failed, + )) } /// Build a final state directly from runtime execution status. diff --git a/src/spearlet/execution/execution_status.rs b/src/spearlet/execution/execution_status.rs index dc288044..a1f1fc1a 100644 --- a/src/spearlet/execution/execution_status.rs +++ b/src/spearlet/execution/execution_status.rs @@ -93,7 +93,8 @@ impl ExecutionPublicStatus { /// Convert a spearlet execution proto enum value into the normalized public status model. /// 将 spearlet 执行 proto 枚举值转换为规范化公开状态模型。 pub(crate) fn from_spearlet_proto(status: i32) -> Self { - match SpearletExecutionStatus::try_from(status).unwrap_or(SpearletExecutionStatus::Unspecified) + match SpearletExecutionStatus::try_from(status) + .unwrap_or(SpearletExecutionStatus::Unspecified) { SpearletExecutionStatus::Pending => Self::Pending, SpearletExecutionStatus::Running => Self::Running, diff --git a/src/spearlet/execution/host_api/core.rs b/src/spearlet/execution/host_api/core.rs index 83f64921..711ca39f 100644 --- a/src/spearlet/execution/host_api/core.rs +++ b/src/spearlet/execution/host_api/core.rs @@ -1,8 +1,8 @@ -use crate::spearlet::execution::ai::router::Router; -use crate::spearlet::execution::ai::AiEngine; #[cfg(not(test))] use crate::spearlet::execution::ai::engine_holder::global as global_engine_holder; use crate::spearlet::execution::ai::engine_holder::EngineHolder; +use crate::spearlet::execution::ai::router::Router; +use crate::spearlet::execution::ai::AiEngine; use crate::spearlet::execution::host_api::iface::{HttpCallResult, SpearHostApi}; use crate::spearlet::execution::hostcall::fd_table::FdTable; use crate::spearlet::execution::ExecutionError; @@ -216,7 +216,10 @@ impl DefaultHostApi { pub fn new(runtime_config: super::super::runtime::RuntimeConfig) -> Self { #[cfg(test)] let ai_engine_holder = { - let (registry, policy) = crate::spearlet::execution::ai::router::builder::build_registry_from_runtime_config(&runtime_config); + let (registry, policy) = + crate::spearlet::execution::ai::router::builder::build_registry_from_runtime_config( + &runtime_config, + ); let grpc_filter_stream = runtime_config .spearlet_config .as_ref() @@ -346,6 +349,16 @@ impl DefaultHostApi { ); } + crate::spearlet::debug_reporter::report_log( + "wasm_log_write", + "wasm", + level, + message, + task_id.as_deref(), + execution_id_for_entry.as_deref(), + instance_id_for_entry.as_deref(), + ); + match level { "trace" => tracing::trace!( task_id = task_id, diff --git a/src/spearlet/execution/host_api/rtasr/websocket.rs b/src/spearlet/execution/host_api/rtasr/websocket.rs index 52100d61..2cee8406 100644 --- a/src/spearlet/execution/host_api/rtasr/websocket.rs +++ b/src/spearlet/execution/host_api/rtasr/websocket.rs @@ -11,9 +11,8 @@ use super::super::util::{ }; use super::segmentation::maybe_enqueue_autoflush_locked; -type WsStream = tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, ->; +type WsStream = + tokio_tungstenite::WebSocketStream>; type WsWrite = futures::stream::SplitSink; type WsRead = futures::stream::SplitStream; @@ -116,28 +115,27 @@ async fn prepare_vars( let hv = expand_template(v, &vars, global_env); req = req.header(k, hv); } - let resp = tokio::time::timeout(std::time::Duration::from_secs(20), req.json(&body).send()) - .await - .map_err(|_| "prepare http request timed out".to_string())? - .map_err(|e| format!("prepare http request failed: {e}"))?; + let resp = tokio::time::timeout( + std::time::Duration::from_secs(20), + req.json(&body).send(), + ) + .await + .map_err(|_| "prepare http request timed out".to_string())? + .map_err(|e| format!("prepare http request failed: {e}"))?; let status = resp.status(); let bytes = resp .bytes() .await .map_err(|e| format!("prepare http read failed: {e}"))?; - let json_v: serde_json::Value = - serde_json::from_slice(&bytes).map_err(|e| format!("prepare http invalid json: {e}"))?; + let json_v: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| format!("prepare http invalid json: {e}"))?; if !status.is_success() { let msg = json_v .get("error") .and_then(|x| x.get("message")) .and_then(|x| x.as_str()) .unwrap_or("upstream error"); - return Err(format!( - "prepare http failed: {}: {}", - status.as_u16(), - msg - )); + return Err(format!("prepare http failed: {}: {}", status.as_u16(), msg)); } let extracted = extract_json_path(&json_v, &p.extract_json_path) .ok_or_else(|| format!("prepare extract failed: {}", p.extract_json_path))?; @@ -179,7 +177,8 @@ async fn send_client_events( ) -> Result<(), String> { use futures::SinkExt; for ev in client_events.iter() { - let txt = serde_json::to_string(ev).map_err(|e| format!("websocket event encode failed: {e}"))?; + let txt = + serde_json::to_string(ev).map_err(|e| format!("websocket event encode failed: {e}"))?; ws_write .send(tokio_tungstenite::tungstenite::Message::Text(txt)) .await @@ -188,7 +187,10 @@ async fn send_client_events( Ok(()) } -async fn pop_send_item_and_update_mask(table: &FdTable, fd: i32) -> Result, String> { +async fn pop_send_item_and_update_mask( + table: &FdTable, + fd: i32, +) -> Result, String> { let Some(entry) = table.get(fd) else { return Ok(None); }; diff --git a/src/spearlet/execution/manager.rs b/src/spearlet/execution/manager.rs index 864403e1..c4bb6899 100644 --- a/src/spearlet/execution/manager.rs +++ b/src/spearlet/execution/manager.rs @@ -19,17 +19,17 @@ use super::{ RuntimeManager, }, scheduler::{InstanceScheduler, SchedulingPolicy}, + sms_reporter::{SmsAppendLogLine, SmsReporter}, sms_status_adapter::{ local_instance_status_to_sms, local_task_status_to_sms, observed_instance_status_to_sms, runtime_execution_status_to_sms, }, - sms_reporter::{SmsAppendLogLine, SmsReporter}, + task::{Task, TaskId}, task_materializer::{ fetch_sms_task, materialize_local_artifact_from_sms_task, materialize_local_task_from_sms_task, materialize_sms_task, }, task_runtime_cleanup::finalize_local_task_removal, - task::{Task, TaskId}, ExecutionError, ExecutionResult, DEFAULT_ENTRY_FUNCTION_NAME, }; use crate::proto::spearlet::{ExecutionMode as ProtoExecutionMode, InvokeRequest}; @@ -554,13 +554,15 @@ impl TaskExecutionManager { .collect(); for task_id in &local_task_ids { let desired = desired_by_task.get(task_id).copied().unwrap_or(0); - self.reconcile_task_replica_assignment(task_id, desired).await?; + self.reconcile_task_replica_assignment(task_id, desired) + .await?; } for (task_id, desired) in desired_by_task { if local_task_ids.iter().any(|local| local == task_id) { continue; } - self.reconcile_task_replica_assignment(task_id, *desired).await?; + self.reconcile_task_replica_assignment(task_id, *desired) + .await?; } Ok(()) } @@ -617,10 +619,7 @@ impl TaskExecutionManager { Ok(()) } - pub async fn ensure_task_meets_desired_replicas( - &self, - task_id: &str, - ) -> ExecutionResult<()> { + pub async fn ensure_task_meets_desired_replicas(&self, task_id: &str) -> ExecutionResult<()> { let Some(task) = self.get_task_by_id(task_id) else { return Ok(()); }; @@ -659,9 +658,12 @@ impl TaskExecutionManager { &self, task_id_filter: Option<&str>, ) -> ExecutionResult<()> { - let channel = self.sms_channel.clone().ok_or_else(|| ExecutionError::RuntimeError { - message: "sms_grpc_addr is empty".to_string(), - })?; + let channel = self + .sms_channel + .clone() + .ok_or_else(|| ExecutionError::RuntimeError { + message: "sms_grpc_addr is empty".to_string(), + })?; let node_uuid = self.spearlet_config.compute_node_uuid(); let mut client = crate::proto::sms::task_placement_assignment_service_client::TaskPlacementAssignmentServiceClient::new( @@ -684,7 +686,8 @@ impl TaskExecutionManager { if let Some(task_id) = task_id_filter { let desired = desired_by_task.get(task_id).copied().unwrap_or(0); - self.reconcile_task_replica_assignment(task_id, desired).await + self.reconcile_task_replica_assignment(task_id, desired) + .await } else { self.reconcile_node_task_assignments(&desired_by_task).await } @@ -783,7 +786,10 @@ impl TaskExecutionManager { .map(|entry| entry.key().clone()) .collect(); for instance_id in instance_ids { - match self.drain_and_destroy_instance(&instance_id, reason.clone()).await { + match self + .drain_and_destroy_instance(&instance_id, reason.clone()) + .await + { Ok(_) => {} Err(ExecutionError::InstanceNotFound { .. }) => {} Err(e) => return Err(e), @@ -801,7 +807,8 @@ impl TaskExecutionManager { self.desired_task_instances.remove(task_id); self.task_reconcile_locks.remove(task_id); - self.sms_reporter.acknowledge_task_deletion(task_id.to_string()); + self.sms_reporter + .acknowledge_task_deletion(task_id.to_string()); Ok(()) } @@ -1039,8 +1046,13 @@ impl TaskExecutionManager { _ => "failed", } .to_string(); - self.sms_reporter - .update_task_result(&task_id, "".to_string(), status, completed_at, meta); + self.sms_reporter.update_task_result( + &task_id, + "".to_string(), + status, + completed_at, + meta, + ); } } _ => {} @@ -1171,7 +1183,10 @@ impl TaskExecutionManager { )], ) .await; - let _ = self.sms_reporter.finalize_execution_logs(execution_id).await; + let _ = self + .sms_reporter + .finalize_execution_logs(execution_id) + .await; } /// Report the final execution and instance state after completion. / 在完成后上报最终执行态与实例态。 @@ -1308,7 +1323,10 @@ impl TaskExecutionManager { self.flush_wasm_logs_if_needed(instance, execution_id, log_next_seq, wasm_last_seq) .await; - let _ = self.sms_reporter.finalize_execution_logs(execution_id).await; + let _ = self + .sms_reporter + .finalize_execution_logs(execution_id) + .await; let mut metadata = std::collections::HashMap::new(); metadata.insert("error_message".to_string(), error_message.to_string()); @@ -1387,7 +1405,7 @@ impl TaskExecutionManager { &mut wasm_last_seq, &msg, ) - .await; + .await; return Err(e); } }; @@ -1451,8 +1469,13 @@ impl TaskExecutionManager { final_meta, ); - self.flush_wasm_logs_if_needed(&instance, &execution_id, &mut log_next_seq, &mut wasm_last_seq) - .await; + self.flush_wasm_logs_if_needed( + &instance, + &execution_id, + &mut log_next_seq, + &mut wasm_last_seq, + ) + .await; debug!( execution_id = %execution_id, invocation_id = %invocation_id, @@ -1467,7 +1490,7 @@ impl TaskExecutionManager { duration_ms, &mut log_next_seq, ) - .await; + .await; Ok(self.build_finished_execution_response( execution_id, @@ -1525,16 +1548,15 @@ impl TaskExecutionManager { if batch.len() >= 200 { let _ = self .sms_reporter - .append_execution_logs( - execution_id, - next_seq, - std::mem::take(&mut batch), - ) + .append_execution_logs(execution_id, next_seq, std::mem::take(&mut batch)) .await; } } if !batch.is_empty() { - let _ = self.sms_reporter.append_execution_logs(execution_id, next_seq, batch).await; + let _ = self + .sms_reporter + .append_execution_logs(execution_id, next_seq, batch) + .await; } Ok(()) } @@ -1543,12 +1565,6 @@ impl TaskExecutionManager { self.artifacts.get(artifact_id).map(|a| a.clone()) } - /// Compatibility getter retained for external tests and callers. - /// 为外部测试和调用方保留的兼容查询入口。 - pub fn get_artifact(&self, artifact_id: &ArtifactId) -> Option> { - self.get_artifact_by_id(artifact_id) - } - pub fn create_artifact_with_id( &self, artifact_id: String, @@ -1667,8 +1683,10 @@ impl TaskExecutionManager { &self, task_id: &str, ) -> ExecutionResult> { - let sms_task = fetch_sms_task(self.sms_channel.clone(), &self.spearlet_config, task_id).await?; - self.materialize_local_task_from_sms_snapshot(&sms_task).await + let sms_task = + fetch_sms_task(self.sms_channel.clone(), &self.spearlet_config, task_id).await?; + self.materialize_local_task_from_sms_snapshot(&sms_task) + .await } /// Sync a task from an SMS create event into local runtime state and mark it ready. @@ -1709,10 +1727,7 @@ impl TaskExecutionManager { } /// Build instance config and inject artifact snapshot when available. / 构建实例配置,并在可用时注入 artifact 快照。 - fn prepare_instance_config( - &self, - task: &Arc, - ) -> super::instance::InstanceConfig { + fn prepare_instance_config(&self, task: &Arc) -> super::instance::InstanceConfig { let mut instance_config = task.create_instance_config(); if let Some(artifact_entry) = self.artifacts.get(task.artifact_id()) { let artifact = artifact_entry.value(); @@ -1806,7 +1821,10 @@ impl TaskExecutionManager { } /// Stop instance / 停止实例 - async fn stop_and_unregister_instance(&self, instance: &Arc) -> ExecutionResult<()> { + async fn stop_and_unregister_instance( + &self, + instance: &Arc, + ) -> ExecutionResult<()> { let runtime = self .runtime_manager .get_runtime(&instance.config.runtime_type) @@ -1977,9 +1995,7 @@ impl TaskExecutionManager { let instance_status = self .instances .get(&inflight_execution.instance_id) - .map(|instance| { - observed_instance_status_to_sms(&instance.status(), false) as i32 - }) + .map(|instance| observed_instance_status_to_sms(&instance.status(), false) as i32) .unwrap_or(crate::proto::sms::InstanceStatus::Unknown as i32); self.report_final_execution_state( &inflight_execution.invocation_id, @@ -2028,7 +2044,8 @@ impl TaskExecutionManager { &self, ev: ExecutionCompletionEvent, ) -> ExecutionResult<()> { - let Some((_, inflight_execution)) = self.inflight_async_executions.remove(&ev.execution_id) else { + let Some((_, inflight_execution)) = self.inflight_async_executions.remove(&ev.execution_id) + else { return Ok(()); }; @@ -2289,16 +2306,16 @@ impl TaskExecutionManager { match error { RuntimeExecutionError::InstanceNotFound { instance_id } => { format!("Instance not found: {}", instance_id) - }, + } RuntimeExecutionError::InstanceNotReady { instance_id } => { format!("Instance not ready: {}", instance_id) - }, + } RuntimeExecutionError::ExecutionTimeout { timeout_ms } => { format!("Execution timeout after {} ms", timeout_ms) - }, + } RuntimeExecutionError::ResourceLimitExceeded { resource, limit } => { format!("Resource limit exceeded: {} (limit: {})", resource, limit) - }, + } RuntimeExecutionError::ConfigurationError { message } => message.clone(), RuntimeExecutionError::RuntimeError { message } => message.clone(), RuntimeExecutionError::IoError { message } => message.clone(), diff --git a/src/spearlet/execution/manager_tests.rs b/src/spearlet/execution/manager_tests.rs index 1e98c974..f285fc00 100644 --- a/src/spearlet/execution/manager_tests.rs +++ b/src/spearlet/execution/manager_tests.rs @@ -68,10 +68,7 @@ impl Runtime for DummyRuntime { ) -> super::ExecutionResult<()> { Ok(()) } - fn validate_config( - &self, - _config: &instance::InstanceConfig, - ) -> super::ExecutionResult<()> { + fn validate_config(&self, _config: &instance::InstanceConfig) -> super::ExecutionResult<()> { Ok(()) } fn get_capabilities(&self) -> RuntimeCapabilities { @@ -148,10 +145,7 @@ impl Runtime for DelayedRuntime { ) -> super::ExecutionResult<()> { Ok(()) } - fn validate_config( - &self, - _config: &instance::InstanceConfig, - ) -> super::ExecutionResult<()> { + fn validate_config(&self, _config: &instance::InstanceConfig) -> super::ExecutionResult<()> { Ok(()) } fn get_capabilities(&self) -> RuntimeCapabilities { @@ -399,10 +393,7 @@ async fn test_invocation_requires_preprovisioned_instance() { }) .await .unwrap_err(); - assert!(matches!( - error, - ExecutionError::SchedulingError { .. } - )); + assert!(matches!(error, ExecutionError::SchedulingError { .. })); } #[tokio::test] @@ -566,10 +557,15 @@ async fn test_underprovisioned_task_is_reconciled_back_to_desired_replicas() { .unwrap(); let task = manager.get_task_by_id("task-self-heal").unwrap(); - let current_instances: Vec<_> = - task.instances.iter().map(|entry| entry.key().clone()).collect(); + let current_instances: Vec<_> = task + .instances + .iter() + .map(|entry| entry.key().clone()) + .collect(); assert_eq!(current_instances.len(), 2); - assert!(!current_instances.iter().any(|id| id == &initial_instances[0])); + assert!(!current_instances + .iter() + .any(|id| id == &initial_instances[0])); } #[tokio::test] @@ -638,7 +634,10 @@ async fn test_stop_instance_removes_from_task_and_manager() { assert_eq!(task.instance_count(), 1); assert!(manager.get_instance(&instance.id().to_string()).is_some()); - manager.stop_and_unregister_instance(&instance).await.unwrap(); + manager + .stop_and_unregister_instance(&instance) + .await + .unwrap(); assert_eq!(task.instance_count(), 0); assert!(task.get_instance(instance.id()).is_none()); @@ -745,7 +744,9 @@ async fn test_destroy_instance_clears_active_execution_registry() { Some("instance destroyed: test destroy") ); assert!(manager.get_instance(&instance.id().to_string()).is_none()); - assert!(manager.active_execution_ids_for_instance(instance.id()).is_empty()); + assert!(manager + .active_execution_ids_for_instance(instance.id()) + .is_empty()); } #[tokio::test] diff --git a/src/spearlet/execution/mod.rs b/src/spearlet/execution/mod.rs index 48d9f71f..86c2ed97 100644 --- a/src/spearlet/execution/mod.rs +++ b/src/spearlet/execution/mod.rs @@ -47,11 +47,11 @@ pub mod manager; pub mod pool; pub mod runtime; pub mod scheduler; -mod sms_status_adapter; mod sms_reporter; +mod sms_status_adapter; pub mod task; -pub(crate) mod task_public_status; mod task_materializer; +pub(crate) mod task_public_status; mod task_runtime_cleanup; /// Default entry function name placeholder. diff --git a/src/spearlet/execution/runtime/wasm.rs b/src/spearlet/execution/runtime/wasm.rs index f1786a31..96d6e809 100644 --- a/src/spearlet/execution/runtime/wasm.rs +++ b/src/spearlet/execution/runtime/wasm.rs @@ -541,10 +541,13 @@ impl WasmRuntime { function_name: &str, timeout_ms: Option, context_data: &std::collections::HashMap, - _input_data: &[u8], + input_data: &[u8], ) -> ExecutionResult> { let start_time = Instant::now(); + #[cfg(feature = "wasmedge")] + let _ = input_data; + #[cfg(feature = "wasmedge")] let output = { let _exec_guard = match instance_handle.exec_lock.try_lock() { @@ -608,6 +611,7 @@ impl WasmRuntime { function_name, input_data.len() ) + .into_bytes() }; let execution_time = start_time.elapsed(); diff --git a/src/spearlet/execution/runtime/wasm_hostcalls.rs b/src/spearlet/execution/runtime/wasm_hostcalls.rs index 1b9d5ad6..7f79feb9 100644 --- a/src/spearlet/execution/runtime/wasm_hostcalls.rs +++ b/src/spearlet/execution/runtime/wasm_hostcalls.rs @@ -17,9 +17,6 @@ use wasmedge_sys::ffi; use wasmedge_sys::instance::function::AsFunc; use wasmedge_sys::{Executor, Function}; -// Helper function to extract DefaultHostApi from host data -fn _unused() {} - const SPEAR_ERR_INVALID_FD: i32 = -SPEAR_EBADF; const SPEAR_ERR_INVALID_PTR: i32 = -SPEAR_EFAULT; const SPEAR_ERR_BUFFER_TOO_SMALL: i32 = -SPEAR_ENOSPC; diff --git a/src/spearlet/execution/sms_reporter.rs b/src/spearlet/execution/sms_reporter.rs index 01bc6227..da8266ba 100644 --- a/src/spearlet/execution/sms_reporter.rs +++ b/src/spearlet/execution/sms_reporter.rs @@ -5,13 +5,12 @@ use tokio::time::timeout; use tonic::transport::Channel; use crate::proto::sms::{ - DeleteInstanceRequest, execution_log_ingest_service_client::ExecutionLogIngestServiceClient, execution_registry_service_client::ExecutionRegistryServiceClient, instance_registry_service_client::InstanceRegistryServiceClient, - task_service_client::TaskServiceClient, CompleteTaskDeletionRequest, Execution, ExecutionLogLine, - FinalizeExecutionLogsRequest, Instance, TaskStatus, UpdateTaskResultRequest, - UpdateTaskStatusRequest, + task_service_client::TaskServiceClient, CompleteTaskDeletionRequest, DeleteInstanceRequest, + Execution, ExecutionLogLine, FinalizeExecutionLogsRequest, Instance, TaskStatus, + UpdateTaskResultRequest, UpdateTaskStatusRequest, }; use crate::spearlet::config::SpearletConfig; @@ -212,7 +211,10 @@ impl SmsReporter { message: format!("DeleteInstance timed out for instance {}", instance_id), })? .map_err(|e| ExecutionError::RuntimeError { - message: format!("DeleteInstance RPC failed for instance {}: {}", instance_id, e), + message: format!( + "DeleteInstance RPC failed for instance {}: {}", + instance_id, e + ), })?; Ok(()) } @@ -359,7 +361,11 @@ impl SmsReporter { let request = tonic::Request::new(FinalizeExecutionLogsRequest { execution_id: execution_id.to_string(), }); - let _ = timeout(self.timeout_window(), client.finalize_execution_logs(request)).await; + let _ = timeout( + self.timeout_window(), + client.finalize_execution_logs(request), + ) + .await; Ok(()) } } diff --git a/src/spearlet/execution/sms_status_adapter.rs b/src/spearlet/execution/sms_status_adapter.rs index 3ab933be..3c8e59b5 100644 --- a/src/spearlet/execution/sms_status_adapter.rs +++ b/src/spearlet/execution/sms_status_adapter.rs @@ -4,8 +4,8 @@ use crate::proto::sms::{ }; use super::{ - instance::InstanceStatus as LocalInstanceStatus, runtime::ExecutionStatus as RuntimeExecutionStatus, - task::TaskStatus as LocalTaskStatus, + instance::InstanceStatus as LocalInstanceStatus, + runtime::ExecutionStatus as RuntimeExecutionStatus, task::TaskStatus as LocalTaskStatus, }; /// Map runtime execution status into the SMS execution status model. @@ -62,9 +62,7 @@ pub(super) fn local_task_status_to_sms( /// Map local instance lifecycle into the SMS instance status model. /// 将本地 instance 生命周期映射到 SMS instance 状态模型。 -pub(super) fn local_instance_status_to_sms( - status: &LocalInstanceStatus, -) -> SmsInstanceStatus { +pub(super) fn local_instance_status_to_sms(status: &LocalInstanceStatus) -> SmsInstanceStatus { match status { LocalInstanceStatus::Creating | LocalInstanceStatus::Starting => SmsInstanceStatus::Unknown, LocalInstanceStatus::Ready => SmsInstanceStatus::Idle, diff --git a/src/spearlet/execution/task_materializer.rs b/src/spearlet/execution/task_materializer.rs index baf47f8f..de64bb8e 100644 --- a/src/spearlet/execution/task_materializer.rs +++ b/src/spearlet/execution/task_materializer.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, sync::Arc, time::{Duration, Instant}}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; use sha2::Digest; use tokio::time::timeout; @@ -17,27 +21,32 @@ use super::{ /// Build a stable artifact id and spec from an SMS task definition. /// 从 SMS task 定义构建稳定的 artifact id 和 spec。 fn artifact_spec_from_sms_task(sms_task: &SmsTask) -> (String, ArtifactSpec) { - let (runtime_type, location_opt, checksum_opt, env) = if let Some(executable) = &sms_task.executable - { - let runtime_type = match executable.r#type { - 3 => RuntimeType::Kubernetes, - 4 => RuntimeType::Wasm, - _ => RuntimeType::Process, - }; - let location_opt = if executable.uri.is_empty() { - None - } else { - Some(executable.uri.clone()) - }; - let checksum_opt = if executable.checksum_sha256.is_empty() { - None + let (runtime_type, location_opt, checksum_opt, env) = + if let Some(executable) = &sms_task.executable { + let runtime_type = match executable.r#type { + 3 => RuntimeType::Kubernetes, + 4 => RuntimeType::Wasm, + _ => RuntimeType::Process, + }; + let location_opt = if executable.uri.is_empty() { + None + } else { + Some(executable.uri.clone()) + }; + let checksum_opt = if executable.checksum_sha256.is_empty() { + None + } else { + Some(executable.checksum_sha256.clone()) + }; + ( + runtime_type, + location_opt, + checksum_opt, + executable.env.clone(), + ) } else { - Some(executable.checksum_sha256.clone()) + (RuntimeType::Process, None, None, HashMap::new()) }; - (runtime_type, location_opt, checksum_opt, executable.env.clone()) - } else { - (RuntimeType::Process, None, None, HashMap::new()) - }; let artifact_id = if let Some(checksum) = &checksum_opt { checksum.clone() @@ -149,8 +158,11 @@ pub(super) async fn fetch_sms_task( message: last_error.unwrap_or_else(|| "connect sms timeout".to_string()), }); } - let per_attempt = remaining.min(Duration::from_secs(5)).max(Duration::from_millis(1)); - let mut client = crate::proto::sms::task_service_client::TaskServiceClient::new(channel.clone()); + let per_attempt = remaining + .min(Duration::from_secs(5)) + .max(Duration::from_millis(1)); + let mut client = + crate::proto::sms::task_service_client::TaskServiceClient::new(channel.clone()); let request = client.get_task(crate::proto::sms::GetTaskRequest { task_id: task_id.to_string(), }); diff --git a/src/spearlet/function_service.rs b/src/spearlet/function_service.rs index 20733349..4e31fd1d 100644 --- a/src/spearlet/function_service.rs +++ b/src/spearlet/function_service.rs @@ -20,8 +20,9 @@ use crate::proto::spearlet::{ use crate::spearlet::execution::{ execution_status::ExecutionPublicStatus, runtime::{ResourcePoolConfig, RuntimeConfig, RuntimeFactory, RuntimeManager}, - ExecutionError, InstancePool, InstancePoolConfig, InstanceScheduler, SchedulingPolicy, - TaskExecutionManager, TaskExecutionManagerConfig, DEFAULT_ENTRY_FUNCTION_NAME, + ExecutionError, ExecutionResponse, InstancePool, InstancePoolConfig, InstanceScheduler, + SchedulingPolicy, TaskExecutionManager, TaskExecutionManagerConfig, + DEFAULT_ENTRY_FUNCTION_NAME, }; use crate::spearlet::SpearletConfig; @@ -39,6 +40,7 @@ pub struct FunctionServiceStats { } /// Function service implementation / 函数服务实现 +#[derive(Clone)] pub struct FunctionServiceImpl { /// Task execution manager / 任务执行管理器 execution_manager: Arc, @@ -112,6 +114,13 @@ impl FunctionServiceImpl { ExecutionPublicStatus::from_public_str(status).to_spearlet_proto() } + fn execution_error_to_proto(error_message: Option) -> Option { + error_message.map(|message| ProtoError { + code: "EXECUTION_ERROR".to_string(), + message, + }) + } + fn system_time_to_timestamp(t: SystemTime) -> Option { let d = t.duration_since(UNIX_EPOCH).ok()?; Some(prost_types::Timestamp { @@ -120,7 +129,7 @@ impl FunctionServiceImpl { }) } - async fn invoke_once(&self, mut req: InvokeRequest) -> Result { + fn normalize_invoke_request(&self, mut req: InvokeRequest) -> Result { if req.invocation_id.is_empty() { req.invocation_id = Uuid::new_v4().to_string(); } @@ -136,7 +145,91 @@ impl FunctionServiceImpl { if req.mode == 0 { req.mode = ExecutionMode::Sync as i32; } + Ok(req) + } + + fn payload_with_content_type( + content_type: String, + data: Vec, + include_output: bool, + ) -> Option { + Some(Payload { + content_type, + data: if include_output { data } else { Vec::new() }, + }) + } + fn invoke_response_from_execution( + &self, + invocation_id: String, + execution_id: String, + input_content_type: String, + execution: ExecutionResponse, + ) -> InvokeResponse { + let completed = execution.is_completed(); + let timestamp = execution.timestamp; + InvokeResponse { + invocation_id, + execution_id, + instance_id: execution.instance_id, + status: Self::to_proto_status(execution.status.as_str()), + output: Self::payload_with_content_type( + input_content_type, + execution.output_data, + true, + ), + error: Self::execution_error_to_proto(execution.error_message), + started_at: Self::system_time_to_timestamp(timestamp), + completed_at: if completed { + Self::system_time_to_timestamp(timestamp) + } else { + None + }, + } + } + + fn execution_to_proto( + execution: ExecutionResponse, + include_output: bool, + output_content_type: &str, + ) -> Execution { + let completed = execution.is_completed(); + let timestamp = execution.timestamp; + Execution { + invocation_id: execution.invocation_id, + execution_id: execution.execution_id, + task_id: execution.task_id, + function_name: execution.function_name, + instance_id: execution.instance_id, + status: Self::to_proto_status(execution.status.as_str()), + output: Self::payload_with_content_type( + output_content_type.to_string(), + execution.output_data, + include_output, + ), + error: Self::execution_error_to_proto(execution.error_message), + started_at: Self::system_time_to_timestamp(timestamp), + completed_at: if completed { + Self::system_time_to_timestamp(timestamp) + } else { + None + }, + } + } + + fn map_terminate_execution_error(error: ExecutionError) -> Status { + match error { + ExecutionError::InvalidRequest { message } + if message.starts_with("execution not found:") => + { + Status::not_found(message) + } + other => Status::internal(other.to_string()), + } + } + + async fn invoke_once(&self, req: InvokeRequest) -> Result { + let req = self.normalize_invoke_request(req)?; let execution_id = req.execution_id.clone(); let invocation_id = req.invocation_id.clone(); let input_ct = req @@ -151,33 +244,7 @@ impl FunctionServiceImpl { .await .map_err(|e| Status::internal(e.to_string()))?; - let instance_id = resp.instance_id.clone(); - let status = Self::to_proto_status(resp.status.as_str()); - let error = resp.error_message.clone().map(|m| ProtoError { - code: "EXECUTION_ERROR".to_string(), - message: m, - }); - let completed = resp.is_completed(); - let timestamp = resp.timestamp; - let output_data = resp.output_data; - - Ok(InvokeResponse { - invocation_id, - execution_id, - instance_id, - status, - output: Some(Payload { - content_type: input_ct, - data: output_data, - }), - error, - started_at: Self::system_time_to_timestamp(timestamp), - completed_at: if completed { - Self::system_time_to_timestamp(timestamp) - } else { - None - }, - }) + Ok(self.invoke_response_from_execution(invocation_id, execution_id, input_ct, resp)) } /// Get service statistics / 获取服务统计信息 @@ -202,6 +269,12 @@ impl FunctionServiceImpl { } } +impl From> for FunctionServiceImpl { + fn from(value: Arc) -> Self { + value.as_ref().clone() + } +} + #[tonic::async_trait] impl InvocationService for FunctionServiceImpl { async fn invoke( @@ -220,49 +293,17 @@ impl ExecutionService for FunctionServiceImpl { request: Request, ) -> Result, Status> { let req = request.into_inner(); - let Some(resp) = self.execution_manager.get_execution_status(&req.execution_id) else { + let Some(resp) = self + .execution_manager + .get_execution_status(&req.execution_id) + else { return Err(Status::not_found("execution not found")); }; - - let invocation_id = resp.invocation_id.clone(); - let task_id = resp.task_id.clone(); - let function_name = resp.function_name.clone(); - let instance_id = resp.instance_id.clone(); - - let output = if req.include_output { - Some(Payload { - content_type: "application/octet-stream".to_string(), - data: resp.output_data.clone(), - }) - } else { - Some(Payload { - content_type: "application/octet-stream".to_string(), - data: Vec::new(), - }) - }; - - let status = Self::to_proto_status(resp.status.as_str()); - let error = resp.error_message.clone().map(|m| ProtoError { - code: "EXECUTION_ERROR".to_string(), - message: m, - }); - - Ok(Response::new(Execution { - invocation_id, - execution_id: resp.execution_id.clone(), - task_id, - function_name, - instance_id, - status, - output, - error, - started_at: Self::system_time_to_timestamp(resp.timestamp), - completed_at: if resp.is_completed() { - Self::system_time_to_timestamp(resp.timestamp) - } else { - None - }, - })) + Ok(Response::new(Self::execution_to_proto( + resp, + req.include_output, + "application/octet-stream", + ))) } async fn terminate_execution( @@ -278,14 +319,7 @@ impl ExecutionService for FunctionServiceImpl { self.execution_manager .request_execution_termination(&req.execution_id, reason) .await - .map_err(|e| match e { - ExecutionError::InvalidRequest { message } - if message.starts_with("execution not found:") => - { - Status::not_found(message) - } - _ => Status::internal(e.to_string()), - })?; + .map_err(Self::map_terminate_execution_error)?; Ok(Response::new(TerminateExecutionResponse { success: true, @@ -316,28 +350,7 @@ impl ExecutionService for FunctionServiceImpl { let executions = items .into_iter() - .map(|r| Execution { - invocation_id: r.invocation_id.clone(), - execution_id: r.execution_id.clone(), - task_id: r.task_id.clone(), - function_name: r.function_name.clone(), - instance_id: r.instance_id.clone(), - status: Self::to_proto_status(r.status.as_str()), - output: Some(Payload { - content_type: "application/octet-stream".to_string(), - data: Vec::new(), - }), - error: r.error_message.clone().map(|m| ProtoError { - code: "EXECUTION_ERROR".to_string(), - message: m, - }), - started_at: Self::system_time_to_timestamp(r.timestamp), - completed_at: if r.is_completed() { - Self::system_time_to_timestamp(r.timestamp) - } else { - None - }, - }) + .map(|execution| Self::execution_to_proto(execution, false, "application/octet-stream")) .collect(); Ok(Response::new(ListExecutionsResponse { @@ -347,43 +360,11 @@ impl ExecutionService for FunctionServiceImpl { } } -#[tonic::async_trait] -impl InvocationService for Arc { - async fn invoke( - &self, - request: Request, - ) -> Result, Status> { - (**self).invoke(request).await - } -} - -#[tonic::async_trait] -impl ExecutionService for Arc { - async fn get_execution( - &self, - request: Request, - ) -> Result, Status> { - (**self).get_execution(request).await - } - - async fn terminate_execution( - &self, - request: Request, - ) -> Result, Status> { - (**self).terminate_execution(request).await - } - - async fn list_executions( - &self, - request: Request, - ) -> Result, Status> { - (**self).list_executions(request).await - } -} - #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::time::SystemTime; #[tokio::test] async fn test_function_service_initializes_runtimes() { @@ -397,4 +378,60 @@ mod tests { assert!(types.contains(&crate::spearlet::execution::RuntimeType::Wasm)); assert!(types.contains(&crate::spearlet::execution::RuntimeType::Kubernetes)); } + + #[tokio::test] + async fn test_normalize_invoke_request_applies_defaults() { + let service = + FunctionServiceImpl::new(Arc::new(crate::spearlet::SpearletConfig::default()), None) + .await + .unwrap(); + + let normalized = service + .normalize_invoke_request(InvokeRequest { + invocation_id: String::new(), + execution_id: String::new(), + task_id: "task-a".to_string(), + function_name: String::new(), + input: None, + headers: HashMap::new(), + environment: HashMap::new(), + mode: 0, + session_id: String::new(), + force_new_instance: false, + metadata: HashMap::new(), + timeout_ms: 0, + }) + .unwrap(); + + assert!(!normalized.invocation_id.is_empty()); + assert!(!normalized.execution_id.is_empty()); + assert_eq!( + normalized.function_name, + DEFAULT_ENTRY_FUNCTION_NAME.to_string() + ); + assert_eq!(normalized.mode, ExecutionMode::Sync as i32); + } + + #[test] + fn test_execution_to_proto_hides_output_when_not_requested() { + let execution = ExecutionResponse { + execution_id: "exec-1".to_string(), + invocation_id: "inv-1".to_string(), + task_id: "task-1".to_string(), + function_name: "run".to_string(), + instance_id: "inst-1".to_string(), + output_data: b"payload".to_vec(), + status: "completed".to_string(), + error_message: None, + execution_time_ms: 12, + metadata: HashMap::new(), + timestamp: SystemTime::now(), + }; + + let proto = + FunctionServiceImpl::execution_to_proto(execution, false, "application/octet-stream"); + + assert_eq!(proto.output.unwrap().data, Vec::::new()); + assert_eq!(proto.status, ExecutionStatus::Completed as i32); + } } diff --git a/src/spearlet/grpc_server.rs b/src/spearlet/grpc_server.rs index ec52420b..da4aafd1 100644 --- a/src/spearlet/grpc_server.rs +++ b/src/spearlet/grpc_server.rs @@ -22,11 +22,11 @@ pub struct GrpcServer { /// Server configuration / 服务器配置 config: Arc, /// Object service implementation / 对象服务实现 - object_service: Arc, + object_service: ObjectServiceImpl, /// Function service implementation / 函数服务实现 - function_service: Arc, + function_service: FunctionServiceImpl, /// Instance service implementation / 实例服务实现 - instance_service: Arc, + instance_service: InstanceServiceImpl, } impl GrpcServer { @@ -35,12 +35,9 @@ impl GrpcServer { config: Arc, sms_channel: Option, ) -> Result> { - let object_service = Arc::new(ObjectServiceImpl::new_with_memory( - config.storage.max_object_size, - )); - let function_service = - Arc::new(FunctionServiceImpl::new(config.clone(), sms_channel).await?); - let instance_service = Arc::new(InstanceServiceImpl::new(function_service.clone())); + let object_service = ObjectServiceImpl::new_with_memory(config.storage.max_object_size); + let function_service = FunctionServiceImpl::new(config.clone(), sms_channel).await?; + let instance_service = InstanceServiceImpl::new(function_service.clone()); Ok(Self { config, @@ -50,13 +47,13 @@ impl GrpcServer { }) } - /// Get object service reference / 获取对象服务引用 - pub fn get_object_service(&self) -> Arc { + /// Get object service snapshot / 获取对象服务快照 + pub fn get_object_service(&self) -> ObjectServiceImpl { self.object_service.clone() } - /// Get function service reference / 获取函数服务引用 - pub fn get_function_service(&self) -> Arc { + /// Get function service snapshot / 获取函数服务快照 + pub fn get_function_service(&self) -> FunctionServiceImpl { self.function_service.clone() } @@ -117,10 +114,10 @@ impl GrpcServer { ) -> Result< ( SocketAddr, - ObjectServiceServer>, - InvocationServiceServer>, - ExecutionServiceServer>, - InstanceServiceServer>, + ObjectServiceServer, + InvocationServiceServer, + ExecutionServiceServer, + InstanceServiceServer, ), Box, > { @@ -155,18 +152,19 @@ impl GrpcServer { /// Health service for monitoring / 用于监控的健康服务 pub struct HealthService { - object_service: Arc, - function_service: Arc, + object_service: ObjectServiceImpl, + function_service: FunctionServiceImpl, } impl HealthService { - pub fn new( - object_service: Arc, - function_service: Arc, - ) -> Self { + pub fn new(object_service: T, function_service: U) -> Self + where + T: Into, + U: Into, + { Self { - object_service, - function_service, + object_service: object_service.into(), + function_service: function_service.into(), } } diff --git a/src/spearlet/grpc_server_test.rs b/src/spearlet/grpc_server_test.rs index dc1f7ea1..fc690b1b 100644 --- a/src/spearlet/grpc_server_test.rs +++ b/src/spearlet/grpc_server_test.rs @@ -35,7 +35,7 @@ async fn test_grpc_server_creation() { // Verify server has object service / 验证服务器有对象服务 let object_service = server.get_object_service(); - assert!(Arc::strong_count(&object_service) > 0); + assert_eq!(object_service.get_stats().await.object_count, 0); } #[tokio::test] @@ -50,7 +50,7 @@ async fn test_grpc_server_config() { let object_service = server.get_object_service(); // Verify object service is created / 验证对象服务已创建 - assert!(Arc::strong_count(&object_service) > 0); + assert_eq!(object_service.get_stats().await.object_count, 0); } #[tokio::test] @@ -126,8 +126,20 @@ async fn test_multiple_grpc_servers() { let service1 = server1.get_object_service(); let service2 = server2.get_object_service(); - // Services should be different instances / 服务应该是不同的实例 - assert!(!Arc::ptr_eq(&service1, &service2)); + // Different servers should keep isolated object stores / 不同服务器应保持各自独立的对象存储 + use crate::proto::spearlet::{object_service_server::ObjectService, PutObjectRequest}; + use tonic::Request; + + let put_request = Request::new(PutObjectRequest { + key: "server-1-only".to_string(), + value: b"value".to_vec(), + metadata: std::collections::HashMap::new(), + overwrite: false, + }); + service1.put_object(put_request).await.unwrap(); + + assert_eq!(service1.get_stats().await.object_count, 1); + assert_eq!(service2.get_stats().await.object_count, 0); } #[tokio::test] @@ -142,7 +154,7 @@ async fn test_grpc_server_tls_config() { let object_service = server.get_object_service(); // Verify object service is created even with TLS config / 验证即使有TLS配置也能创建对象服务 - assert!(Arc::strong_count(&object_service) > 0); + assert_eq!(object_service.get_stats().await.object_count, 0); } #[tokio::test] @@ -160,8 +172,20 @@ async fn test_grpc_server_different_ports() { let service1 = server1.get_object_service(); let service2 = server2.get_object_service(); - // Services should be different instances / 服务应该是不同的实例 - assert!(!Arc::ptr_eq(&service1, &service2)); + // Different ports should still keep isolated stores / 不同端口的服务仍应保持独立存储 + use crate::proto::spearlet::{object_service_server::ObjectService, PutObjectRequest}; + use tonic::Request; + + let put_request = Request::new(PutObjectRequest { + key: "port-1-only".to_string(), + value: b"value".to_vec(), + metadata: std::collections::HashMap::new(), + overwrite: false, + }); + service1.put_object(put_request).await.unwrap(); + + assert_eq!(service1.get_stats().await.object_count, 1); + assert_eq!(service2.get_stats().await.object_count, 0); } #[tokio::test] @@ -207,7 +231,7 @@ async fn test_grpc_server_edge_cases() { let server = GrpcServer::new(Arc::new(config), None).await.unwrap(); let object_service = server.get_object_service(); - assert!(Arc::strong_count(&object_service) > 0); + assert_eq!(object_service.get_stats().await.object_count, 0); // Test with very high port number / 测试非常高的端口号 let mut config2 = create_test_config(); @@ -215,7 +239,7 @@ async fn test_grpc_server_edge_cases() { let server2 = GrpcServer::new(Arc::new(config2), None).await.unwrap(); let object_service2 = server2.get_object_service(); - assert!(Arc::strong_count(&object_service2) > 0); + assert_eq!(object_service2.get_stats().await.object_count, 0); } #[tokio::test] @@ -254,7 +278,7 @@ async fn test_grpc_server_concurrent_creation() { join_set.spawn(async move { let server = GrpcServer::new(Arc::new(config), None).await.unwrap(); let object_service = server.get_object_service(); - Arc::strong_count(&object_service) > 0 + object_service.get_stats().await.object_count == 0 }); } @@ -302,7 +326,7 @@ mod integration_tests { for _ in 0..100 { let server = GrpcServer::new(config.clone(), None).await.unwrap(); let object_service = server.get_object_service(); - assert!(Arc::strong_count(&object_service) > 0); + assert_eq!(object_service.get_stats().await.object_count, 0); // Server goes out of scope and is dropped / 服务器超出作用域并被丢弃 } } diff --git a/src/spearlet/http_gateway.rs b/src/spearlet/http_gateway.rs index cc246b4c..74db8765 100644 --- a/src/spearlet/http_gateway.rs +++ b/src/spearlet/http_gateway.rs @@ -1,7 +1,9 @@ //! HTTP gateway implementation for spearlet //! spearlet的HTTP gateway实现 +mod backend_preflight_handlers; mod execution_handlers; +mod local_model_handlers; mod monitoring_handlers; mod object_handlers; mod task_handlers; @@ -79,11 +81,17 @@ pub(crate) fn build_router(state: AppState, swagger_enabled: bool) -> Router { .route("/objects/{key}", get(object_handlers::get_object)) .route("/objects", get(object_handlers::list_objects)) .route("/objects/{key}/refs", post(object_handlers::add_object_ref)) - .route("/objects/{key}/refs", delete(object_handlers::remove_object_ref)) + .route( + "/objects/{key}/refs", + delete(object_handlers::remove_object_ref), + ) .route("/objects/{key}/pin", post(object_handlers::pin_object)) .route("/objects/{key}/pin", delete(object_handlers::unpin_object)) .route("/objects/{key}", delete(object_handlers::delete_object)) - .route("/functions/execute", post(execution_handlers::execute_function)) + .route( + "/functions/execute", + post(execution_handlers::execute_function), + ) .route( "/functions/executions/{execution_id}", get(execution_handlers::get_execution_status), @@ -94,14 +102,31 @@ pub(crate) fn build_router(state: AppState, swagger_enabled: bool) -> Router { ) .route("/tasks", get(task_handlers::list_tasks)) .route("/tasks/{task_id}", get(task_handlers::get_task)) - .route("/tasks/{task_id}/executions", get(task_handlers::get_task_executions)) + .route( + "/tasks/{task_id}/executions", + get(task_handlers::get_task_executions), + ) .route("/monitoring/stats", get(monitoring_handlers::get_stats)) - .route("/monitoring/health", get(monitoring_handlers::get_health_status)) - .route("/monitoring/ai/backends", get(monitoring_handlers::get_ai_backends)) + .route( + "/monitoring/health", + get(monitoring_handlers::get_health_status), + ) + .route( + "/monitoring/ai/backends", + get(monitoring_handlers::get_ai_backends), + ) .route( "/monitoring/ai/credentials", get(monitoring_handlers::get_ai_credentials), ) + .route( + "/internal/ai/local-models/preflight", + post(local_model_handlers::preflight_local_model), + ) + .route( + "/internal/ai/backends/preflight", + post(backend_preflight_handlers::preflight_backend), + ) .route( "/api/v1/executions/{execution_id}/streams/ws", get(user_stream_ws), @@ -298,11 +323,9 @@ async fn e2e_llm_router_filter( let mut kept: Vec<&crate::spearlet::execution::ai::router::registry::BackendInstance> = candidates; - let outcome = - crate::spearlet::execution::ai::router::filter_decision::apply_filter_response( - &resp, - &mut kept, - ); + let outcome = crate::spearlet::execution::ai::router::filter_decision::apply_filter_response( + &resp, &mut kept, + ); let kept_names: Vec = kept.iter().map(|c| c.spec.name.clone()).collect(); let dropped_names = outcome.dropped_names; @@ -334,18 +357,21 @@ async fn e2e_llm_router_filter( impl HttpGateway { /// Create new HTTP gateway / 创建新的HTTP网关 - pub fn new( + pub fn new( config: Arc, health_service: Arc, - function_service: Arc, + function_service: T, object_client: ObjectServiceClient, invocation_client: InvocationServiceClient, execution_client: ExecutionServiceClient, - ) -> Self { + ) -> Self + where + T: Into>, + { Self { config, health_service, - function_service, + function_service: function_service.into(), object_client, invocation_client, execution_client, diff --git a/src/spearlet/http_gateway/backend_preflight_handlers.rs b/src/spearlet/http_gateway/backend_preflight_handlers.rs new file mode 100644 index 00000000..6249b8b9 --- /dev/null +++ b/src/spearlet/http_gateway/backend_preflight_handlers.rs @@ -0,0 +1,607 @@ +use axum::{extract::State, http::StatusCode, Json}; +use serde::{Deserialize, Serialize}; + +use crate::ai_backend_types::{CanonicalBackendKind, CanonicalBackendProvider}; +use crate::proto::sms::{BackendHosting, BackendOrigin, BackendSpec}; +use crate::spearlet::ai::credential_resolver::global as global_credential_resolver; +use crate::spearlet::ai::remote_preflight::{ + default_requested_checks, preflight_remote_backend, RemoteBackendPreflightCheck, +}; + +use super::AppState; + +/// Node-side backend preflight request. +/// 节点侧 backend 预检请求。 +#[derive(Debug, Clone, Deserialize)] +pub(super) struct BackendPreflightRequest { + pub(super) backend: BackendPreflightBackend, + pub(super) requested_checks: Option>, +} + +/// Canonical backend draft used by node-side preflight. +/// 节点侧预检使用的规范 backend 草稿。 +#[derive(Debug, Clone, Deserialize)] +pub(super) struct BackendPreflightBackend { + pub(super) provider: String, + pub(super) model: String, + pub(super) hosting: String, + pub(super) backend_kind: String, + pub(super) base_url: Option, + pub(super) credential_ref: Option, + pub(super) operations: Option>, + pub(super) features: Option>, + pub(super) transports: Option>, +} + +/// Node-side backend preflight response. +/// 节点侧 backend 预检响应。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +pub(super) enum BackendPreflightOutcomeResponse { + Success { + phase: String, + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + }, + Failure { + phase: String, + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: Option, + model_accessible: Option, + }, +} + +/// Provider-specific OpenAI-compatible remote-preflight outcome. +/// OpenAI-compatible remote-preflight 的 provider-specific 结果。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +pub(super) enum BackendPreflightOpenAiCompatibleOutcomeResponse { + ConnectivityVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + }, + AuthVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: bool, + }, + ModelAccessVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + auth_valid: bool, + model_accessible: bool, + }, + ConnectivityFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + }, + AuthFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: bool, + }, + ModelAccessFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + auth_valid: bool, + model_accessible: bool, + }, +} + +/// Provider-specific Ollama remote-preflight outcome. +/// Ollama remote-preflight 的 provider-specific 结果。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome_kind", rename_all = "snake_case")] +pub(super) enum BackendPreflightOllamaOutcomeResponse { + ConnectivityVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + }, + ModelAccessVerified { + resolved_endpoint: Option, + http_status: Option, + checks: Vec, + model_accessible: bool, + }, + ConnectivityFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + }, + ModelAccessFailed { + error_code: String, + resolved_endpoint: Option, + http_status: Option, + provider_code: Option, + checks: Vec, + model_accessible: bool, + }, +} + +/// Typed provider-family result for node-side remote preflight. +/// 节点侧 remote preflight 的强类型 provider-family 结果。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "provider_family", rename_all = "snake_case")] +pub(super) enum BackendPreflightProviderResultResponse { + OpenAiCompatible { + outcome: BackendPreflightOpenAiCompatibleOutcomeResponse, + }, + Ollama { + outcome: BackendPreflightOllamaOutcomeResponse, + }, + Unknown { + provider: String, + outcome: BackendPreflightOutcomeResponse, + }, +} + +/// Node-side backend preflight response envelope. +/// 节点侧 backend 预检响应包裹结构。 +#[derive(Debug, Clone, Serialize)] +pub(super) struct BackendPreflightResponse { + pub(super) success: bool, + pub(super) result: BackendPreflightProviderResultResponse, + pub(super) message: String, + pub(super) latency_ms: Option, +} + +/// Typed provider family for node-side remote preflight DTO mapping. +/// 节点侧 remote preflight DTO 映射使用的强类型 provider 家族。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum BackendPreflightProviderFamily { + OpenAiCompatible, + Ollama, + Unknown(String), +} + +impl BackendPreflightProviderFamily { + /// Parse provider family from backend input boundary values. + /// 从 backend 输入边界值解析 provider family。 + fn parse(provider: &str, backend_kind: &str) -> Self { + match CanonicalBackendProvider::parse(provider) { + CanonicalBackendProvider::OpenAi => Self::OpenAiCompatible, + CanonicalBackendProvider::Ollama => Self::Ollama, + CanonicalBackendProvider::Unknown(_) => { + match CanonicalBackendKind::parse(backend_kind).inferred_provider() { + CanonicalBackendProvider::OpenAi => Self::OpenAiCompatible, + CanonicalBackendProvider::Ollama => Self::Ollama, + _ => Self::Unknown(provider.trim().to_string()), + } + } + _ => Self::Unknown(provider.trim().to_string()), + } + } + + /// Wrap one typed outcome into the provider-family result. + /// 将一个强类型 outcome 包装成 provider-family 结果。 + fn wrap(self, outcome: BackendPreflightOutcomeResponse) -> BackendPreflightProviderResultResponse { + match self { + Self::OpenAiCompatible => { + BackendPreflightProviderResultResponse::OpenAiCompatible { + outcome: BackendPreflightOpenAiCompatibleOutcomeResponse::from_generic( + outcome, + ), + } + } + Self::Ollama => BackendPreflightProviderResultResponse::Ollama { + outcome: BackendPreflightOllamaOutcomeResponse::from_generic(outcome), + }, + Self::Unknown(provider) => { + BackendPreflightProviderResultResponse::Unknown { provider, outcome } + } + } + } +} + +impl BackendPreflightOpenAiCompatibleOutcomeResponse { + /// Convert one generic remote-preflight outcome into the OpenAI-compatible shape. + /// 将通用 remote-preflight 结果转换为 OpenAI-compatible 形态。 + fn from_generic(outcome: BackendPreflightOutcomeResponse) -> Self { + match outcome { + BackendPreflightOutcomeResponse::Success { + phase, + resolved_endpoint, + http_status, + checks, + auth_valid, + model_accessible, + } => match phase.as_str() { + "model_access" => Self::ModelAccessVerified { + resolved_endpoint, + http_status, + checks, + auth_valid: auth_valid.unwrap_or(true), + model_accessible: model_accessible.unwrap_or(true), + }, + "auth" => Self::AuthVerified { + resolved_endpoint, + http_status, + checks, + auth_valid: auth_valid.unwrap_or(true), + }, + _ => Self::ConnectivityVerified { + resolved_endpoint, + http_status, + checks, + }, + }, + BackendPreflightOutcomeResponse::Failure { + phase, + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + auth_valid, + model_accessible, + } => match phase.as_str() { + "model_access" => Self::ModelAccessFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + // Failure at model-access phase implies auth already passed. + // model_access 阶段失败意味着 auth 已经通过。 + auth_valid: auth_valid.unwrap_or(true), + model_accessible: model_accessible.unwrap_or(false), + }, + "auth" => Self::AuthFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + auth_valid: auth_valid.unwrap_or(false), + }, + _ => Self::ConnectivityFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + }, + }, + } + } +} + +impl BackendPreflightOllamaOutcomeResponse { + /// Convert one generic remote-preflight outcome into the Ollama shape. + /// 将通用 remote-preflight 结果转换为 Ollama 形态。 + fn from_generic(outcome: BackendPreflightOutcomeResponse) -> Self { + match outcome { + BackendPreflightOutcomeResponse::Success { + phase, + resolved_endpoint, + http_status, + checks, + model_accessible, + .. + } => match phase.as_str() { + "model_access" => Self::ModelAccessVerified { + resolved_endpoint, + http_status, + checks, + model_accessible: model_accessible.unwrap_or(true), + }, + _ => Self::ConnectivityVerified { + resolved_endpoint, + http_status, + checks, + }, + }, + BackendPreflightOutcomeResponse::Failure { + phase, + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + model_accessible, + .. + } => match phase.as_str() { + "model_access" => Self::ModelAccessFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + model_accessible: model_accessible.unwrap_or(false), + }, + _ => Self::ConnectivityFailed { + error_code, + resolved_endpoint, + http_status, + provider_code, + checks, + }, + }, + } + } +} + +/// Build a typed remote-preflight failure response. +/// 构建强类型 remote-preflight 失败响应。 +fn backend_preflight_failure_response( + provider: String, + backend_kind: String, + phase: String, + error_code: String, + message: String, +) -> BackendPreflightResponse { + BackendPreflightResponse { + success: false, + result: BackendPreflightProviderFamily::parse(&provider, &backend_kind).wrap( + BackendPreflightOutcomeResponse::Failure { + phase, + error_code, + resolved_endpoint: None, + http_status: None, + provider_code: None, + checks: vec![], + auth_valid: None, + model_accessible: None, + }), + message, + latency_ms: None, + } +} + +/// POST /internal/ai/backends/preflight +/// 在 SPEARlet 节点本地验证 remote backend 的可达性与权限。 +pub(super) async fn preflight_backend( + State(_state): State, + Json(body): Json, +) -> (StatusCode, Json) { + if body.backend.hosting.trim().to_ascii_lowercase() != "remote" { + return ( + StatusCode::BAD_REQUEST, + Json(backend_preflight_failure_response( + body.backend.provider.trim().to_string(), + body.backend.backend_kind.trim().to_string(), + "input".to_string(), + "invalid_input".to_string(), + "only remote backend preflight is supported on this route".to_string(), + )), + ); + } + + let Some(resolver) = global_credential_resolver() else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(backend_preflight_failure_response( + body.backend.provider.trim().to_string(), + body.backend.backend_kind.trim().to_string(), + "input".to_string(), + "internal_error".to_string(), + "global credential resolver is not initialized".to_string(), + )), + ); + }; + + let spec = BackendSpec { + name: "preflight-backend".to_string(), + kind: body.backend.backend_kind.trim().to_string(), + operations: body.backend.operations.unwrap_or_default(), + features: body.backend.features.unwrap_or_default(), + transports: body.backend.transports.unwrap_or_default(), + weight: 100, + priority: 0, + base_url: body.backend.base_url.unwrap_or_default().trim().to_string(), + provider: body.backend.provider.trim().to_string(), + model: body.backend.model.trim().to_string(), + credential_ref: body + .backend + .credential_ref + .unwrap_or_default() + .trim() + .to_string(), + hosting: BackendHosting::Remote as i32, + origin: BackendOrigin::Sms as i32, + deployment_id: String::new(), + }; + + let requested_checks = body + .requested_checks + .unwrap_or_else(|| default_requested_checks(&spec)); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let report = preflight_remote_backend(&http, &resolver, &spec, &requested_checks).await; + let status = if report.success { + StatusCode::OK + } else { + StatusCode::BAD_REQUEST + }; + + ( + status, + Json(BackendPreflightResponse { + success: report.success, + result: BackendPreflightProviderFamily::parse( + &report.provider, + &body.backend.backend_kind, + ) + .wrap(if report.success { + BackendPreflightOutcomeResponse::Success { + phase: report.phase, + resolved_endpoint: report.resolved_endpoint, + http_status: report.http_status, + checks: report.checks, + auth_valid: report.auth_valid, + model_accessible: report.model_accessible, + } + } else { + BackendPreflightOutcomeResponse::Failure { + phase: report.phase, + error_code: report + .error_code + .unwrap_or_else(|| "unknown_failure".to_string()), + resolved_endpoint: report.resolved_endpoint, + http_status: report.http_status, + provider_code: report.provider_code, + checks: report.checks, + auth_valid: report.auth_valid, + model_accessible: report.model_accessible, + } + }), + message: report.message, + latency_ms: report.latency_ms, + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_preflight_provider_family_parses_openai_from_kind_alias() { + assert_eq!( + BackendPreflightProviderFamily::parse("", "openai_realtime_ws"), + BackendPreflightProviderFamily::OpenAiCompatible + ); + } + + #[test] + fn backend_preflight_provider_family_preserves_unknown_provider() { + assert_eq!( + BackendPreflightProviderFamily::parse("custom-provider", "custom-kind"), + BackendPreflightProviderFamily::Unknown("custom-provider".to_string()) + ); + } + + #[test] + fn openai_compatible_outcome_refines_model_access_success() { + let outcome = BackendPreflightOpenAiCompatibleOutcomeResponse::from_generic( + BackendPreflightOutcomeResponse::Success { + phase: "model_access".to_string(), + resolved_endpoint: Some("https://api.openai.com/v1/models".to_string()), + http_status: Some(200), + checks: vec![RemoteBackendPreflightCheck { + name: "model_access".to_string(), + ok: true, + }], + auth_valid: Some(true), + model_accessible: Some(true), + }, + ); + + assert!(matches!( + outcome, + BackendPreflightOpenAiCompatibleOutcomeResponse::ModelAccessVerified { + resolved_endpoint: Some(_), + http_status: Some(200), + auth_valid: true, + model_accessible: true, + .. + } + )); + } + + #[test] + fn ollama_outcome_refines_connectivity_success() { + let outcome = BackendPreflightOllamaOutcomeResponse::from_generic( + BackendPreflightOutcomeResponse::Success { + phase: "connectivity".to_string(), + resolved_endpoint: Some("http://ollama.local/api/tags".to_string()), + http_status: Some(200), + checks: vec![RemoteBackendPreflightCheck { + name: "connectivity".to_string(), + ok: true, + }], + auth_valid: None, + model_accessible: None, + }, + ); + + assert!(matches!( + outcome, + BackendPreflightOllamaOutcomeResponse::ConnectivityVerified { + resolved_endpoint: Some(_), + http_status: Some(200), + .. + } + )); + } + + #[test] + fn openai_compatible_outcome_refines_auth_failure() { + let outcome = BackendPreflightOpenAiCompatibleOutcomeResponse::from_generic( + BackendPreflightOutcomeResponse::Failure { + phase: "auth".to_string(), + error_code: "auth_invalid".to_string(), + resolved_endpoint: Some("https://api.openai.com/v1/models".to_string()), + http_status: Some(401), + provider_code: Some("invalid_api_key".to_string()), + checks: vec![RemoteBackendPreflightCheck { + name: "auth".to_string(), + ok: false, + }], + auth_valid: Some(false), + model_accessible: None, + }, + ); + + assert!(matches!( + outcome, + BackendPreflightOpenAiCompatibleOutcomeResponse::AuthFailed { + http_status: Some(401), + auth_valid: false, + .. + } + )); + } + + #[test] + fn ollama_outcome_refines_model_access_failure() { + let outcome = BackendPreflightOllamaOutcomeResponse::from_generic( + BackendPreflightOutcomeResponse::Failure { + phase: "model_access".to_string(), + error_code: "model_not_found".to_string(), + resolved_endpoint: Some("http://ollama.local/api/tags".to_string()), + http_status: Some(200), + provider_code: None, + checks: vec![RemoteBackendPreflightCheck { + name: "model_access".to_string(), + ok: false, + }], + auth_valid: None, + model_accessible: Some(false), + }, + ); + + assert!(matches!( + outcome, + BackendPreflightOllamaOutcomeResponse::ModelAccessFailed { + http_status: Some(200), + model_accessible: false, + .. + } + )); + } +} diff --git a/src/spearlet/http_gateway/execution_handlers.rs b/src/spearlet/http_gateway/execution_handlers.rs index 31ccbccc..ab7f94c1 100644 --- a/src/spearlet/http_gateway/execution_handlers.rs +++ b/src/spearlet/http_gateway/execution_handlers.rs @@ -7,9 +7,7 @@ use base64::{engine::general_purpose, Engine as _}; use serde::Deserialize; use tracing::{debug, error}; -use crate::proto::spearlet::{ - GetExecutionRequest, InvokeRequest, TerminateExecutionRequest, -}; +use crate::proto::spearlet::{GetExecutionRequest, InvokeRequest, TerminateExecutionRequest}; use crate::spearlet::execution::execution_status::ExecutionPublicStatus; use super::AppState; diff --git a/src/spearlet/http_gateway/local_model_handlers.rs b/src/spearlet/http_gateway/local_model_handlers.rs new file mode 100644 index 00000000..449f3c55 --- /dev/null +++ b/src/spearlet/http_gateway/local_model_handlers.rs @@ -0,0 +1,266 @@ +use axum::{extract::State, http::StatusCode, Json}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; + +use crate::ai_backend_types::CanonicalBackendProvider; +use crate::spearlet::local_models::llamacpp::preflight_model_source; + +use super::AppState; + +/// Node-side local model preflight request. +/// 节点侧本地模型预检请求。 +#[derive(Debug, Clone, Deserialize)] +pub(super) struct LocalModelPreflightRequest { + pub(super) provider: String, + pub(super) model: String, + pub(super) model_url: Option, + pub(super) model_path: Option, + pub(super) skip_download: Option, + pub(super) download_timeout_s: Option, +} + +/// Node-side local model preflight response. +/// 节点侧本地模型预检响应。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "source_kind", rename_all = "snake_case")] +pub(super) enum LocalModelPreflightDetailsResponse { + Unavailable, + ModelPath { + effective_model_path: String, + }, + ModelUrl { + effective_model_path: String, + final_url: String, + http_status: u16, + content_length: Option, + }, +} + +/// Node-side local model preflight response envelope. +/// 节点侧本地模型预检响应包裹结构。 +#[derive(Debug, Clone, Serialize)] +pub(super) struct LocalModelPreflightResponse { + pub(super) success: bool, + pub(super) details: LocalModelPreflightDetailsResponse, + pub(super) message: String, +} + +/// Typed provider family selector for local-model preflight. +/// local-model preflight 的强类型 provider 家族选择器。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum LocalModelPreflightProvider { + /// llama.cpp local-model preflight family. + /// llama.cpp 本地模型预检家族。 + LlamaCpp, + /// Unsupported local-model preflight provider. + /// 不支持的本地模型预检 provider。 + Unsupported(String), +} + +impl LocalModelPreflightProvider { + /// Parse request provider into typed local preflight family. + /// 将请求 provider 解析为强类型 local preflight 家族。 + fn parse(value: &str) -> Self { + match CanonicalBackendProvider::parse(value) { + CanonicalBackendProvider::LlamaCpp => Self::LlamaCpp, + other => Self::Unsupported(other.as_str().to_string()), + } + } +} + +/// Typed llama.cpp preflight params builder. +/// 强类型 llama.cpp 预检参数构建器。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct LlamaCppPreflightParams { + model_url: Option, + model_path: Option, + skip_download: bool, + download_timeout_s: Option, +} + +impl LlamaCppPreflightParams { + /// Build typed llama.cpp preflight params from request body. + /// 从请求体构建强类型 llama.cpp 预检参数。 + fn from_request(body: &LocalModelPreflightRequest) -> Self { + Self { + model_url: body + .model_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + model_path: body + .model_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + skip_download: body.skip_download.unwrap_or(false), + download_timeout_s: body.download_timeout_s, + } + } + + /// Render typed params into the legacy key-value shape. + /// 将强类型参数渲染为遗留 key-value 形态。 + fn to_metadata_map(&self) -> HashMap { + let mut params = HashMap::new(); + if let Some(model_url) = &self.model_url { + params.insert("model_url".to_string(), model_url.clone()); + } + if let Some(model_path) = &self.model_path { + params.insert("model_path".to_string(), model_path.clone()); + } + if self.skip_download { + params.insert("skip_download".to_string(), "1".to_string()); + } + if let Some(download_timeout_s) = self.download_timeout_s { + params.insert( + "download_timeout_s".to_string(), + download_timeout_s.to_string(), + ); + } + params + } +} + +/// Build a failed node-side preflight response without details. +/// 构建不包含 details 的失败节点侧预检响应。 +fn local_model_preflight_error(message: String) -> LocalModelPreflightResponse { + LocalModelPreflightResponse { + success: false, + details: LocalModelPreflightDetailsResponse::Unavailable, + message, + } +} + +/// POST /internal/ai/local-models/preflight +/// 在 SPEARlet 节点本地验证模型来源是否可用。 +pub(super) async fn preflight_local_model( + State(state): State, + Json(body): Json, +) -> (StatusCode, Json) { + let provider_family = LocalModelPreflightProvider::parse(&body.provider); + if let LocalModelPreflightProvider::Unsupported(provider) = provider_family { + return ( + StatusCode::BAD_REQUEST, + Json(local_model_preflight_error(format!( + "only local llamacpp preflight is supported (provider={})", + provider + ))), + ); + } + if body.model.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(local_model_preflight_error("model is required".to_string())), + ); + } + + let typed_params = LlamaCppPreflightParams::from_request(&body); + let params = typed_params.to_metadata_map(); + + // Use a short probe timeout here so create-time feedback stays fast while still + // reusing the same node-side network path as the real download logic. + // 这里使用较短的探测超时,以保证创建时反馈足够快,同时仍复用真实下载所处的节点网络路径。 + let timeout_s = body.download_timeout_s.unwrap_or(10).clamp(1, 30); + let http = match reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_s)) + .build() + { + Ok(client) => client, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(local_model_preflight_error(error.to_string())), + ) + } + }; + + match preflight_model_source( + &http, + &state.config.local_models_dir, + body.model.trim(), + ¶ms, + ) + .await + { + Ok(report) => ( + StatusCode::OK, + Json(LocalModelPreflightResponse { + success: true, + details: match report.source_kind.as_str() { + "model_path" => LocalModelPreflightDetailsResponse::ModelPath { + effective_model_path: report.effective_model_path, + }, + "model_url" => LocalModelPreflightDetailsResponse::ModelUrl { + effective_model_path: report.effective_model_path, + final_url: report + .final_url + .unwrap_or_else(|| "unknown".to_string()), + http_status: report.http_status.unwrap_or(200), + content_length: report.content_length, + }, + _ => LocalModelPreflightDetailsResponse::Unavailable, + }, + message: report.message, + }), + ), + Err(message) => ( + StatusCode::BAD_REQUEST, + Json(local_model_preflight_error(message)), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_model_preflight_provider_parses_aliases() { + assert_eq!( + LocalModelPreflightProvider::parse("llama.cpp"), + LocalModelPreflightProvider::LlamaCpp + ); + assert_eq!( + LocalModelPreflightProvider::parse("llamacpp"), + LocalModelPreflightProvider::LlamaCpp + ); + } + + #[test] + fn local_model_preflight_provider_rejects_non_llamacpp() { + assert_eq!( + LocalModelPreflightProvider::parse("vllm"), + LocalModelPreflightProvider::Unsupported("vllm".to_string()) + ); + } + + #[test] + fn llamacpp_preflight_params_builds_trimmed_metadata_map() { + let body = LocalModelPreflightRequest { + provider: "llamacpp".to_string(), + model: "qwen".to_string(), + model_url: Some(" http://example.com/model.gguf ".to_string()), + model_path: Some(" /tmp/qwen.gguf ".to_string()), + skip_download: Some(true), + download_timeout_s: Some(15), + }; + let params = LlamaCppPreflightParams::from_request(&body).to_metadata_map(); + + assert_eq!( + params.get("model_url").map(String::as_str), + Some("http://example.com/model.gguf") + ); + assert_eq!( + params.get("model_path").map(String::as_str), + Some("/tmp/qwen.gguf") + ); + assert_eq!(params.get("skip_download").map(String::as_str), Some("1")); + assert_eq!( + params.get("download_timeout_s").map(String::as_str), + Some("15") + ); + } +} diff --git a/src/spearlet/http_gateway/monitoring_handlers.rs b/src/spearlet/http_gateway/monitoring_handlers.rs index f1f257f6..738e65f0 100644 --- a/src/spearlet/http_gateway/monitoring_handlers.rs +++ b/src/spearlet/http_gateway/monitoring_handlers.rs @@ -16,7 +16,10 @@ pub(super) async fn get_stats( debug!("GET /monitoring/stats"); let stats = state.function_service.get_stats().await; - let exec_stats = state.function_service.get_execution_manager().get_statistics(); + let exec_stats = state + .function_service + .get_execution_manager() + .get_statistics(); Ok(Json(serde_json::json!({ "total_executions": exec_stats.total_executions, diff --git a/src/spearlet/instance_service.rs b/src/spearlet/instance_service.rs index 6e11d455..7b4e92ec 100644 --- a/src/spearlet/instance_service.rs +++ b/src/spearlet/instance_service.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use tonic::{Request, Response, Status}; use crate::proto::spearlet::instance_service_server::InstanceService; @@ -7,23 +6,63 @@ use crate::proto::spearlet::{ ReconcileTaskAssignmentsNowResponse, }; use crate::spearlet::execution::ExecutionError; +use crate::spearlet::execution::TaskExecutionManager; use crate::spearlet::function_service::FunctionServiceImpl; +use std::sync::Arc; +#[derive(Clone)] pub struct InstanceServiceImpl { - function_service: Arc, + function_service: FunctionServiceImpl, } impl InstanceServiceImpl { - pub fn new(function_service: Arc) -> Self { + pub fn new(function_service: FunctionServiceImpl) -> Self { Self { function_service } } + fn execution_manager(&self) -> Arc { + self.function_service.get_execution_manager() + } + + fn optional_non_empty(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + + fn task_filter(task_id: String) -> Option { + Self::optional_non_empty(task_id) + } + fn map_destroy_instance_error(err: ExecutionError) -> Status { match err { - ExecutionError::InstanceNotFound { id } => Status::not_found(format!("Instance not found: {}", id)), + ExecutionError::InstanceNotFound { id } => { + Status::not_found(format!("Instance not found: {}", id)) + } other => Status::internal(other.to_string()), } } + + fn map_internal_error(error: E) -> Status { + Status::internal(error.to_string()) + } + + async fn reconcile_task_after_destroy( + &self, + execution_manager: &Arc, + task_id: Option, + ) -> Result<(), Status> { + let Some(task_id) = task_id else { + return Ok(()); + }; + execution_manager + .ensure_task_meets_desired_replicas(&task_id) + .await + .map_err(Self::map_internal_error) + } } #[tonic::async_trait] @@ -34,26 +73,18 @@ impl InstanceService for InstanceServiceImpl { ) -> Result, Status> { let req = request.into_inner(); let instance_id = req.instance_id; - let execution_manager = self.function_service.get_execution_manager(); + let execution_manager = self.execution_manager(); let task_id = execution_manager .get_instance(&instance_id) .map(|instance| instance.task_id().to_string()); - let reason = if req.reason.is_empty() { - None - } else { - Some(req.reason) - }; + let reason = Self::optional_non_empty(req.reason); execution_manager .drain_and_destroy_instance(&instance_id, reason) .await .map_err(Self::map_destroy_instance_error)?; - if let Some(task_id) = task_id { - execution_manager - .ensure_task_meets_desired_replicas(&task_id) - .await - .map_err(|e| Status::internal(e.to_string()))?; - } + self.reconcile_task_after_destroy(&execution_manager, task_id) + .await?; Ok(Response::new(DestroyInstanceResponse { success: true, @@ -65,17 +96,11 @@ impl InstanceService for InstanceServiceImpl { &self, request: Request, ) -> Result, Status> { - let task_id = request.into_inner().task_id; - let filter = if task_id.trim().is_empty() { - None - } else { - Some(task_id.as_str()) - }; - self.function_service - .get_execution_manager() - .reconcile_assignments_from_sms(filter) + let filter = Self::task_filter(request.into_inner().task_id); + self.execution_manager() + .reconcile_assignments_from_sms(filter.as_deref()) .await - .map_err(|error| Status::internal(error.to_string()))?; + .map_err(Self::map_internal_error)?; Ok(Response::new(ReconcileTaskAssignmentsNowResponse { accepted: true, message: "reconcile requested".to_string(), @@ -83,19 +108,32 @@ impl InstanceService for InstanceServiceImpl { } } -#[tonic::async_trait] -impl InstanceService for Arc { - async fn destroy_instance( - &self, - request: Request, - ) -> Result, Status> { - (**self).destroy_instance(request).await +#[cfg(test)] +mod tests { + use super::InstanceServiceImpl; + + #[test] + fn optional_non_empty_trims_blank_values() { + assert_eq!( + InstanceServiceImpl::optional_non_empty("".to_string()), + None + ); + assert_eq!( + InstanceServiceImpl::optional_non_empty(" ".to_string()), + None + ); + assert_eq!( + InstanceServiceImpl::optional_non_empty(" manual ".to_string()), + Some("manual".to_string()) + ); } - async fn reconcile_task_assignments_now( - &self, - request: Request, - ) -> Result, Status> { - (**self).reconcile_task_assignments_now(request).await + #[test] + fn task_filter_reuses_optional_string_rules() { + assert_eq!(InstanceServiceImpl::task_filter("".to_string()), None); + assert_eq!( + InstanceServiceImpl::task_filter(" task-a ".to_string()), + Some("task-a".to_string()) + ); } } diff --git a/src/spearlet/local_models/llamacpp.rs b/src/spearlet/local_models/llamacpp.rs index a4bdafc6..e3da0002 100644 --- a/src/spearlet/local_models/llamacpp.rs +++ b/src/spearlet/local_models/llamacpp.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use futures::StreamExt; +use reqwest::header::RANGE; use reqwest::Client; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; @@ -26,6 +27,18 @@ pub struct LlamaCppSupervisor { local_models_dir: String, } +/// Local model source preflight snapshot / 本地模型来源预检快照 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelSourcePreflight { + pub source_kind: String, + pub effective_model_path: String, + pub model_path_exists: bool, + pub final_url: Option, + pub http_status: Option, + pub content_length: Option, + pub message: String, +} + struct Inner { procs: HashMap, } @@ -36,6 +49,53 @@ struct ManagedProc { backend: BackendInfo, } +/// llama.cpp readiness probe mode used during process startup. +/// llama.cpp 进程启动阶段使用的就绪探测模式。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum LlamaCppReadyProbe { + /// Probe the HTTP API until it becomes reachable. + /// 轮询 HTTP API,直到端点可访问。 + Http, + /// Skip readiness probing entirely. + /// 完全跳过就绪探测。 + None, +} + +/// Typed llama.cpp launch mode derived from backend metadata. +/// 从 backend metadata 派生的强类型 llama.cpp 启动模式。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum LlamaCppLaunchMode { + /// Managed llama-server launch with structured runtime options. + /// 使用结构化运行参数启动托管 llama-server。 + Managed { + threads: Option, + ctx_size: Option, + }, + /// Raw process launch with fully provided command arguments. + /// 使用完整命令参数启动原始进程。 + Raw { server_cmd_args: Vec }, +} + +/// Typed llama.cpp launch options derived from backend metadata. +/// 从 backend metadata 派生的强类型 llama.cpp 启动选项。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct LlamaCppLaunchOptions { + server_cmd: String, + ready_probe: LlamaCppReadyProbe, + start_timeout_s: u64, + mode: LlamaCppLaunchMode, +} + +/// Typed llama.cpp model-source configuration derived from backend metadata. +/// 从 backend metadata 派生的强类型 llama.cpp 模型来源配置。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct LlamaCppModelSource { + model_path: PathBuf, + model_url: Option, + skip_download: bool, + download_timeout_s: Option, +} + impl LlamaCppSupervisor { pub fn new(cfg: &SpearletConfig) -> Self { let local_models_dir = if cfg.local_models_dir.trim().is_empty() { @@ -114,59 +174,34 @@ impl LlamaCppSupervisor { let port = allocate_local_port().await.map_err(|e| e.to_string())?; let base_url = format!("http://127.0.0.1:{}/v1", port); + let launch = parse_llamacpp_launch_options(params)?; - let ready_probe = params - .get("ready_probe") - .map(|s| s.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "http".to_string()); - - let server_mode = params - .get("server_mode") - .map(|s| s.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "llama".to_string()); - - let server_cmd = params - .get("server_cmd") - .cloned() - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "llama-server".to_string()); - - let mut cmd = Command::new(server_cmd); + let mut cmd = Command::new(&launch.server_cmd); cmd.kill_on_drop(true); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::null()); cmd.stderr(Stdio::null()); - if server_mode == "raw" { - let raw_args = params.get("server_cmd_args").cloned().unwrap_or_default(); - let args = split_args(&raw_args); - if args.is_empty() { - return Err("server_mode=raw requires server_cmd_args".to_string()); - } - cmd.args(args); - } else { - let model_path = resolve_model_path(&self.local_models_dir, model, params)?; - if !model_path.exists() { - download_model(http, &model_path, params).await?; + match &launch.mode { + LlamaCppLaunchMode::Raw { server_cmd_args } => { + cmd.args(server_cmd_args); } + LlamaCppLaunchMode::Managed { threads, ctx_size } => { + let model_source = resolve_model_source(&self.local_models_dir, model, params)?; + if !model_source.model_path.exists() { + download_model(http, &model_source).await?; + } - cmd.arg("-m").arg(model_path); - cmd.arg("--host").arg("127.0.0.1"); - cmd.arg("--port").arg(port.to_string()); + cmd.arg("-m").arg(&model_source.model_path); + cmd.arg("--host").arg("127.0.0.1"); + cmd.arg("--port").arg(port.to_string()); - if let Some(n_threads) = params - .get("threads") - .cloned() - .filter(|v| !v.trim().is_empty()) - { - cmd.arg("--threads").arg(n_threads); - } - if let Some(ctx) = params - .get("ctx_size") - .cloned() - .filter(|v| !v.trim().is_empty()) - { - cmd.arg("--ctx-size").arg(ctx); + if let Some(n_threads) = threads { + cmd.arg("--threads").arg(n_threads); + } + if let Some(ctx) = ctx_size { + cmd.arg("--ctx-size").arg(ctx); + } } } @@ -177,12 +212,8 @@ impl LlamaCppSupervisor { ) })?; - if ready_probe != "none" { - let start_timeout_s = params - .get("start_timeout_s") - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(120); - wait_ready(http, &base_url, Duration::from_secs(start_timeout_s)).await?; + if launch.ready_probe != LlamaCppReadyProbe::None { + wait_ready(http, &base_url, Duration::from_secs(launch.start_timeout_s)).await?; } let backend = BackendInfo { @@ -237,6 +268,62 @@ fn split_args(s: &str) -> Vec { s.split_whitespace().map(|x| x.to_string()).collect() } +fn parse_llamacpp_launch_options( + params: &HashMap, +) -> Result { + let ready_probe = match params + .get("ready_probe") + .map(|s| s.trim().to_ascii_lowercase()) + .unwrap_or_else(|| "http".to_string()) + .as_str() + { + "none" => LlamaCppReadyProbe::None, + _ => LlamaCppReadyProbe::Http, + }; + let server_cmd = params + .get("server_cmd") + .cloned() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "llama-server".to_string()); + let start_timeout_s = params + .get("start_timeout_s") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(120); + + let mode = match params + .get("server_mode") + .map(|s| s.trim().to_ascii_lowercase()) + .unwrap_or_else(|| "llama".to_string()) + .as_str() + { + "raw" => { + let raw_args = params.get("server_cmd_args").cloned().unwrap_or_default(); + let server_cmd_args = split_args(&raw_args); + if server_cmd_args.is_empty() { + return Err("server_mode=raw requires server_cmd_args".to_string()); + } + LlamaCppLaunchMode::Raw { server_cmd_args } + } + _ => LlamaCppLaunchMode::Managed { + threads: params + .get("threads") + .cloned() + .filter(|value| !value.trim().is_empty()), + ctx_size: params + .get("ctx_size") + .cloned() + .filter(|value| !value.trim().is_empty()), + }, + }; + + Ok(LlamaCppLaunchOptions { + server_cmd, + ready_probe, + start_timeout_s, + mode, + }) +} + fn resolve_model_path( local_models_dir: &str, model: &str, @@ -263,34 +350,57 @@ fn resolve_model_path( e ) })?; - let file = if let Some(model_url) = params + let file = match params .get("model_url") .cloned() .filter(|s| !s.trim().is_empty()) { - let name = Url::parse(&model_url) - .ok() - .and_then(|u| { - u.path_segments() - .and_then(|mut s| s.next_back().map(|x| x.to_string())) - }) - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| sanitize_component(model)); - let s = sanitize_component(&name); - if s.to_ascii_lowercase().ends_with(".gguf") { - s - } else { - format!("{}.gguf", s) + Some(model_url) => { + let name = Url::parse(&model_url) + .ok() + .and_then(|u| { + u.path_segments() + .and_then(|mut s| s.next_back().map(|x| x.to_string())) + }) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| sanitize_component(model)); + let mut file = sanitize_component(&name); + if !file.to_ascii_lowercase().ends_with(".gguf") { + file.push_str(".gguf"); + } + file } - } else { - format!("{}.gguf", sanitize_component(model)) + None => format!("{}.gguf", sanitize_component(model)), }; Ok(dir.join(file)) } +fn resolve_model_source( + local_models_dir: &str, + model: &str, + params: &HashMap, +) -> Result { + Ok(LlamaCppModelSource { + model_path: resolve_model_path(local_models_dir, model, params)?, + model_url: params + .get("model_url") + .cloned() + .filter(|value| !value.trim().is_empty()), + skip_download: params + .get("skip_download") + .map(|value| parse_truthy(value)) + .unwrap_or(false), + download_timeout_s: params + .get("download_timeout_s") + .and_then(|value| value.trim().parse::().ok()), + }) +} + #[cfg(test)] mod tests { use super::*; + use axum::{routing::get, Router}; + use tokio::net::TcpListener; #[test] fn resolve_model_path_errors_when_data_dir_is_not_a_directory() { @@ -307,6 +417,109 @@ mod tests { .unwrap_err(); assert!(err.contains("failed to create llamacpp model dir")); } + + #[tokio::test] + async fn preflight_model_source_accepts_existing_model_path() { + let tmp = tempfile::tempdir().unwrap(); + let model_path = tmp.path().join("models").join("qwen.gguf"); + std::fs::create_dir_all(model_path.parent().unwrap()).unwrap(); + std::fs::write(&model_path, b"ok").unwrap(); + + let client = Client::builder().build().unwrap(); + let mut params = HashMap::new(); + params.insert( + "model_path".to_string(), + model_path.to_string_lossy().to_string(), + ); + + let report = preflight_model_source( + &client, + tmp.path().to_string_lossy().as_ref(), + "qwen", + ¶ms, + ) + .await + .unwrap(); + assert_eq!(report.source_kind, "model_path"); + assert!(report.model_path_exists); + assert_eq!(report.final_url, None); + } + + #[tokio::test] + async fn preflight_model_source_verifies_model_url() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().route( + "/model.gguf", + get(|| async { ([("content-length", "16")], "0123456789abcdef") }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = Client::builder().build().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let mut params = HashMap::new(); + params.insert( + "model_url".to_string(), + format!("http://{}/model.gguf", addr), + ); + + let report = preflight_model_source( + &client, + tmp.path().to_string_lossy().as_ref(), + "qwen", + ¶ms, + ) + .await + .unwrap(); + assert_eq!(report.source_kind, "model_url"); + assert!(!report.model_path_exists); + assert_eq!(report.http_status, Some(200)); + assert!(report.final_url.unwrap().contains("/model.gguf")); + + server.abort(); + } + + #[tokio::test] + async fn preflight_model_source_rejects_missing_file_when_skip_download_is_enabled() { + let client = Client::builder().build().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let mut params = HashMap::new(); + params.insert( + "model_path".to_string(), + "/tmp/does-not-exist.gguf".to_string(), + ); + params.insert("skip_download".to_string(), "1".to_string()); + + let error = preflight_model_source( + &client, + tmp.path().to_string_lossy().as_ref(), + "qwen", + ¶ms, + ) + .await + .unwrap_err(); + assert!(error.contains("skip_download=1")); + } + + #[test] + fn parse_llamacpp_launch_options_requires_raw_args() { + let mut params = HashMap::new(); + params.insert("server_mode".to_string(), "raw".to_string()); + + let error = parse_llamacpp_launch_options(¶ms).unwrap_err(); + assert!(error.contains("server_mode=raw requires server_cmd_args")); + } + + #[test] + fn resolve_model_source_parses_truthy_skip_download() { + let mut params = HashMap::new(); + params.insert("skip_download".to_string(), "true".to_string()); + + let source = resolve_model_source("/tmp/models", "qwen", ¶ms).expect("model source"); + assert!(source.skip_download); + } } fn sanitize_component(s: &str) -> String { @@ -327,36 +540,132 @@ fn sanitize_component(s: &str) -> String { out.trim_matches('_').to_string() } -async fn download_model( +/// Preflight local model source resolution from the node-side runtime. +/// 在节点侧运行时执行本地模型来源预检。 +pub async fn preflight_model_source( http: &Client, - model_path: &Path, + local_models_dir: &str, + model: &str, params: &HashMap, -) -> Result<(), String> { - if params - .get("skip_download") - .map(|v| v.trim() == "1") - .unwrap_or(false) - { +) -> Result { + let source = resolve_model_source(local_models_dir, model, params)?; + if tokio::fs::metadata(&source.model_path).await.is_ok() { + return Ok(ModelSourcePreflight { + source_kind: "model_path".to_string(), + effective_model_path: source.model_path.display().to_string(), + model_path_exists: true, + final_url: None, + http_status: None, + content_length: None, + message: "model file already exists".to_string(), + }); + } + + if source.skip_download { return Err("model file missing and skip_download=1".to_string()); } - let parent = model_path + let model_url = source.model_url.ok_or_else(|| { + "model file missing; set params.model_url (http/https .gguf) or params.model_path" + .to_string() + })?; + let timeout_s = source.download_timeout_s.unwrap_or(10).clamp(1, 30); + let (final_url, http_status, content_length) = + verify_model_url_access(http, &model_url, timeout_s).await?; + + Ok(ModelSourcePreflight { + source_kind: "model_url".to_string(), + effective_model_path: source.model_path.display().to_string(), + model_path_exists: false, + final_url: Some(final_url), + http_status: Some(http_status), + content_length, + message: "model_url is reachable from the node".to_string(), + }) +} + +/// Verify model_url reachability without downloading the full artifact. +/// 在不下载完整模型文件的情况下验证 model_url 可访问性。 +async fn verify_model_url_access( + http: &Client, + model_url: &str, + timeout_s: u64, +) -> Result<(String, u16, Option), String> { + let url = Url::parse(model_url).map_err(|e| format!("invalid model_url: {}", e))?; + match url.scheme() { + "http" | "https" => {} + scheme => return Err(format!("unsupported model_url scheme: {}", scheme)), + } + + let head_attempt = async { + let response = http + .head(model_url) + .send() + .await + .map_err(|e| format!("model_url preflight HEAD failed: {}", e))?; + if response.status().is_success() { + return Ok(( + response.url().to_string(), + response.status().as_u16(), + response.content_length(), + )); + } + Err(format!( + "model_url preflight HEAD failed: http_status={} url={}", + response.status(), + model_url + )) + }; + + if let Ok(result) = timeout(Duration::from_secs(timeout_s), head_attempt).await { + if let Ok(success) = result { + return Ok(success); + } + } + + let range_attempt = async { + let response = http + .get(model_url) + .header(RANGE, "bytes=0-0") + .send() + .await + .map_err(|e| format!("model_url preflight GET failed: {}", e))?; + if response.status().is_success() || response.status().as_u16() == 206 { + return Ok(( + response.url().to_string(), + response.status().as_u16(), + response.content_length(), + )); + } + Err(format!( + "model_url preflight GET failed: http_status={} url={}", + response.status(), + model_url + )) + }; + + match timeout(Duration::from_secs(timeout_s), range_attempt).await { + Ok(result) => result, + Err(_) => Err("model_url preflight timeout".to_string()), + } +} + +async fn download_model(http: &Client, source: &LlamaCppModelSource) -> Result<(), String> { + if source.skip_download { + return Err("model file missing and skip_download=1".to_string()); + } + + let parent = source + .model_path .parent() .ok_or_else(|| "invalid model_path".to_string())?; tokio::fs::create_dir_all(parent) .await .map_err(|e| e.to_string())?; - if let Some(model_url) = params - .get("model_url") - .cloned() - .filter(|s| !s.trim().is_empty()) - { - let timeout_s = params - .get("download_timeout_s") - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(3600); - return download_model_from_url(http, &model_url, model_path, timeout_s).await; + if let Some(model_url) = &source.model_url { + let timeout_s = source.download_timeout_s.unwrap_or(3600); + return download_model_from_url(http, model_url, &source.model_path, timeout_s).await; } Err( "model file missing; set params.model_url (http/https .gguf) or params.model_path" @@ -364,6 +673,13 @@ async fn download_model( ) } +fn parse_truthy(value: &str) -> bool { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + _ => false, + } +} + async fn download_model_from_url( http: &Client, model_url: &str, diff --git a/src/spearlet/local_models/provider.rs b/src/spearlet/local_models/provider.rs index a2cf001c..17b3ad8f 100644 --- a/src/spearlet/local_models/provider.rs +++ b/src/spearlet/local_models/provider.rs @@ -1,6 +1,8 @@ //! Shared local-provider resolution helpers. //! 共享的本地 provider 解析辅助。 +use crate::ai_backend_types::{CanonicalBackendKind, CanonicalBackendProvider}; + /// Canonical local provider kind recognized by node-side controllers. /// 节点侧控制器识别的规范本地 provider 类型。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -21,10 +23,10 @@ where .map(|value| value.trim().to_ascii_lowercase()) .collect::>(); - if normalized.iter().any(|value| is_llamacpp_kind(value)) { + if normalized.iter().any(|value| is_llamacpp_family(value)) { return LocalProviderKind::LlamaCpp; } - if normalized.iter().any(|value| is_vllm_kind(value)) { + if normalized.iter().any(|value| is_vllm_family(value)) { return LocalProviderKind::Vllm; } @@ -36,17 +38,33 @@ where ) } -fn is_llamacpp_kind(kind: &str) -> bool { - kind == "llamacpp" || kind == "llama_cpp" || kind == "llama.cpp" +/// Resolve a local provider kind from assignment backend/spec fields. +/// 从 assignment 的 backend/spec 字段解析本地 provider 类型。 +pub fn resolve_local_provider_kind_for_assignment( + backend_kind: &str, + spec_kind: &str, + spec_provider: &str, +) -> LocalProviderKind { + resolve_local_provider_kind([backend_kind, spec_kind, spec_provider]) +} + +fn is_llamacpp_family(value: &str) -> bool { + CanonicalBackendKind::parse(value) == CanonicalBackendKind::LlamaCpp + || CanonicalBackendProvider::parse(value) == CanonicalBackendProvider::LlamaCpp } -fn is_vllm_kind(kind: &str) -> bool { - kind == "vllm" || kind == "vllm-openai" +fn is_vllm_family(value: &str) -> bool { + matches!( + CanonicalBackendKind::parse(value), + CanonicalBackendKind::Vllm | CanonicalBackendKind::VllmOpenAi + ) || CanonicalBackendProvider::parse(value) == CanonicalBackendProvider::Vllm } #[cfg(test)] mod tests { - use super::{resolve_local_provider_kind, LocalProviderKind}; + use super::{ + resolve_local_provider_kind, resolve_local_provider_kind_for_assignment, LocalProviderKind, + }; #[test] fn resolves_llamacpp_from_aliases() { @@ -75,4 +93,16 @@ mod tests { LocalProviderKind::Unsupported("mystery-provider".to_string()) ); } + + #[test] + fn resolves_assignment_provider_kind_from_backend_and_spec_fields() { + assert_eq!( + resolve_local_provider_kind_for_assignment("custom", "vllm-openai", "vllm"), + LocalProviderKind::Vllm + ); + assert_eq!( + resolve_local_provider_kind_for_assignment("llama.cpp", "", ""), + LocalProviderKind::LlamaCpp + ); + } } diff --git a/src/spearlet/local_models/vllm.rs b/src/spearlet/local_models/vllm.rs index 4885c3c7..2878e4c7 100644 --- a/src/spearlet/local_models/vllm.rs +++ b/src/spearlet/local_models/vllm.rs @@ -18,12 +18,35 @@ use crate::spearlet::config::SpearletConfig; pub enum VllmPreparedAssignment { /// Use an already-running node-local HTTP endpoint. /// 使用节点上已经运行的本地 HTTP 端点。 - ExternalEndpoint, + ExternalEndpoint(VllmExternalEndpoint), /// Process orchestration is intentionally not implemented yet. /// 进程编排目前刻意不实现。 Placeholder { reason: String }, } +/// Typed vLLM external endpoint source. +/// 强类型 vLLM 外部端点来源。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VllmEndpointSource { + /// Metadata explicitly requests external endpoint mode. + /// metadata 显式要求 external endpoint 模式。 + MetadataMode, + /// Metadata says the process is already managed externally. + /// metadata 表示该进程已由外部托管。 + ManagedExternally, + /// `spec.base_url` implies an existing node-local endpoint. + /// `spec.base_url` 隐含已有节点本地端点。 + SpecBaseUrl, +} + +/// Typed vLLM external endpoint resolved from spec and metadata. +/// 从 spec 与 metadata 解析出的强类型 vLLM 外部端点。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VllmExternalEndpoint { + pub base_url: String, + pub source: VllmEndpointSource, +} + /// Lightweight vLLM supervisor skeleton. /// 轻量级 vLLM supervisor 骨架。 #[derive(Debug, Clone, Default)] @@ -43,10 +66,9 @@ impl VllmSupervisor { spec: &BackendSpec, params: &HashMap, ) -> VllmPreparedAssignment { - let mode = resolve_vllm_assignment_mode(spec, params); - match mode { - VllmAssignmentMode::ExternalEndpoint => VllmPreparedAssignment::ExternalEndpoint, - VllmAssignmentMode::Placeholder => VllmPreparedAssignment::Placeholder { + match resolve_vllm_external_endpoint(spec, params) { + Some(endpoint) => VllmPreparedAssignment::ExternalEndpoint(endpoint), + None => VllmPreparedAssignment::Placeholder { reason: "placeholder: vLLM local backend process orchestration is not implemented yet; set metadata.mode=external_endpoint with spec.base_url to use an existing node-local vLLM service".to_string(), }, } @@ -56,7 +78,7 @@ impl VllmSupervisor { /// vLLM assignment execution mode resolved from backend metadata. /// 从 backend metadata 解析出的 vLLM assignment 执行模式。 #[derive(Debug, Clone, PartialEq, Eq)] -enum VllmAssignmentMode { +pub enum VllmAssignmentMode { /// Use an already-running node-local HTTP endpoint. /// 使用节点上已经存在的 HTTP 服务端点。 ExternalEndpoint, @@ -67,37 +89,67 @@ enum VllmAssignmentMode { /// Resolve the execution mode for a vLLM assignment. /// 解析 vLLM assignment 的执行模式。 -fn resolve_vllm_assignment_mode( +pub fn infer_vllm_assignment_mode( spec: &BackendSpec, params: &HashMap, ) -> VllmAssignmentMode { + if resolve_vllm_external_endpoint(spec, params).is_some() { + VllmAssignmentMode::ExternalEndpoint + } else { + VllmAssignmentMode::Placeholder + } +} + +/// Resolve the typed vLLM external endpoint, if one is available. +/// 解析强类型 vLLM 外部端点;若当前没有可用端点则返回空。 +pub fn resolve_vllm_external_endpoint( + spec: &BackendSpec, + params: &HashMap, +) -> Option { + let base_url = spec.base_url.trim(); + if base_url.is_empty() { + return None; + } + let mode = params .get("mode") .map(|value| value.trim().to_ascii_lowercase()) .unwrap_or_default(); + if mode == "external_endpoint" { + return Some(VllmExternalEndpoint { + base_url: base_url.to_string(), + source: VllmEndpointSource::MetadataMode, + }); + } + let managed_externally = params .get("managed_externally") .map(|value| parse_truthy(value)) .unwrap_or(false); - - if mode == "external_endpoint" || managed_externally { - return VllmAssignmentMode::ExternalEndpoint; + if managed_externally { + return Some(VllmExternalEndpoint { + base_url: base_url.to_string(), + source: VllmEndpointSource::ManagedExternally, + }); } - if !spec.base_url.trim().is_empty() && params.get("mode").is_none() { - return VllmAssignmentMode::ExternalEndpoint; + if params.get("mode").is_none() { + return Some(VllmExternalEndpoint { + base_url: base_url.to_string(), + source: VllmEndpointSource::SpecBaseUrl, + }); } - VllmAssignmentMode::Placeholder + None } /// Parse a truthy string used by metadata params. /// 解析 metadata 参数中使用的布尔真值字符串。 fn parse_truthy(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + _ => false, + } } #[cfg(test)] @@ -129,10 +181,18 @@ mod tests { let supervisor = VllmSupervisor::new(&SpearletConfig::default()); let mut params = HashMap::new(); params.insert("mode".to_string(), "external_endpoint".to_string()); - - let prepared = supervisor.prepare_assignment(&sample_spec(), ¶ms); - - assert_eq!(prepared, VllmPreparedAssignment::ExternalEndpoint); + let mut spec = sample_spec(); + spec.base_url = "http://127.0.0.1:8000/v1".to_string(); + + let prepared = supervisor.prepare_assignment(&spec, ¶ms); + + assert_eq!( + prepared, + VllmPreparedAssignment::ExternalEndpoint(VllmExternalEndpoint { + base_url: "http://127.0.0.1:8000/v1".to_string(), + source: VllmEndpointSource::MetadataMode, + }) + ); } #[test] @@ -147,4 +207,28 @@ mod tests { other => panic!("expected placeholder, got {other:?}"), } } + + #[test] + fn resolve_external_endpoint_uses_spec_base_url_implicitly() { + let mut spec = sample_spec(); + spec.base_url = "http://127.0.0.1:8001/v1".to_string(); + + let endpoint = + resolve_vllm_external_endpoint(&spec, &HashMap::new()).expect("external endpoint"); + assert_eq!(endpoint.base_url, "http://127.0.0.1:8001/v1"); + assert_eq!(endpoint.source, VllmEndpointSource::SpecBaseUrl); + } + + #[test] + fn resolve_external_endpoint_uses_managed_externally_flag() { + let mut spec = sample_spec(); + spec.base_url = "http://127.0.0.1:8002/v1".to_string(); + let mut params = HashMap::new(); + params.insert("managed_externally".to_string(), "true".to_string()); + + let endpoint = + resolve_vllm_external_endpoint(&spec, ¶ms).expect("external endpoint"); + assert_eq!(endpoint.base_url, "http://127.0.0.1:8002/v1"); + assert_eq!(endpoint.source, VllmEndpointSource::ManagedExternally); + } } diff --git a/src/spearlet/mcp/registry_sync.rs b/src/spearlet/mcp/registry_sync.rs index 751a35ad..08cef519 100644 --- a/src/spearlet/mcp/registry_sync.rs +++ b/src/spearlet/mcp/registry_sync.rs @@ -9,8 +9,8 @@ use tracing::{debug, warn}; use crate::proto::sms::mcp_registry_service_client::McpRegistryServiceClient; use crate::proto::sms::{ListMcpServersRequest, McpServerRecord, WatchMcpServersRequest}; -use crate::spearlet::controller::Controller; use crate::spearlet::config::SpearletConfig; +use crate::spearlet::controller::Controller; #[derive(Debug, Clone, Default)] pub struct McpRegistrySnapshot { diff --git a/src/spearlet/mod.rs b/src/spearlet/mod.rs index 2bd40a3a..b6b03406 100644 --- a/src/spearlet/mod.rs +++ b/src/spearlet/mod.rs @@ -27,11 +27,11 @@ //! └─────────────────┘ //! ``` -pub mod backend_reporter; -pub(crate) mod debug_reporter; pub mod ai; -pub mod controller; +pub mod backend_reporter; pub mod config; +pub mod controller; +pub(crate) mod debug_reporter; pub mod execution; pub mod function_service; pub mod grpc_server; @@ -45,8 +45,8 @@ pub mod param_keys; pub mod registration; pub mod sms_connector; pub mod task_assignments; -pub mod task_events; mod task_event_cursor; +pub mod task_events; #[cfg(test)] mod config_test; diff --git a/src/spearlet/object_service.rs b/src/spearlet/object_service.rs index bb45c7a3..09fec5b7 100644 --- a/src/spearlet/object_service.rs +++ b/src/spearlet/object_service.rs @@ -17,6 +17,14 @@ use crate::proto::spearlet::{ RemoveObjectRefResponse, UnpinObjectRequest, UnpinObjectResponse, }; +/// Get the current unix timestamp in seconds / 获取当前 Unix 秒级时间戳 +fn current_unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + /// Object key generation helpers / 对象键生成辅助函数 mod object_keys { /// Generate object key / 生成对象键 @@ -52,10 +60,7 @@ struct StoredObject { impl StoredObject { fn new(key: String, value: Vec, metadata: HashMap) -> Self { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let now = current_unix_timestamp(); Self { key, @@ -96,76 +101,30 @@ impl StoredObject { fn update_value(&mut self, value: Vec, metadata: HashMap) { self.value = value; self.metadata = metadata; - self.updated_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + self.touch(); } -} -// Implement ObjectService for Arc to support gRPC server -// 为Arc实现ObjectService trait以支持gRPC服务器 -#[tonic::async_trait] -impl ObjectService for Arc { - async fn put_object( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().put_object(request).await - } - - async fn get_object( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().get_object(request).await - } - - async fn list_objects( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().list_objects(request).await - } - - async fn add_object_ref( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().add_object_ref(request).await - } - - async fn remove_object_ref( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().remove_object_ref(request).await - } - - async fn pin_object( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().pin_object(request).await + fn touch(&mut self) { + self.updated_at = current_unix_timestamp(); } +} - async fn unpin_object( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().unpin_object(request).await - } +/// Aggregated scan result for object storage / 对象存储扫描后的聚合结果 +#[derive(Debug, Default)] +struct ObjectScanStats { + object_count: usize, + total_size: u64, + pinned_count: usize, +} - async fn delete_object( - &self, - request: Request, - ) -> Result, Status> { - self.as_ref().delete_object(request).await - } +/// Persistence action after mutating an object / 对象变更后的持久化动作 +enum ObjectPersistenceAction { + Save(StoredObject), + Delete, } /// Object service implementation / 对象服务实现 -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ObjectServiceImpl { /// KV store for object storage / 对象存储的KV存储 kv_store: Arc, @@ -191,62 +150,148 @@ impl ObjectServiceImpl { } } - /// Get object count / 获取对象数量 - pub async fn object_count(&self) -> usize { - match self + /// Load a stored object from KV storage by internal key. + /// 按内部键从 KV 存储加载对象。 + async fn load_stored_object(&self, kv_key: &String) -> Result, String> { + let Some(data) = self .kv_store - .scan_prefix(object_keys::object_prefix()) + .get(kv_key) .await - { - Ok(pairs) => pairs.len(), - Err(_) => 0, - } + .map_err(|err| format!("Failed to get object: {err}"))? + else { + return Ok(None); + }; + + serialization::deserialize::(&data) + .map(Some) + .map_err(|err| format!("Failed to deserialize object: {err}")) } - /// Get total object size / 获取对象总大小 - pub async fn total_object_size(&self) -> u64 { - match self - .kv_store - .scan_prefix(object_keys::object_prefix()) + /// Store a serialized object back into KV storage by internal key. + /// 按内部键将序列化后的对象写回 KV 存储。 + async fn store_stored_object( + &self, + kv_key: &String, + object: &StoredObject, + ) -> Result<(), String> { + let serialized = serialization::serialize(object) + .map_err(|err| format!("Failed to serialize object: {err}"))?; + self.kv_store + .put(kv_key, &serialized) .await - { - Ok(pairs) => { - let mut total_size = 0u64; - for pair in pairs { - if let Ok(stored_obj) = serialization::deserialize::(&pair.value) - { - total_size += stored_obj.value.len() as u64; - } - } - total_size + .map_err(|err| format!("Failed to store object: {err}")) + } + + /// Delete a stored object by internal key. + /// 按内部键删除对象。 + async fn delete_stored_object(&self, kv_key: &String) -> Result<(), String> { + self.kv_store + .delete(kv_key) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to delete object: {err}")) + } + + /// Persist the mutation result chosen by an object update closure. + /// 持久化对象更新闭包选择的变更结果。 + async fn persist_object_action( + &self, + request_key: &str, + kv_key: &String, + action: ObjectPersistenceAction, + ) -> Result<(), Status> { + match action { + ObjectPersistenceAction::Save(object) => self + .store_stored_object(kv_key, &object) + .await + .map_err(|message| { + error!("Failed to save updated object {}: {}", request_key, message); + Status::internal(message) + }), + ObjectPersistenceAction::Delete => { + self.delete_stored_object(kv_key).await.map_err(|message| { + error!("Failed to delete object {}: {}", request_key, message); + Status::internal(message) + }) } - Err(_) => 0, } } - /// Get pinned object count / 获取固定对象数量 - pub async fn pinned_object_count(&self) -> usize { + /// Load an existing object, apply a mutation, and persist the chosen action. + /// 加载已有对象,执行一次变更,并持久化对应动作。 + async fn update_existing_object( + &self, + request_key: &str, + kv_key: &String, + not_found_response: R, + mutate: F, + ) -> Result + where + F: FnOnce(&mut StoredObject) -> Result<(ObjectPersistenceAction, R), R>, + { + let Some(mut object) = self.load_stored_object(kv_key).await.map_err(|message| { + error!("Failed to get object {}: {}", request_key, message); + Status::internal(message) + })? + else { + return Ok(not_found_response); + }; + + let (action, response) = match mutate(&mut object) { + Ok(result) => result, + Err(response) => return Ok(response), + }; + + self.persist_object_action(request_key, kv_key, action) + .await?; + Ok(response) + } + + /// Scan stored objects once and aggregate lightweight statistics. + /// 单次扫描对象存储并聚合轻量级统计信息。 + async fn scan_object_stats(&self) -> ObjectScanStats { match self .kv_store .scan_prefix(object_keys::object_prefix()) .await { Ok(pairs) => { - let mut pinned_count = 0; + let mut stats = ObjectScanStats { + object_count: pairs.len(), + ..ObjectScanStats::default() + }; + for pair in pairs { if let Ok(stored_obj) = serialization::deserialize::(&pair.value) { + stats.total_size += stored_obj.value.len() as u64; if stored_obj.pinned { - pinned_count += 1; + stats.pinned_count += 1; } } } - pinned_count + + stats } - Err(_) => 0, + Err(_) => ObjectScanStats::default(), } } + /// Get object count / 获取对象数量 + pub async fn object_count(&self) -> usize { + self.scan_object_stats().await.object_count + } + + /// Get total object size / 获取对象总大小 + pub async fn total_object_size(&self) -> u64 { + self.scan_object_stats().await.total_size + } + + /// Get pinned object count / 获取固定对象数量 + pub async fn pinned_object_count(&self) -> usize { + self.scan_object_stats().await.pinned_count + } + /// Cleanup objects with zero references / 清理零引用对象 pub async fn cleanup_objects(&self) -> usize { match self @@ -278,37 +323,25 @@ impl ObjectServiceImpl { /// Get service statistics / 获取服务统计信息 pub async fn get_stats(&self) -> ObjectServiceStats { + let stats = self.scan_object_stats().await; ObjectServiceStats { - object_count: self.object_count().await, - total_size: self.total_object_size().await, - pinned_count: self.pinned_object_count().await, + object_count: stats.object_count, + total_size: stats.total_size, + pinned_count: stats.pinned_count, } } -} - -#[tonic::async_trait] -impl ObjectService for ObjectServiceImpl { - /// Put or overwrite object content / 写入或覆盖对象内容 - async fn put_object( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - debug!("PutObject request for key: {}", req.key); - - // Validate key / 验证键 + fn validate_put_request(&self, req: &PutObjectRequest) -> Result<(), PutObjectResponse> { if req.key.is_empty() { - return Ok(Response::new(PutObjectResponse { + return Err(PutObjectResponse { success: false, message: "Object key cannot be empty".to_string(), object_meta: None, - })); + }); } - // Validate object size / 验证对象大小 if req.value.len() as u64 > self.max_object_size { - return Ok(Response::new(PutObjectResponse { + return Err(PutObjectResponse { success: false, message: format!( "Object size {} exceeds maximum size {}", @@ -316,61 +349,81 @@ impl ObjectService for ObjectServiceImpl { self.max_object_size ), object_meta: None, - })); + }); } - let kv_key = object_keys::object_key(&req.key); + Ok(()) + } - // Check if object exists and overwrite flag / 检查对象是否存在和覆盖标志 - if let Ok(Some(existing_data)) = self.kv_store.get(&kv_key).await { - if !req.overwrite { - if let Ok(existing_obj) = serialization::deserialize::(&existing_data) - { - return Ok(Response::new(PutObjectResponse { - success: false, - message: "Object already exists and overwrite is false".to_string(), - object_meta: Some(existing_obj.to_object_meta()), - })); - } + fn build_object_for_put( + &self, + req: PutObjectRequest, + existing_object: Option, + ) -> Result { + match existing_object { + Some(existing_obj) if !req.overwrite => Err(PutObjectResponse { + success: false, + message: "Object already exists and overwrite is false".to_string(), + object_meta: Some(existing_obj.to_object_meta()), + }), + Some(mut existing_obj) => { + existing_obj.update_value(req.value, req.metadata); + Ok(existing_obj) } + None => Ok(StoredObject::new(req.key, req.value, req.metadata)), } + } +} - // Create or update object / 创建或更新对象 - let stored_obj = if let Ok(Some(existing_data)) = self.kv_store.get(&kv_key).await { - if let Ok(mut existing_obj) = serialization::deserialize::(&existing_data) - { - existing_obj.update_value(req.value, req.metadata); - existing_obj - } else { - StoredObject::new(req.key.clone(), req.value, req.metadata) - } - } else { - StoredObject::new(req.key.clone(), req.value, req.metadata) - }; +impl From> for ObjectServiceImpl { + fn from(value: Arc) -> Self { + value.as_ref().clone() + } +} - // Store object in KV store / 在KV存储中存储对象 - match serialization::serialize(&stored_obj) { - Ok(serialized_data) => { - if let Err(e) = self.kv_store.put(&kv_key, &serialized_data).await { - error!("Failed to store object {}: {:?}", req.key, e); - return Ok(Response::new(PutObjectResponse { - success: false, - message: format!("Failed to store object: {:?}", e), - object_meta: None, - })); - } - } - Err(e) => { - error!("Failed to serialize object {}: {:?}", req.key, e); +#[tonic::async_trait] +impl ObjectService for ObjectServiceImpl { + /// Put or overwrite object content / 写入或覆盖对象内容 + async fn put_object( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + debug!("PutObject request for key: {}", req.key); + + if let Err(response) = self.validate_put_request(&req) { + return Ok(Response::new(response)); + } + + let kv_key = object_keys::object_key(&req.key); + + let existing_object = match self.load_stored_object(&kv_key).await { + Ok(object) => object, + Err(message) => { + error!("Failed to load object {}: {}", req.key, message); return Ok(Response::new(PutObjectResponse { success: false, - message: format!("Failed to serialize object: {:?}", e), + message, object_meta: None, })); } + }; + let stored_obj = match self.build_object_for_put(req, existing_object) { + Ok(object) => object, + Err(response) => return Ok(Response::new(response)), + }; + + if let Err(message) = self.store_stored_object(&kv_key, &stored_obj).await { + error!("Failed to store object {}: {}", stored_obj.key, message); + return Ok(Response::new(PutObjectResponse { + success: false, + message, + object_meta: None, + })); } - info!("Successfully put object: {}", req.key); + info!("Successfully put object: {}", stored_obj.key); Ok(Response::new(PutObjectResponse { success: true, @@ -390,24 +443,15 @@ impl ObjectService for ObjectServiceImpl { let key = object_keys::object_key(&req.key); - match self.kv_store.get(&key).await { - Ok(Some(data)) => match serialization::deserialize::(&data) { - Ok(obj) => { - info!("Successfully retrieved object: {}", req.key); - Ok(Response::new(GetObjectResponse { - found: true, - message: "Object retrieved successfully".to_string(), - object: Some(obj.to_object()), - })) - } - Err(e) => { - error!("Failed to deserialize object {}: {}", req.key, e); - Err(Status::internal(format!( - "Failed to deserialize object: {}", - e - ))) - } - }, + match self.load_stored_object(&key).await { + Ok(Some(obj)) => { + info!("Successfully retrieved object: {}", req.key); + Ok(Response::new(GetObjectResponse { + found: true, + message: "Object retrieved successfully".to_string(), + object: Some(obj.to_object()), + })) + } Ok(None) => { warn!("Object not found: {}", req.key); Ok(Response::new(GetObjectResponse { @@ -416,9 +460,9 @@ impl ObjectService for ObjectServiceImpl { object: None, })) } - Err(e) => { - error!("Failed to get object {}: {}", req.key, e); - Err(Status::internal(format!("Failed to get object: {}", e))) + Err(message) => { + error!("Failed to get object {}: {}", req.key, message); + Err(Status::internal(message)) } } } @@ -516,70 +560,40 @@ impl ObjectService for ObjectServiceImpl { ); let key = object_keys::object_key(&req.key); - - match self.kv_store.get(&key).await { - Ok(Some(data)) => { - match serialization::deserialize::(&data) { - Ok(mut obj) => { - obj.ref_count += count; - let new_ref_count = obj.ref_count; - - // Save updated object back to KV store / 将更新后的对象保存回 KV 存储 - match serialization::serialize(&obj) { - Ok(serialized_data) => { - match self.kv_store.put(&key, &serialized_data).await { - Ok(_) => { - info!( - "Added {} references to object: {}, new count: {}", - count, req.key, new_ref_count - ); - Ok(Response::new(AddObjectRefResponse { - success: true, - message: "Reference count added successfully" - .to_string(), - new_ref_count, - })) - } - Err(e) => { - error!("Failed to save updated object {}: {}", req.key, e); - Err(Status::internal(format!( - "Failed to save updated object: {}", - e - ))) - } - } - } - Err(e) => { - error!("Failed to serialize updated object {}: {}", req.key, e); - Err(Status::internal(format!( - "Failed to serialize updated object: {}", - e - ))) - } - } - } - Err(e) => { - error!("Failed to deserialize object {}: {}", req.key, e); - Err(Status::internal(format!( - "Failed to deserialize object: {}", - e - ))) - } - } - } - Ok(None) => { - warn!("Cannot add reference to non-existent object: {}", req.key); - Ok(Response::new(AddObjectRefResponse { + let response = self + .update_existing_object( + &req.key, + &key, + AddObjectRefResponse { success: false, message: "Object not found".to_string(), new_ref_count: 0, - })) - } - Err(e) => { - error!("Failed to get object {}: {}", req.key, e); - Err(Status::internal(format!("Failed to get object: {}", e))) - } + }, + |object| { + object.ref_count += count; + let new_ref_count = object.ref_count; + Ok(( + ObjectPersistenceAction::Save(object.clone()), + AddObjectRefResponse { + success: true, + message: "Reference count added successfully".to_string(), + new_ref_count, + }, + )) + }, + ) + .await?; + + if !response.success { + warn!("Cannot add reference to non-existent object: {}", req.key); + } else { + info!( + "Added {} references to object: {}, new count: {}", + count, req.key, response.new_ref_count + ); } + + Ok(Response::new(response)) } /// Remove reference count for lifecycle management / 减少引用计数,用于生命周期管理 @@ -596,96 +610,57 @@ impl ObjectService for ObjectServiceImpl { ); let key = object_keys::object_key(&req.key); - - match self.kv_store.get(&key).await { - Ok(Some(data)) => { - match serialization::deserialize::(&data) { - Ok(mut obj) => { - obj.ref_count = std::cmp::max(0, obj.ref_count - count); - let new_ref_count = obj.ref_count; - let object_deleted = new_ref_count == 0 && !obj.pinned; - - if object_deleted { - // Delete object from KV store / 从 KV 存储中删除对象 - match self.kv_store.delete(&key).await { - Ok(_) => { - info!("Removed object {} due to zero references", req.key); - Ok(Response::new(RemoveObjectRefResponse { - success: true, - message: "Object deleted due to zero references" - .to_string(), - new_ref_count, - deleted: object_deleted, - })) - } - Err(e) => { - error!("Failed to delete object {}: {}", req.key, e); - Err(Status::internal(format!("Failed to delete object: {}", e))) - } - } - } else { - // Save updated object back to KV store / 将更新后的对象保存回 KV 存储 - match serialization::serialize(&obj) { - Ok(serialized_data) => { - match self.kv_store.put(&key, &serialized_data).await { - Ok(_) => { - info!("Removed {} references from object: {}, new count: {}", count, req.key, new_ref_count); - Ok(Response::new(RemoveObjectRefResponse { - success: true, - message: "Reference count removed successfully" - .to_string(), - new_ref_count, - deleted: object_deleted, - })) - } - Err(e) => { - error!( - "Failed to save updated object {}: {}", - req.key, e - ); - Err(Status::internal(format!( - "Failed to save updated object: {}", - e - ))) - } - } - } - Err(e) => { - error!("Failed to serialize updated object {}: {}", req.key, e); - Err(Status::internal(format!( - "Failed to serialize updated object: {}", - e - ))) - } - } - } - } - Err(e) => { - error!("Failed to deserialize object {}: {}", req.key, e); - Err(Status::internal(format!( - "Failed to deserialize object: {}", - e - ))) - } - } - } - Ok(None) => { - warn!( - "Cannot remove reference from non-existent object: {}", - req.key - ); - Ok(Response::new(RemoveObjectRefResponse { + let response = self + .update_existing_object( + &req.key, + &key, + RemoveObjectRefResponse { success: false, message: "Object not found".to_string(), new_ref_count: 0, deleted: false, - })) - } - Err(e) => { - error!("Failed to get object {}: {}", req.key, e); - Err(Status::internal(format!("Failed to get object: {}", e))) - } + }, + |object| { + object.ref_count = std::cmp::max(0, object.ref_count - count); + let new_ref_count = object.ref_count; + let deleted = new_ref_count == 0 && !object.pinned; + let action = if deleted { + ObjectPersistenceAction::Delete + } else { + ObjectPersistenceAction::Save(object.clone()) + }; + Ok(( + action, + RemoveObjectRefResponse { + success: true, + message: if deleted { + "Object deleted due to zero references".to_string() + } else { + "Reference count removed successfully".to_string() + }, + new_ref_count, + deleted, + }, + )) + }, + ) + .await?; + + if !response.success { + warn!( + "Cannot remove reference from non-existent object: {}", + req.key + ); + } else if response.deleted { + info!("Removed object {} due to zero references", req.key); + } else { + info!( + "Removed {} references from object: {}, new count: {}", + count, req.key, response.new_ref_count + ); } + + Ok(Response::new(response)) } /// Pin object to disable automatic garbage collection / 将对象标记为常驻,禁用自动回收机制 @@ -698,68 +673,48 @@ impl ObjectService for ObjectServiceImpl { debug!("PinObject request for key: {}", req.key); let key = object_keys::object_key(&req.key); - - match self.kv_store.get(&key).await { - Ok(Some(data)) => match serialization::deserialize::(&data) { - Ok(mut obj) => { - let was_already_pinned = obj.pinned; - obj.pinned = true; - - match serialization::serialize(&obj) { - Ok(serialized_data) => { - match self.kv_store.put(&key, &serialized_data).await { - Ok(_) => { - info!( - "Pinned object: {}, was already pinned: {}", - req.key, was_already_pinned - ); - - Ok(Response::new(PinObjectResponse { - success: true, - message: "Object pinned successfully".to_string(), - })) - } - Err(e) => { - error!("Failed to store pinned object {}: {:?}", req.key, e); - Ok(Response::new(PinObjectResponse { - success: false, - message: format!("Failed to store pinned object: {:?}", e), - })) - } - } - } - Err(e) => { - error!("Failed to serialize object {}: {:?}", req.key, e); - Ok(Response::new(PinObjectResponse { - success: false, - message: format!("Failed to serialize object: {:?}", e), - })) - } - } - } - Err(e) => { - error!("Failed to deserialize object {}: {:?}", req.key, e); - Ok(Response::new(PinObjectResponse { - success: false, - message: format!("Failed to deserialize object: {:?}", e), - })) - } - }, - Ok(None) => { - warn!("Cannot pin non-existent object: {}", req.key); - Ok(Response::new(PinObjectResponse { + let mut was_already_pinned = false; + let response = match self + .update_existing_object( + &req.key, + &key, + PinObjectResponse { success: false, message: "Object not found".to_string(), - })) - } - Err(e) => { - error!("Failed to get object {} from KV store: {:?}", req.key, e); - Ok(Response::new(PinObjectResponse { + }, + |object| { + was_already_pinned = object.pinned; + object.pinned = true; + Ok(( + ObjectPersistenceAction::Save(object.clone()), + PinObjectResponse { + success: true, + message: "Object pinned successfully".to_string(), + }, + )) + }, + ) + .await + { + Ok(response) => response, + Err(status) => { + return Ok(Response::new(PinObjectResponse { success: false, - message: format!("Failed to get object from KV store: {:?}", e), + message: status.message().to_string(), })) } + }; + + if !response.success { + warn!("Cannot pin non-existent object: {}", req.key); + } else { + info!( + "Pinned object: {}, was already pinned: {}", + req.key, was_already_pinned + ); } + + Ok(Response::new(response)) } /// Unpin object to restore normal garbage collection / 取消常驻标记,恢复为正常回收状态 @@ -772,79 +727,51 @@ impl ObjectService for ObjectServiceImpl { debug!("UnpinObject request for key: {}", req.key); let key = object_keys::object_key(&req.key); - - match self.kv_store.get(&key).await { - Ok(Some(data)) => match serialization::deserialize::(&data) { - Ok(mut obj) => { - if !obj.pinned { - warn!("Object {} is not pinned", req.key); - Ok(Response::new(UnpinObjectResponse { + let response = match self + .update_existing_object( + &req.key, + &key, + UnpinObjectResponse { + success: false, + message: "Object not found".to_string(), + }, + |object| { + if !object.pinned { + return Err(UnpinObjectResponse { success: false, message: "Object is not pinned".to_string(), - })) - } else { - obj.pinned = false; - obj.updated_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - match serialization::serialize(&obj) { - Ok(serialized) => match self.kv_store.put(&key, &serialized).await { - Ok(_) => { - info!("Unpinned object: {}", req.key); - Ok(Response::new(UnpinObjectResponse { - success: true, - message: "Object unpinned successfully".to_string(), - })) - } - Err(e) => { - error!( - "Failed to update object {} in KV store: {:?}", - req.key, e - ); - Ok(Response::new(UnpinObjectResponse { - success: false, - message: format!( - "Failed to update object in KV store: {:?}", - e - ), - })) - } - }, - Err(e) => { - error!("Failed to serialize object {}: {:?}", req.key, e); - Ok(Response::new(UnpinObjectResponse { - success: false, - message: format!("Failed to serialize object: {:?}", e), - })) - } - } + }); } - } - Err(e) => { - error!("Failed to deserialize object {}: {:?}", req.key, e); - Ok(Response::new(UnpinObjectResponse { - success: false, - message: format!("Failed to deserialize object: {:?}", e), - })) - } - }, - Ok(None) => { - warn!("Cannot unpin non-existent object: {}", req.key); - Ok(Response::new(UnpinObjectResponse { - success: false, - message: "Object not found".to_string(), - })) - } - Err(e) => { - error!("Failed to get object {} from KV store: {:?}", req.key, e); - Ok(Response::new(UnpinObjectResponse { + + object.pinned = false; + object.touch(); + Ok(( + ObjectPersistenceAction::Save(object.clone()), + UnpinObjectResponse { + success: true, + message: "Object unpinned successfully".to_string(), + }, + )) + }, + ) + .await + { + Ok(response) => response, + Err(status) => { + return Ok(Response::new(UnpinObjectResponse { success: false, - message: format!("Failed to get object from KV store: {:?}", e), + message: status.message().to_string(), })) } + }; + + match response.message.as_str() { + "Object not found" => warn!("Cannot unpin non-existent object: {}", req.key), + "Object is not pinned" => warn!("Object {} is not pinned", req.key), + _ => info!("Unpinned object: {}", req.key), } + + Ok(Response::new(response)) } /// Delete object (for debugging and manual cleanup) / 删除对象(用于调试和手动清理) @@ -860,75 +787,57 @@ impl ObjectService for ObjectServiceImpl { ); let key = object_keys::object_key(&req.key); - - match self.kv_store.get(&key).await { - Ok(Some(data)) => match serialization::deserialize::(&data) { - Ok(obj) => { - let was_pinned = obj.pinned; - + let mut was_pinned = false; + let response = match self + .update_existing_object( + &req.key, + &key, + DeleteObjectResponse { + success: false, + message: "Object not found".to_string(), + deleted: false, + }, + |object| { + was_pinned = object.pinned; if was_pinned && !req.force { - warn!( - "Cannot delete pinned object without force flag: {}", - req.key - ); - Ok(Response::new(DeleteObjectResponse { + return Err(DeleteObjectResponse { success: false, message: "Cannot delete pinned object without force flag".to_string(), deleted: false, - })) - } else { - match self.kv_store.delete(&key).await { - Ok(_) => { - info!("Deleted object: {}, was pinned: {}", req.key, was_pinned); - - Ok(Response::new(DeleteObjectResponse { - success: true, - message: "Object deleted successfully".to_string(), - deleted: true, - })) - } - Err(e) => { - error!( - "Failed to delete object {} from KV store: {:?}", - req.key, e - ); - Ok(Response::new(DeleteObjectResponse { - success: false, - message: format!( - "Failed to delete object from KV store: {:?}", - e - ), - deleted: false, - })) - } - } + }); } - } - Err(e) => { - error!("Failed to deserialize object {}: {:?}", req.key, e); - Ok(Response::new(DeleteObjectResponse { - success: false, - message: format!("Failed to deserialize object: {:?}", e), - deleted: false, - })) - } - }, - Ok(None) => { - warn!("Cannot delete non-existent object: {}", req.key); - Ok(Response::new(DeleteObjectResponse { - success: false, - message: "Object not found".to_string(), - deleted: false, - })) - } - Err(e) => { - error!("Failed to get object {} from KV store: {:?}", req.key, e); - Ok(Response::new(DeleteObjectResponse { + + Ok(( + ObjectPersistenceAction::Delete, + DeleteObjectResponse { + success: true, + message: "Object deleted successfully".to_string(), + deleted: true, + }, + )) + }, + ) + .await + { + Ok(response) => response, + Err(status) => { + return Ok(Response::new(DeleteObjectResponse { success: false, - message: format!("Failed to get object from KV store: {:?}", e), + message: status.message().to_string(), deleted: false, })) } + }; + + match response.message.as_str() { + "Object not found" => warn!("Cannot delete non-existent object: {}", req.key), + "Cannot delete pinned object without force flag" => warn!( + "Cannot delete pinned object without force flag: {}", + req.key + ), + _ => info!("Deleted object: {}, was pinned: {}", req.key, was_pinned), } + + Ok(Response::new(response)) } } diff --git a/src/spearlet/object_service_test.rs b/src/spearlet/object_service_test.rs index a37425d7..b99c47de 100644 --- a/src/spearlet/object_service_test.rs +++ b/src/spearlet/object_service_test.rs @@ -7,7 +7,7 @@ use tonic::Request; use crate::proto::spearlet::{ object_service_server::ObjectService, AddObjectRefRequest, DeleteObjectRequest, GetObjectRequest, ListObjectsRequest, PinObjectRequest, PutObjectRequest, - RemoveObjectRefRequest, + RemoveObjectRefRequest, UnpinObjectRequest, }; use crate::spearlet::object_service::ObjectServiceImpl; @@ -59,6 +59,90 @@ async fn test_put_object() { let stats = service.get_stats().await; assert_eq!(stats.object_count, 1); assert_eq!(stats.total_size, 10); + assert_eq!(stats.pinned_count, 0); +} + +#[tokio::test] +async fn test_get_stats_tracks_pinned_objects() { + // Test stats aggregation for total size and pinned count / 测试总大小和固定对象数量统计 + let service = create_test_service(); + + for (key, value) in [("stats-a", b"abc".to_vec()), ("stats-b", b"hello".to_vec())] { + let put_request = Request::new(PutObjectRequest { + key: key.to_string(), + value, + metadata: create_test_metadata(), + overwrite: false, + }); + service.put_object(put_request).await.unwrap(); + } + + let pin_request = Request::new(PinObjectRequest { + key: "stats-b".to_string(), + }); + service.pin_object(pin_request).await.unwrap(); + + let stats = service.get_stats().await; + assert_eq!(stats.object_count, 2); + assert_eq!(stats.total_size, 8); + assert_eq!(stats.pinned_count, 1); +} + +#[tokio::test] +async fn test_overwrite_preserves_refcount_and_pin_state() { + // Test overwrite only refreshes payload-related fields / 测试覆盖写入只刷新载荷相关字段 + let service = create_test_service(); + + let first_put = Request::new(PutObjectRequest { + key: "stateful-key".to_string(), + value: b"v1".to_vec(), + metadata: create_test_metadata(), + overwrite: false, + }); + service.put_object(first_put).await.unwrap(); + + let add_ref_request = Request::new(AddObjectRefRequest { + key: "stateful-key".to_string(), + count: 2, + }); + service.add_object_ref(add_ref_request).await.unwrap(); + + let pin_request = Request::new(PinObjectRequest { + key: "stateful-key".to_string(), + }); + service.pin_object(pin_request).await.unwrap(); + + let mut overwritten_metadata = HashMap::new(); + overwritten_metadata.insert("version".to_string(), "v2".to_string()); + let overwrite_request = Request::new(PutObjectRequest { + key: "stateful-key".to_string(), + value: b"updated".to_vec(), + metadata: overwritten_metadata.clone(), + overwrite: true, + }); + let overwrite_response = service + .put_object(overwrite_request) + .await + .unwrap() + .into_inner(); + assert!(overwrite_response.success); + + let get_request = Request::new(GetObjectRequest { + key: "stateful-key".to_string(), + include_value: true, + }); + let object = service + .get_object(get_request) + .await + .unwrap() + .into_inner() + .object + .unwrap(); + + assert_eq!(object.value, b"updated"); + assert_eq!(object.ref_count, 3); + assert!(object.pinned); + assert_eq!(object.metadata, overwritten_metadata); } #[tokio::test] @@ -233,6 +317,32 @@ async fn test_pin_object() { assert!(response.success); } +#[tokio::test] +async fn test_unpin_non_pinned_object_returns_business_error() { + // Test unpinning without pin state / 测试未 pin 状态下的 unpin 行为 + let service = create_test_service(); + + let put_request = Request::new(PutObjectRequest { + key: "test-key".to_string(), + value: b"test-value".to_vec(), + metadata: create_test_metadata(), + overwrite: false, + }); + service.put_object(put_request).await.unwrap(); + + let unpin_request = Request::new(UnpinObjectRequest { + key: "test-key".to_string(), + }); + let response = service + .unpin_object(unpin_request) + .await + .unwrap() + .into_inner(); + + assert!(!response.success); + assert_eq!(response.message, "Object is not pinned"); +} + #[tokio::test] async fn test_delete_object() { // Test deleting an object / 测试删除对象 @@ -259,3 +369,38 @@ async fn test_delete_object() { let response = response.unwrap().into_inner(); assert!(response.success); } + +#[tokio::test] +async fn test_remove_last_reference_deletes_unpinned_object() { + // Test removing the last reference deletes unpinned objects / 测试移除最后一个引用时未 pin 对象被删除 + let service = create_test_service(); + + let put_request = Request::new(PutObjectRequest { + key: "delete-on-zero".to_string(), + value: b"payload".to_vec(), + metadata: create_test_metadata(), + overwrite: false, + }); + service.put_object(put_request).await.unwrap(); + + let remove_ref_request = Request::new(RemoveObjectRefRequest { + key: "delete-on-zero".to_string(), + count: 1, + }); + let response = service + .remove_object_ref(remove_ref_request) + .await + .unwrap() + .into_inner(); + + assert!(response.success); + assert!(response.deleted); + assert_eq!(response.new_ref_count, 0); + + let get_request = Request::new(GetObjectRequest { + key: "delete-on-zero".to_string(), + include_value: true, + }); + let get_response = service.get_object(get_request).await.unwrap().into_inner(); + assert!(!get_response.found); +} diff --git a/src/spearlet/ollama_discovery.rs b/src/spearlet/ollama_discovery.rs index 4ef2d174..cf577795 100644 --- a/src/spearlet/ollama_discovery.rs +++ b/src/spearlet/ollama_discovery.rs @@ -50,8 +50,7 @@ pub async fn maybe_import_ollama_serving_models(cfg: &mut SpearletConfig) -> Res return Ok(0); } - let existing_names: HashSet = - cfg.ai.backends.iter().map(|b| b.name.clone()).collect(); + let existing_names: HashSet = cfg.ai.backends.iter().map(|b| b.name.clone()).collect(); let mut imported = 0usize; for model in model_names { diff --git a/src/spearlet/task_event_cursor.rs b/src/spearlet/task_event_cursor.rs index 466cf5d5..0baac93e 100644 --- a/src/spearlet/task_event_cursor.rs +++ b/src/spearlet/task_event_cursor.rs @@ -16,8 +16,8 @@ impl TaskEventCursorStore { /// 根据 spearlet 配置创建游标存储。 pub(crate) fn new(config: &SpearletConfig) -> Self { let node_uuid = config.compute_node_uuid(); - let path = - PathBuf::from(&config.storage.data_dir).join(format!("task_events_cursor_{}.json", node_uuid)); + let path = PathBuf::from(&config.storage.data_dir) + .join(format!("task_events_cursor_{}.json", node_uuid)); Self { path } } @@ -43,7 +43,11 @@ impl TaskEventCursorStore { } match fs::File::create(&self.path) { Ok(mut file) => { - if let Err(error) = file.write_all(serde_json::to_string(&value).unwrap_or_else(|_| "0".to_string()).as_bytes()) { + if let Err(error) = file.write_all( + serde_json::to_string(&value) + .unwrap_or_else(|_| "0".to_string()) + .as_bytes(), + ) { warn!(error = %error, path = %self.path.display(), "Failed to write task cursor"); } } diff --git a/src/spearlet/task_events.rs b/src/spearlet/task_events.rs index d09faee3..f3e29881 100644 --- a/src/spearlet/task_events.rs +++ b/src/spearlet/task_events.rs @@ -1,15 +1,16 @@ -use std::{sync::Arc, time::Duration}; use prost::Message; +use std::{sync::Arc, time::Duration}; use tokio::sync::RwLock; use tokio_stream::StreamExt; use tonic::transport::Channel; +#[cfg(test)] +use crate::proto::sms::Task; use crate::proto::sms::{ events_service_client::EventsServiceClient, subscribe_events_selector::Selector, EventEnvelope, - EventOp, ResourceType, SubscribeEventsRequest, SubscribeEventsSelector, TaskEvent, TaskEventKind, + EventOp, ResourceType, SubscribeEventsRequest, SubscribeEventsSelector, TaskEvent, + TaskEventKind, }; -#[cfg(test)] -use crate::proto::sms::Task; use crate::spearlet::config::SpearletConfig; use crate::spearlet::execution::{manager::TaskExecutionManager, ExecutionError}; use crate::spearlet::task_event_cursor::TaskEventCursorStore; @@ -67,24 +68,24 @@ impl TaskEventSubscriber { let per_attempt = Duration::from_millis(cfg.sms_connect_timeout_ms) .min(Duration::from_secs(5)) .max(Duration::from_millis(1)); - let mut stream = match tokio::time::timeout( - per_attempt, - events_client.subscribe_events(req), - ) - .await - { - Ok(Ok(r)) => r.into_inner(), - Ok(Err(e)) => { - warn!(error = %e, "SubscribeEvents RPC failed, retrying"); - tokio::time::sleep(Duration::from_millis(cfg.sms_connect_retry_ms)).await; - continue; - } - Err(_) => { - warn!("SubscribeEvents RPC timeout, retrying"); - tokio::time::sleep(Duration::from_millis(cfg.sms_connect_retry_ms)).await; - continue; - } - }; + let mut stream = + match tokio::time::timeout(per_attempt, events_client.subscribe_events(req)) + .await + { + Ok(Ok(r)) => r.into_inner(), + Ok(Err(e)) => { + warn!(error = %e, "SubscribeEvents RPC failed, retrying"); + tokio::time::sleep(Duration::from_millis(cfg.sms_connect_retry_ms)) + .await; + continue; + } + Err(_) => { + warn!("SubscribeEvents RPC timeout, retrying"); + tokio::time::sleep(Duration::from_millis(cfg.sms_connect_retry_ms)) + .await; + continue; + } + }; loop { match stream.next().await { Some(Ok(env)) => { @@ -136,7 +137,8 @@ impl TaskEventSubscriber { debug!(event_id = %env.event_id, resource_type = env.resource_type, "Ignoring non-task unified event"); return Ok(()); } - if !matches!(env.op, x if x == EventOp::Create as i32 || x == EventOp::Cancel as i32 || x == EventOp::Update as i32) { + if !matches!(env.op, x if x == EventOp::Create as i32 || x == EventOp::Cancel as i32 || x == EventOp::Update as i32) + { debug!(event_id = %env.event_id, op = env.op, "Ignoring unsupported task unified event op"); return Ok(()); } @@ -161,7 +163,10 @@ impl TaskEventSubscriber { ev: TaskEvent, ) -> Result<(), String> { if ev.kind == TaskEventKind::Create as i32 { - match mgr.materialize_local_task_from_sms_create_event(&ev.task_id).await { + match mgr + .materialize_local_task_from_sms_create_event(&ev.task_id) + .await + { Ok(_) => {} Err(ExecutionError::TaskNotFound { .. }) => { warn!( @@ -173,7 +178,10 @@ impl TaskEventSubscriber { Err(error) => return Err(error.to_string()), } } else if ev.kind == TaskEventKind::Update as i32 { - match mgr.fetch_and_materialize_local_task_by_id(&ev.task_id).await { + match mgr + .fetch_and_materialize_local_task_by_id(&ev.task_id) + .await + { Ok(_) => {} Err(ExecutionError::TaskNotFound { .. }) => { warn!( @@ -227,8 +235,8 @@ impl TaskEventSubscriber { mod tests { use super::*; use crate::proto::sms::{ - task_service_server::TaskServiceServer, - Task, TaskEvent, TaskEventKind, TaskExecutable, TaskPriority, TaskStatus, + task_service_server::TaskServiceServer, Task, TaskEvent, TaskEventKind, TaskExecutable, + TaskPriority, TaskStatus, }; use crate::sms::service::SmsServiceImpl; use crate::spearlet::execution::instance; @@ -411,7 +419,10 @@ mod tests { tokio::time::sleep(Duration::from_millis(50)).await; let local_task = mgr.get_task_by_id("task-x").expect("task should exist"); - assert_eq!(local_task.status(), crate::spearlet::execution::task::TaskStatus::Ready); + assert_eq!( + local_task.status(), + crate::spearlet::execution::task::TaskStatus::Ready + ); assert!(mgr.get_artifact_by_id("deadbeef").is_some()); } diff --git a/static/.gitkeep b/static/.gitkeep deleted file mode 100644 index 48cdce85..00000000 --- a/static/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/tests/ai_backend_preflight_web_admin_tests.rs b/tests/ai_backend_preflight_web_admin_tests.rs new file mode 100644 index 00000000..5552d1d3 --- /dev/null +++ b/tests/ai_backend_preflight_web_admin_tests.rs @@ -0,0 +1,598 @@ +use axum::{routing::post, Json, Router}; +use axum_test::TestServer; +use serde_json::json; +use spear_next::proto::sms::{ + admin_credential_service_server::AdminCredentialServiceServer, + ai_backend_control_plane_service_client::AiBackendControlPlaneServiceClient, + ai_backend_control_plane_service_server::AiBackendControlPlaneServiceServer, + backend_registry_service_client::BackendRegistryServiceClient, + backend_registry_service_server::BackendRegistryServiceServer, + node_service_client::NodeServiceClient, node_service_server::NodeServiceServer, Node, + RegisterNodeRequest, +}; +use spear_next::sms::config::SmsConfig; +use spear_next::sms::gateway::GatewayState; +use spear_next::sms::service::SmsServiceImpl; +use spear_next::sms::web_admin::create_admin_router; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; +use tonic::transport::Server; +use uuid::Uuid; + +async fn start_sms_grpc() -> (tokio::task::JoinHandle<()>, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let sms_service = + SmsServiceImpl::with_storage_config(&spear_next::config::base::StorageConfig { + backend: "memory".to_string(), + ..Default::default() + }) + .await; + + let handle = tokio::spawn(async move { + Server::builder() + .add_service(NodeServiceServer::new(sms_service.clone())) + .add_service(BackendRegistryServiceServer::new(sms_service.clone())) + .add_service(AiBackendControlPlaneServiceServer::new(sms_service.clone())) + .add_service(AdminCredentialServiceServer::new(sms_service)) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + + (handle, format!("http://{}", addr)) +} + +async fn create_admin_test_server( + grpc_url: &str, +) -> ( + TestServer, + NodeServiceClient, + BackendRegistryServiceClient, +) { + let channel = tonic::transport::Channel::from_shared(grpc_url.to_string()) + .unwrap() + .connect() + .await + .unwrap(); + + let state = GatewayState { + config: Arc::new(SmsConfig::default()), + node_client: NodeServiceClient::new(channel.clone()), + task_client: spear_next::proto::sms::task_service_client::TaskServiceClient::new( + channel.clone(), + ), + task_assignment_client: spear_next::proto::sms::task_placement_assignment_service_client::TaskPlacementAssignmentServiceClient::new(channel.clone()), + placement_client: spear_next::proto::sms::placement_service_client::PlacementServiceClient::new( + channel.clone(), + ), + instance_registry_client: spear_next::proto::sms::instance_registry_service_client::InstanceRegistryServiceClient::new( + channel.clone(), + ), + execution_registry_client: spear_next::proto::sms::execution_registry_service_client::ExecutionRegistryServiceClient::new( + channel.clone(), + ), + execution_index_client: spear_next::proto::sms::execution_index_service_client::ExecutionIndexServiceClient::new( + channel.clone(), + ), + mcp_registry_client: spear_next::proto::sms::mcp_registry_service_client::McpRegistryServiceClient::new( + channel.clone(), + ), + backend_registry_client: BackendRegistryServiceClient::new(channel.clone()), + ai_backend_control_plane_client: AiBackendControlPlaneServiceClient::new(channel.clone()), + admin_credential_client: + spear_next::proto::sms::admin_credential_service_client::AdminCredentialServiceClient::new( + channel.clone(), + ), + stream_sessions: spear_next::sms::gateway::StreamSessionStore::new(), + execution_stream_pool: spear_next::sms::gateway::ExecutionStreamPool::new(), + cancel_token: CancellationToken::new(), + max_upload_bytes: 64 * 1024 * 1024, + files_dir: std::env::temp_dir() + .join(format!("spear-sms-files-{}", Uuid::new_v4())) + .to_string_lossy() + .to_string(), + }; + + let app = create_admin_router(state); + let server = TestServer::new(app.into_make_service()).unwrap(); + + ( + server, + NodeServiceClient::new(channel.clone()), + BackendRegistryServiceClient::new(channel.clone()), + ) +} + +fn remote_openai_model_access_verified_payload() -> serde_json::Value { + json!({ + "success": true, + "result": { + "provider_family": "open_ai_compatible", + "outcome": { + "outcome_kind": "model_access_verified", + "resolved_endpoint": "https://api.openai.com/v1/models", + "http_status": 200, + "checks": [ + {"name": "connectivity", "ok": true}, + {"name": "auth", "ok": true}, + {"name": "model_access", "ok": true} + ], + "auth_valid": true, + "model_accessible": true + } + }, + "message": "authentication and model access verified", + "latency_ms": 42, + }) +} + +fn remote_openai_auth_failed_payload() -> serde_json::Value { + json!({ + "success": false, + "result": { + "provider_family": "open_ai_compatible", + "outcome": { + "outcome_kind": "auth_failed", + "error_code": "auth_invalid", + "resolved_endpoint": "https://api.openai.com/v1/models", + "http_status": 401, + "provider_code": "invalid_api_key", + "checks": [ + {"name": "connectivity", "ok": true}, + {"name": "auth", "ok": false}, + {"name": "model_access", "ok": false} + ], + "auth_valid": false + } + }, + "message": "invalid api key", + "latency_ms": 17, + }) +} + +async fn start_spearlet_preflight_server( + remote_payload: serde_json::Value, +) -> (tokio::task::JoinHandle<()>, u16) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let app = Router::new() + .route( + "/internal/ai/local-models/preflight", + post(|| async { + Json(json!({ + "success": true, + "details": { + "source_kind": "model_url", + "effective_model_path": "/var/lib/spear/local_models/llamacpp/models/qwen.gguf", + "final_url": "https://models.example.com/qwen.gguf", + "http_status": 200, + "content_length": 1024 + }, + "message": "model_url is reachable from the node" + })) + }), + ) + .route( + "/internal/ai/backends/preflight", + post(move || { + let remote_payload = remote_payload.clone(); + async move { Json(remote_payload) } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (handle, port) +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_proxies_to_target_node() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, mut node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + let (spearlet_handle, http_port) = + start_spearlet_preflight_server(remote_openai_model_access_verified_payload()).await; + + let response = node_client + .register_node(RegisterNodeRequest { + node: Some(Node { + uuid: "node-preflight-1".to_string(), + ip_address: "127.0.0.1".to_string(), + port: 50052, + http_port: http_port as i32, + status: "online".to_string(), + last_heartbeat: 0, + registered_at: 0, + metadata: HashMap::new(), + }), + }) + .await + .unwrap() + .into_inner(); + assert!(response.success); + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Local llama.cpp", + "provider": "llamacpp", + "model": "qwen", + "hosting": "local", + "backend_kind": "llamacpp", + "spec": { + "base_url": "", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "local": { + "provider_family": "llama_cpp", + "config": { + "model_url": "https://models.example.com/qwen.gguf", + "skip_download": false, + "download_timeout_s": 30 + } + } + }, + "node_uuids": ["node-preflight-1"] + })) + .await + .json(); + + assert!(body["success"].as_bool().unwrap()); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["node_uuid"], "node-preflight-1"); + assert_eq!(results[0]["success"], true); + assert_eq!(results[0]["details"]["kind"], "local_model"); + assert_eq!(results[0]["details"]["source"]["source_kind"], "model_url"); + assert_eq!(body["message"], serde_json::Value::Null); + + spearlet_handle.abort(); + grpc_handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_supports_remote_backends() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, mut node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + let (spearlet_handle, http_port) = + start_spearlet_preflight_server(remote_openai_model_access_verified_payload()).await; + + let response = node_client + .register_node(RegisterNodeRequest { + node: Some(Node { + uuid: "node-preflight-remote-1".to_string(), + ip_address: "127.0.0.1".to_string(), + port: 50052, + http_port: http_port as i32, + status: "online".to_string(), + last_heartbeat: 0, + registered_at: 0, + metadata: HashMap::new(), + }), + }) + .await + .unwrap() + .into_inner(); + assert!(response.success); + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o-mini", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "credential_ref": "openai-prod", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"] + } + }, + "spec": { + "base_url": "", + "operations": [], + "features": [], + "transports": [], + "weight": 100, + "priority": 0 + }, + "metadata": {} + }, + "node_uuids": ["node-preflight-remote-1"], + "verification_policy": "single_node_strict", + "requested_checks": ["connectivity", "auth", "model_access"] + })) + .await + .json(); + + assert!(body["success"].as_bool().unwrap()); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["node_uuid"], "node-preflight-remote-1"); + assert_eq!(results[0]["success"], true); + assert_eq!(results[0]["details"]["kind"], "remote_provider"); + assert_eq!( + results[0]["details"]["result"]["provider_family"], + "open_ai_compatible" + ); + assert_eq!( + results[0]["details"]["result"]["outcome"]["outcome_kind"], + "model_access_verified" + ); + assert_eq!(results[0]["details"]["result"]["outcome"]["auth_valid"], true); + assert_eq!( + results[0]["details"]["result"]["outcome"]["model_accessible"], + true + ); + + spearlet_handle.abort(); + grpc_handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_surfaces_openai_auth_failure_shape() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, mut node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + let (spearlet_handle, http_port) = + start_spearlet_preflight_server(remote_openai_auth_failed_payload()).await; + + let response = node_client + .register_node(RegisterNodeRequest { + node: Some(Node { + uuid: "node-preflight-remote-failure-1".to_string(), + ip_address: "127.0.0.1".to_string(), + port: 50052, + http_port: http_port as i32, + status: "online".to_string(), + last_heartbeat: 0, + registered_at: 0, + metadata: HashMap::new(), + }), + }) + .await + .unwrap() + .into_inner(); + assert!(response.success); + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o-mini", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "credential_ref": "openai-prod", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"] + } + }, + "spec": { + "base_url": "", + "operations": [], + "features": [], + "transports": [], + "weight": 100, + "priority": 0 + }, + "metadata": {} + }, + "node_uuids": ["node-preflight-remote-failure-1"], + "verification_policy": "single_node_strict", + "requested_checks": ["connectivity", "auth", "model_access"] + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["node_uuid"], "node-preflight-remote-failure-1"); + assert_eq!(results[0]["success"], false); + assert_eq!(results[0]["details"]["kind"], "remote_provider"); + assert_eq!( + results[0]["details"]["result"]["provider_family"], + "open_ai_compatible" + ); + assert_eq!( + results[0]["details"]["result"]["outcome"]["outcome_kind"], + "auth_failed" + ); + assert_eq!(results[0]["details"]["result"]["outcome"]["error_code"], "auth_invalid"); + assert_eq!(results[0]["details"]["result"]["outcome"]["auth_valid"], false); + + spearlet_handle.abort(); + grpc_handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_requires_structured_local_input_for_llamacpp() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, _node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Local llama.cpp", + "provider": "llamacpp", + "model": "qwen", + "hosting": "local", + "backend_kind": "llamacpp", + "spec": { + "base_url": "http://127.0.0.1:8080/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": { + "model_path": "/models/qwen.gguf" + } + }, + "node_uuids": ["node-preflight-local-required-1"] + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!(body["results"].as_array().unwrap().len(), 0); + assert_eq!( + body["message"], + "local provider-specific input is required for backend provider llamacpp" + ); + + grpc_handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_requires_structured_remote_input_for_openai() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, _node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o-mini", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "credential_ref": "openai-prod", + "spec": { + "base_url": "https://api.openai.com/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": {} + }, + "node_uuids": ["node-preflight-remote-required-1"] + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!(body["results"].as_array().unwrap().len(), 0); + assert_eq!( + body["message"], + "remote provider-specific input is required for backend provider openai" + ); + + grpc_handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_rejects_duplicate_remote_openai_sources() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, _node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o-mini", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "credential_ref": "legacy-openai", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "credential_ref": "openai-prod", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"] + } + }, + "spec": { + "base_url": "https://legacy.example.com/v1", + "operations": ["chat_completions"], + "features": ["stream"], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": {} + }, + "node_uuids": ["node-preflight-remote-dup-1"] + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!(body["results"].as_array().unwrap().len(), 0); + assert_eq!( + body["message"], + "remote openai structured input cannot be combined with legacy fields: spec.base_url, credential_ref, spec.operations, spec.features, spec.transports" + ); + + grpc_handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_preflight_rejects_unsupported_provider() { + let (grpc_handle, grpc_url) = start_sms_grpc().await; + let (server, _node_client, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends/preflight") + .json(&json!({ + "backend": { + "display_name": "Custom Remote", + "provider": "anthropic-compatible", + "model": "claude-compatible", + "hosting": "remote", + "backend_kind": "custom_http", + "credential_ref": "custom-secret", + "spec": { + "base_url": "https://custom.example.com/v1", + "operations": ["chat_completions"], + "features": ["supports_tools"], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": { + "tenant": "lab" + } + }, + "node_uuids": ["node-preflight-custom-1"] + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!(body["results"].as_array().unwrap().len(), 0); + assert_eq!( + body["message"], + "unsupported backend provider: anthropic-compatible" + ); + + grpc_handle.abort(); +} diff --git a/tests/ai_models_web_admin_tests.rs b/tests/ai_models_web_admin_tests.rs index d25da198..f563af27 100644 --- a/tests/ai_models_web_admin_tests.rs +++ b/tests/ai_models_web_admin_tests.rs @@ -93,10 +93,7 @@ async fn create_admin_test_server( let app = create_admin_router(state); let server = TestServer::new(app.into_make_service()).unwrap(); - ( - server, - BackendRegistryServiceClient::new(channel.clone()), - ) + (server, BackendRegistryServiceClient::new(channel.clone())) } #[tokio::test] @@ -112,6 +109,12 @@ async fn test_admin_ai_model_views_lists_local_and_remote_models() { "model": "llama3", "hosting": "local", "backend_kind": "llamacpp", + "local": { + "provider_family": "llama_cpp", + "config": { + "model_path": "/models/llama3.gguf" + } + }, "spec": { "base_url": "http://127.0.0.1:8080/v1", "operations": ["chat_completions"], @@ -124,6 +127,11 @@ async fn test_admin_ai_model_views_lists_local_and_remote_models() { .await .json(); assert!(local_backend["success"].as_bool().unwrap()); + assert_eq!(local_backend["backend"]["local"]["provider_family"], "llama_cpp"); + assert_eq!( + local_backend["backend"]["local"]["config"]["model_path"], + "/models/llama3.gguf" + ); let local_backend_id = local_backend["backend"]["backend_id"].as_str().unwrap(); let remote_backend: serde_json::Value = server @@ -134,11 +142,20 @@ async fn test_admin_ai_model_views_lists_local_and_remote_models() { "model": "gpt-4o", "hosting": "remote", "backend_kind": "openai_chat_completion", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"] + } + }, "spec": { - "base_url": "https://api.openai.com/v1", - "operations": ["chat_completions"], + "base_url": "", + "operations": [], "features": [], - "transports": ["http"], + "transports": [], "weight": 100, "priority": 0 } @@ -146,6 +163,14 @@ async fn test_admin_ai_model_views_lists_local_and_remote_models() { .await .json(); assert!(remote_backend["success"].as_bool().unwrap()); + assert_eq!( + remote_backend["backend"]["remote"]["provider_family"], + "open_ai_compatible" + ); + assert_eq!( + remote_backend["backend"]["remote"]["config"]["base_url"], + "https://api.openai.com/v1" + ); let remote_backend_id = remote_backend["backend"]["backend_id"].as_str().unwrap(); let local_placement: serde_json::Value = server @@ -168,10 +193,7 @@ async fn test_admin_ai_model_views_lists_local_and_remote_models() { .json(); assert!(remote_placement["success"].as_bool().unwrap()); - let body: serde_json::Value = server - .get("/admin/api/ai-model-views") - .await - .json(); + let body: serde_json::Value = server.get("/admin/api/ai-model-views").await.json(); assert!(body["success"].as_bool().unwrap()); let views = body["views"].as_array().unwrap(); assert!(views @@ -184,6 +206,64 @@ async fn test_admin_ai_model_views_lists_local_and_remote_models() { handle.abort(); } +#[tokio::test] +async fn test_admin_ai_backend_create_normalizes_local_metadata_shape() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Local llama.cpp", + "provider": "llamacpp", + "model": "llama3", + "hosting": "local", + "backend_kind": "llamacpp", + "spec": { + "base_url": "", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "local": { + "provider_family": "llama_cpp", + "config": { + "model_url": " https://models.example.com/llama3.gguf ", + "skip_download": false, + "download_timeout_s": 60, + "threads": 8 + } + }, + "metadata": { + "extra_key": "preserve-me" + } + })) + .await + .json(); + + assert!(body["success"].as_bool().unwrap()); + assert_eq!( + body["backend"]["metadata"]["model_url"], + "https://models.example.com/llama3.gguf" + ); + assert_eq!(body["backend"]["metadata"]["skip_download"], "false"); + assert_eq!(body["backend"]["metadata"]["download_timeout_s"], "60"); + assert_eq!(body["backend"]["metadata"]["threads"], "8"); + assert_eq!(body["backend"]["metadata"]["extra_key"], "preserve-me"); + assert_eq!(body["backend"]["local"]["provider_family"], "llama_cpp"); + assert_eq!( + body["backend"]["local"]["config"]["model_url"], + "https://models.example.com/llama3.gguf" + ); + assert_eq!(body["backend"]["local"]["config"]["skip_download"], false); + assert_eq!(body["backend"]["local"]["config"]["download_timeout_s"], 60); + assert_eq!(body["backend"]["local"]["config"]["threads"], 8); + + handle.abort(); +} + #[tokio::test] async fn test_admin_ai_model_views_include_backend_ids_and_instances() { let (handle, grpc_url) = start_sms_grpc().await; @@ -197,11 +277,20 @@ async fn test_admin_ai_model_views_include_backend_ids_and_instances() { "model": "gpt-4o", "hosting": "remote", "backend_kind": "openai_chat_completion", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "operations": ["chat_completions"], + "features": ["streaming"], + "transports": ["http"] + } + }, "spec": { - "base_url": "https://api.openai.com/v1", - "operations": ["chat_completions"], - "features": ["streaming"], - "transports": ["http"], + "base_url": "", + "operations": [], + "features": [], + "transports": [], "weight": 100, "priority": 0 } @@ -223,10 +312,7 @@ async fn test_admin_ai_model_views_include_backend_ids_and_instances() { .json(); assert!(placement["success"].as_bool().unwrap()); - let body: serde_json::Value = server - .get("/admin/api/ai-model-views") - .await - .json(); + let body: serde_json::Value = server.get("/admin/api/ai-model-views").await.json(); assert!(body["success"].as_bool().unwrap()); let view = body["views"] .as_array() @@ -236,7 +322,11 @@ async fn test_admin_ai_model_views_include_backend_ids_and_instances() { .cloned() .expect("view"); assert_eq!(view["hosting"], "remote"); - assert!(view["backend_ids"].as_array().unwrap().iter().any(|id| id == backend_id)); + assert!(view["backend_ids"] + .as_array() + .unwrap() + .iter() + .any(|id| id == backend_id)); assert!(view["instances"] .as_array() .unwrap() @@ -245,3 +335,330 @@ async fn test_admin_ai_model_views_include_backend_ids_and_instances() { handle.abort(); } + +#[tokio::test] +async fn test_admin_ai_backend_create_requires_structured_local_input_for_llamacpp() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Local llama.cpp", + "provider": "llamacpp", + "model": "llama3", + "hosting": "local", + "backend_kind": "llamacpp", + "spec": { + "base_url": "http://127.0.0.1:8080/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": { + "model_path": "/models/llama3.gguf" + } + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!( + body["message"], + "local provider-specific input is required for backend provider llamacpp" + ); + + handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_create_rejects_duplicate_remote_openai_sources() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "credential_ref": "legacy-openai", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "credential_ref": "openai-prod", + "operations": ["chat_completions"], + "features": ["stream"], + "transports": ["http"] + } + }, + "spec": { + "base_url": "https://legacy.example.com/v1", + "operations": ["chat_completions"], + "features": ["stream"], + "transports": ["http"], + "weight": 100, + "priority": 0 + } + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!( + body["message"], + "remote openai structured input cannot be combined with legacy fields: spec.base_url, credential_ref, spec.operations, spec.features, spec.transports" + ); + + handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_create_rejects_unsupported_provider() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let body: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Custom Remote", + "provider": "anthropic-compatible", + "model": "claude-compatible", + "hosting": "remote", + "backend_kind": "custom_http", + "credential_ref": "custom-secret", + "spec": { + "base_url": "https://custom.example.com/v1", + "operations": ["chat_completions"], + "features": ["supports_tools"], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": { + "tenant": "lab" + } + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!( + body["message"], + "unsupported backend provider: anthropic-compatible" + ); + + handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_update_requires_structured_remote_input_for_openai() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let created: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"] + } + }, + "spec": { + "base_url": "", + "operations": [], + "features": [], + "transports": [], + "weight": 100, + "priority": 0 + } + })) + .await + .json(); + assert!(created["success"].as_bool().unwrap()); + let backend_id = created["backend"]["backend_id"] + .as_str() + .expect("backend id should exist"); + + let body: serde_json::Value = server + .put(&format!("/admin/api/ai-backends/{backend_id}")) + .json(&serde_json::json!({ + "display_name": "Remote OpenAI Updated", + "provider": "openai", + "model": "gpt-4o-mini", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "credential_ref": "openai-prod", + "spec": { + "base_url": "https://api.openai.com/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + }, + "metadata": {} + })) + .await + .json(); + + assert!(!body["success"].as_bool().unwrap()); + assert_eq!( + body["message"], + "remote provider-specific input is required for backend provider openai" + ); + + handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_detail_returns_typed_remote_read_view() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let created: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Remote OpenAI", + "provider": "openai", + "model": "gpt-4o-mini", + "hosting": "remote", + "backend_kind": "openai_chat_completion", + "remote": { + "provider_family": "open_ai_compatible", + "config": { + "base_url": "https://api.openai.com/v1", + "credential_ref": "openai-prod", + "operations": ["chat_completions", "embeddings"], + "features": ["stream"], + "transports": ["http"] + } + }, + "spec": { + "base_url": "", + "operations": [], + "features": [], + "transports": [], + "weight": 100, + "priority": 0 + } + })) + .await + .json(); + assert!(created["success"].as_bool().unwrap()); + let backend_id = created["backend"]["backend_id"] + .as_str() + .expect("backend id should exist"); + + let body: serde_json::Value = server + .get(&format!("/admin/api/ai-backends/{backend_id}")) + .await + .json(); + + assert!(body["success"].as_bool().unwrap()); + assert!(body["found"].as_bool().unwrap()); + assert_eq!( + body["backend"]["remote"]["provider_family"], + "open_ai_compatible" + ); + assert_eq!( + body["backend"]["remote"]["config"]["base_url"], + "https://api.openai.com/v1" + ); + assert_eq!( + body["backend"]["remote"]["config"]["credential_ref"], + "openai-prod" + ); + assert_eq!( + body["backend"]["remote"]["config"]["operations"], + serde_json::json!(["chat_completions", "embeddings"]) + ); + assert_eq!( + body["backend"]["remote"]["config"]["features"], + serde_json::json!(["stream"]) + ); + assert_eq!( + body["backend"]["remote"]["config"]["transports"], + serde_json::json!(["http"]) + ); + + handle.abort(); +} + +#[tokio::test] +async fn test_admin_ai_backend_list_returns_typed_local_read_view() { + let (handle, grpc_url) = start_sms_grpc().await; + let (server, _backend_registry) = create_admin_test_server(&grpc_url).await; + + let created: serde_json::Value = server + .post("/admin/api/ai-backends") + .json(&serde_json::json!({ + "display_name": "Local llama.cpp", + "provider": "llamacpp", + "model": "llama3.1", + "hosting": "local", + "backend_kind": "llamacpp", + "local": { + "provider_family": "llama_cpp", + "config": { + "model_url": "https://models.example.com/llama3.1.gguf", + "model_path": "/models/llama3.1.gguf", + "skip_download": false, + "download_timeout_s": 90, + "threads": 8, + "ctx_size": 4096 + } + }, + "spec": { + "base_url": "http://127.0.0.1:8080/v1", + "operations": ["chat_completions"], + "features": [], + "transports": ["http"], + "weight": 100, + "priority": 0 + } + })) + .await + .json(); + assert!(created["success"].as_bool().unwrap()); + + let body: serde_json::Value = server.get("/admin/api/ai-backends").await.json(); + + assert!(body["success"].as_bool().unwrap()); + let backend = body["backends"] + .as_array() + .unwrap() + .iter() + .find(|item| item["provider"] == "llamacpp" && item["model"] == "llama3.1") + .cloned() + .expect("local backend should appear in list"); + assert_eq!(backend["local"]["provider_family"], "llama_cpp"); + assert_eq!( + backend["local"]["config"]["model_url"], + "https://models.example.com/llama3.1.gguf" + ); + assert_eq!( + backend["local"]["config"]["model_path"], + "/models/llama3.1.gguf" + ); + assert_eq!(backend["local"]["config"]["skip_download"], false); + assert_eq!(backend["local"]["config"]["download_timeout_s"], 90); + assert_eq!(backend["local"]["config"]["threads"], 8); + assert_eq!(backend["local"]["config"]["ctx_size"], 4096); + + handle.abort(); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1db58263..c16b1451 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -36,9 +36,10 @@ fn resolve_backend(op: &str, transport: &str, kinds: &[&str]) -> Option (SocketAddr, tokio::task::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let svc = Arc::new( - FunctionServiceImpl::new(Arc::new(SpearletConfig::default()), None) - .await - .unwrap(), - ); + let svc = FunctionServiceImpl::new(Arc::new(SpearletConfig::default()), None) + .await + .unwrap(); let invocation = InvocationServiceServer::new(svc.clone()); let execution = ExecutionServiceServer::new(svc); diff --git a/tests/openai_live_chat_tests.rs b/tests/openai_live_chat_tests.rs index cacd252a..3718e4bd 100644 --- a/tests/openai_live_chat_tests.rs +++ b/tests/openai_live_chat_tests.rs @@ -21,12 +21,8 @@ fn test_openai_live_chat_completion() { let model = std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); - let adapter = OpenAIChatCompletionBackendAdapter::new( - "openai-live", - resolved.base_url, - None, - None, - ); + let adapter = + OpenAIChatCompletionBackendAdapter::new("openai-live", resolved.base_url, None, None); let adapter = adapter.with_static_api_key(resolved.api_key); let req = CanonicalRequestEnvelope { diff --git a/web-admin/src/api/ai-backends.ts b/web-admin/src/api/ai-backends.ts index d837788f..f144ea59 100644 --- a/web-admin/src/api/ai-backends.ts +++ b/web-admin/src/api/ai-backends.ts @@ -45,6 +45,8 @@ export type AiBackendSummary = { management_mode: AiBackendManagementMode | 'unspecified' credential_ref?: string | null spec?: AiBackendSpec | null + local?: AiBackendLocalInput + remote?: AiBackendRemoteInput labels?: Record metadata?: Record generation: number @@ -140,6 +142,41 @@ export type MutationAiBackendResponse = { message?: string } +export type AiBackendPreflightNodeResult = { + node_uuid: string + success: boolean + latency_ms?: number | null + details: + | { + kind: 'local_model' + source_kind?: string | null + effective_model_path?: string | null + model_path_exists: boolean + final_url?: string | null + http_status?: number | null + content_length?: number | null + } + | { + kind: 'remote_provider' + provider: string + phase: string + error_code?: string | null + resolved_endpoint?: string | null + http_status?: number | null + provider_code?: string | null + checks: Array<{ name: string; ok: boolean }> + auth_valid?: boolean | null + model_accessible?: boolean | null + } + message: string +} + +export type AiBackendPreflightResponse = { + success: boolean + results?: AiBackendPreflightNodeResult[] + message?: string +} + export type ListAiBackendPlacementsResponse = { success: boolean placements?: AiBackendPlacement[] @@ -203,9 +240,61 @@ export type WriteAiBackendInput = { priority?: number } labels?: Record + local?: WriteAiBackendLocalInput + remote?: WriteAiBackendRemoteInput metadata?: Record } +export type AiBackendLocalInput = + | { + provider_family: 'llama_cpp' + config: { + model_url?: string + model_path?: string + skip_download?: boolean + download_timeout_s?: number + server_mode?: string + server_cmd?: string + server_cmd_args?: string + threads?: number + ctx_size?: number + ready_probe?: string + start_timeout_s?: number + } + } + | { + provider_family: 'vllm' + config: { + mode?: string + managed_externally?: boolean + } + } + +export type AiBackendRemoteInput = + | { + provider_family: 'open_ai_compatible' + config: { + base_url?: string + credential_ref?: string + operations?: string[] + features?: string[] + transports?: string[] + } + } + | { + provider_family: 'ollama' + config: { + base_url?: string + operations?: string[] + features?: string[] + transports?: string[] + } + } + +export type WriteAiBackendLocalInput = AiBackendLocalInput + +export type WriteAiBackendRemoteInput = AiBackendRemoteInput + export type WriteAiBackendPlacementInput = { placement_id?: string backend_id: string @@ -215,6 +304,13 @@ export type WriteAiBackendPlacementInput = { priority_override?: number } +export type PreflightAiBackendInput = { + backend: WriteAiBackendInput + node_uuids: string[] + verification_policy?: 'single_node_strict' | 'sampled_strict' | 'strict_all_nodes' | 'best_effort' + requested_checks?: string[] +} + /** * List unified AI backends. * 列出统一 AI backend 控制面资源。 @@ -253,6 +349,18 @@ export function createAiBackend(input: WriteAiBackendInput) { }) } +/** + * Preflight one AI backend draft on target nodes before creation. + * 在创建前对目标节点执行 AI backend 草稿预检。 + */ +export function preflightAiBackend(input: PreflightAiBackendInput) { + return fetchJson(buildAdminPath('/ai-backends/preflight'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) +} + /** * Update one unified AI backend. * 更新单个统一 AI backend。 diff --git a/web-admin/src/features/ai-backends/AiBackendDetailPage.test.ts b/web-admin/src/features/ai-backends/AiBackendDetailPage.test.ts new file mode 100644 index 00000000..850d7792 --- /dev/null +++ b/web-admin/src/features/ai-backends/AiBackendDetailPage.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import type { AiBackendSummary } from '@/api/ai-backends' +import { + resolveBackendDetailSummary, + resolveBackendProviderFields, +} from '@/features/ai-backends/AiBackendDetailPage' + +describe('AiBackendDetailPage helpers', () => { + it('prefers typed remote config over legacy spec fields for detail summary', () => { + const backend: AiBackendSummary = { + backend_id: 'backend-remote-1', + display_name: 'Remote OpenAI', + provider: 'openai', + model: 'gpt-4o-mini', + hosting: 'remote', + backend_kind: 'openai_chat_completion', + desired_state: 'enabled', + management_mode: 'sms_remote', + credential_ref: null, + spec: { + name: 'backend-remote-1', + kind: 'openai_chat_completion', + operations: ['legacy_operation'], + features: ['legacy_feature'], + transports: ['legacy_transport'], + weight: 100, + priority: 0, + base_url: 'https://legacy.example.com/v1', + provider: 'openai', + model: 'gpt-4o-mini', + credential_ref: 'legacy-credential', + origin: 0, + deployment_id: '', + }, + remote: { + provider_family: 'open_ai_compatible', + config: { + base_url: 'https://api.openai.com/v1', + credential_ref: 'openai-prod', + operations: ['chat_completions', 'embeddings'], + features: ['stream'], + transports: ['http'], + }, + }, + metadata: {}, + labels: {}, + generation: 1, + created_at_ms: 0, + updated_at_ms: 0, + } + + expect(resolveBackendDetailSummary(backend)).toEqual({ + operations: ['chat_completions', 'embeddings'], + features: ['stream'], + transports: ['http'], + baseUrl: 'https://api.openai.com/v1', + credentialRef: 'openai-prod', + }) + }) + + it('exposes typed local provider fields for known local providers', () => { + const backend: AiBackendSummary = { + backend_id: 'backend-local-1', + display_name: 'Local llama.cpp', + provider: 'llamacpp', + model: 'llama3.1', + hosting: 'local', + backend_kind: 'llamacpp', + desired_state: 'enabled', + management_mode: 'sms_local', + credential_ref: null, + spec: { + name: 'backend-local-1', + kind: 'llamacpp', + operations: ['chat_completions'], + features: [], + transports: ['http'], + weight: 100, + priority: 0, + base_url: 'http://127.0.0.1:8080/v1', + provider: 'llamacpp', + model: 'llama3.1', + credential_ref: null, + origin: 0, + deployment_id: '', + }, + local: { + provider_family: 'llama_cpp', + config: { + model_url: 'https://models.example.com/llama3.1.gguf', + model_path: '/models/llama3.1.gguf', + skip_download: false, + download_timeout_s: 90, + threads: 8, + ctx_size: 4096, + }, + }, + metadata: {}, + labels: {}, + generation: 1, + created_at_ms: 0, + updated_at_ms: 0, + } + + expect(resolveBackendProviderFields(backend)).toEqual([ + { label: 'Model URL', value: 'https://models.example.com/llama3.1.gguf' }, + { label: 'Model Path', value: '/models/llama3.1.gguf' }, + { label: 'Skip download', value: 'false' }, + { label: 'Download timeout', value: '90' }, + { label: 'Threads', value: '8' }, + { label: 'Context size', value: '4096' }, + ]) + }) +}) diff --git a/web-admin/src/features/ai-backends/AiBackendDetailPage.tsx b/web-admin/src/features/ai-backends/AiBackendDetailPage.tsx index 822bf0b6..bc2a74ae 100644 --- a/web-admin/src/features/ai-backends/AiBackendDetailPage.tsx +++ b/web-admin/src/features/ai-backends/AiBackendDetailPage.tsx @@ -4,7 +4,7 @@ */ import { Link, useParams } from 'react-router-dom' -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import { Copy } from 'lucide-react' import { toast } from 'sonner' @@ -12,11 +12,13 @@ import { toast } from 'sonner' import { deleteAiBackend, getAiBackend, + type AiBackendRemoteInput, listAiBackendNodeStatuses, listAiBackendPlacements, listAiModelViews, setAiBackendDesiredState, updateAiBackend, + type AiBackendSummary, type AiModelView, type WriteAiBackendInput, } from '@/api/ai-backends' @@ -34,7 +36,7 @@ function StateBadge(props: { state: string }) { return {props.state} } -function SummaryRow(props: { label: string; value: React.ReactNode }) { +function SummaryRow(props: { label: string; value: ReactNode }) { return (
{props.label} @@ -43,6 +45,101 @@ function SummaryRow(props: { label: string; value: React.ReactNode }) { ) } +type BackendDetailSummaryView = { + operations: string[] + features: string[] + transports: string[] + baseUrl: string + credentialRef: string +} + +type BackendDetailProviderField = { + label: string + value: string +} + +function isOpenAiRemoteInput( + value?: AiBackendRemoteInput, +): value is Extract { + return value?.provider_family === 'open_ai_compatible' +} + +function isOllamaRemoteInput( + value?: AiBackendRemoteInput, +): value is Extract { + return value?.provider_family === 'ollama' +} + +function isKnownRemoteProvider(provider: string): boolean { + return provider === 'openai' || provider === 'ollama' +} + +export function resolveBackendDetailSummary(backend: AiBackendSummary): BackendDetailSummaryView { + const remoteOpenAi = isOpenAiRemoteInput(backend.remote) ? backend.remote : undefined + const remoteOllama = isOllamaRemoteInput(backend.remote) ? backend.remote : undefined + const normalizedProvider = backend.provider.trim().toLowerCase() + + return { + operations: isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.operations || remoteOllama?.config.operations || [] + : backend.spec?.operations || [], + features: isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.features || remoteOllama?.config.features || [] + : backend.spec?.features || [], + transports: isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.transports || remoteOllama?.config.transports || [] + : backend.spec?.transports || [], + baseUrl: isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.base_url || remoteOllama?.config.base_url || '' + : backend.spec?.base_url || '', + credentialRef: normalizedProvider === 'openai' + ? remoteOpenAi?.config.credential_ref || '' + : !isKnownRemoteProvider(normalizedProvider) + ? backend.credential_ref || backend.spec?.credential_ref || '' + : '', + } +} + +export function resolveBackendProviderFields(backend: AiBackendSummary): BackendDetailProviderField[] { + if (backend.local?.provider_family === 'llama_cpp') { + const fields: BackendDetailProviderField[] = [] + const config = backend.local.config + if (config.model_url) fields.push({ label: 'Model URL', value: config.model_url }) + if (config.model_path) fields.push({ label: 'Model Path', value: config.model_path }) + if (config.skip_download !== undefined) { + fields.push({ label: 'Skip download', value: String(config.skip_download) }) + } + if (config.download_timeout_s !== undefined) { + fields.push({ label: 'Download timeout', value: String(config.download_timeout_s) }) + } + if (config.threads !== undefined) fields.push({ label: 'Threads', value: String(config.threads) }) + if (config.ctx_size !== undefined) { + fields.push({ label: 'Context size', value: String(config.ctx_size) }) + } + return fields + } + + if (backend.local?.provider_family === 'vllm') { + const fields: BackendDetailProviderField[] = [] + const config = backend.local.config + if (config.mode) fields.push({ label: 'Mode', value: config.mode }) + if (config.managed_externally !== undefined) { + fields.push({ label: 'Managed externally', value: String(config.managed_externally) }) + } + return fields + } + + if (backend.remote?.provider_family === 'open_ai_compatible') { + return [{ label: 'Provider family', value: 'open_ai_compatible' }] + } + + if (backend.remote?.provider_family === 'ollama') { + return [{ label: 'Provider family', value: 'ollama' }] + } + + return [] +} + function RelatedModelViewsPanel(props: { backendId: string provider: string @@ -131,6 +228,8 @@ export default function AiBackendDetailPage() { }) const backend = detailQuery.data?.backend || null + const detailSummary = backend ? resolveBackendDetailSummary(backend) : null + const providerFields = backend ? resolveBackendProviderFields(backend) : [] const updateMutation = useMutation({ mutationFn: async (input: WriteAiBackendInput) => { @@ -278,23 +377,23 @@ export default function AiBackendDetailPage() { {(backend.spec?.operations || []).join(', ') || '-'}} + value={{detailSummary?.operations.join(', ') || '-'}} /> {(backend.spec?.features || []).join(', ') || '-'}} + value={{detailSummary?.features.join(', ') || '-'}} /> {(backend.spec?.transports || []).join(', ') || '-'}} + value={{detailSummary?.transports.join(', ') || '-'}} /> {backend.spec?.base_url || '-'}} + value={{detailSummary?.baseUrl || '-'}} /> {backend.credential_ref || '-'}} + value={{detailSummary?.credentialRef || '-'}} /> @@ -302,6 +401,23 @@ export default function AiBackendDetailPage() {
+ {providerFields.length > 0 ? ( + + + Provider Config + + + {providerFields.map((field) => ( + {field.value || '-'}} + /> + ))} + + + ) : null} + { await waitFor(() => expect(screen.getByRole('option', { name: 'openai-prod' })).toBeTruthy()) await waitFor(() => expect(screen.queryByText('Loading nodes…')).toBeNull()) + fireEvent.change(getSelectForLabel('Credential ref'), { + target: { value: 'openai-prod' }, + }) const textareas = document.body.querySelectorAll('textarea') fireEvent.change(textareas[0], { @@ -122,6 +125,13 @@ describe('AiBackendEditorDialog', () => { expect.objectContaining({ provider: 'openai', model: 'gpt-4o', + credential_ref: undefined, + remote: { + provider_family: 'open_ai_compatible', + config: expect.objectContaining({ + credential_ref: 'openai-prod', + }), + }, labels: { env: 'prod', team: 'ml-platform', @@ -224,8 +234,14 @@ describe('AiBackendEditorDialog', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ spec: expect.objectContaining({ - operations: ['chat_completions', 'embeddings'], + operations: [], }), + remote: { + provider_family: 'open_ai_compatible', + config: expect.objectContaining({ + operations: ['chat_completions', 'embeddings'], + }), + }, }), expect.anything(), ) @@ -250,8 +266,14 @@ describe('AiBackendEditorDialog', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ spec: expect.objectContaining({ - features: ['stream', 'supports_tools'], + features: [], }), + remote: { + provider_family: 'open_ai_compatible', + config: expect.objectContaining({ + features: ['stream', 'supports_tools'], + }), + }, }), expect.anything(), ) @@ -280,9 +302,12 @@ describe('AiBackendEditorDialog', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ hosting: 'local', - metadata: expect.objectContaining({ - model_url: 'https://models.example.com/llama3.1.gguf', - }), + local: { + provider_family: 'llama_cpp', + config: expect.objectContaining({ + model_url: 'https://models.example.com/llama3.1.gguf', + }), + }, }), expect.objectContaining({ scope: 'single_node', @@ -291,7 +316,7 @@ describe('AiBackendEditorDialog', () => { ) }) - it('shows model url field for local llamacpp and syncs it into metadata', async () => { + it('shows model url field for local llamacpp without back-writing it into metadata', async () => { const { onSubmit } = renderDialog({ initialHosting: 'local' }) await waitFor(() => expect(screen.getByRole('option', { name: /spearlet-local/ })).toBeTruthy()) @@ -309,9 +334,13 @@ describe('AiBackendEditorDialog', () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ - metadata: expect.objectContaining({ - model_url: 'https://models.example.com/llama3.1.gguf', - }), + local: { + provider_family: 'llama_cpp', + config: expect.objectContaining({ + model_url: 'https://models.example.com/llama3.1.gguf', + }), + }, + metadata: {}, }), expect.anything(), ) @@ -335,9 +364,51 @@ describe('AiBackendEditorDialog', () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ - metadata: expect.objectContaining({ - model_path: '/models/llama/llama3.1.gguf', - }), + local: { + provider_family: 'llama_cpp', + config: expect.objectContaining({ + model_path: '/models/llama/llama3.1.gguf', + }), + }, + }), + expect.anything(), + ) + }) + + it('does not let metadata textarea override typed local llamacpp fields', async () => { + const { onSubmit } = renderDialog({ initialHosting: 'local' }) + + await waitFor(() => expect(screen.getByRole('option', { name: /spearlet-local/ })).toBeTruthy()) + fireEvent.change(screen.getByPlaceholderText('e.g. OpenAI Production'), { + target: { value: 'Local llama.cpp' }, + }) + fireEvent.change(screen.getByPlaceholderText('e.g. gpt-4.1'), { + target: { value: 'llama3.1' }, + }) + fireEvent.change(screen.getByPlaceholderText('e.g. https://host/path/model.gguf'), { + target: { value: 'https://models.example.com/typed.gguf' }, + }) + + const textareas = document.body.querySelectorAll('textarea') + fireEvent.change(textareas[1], { + target: { + value: '{\n "model_url": "https://models.example.com/legacy.gguf",\n "region": "lab-a"\n}', + }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + local: { + provider_family: 'llama_cpp', + config: expect.objectContaining({ + model_url: 'https://models.example.com/typed.gguf', + }), + }, + metadata: { + region: 'lab-a', + }, }), expect.anything(), ) diff --git a/web-admin/src/features/ai-backends/AiBackendEditorDialog.tsx b/web-admin/src/features/ai-backends/AiBackendEditorDialog.tsx index 0ac84247..5f0be2d2 100644 --- a/web-admin/src/features/ai-backends/AiBackendEditorDialog.tsx +++ b/web-admin/src/features/ai-backends/AiBackendEditorDialog.tsx @@ -23,12 +23,9 @@ import { FEATURE_OPTIONS, formFromBackend, type AiBackendEditorFormState as FormState, - metadataBooleanField, - metadataStringField, OPERATION_OPTIONS, type PlacementPolicyInput, providerOptionsForHosting, - setMetadataStringField, splitCsv, toggleCsvValue, TRANSPORT_OPTIONS, @@ -158,7 +155,6 @@ export default function AiBackendEditorDialog(props: { hosting === 'local' && nodes.length === 1 ? nodes[0].uuid : '', placement_node_uuids: '', model_url: '', - metadata: setMetadataStringField(current.metadata, 'model_url', ''), } }) } @@ -377,30 +373,6 @@ export default function AiBackendEditorDialog(props: { setForm((current) => ({ ...current, metadata: event.target.value, - model_url: - current.hosting === 'local' && current.provider === 'llamacpp' - ? metadataStringField(event.target.value, 'model_url') - : current.model_url, - model_path: - current.hosting === 'local' && current.provider === 'llamacpp' - ? metadataStringField(event.target.value, 'model_path') - : current.model_path, - skip_download: - current.hosting === 'local' && current.provider === 'llamacpp' - ? metadataBooleanField(event.target.value, 'skip_download') - : current.skip_download, - download_timeout_s: - current.hosting === 'local' && current.provider === 'llamacpp' - ? metadataStringField(event.target.value, 'download_timeout_s') - : current.download_timeout_s, - threads: - current.hosting === 'local' && current.provider === 'llamacpp' - ? metadataStringField(event.target.value, 'threads') - : current.threads, - ctx_size: - current.hosting === 'local' && current.provider === 'llamacpp' - ? metadataStringField(event.target.value, 'ctx_size') - : current.ctx_size, })) } placeholder={'{\n "region": "us-east-1"\n}'} diff --git a/web-admin/src/features/ai-backends/AiBackendEditorForm.test.ts b/web-admin/src/features/ai-backends/AiBackendEditorForm.test.ts new file mode 100644 index 00000000..815f96c3 --- /dev/null +++ b/web-admin/src/features/ai-backends/AiBackendEditorForm.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' + +import type { AiBackendSummary } from '@/api/ai-backends' +import { + buildPayload, + emptyForm, + formFromBackend, + validateForm, +} from '@/features/ai-backends/AiBackendEditorForm' + +describe('buildPayload', () => { + it('serializes local llamacpp inputs into structured local config and removes duplicate metadata keys', () => { + const form = emptyForm('local') + form.display_name = 'Local llama.cpp' + form.provider = 'llamacpp' + form.model = 'llama3.1' + form.backend_kind = 'llamacpp' + form.base_url = 'http://127.0.0.1:8080/v1' + form.operations = 'chat_completions' + form.transports = 'http' + form.model_url = 'https://models.example.com/llama3.1.gguf' + form.threads = '8' + form.ctx_size = '4096' + form.metadata = JSON.stringify( + { + model_url: 'stale-url', + threads: '4', + ctx_size: '2048', + region: 'lab-a', + }, + null, + 2, + ) + + const payload = buildPayload(form) + + expect(payload.local).toEqual({ + provider_family: 'llama_cpp', + config: { + model_url: 'https://models.example.com/llama3.1.gguf', + model_path: undefined, + skip_download: undefined, + download_timeout_s: undefined, + threads: 8, + ctx_size: 4096, + }, + }) + expect(payload.metadata).toEqual({ + region: 'lab-a', + }) + expect(payload.spec).toEqual( + expect.objectContaining({ + base_url: 'http://127.0.0.1:8080/v1', + operations: ['chat_completions'], + transports: ['http'], + }), + ) + }) + + it('serializes remote openai inputs into structured remote config and clears duplicate legacy spec fields', () => { + const form = emptyForm('remote') + form.display_name = 'Remote OpenAI' + form.provider = 'openai' + form.model = 'gpt-4o-mini' + form.backend_kind = 'openai_chat_completion' + form.credential_ref = 'openai-prod' + form.base_url = 'https://api.openai.com/v1' + form.operations = 'chat_completions, embeddings' + form.features = 'stream' + form.transports = 'http' + form.weight = '120' + form.priority = '5' + form.metadata = JSON.stringify( + { + region: 'us-east-1', + }, + null, + 2, + ) + + const payload = buildPayload(form) + + expect(payload.credential_ref).toBeUndefined() + expect(payload.remote).toEqual({ + provider_family: 'open_ai_compatible', + config: { + base_url: 'https://api.openai.com/v1', + credential_ref: 'openai-prod', + operations: ['chat_completions', 'embeddings'], + features: ['stream'], + transports: ['http'], + }, + }) + expect(payload.spec).toEqual({ + base_url: undefined, + operations: [], + features: [], + transports: [], + weight: 120, + priority: 5, + }) + expect(payload.metadata).toEqual({ + region: 'us-east-1', + }) + }) + + it('rejects unsupported providers instead of preserving legacy transition payloads', () => { + const form = emptyForm('remote') + form.display_name = 'Custom Remote' + form.provider = 'anthropic-compatible' + form.model = 'claude-compatible' + form.backend_kind = 'custom_http' + form.credential_ref = 'custom-secret' + form.base_url = 'https://custom.example.com/v1' + form.operations = 'chat_completions' + form.features = 'supports_tools' + form.transports = 'http' + form.metadata = JSON.stringify( + { + tenant: 'lab', + }, + null, + 2, + ) + + expect(validateForm(form)).toContain( + 'Provider anthropic-compatible is not supported for remote backends', + ) + expect(() => buildPayload(form)).toThrow( + 'Unsupported provider for remote backends: anthropic-compatible', + ) + }) + + it('rehydrates local llamacpp form fields from typed local config before legacy metadata fallback', () => { + const backend: AiBackendSummary = { + backend_id: 'backend-local-1', + display_name: 'Local llama.cpp', + provider: 'llamacpp', + model: 'llama3.1', + hosting: 'local', + backend_kind: 'llamacpp', + desired_state: 'enabled', + management_mode: 'sms_local', + credential_ref: null, + spec: { + name: 'backend-local-1', + kind: 'llamacpp', + operations: ['chat_completions'], + features: [], + transports: ['http'], + weight: 100, + priority: 0, + base_url: 'http://127.0.0.1:8080/v1', + provider: 'llamacpp', + model: 'llama3.1', + credential_ref: null, + origin: 0, + deployment_id: '', + }, + local: { + provider_family: 'llama_cpp', + config: { + model_url: 'https://models.example.com/llama3.1.gguf', + model_path: '/models/llama3.1.gguf', + skip_download: false, + download_timeout_s: 90, + threads: 8, + ctx_size: 4096, + }, + }, + metadata: { + model_url: 'stale-url', + model_path: '/legacy/path.gguf', + skip_download: true, + download_timeout_s: '15', + threads: '2', + ctx_size: '1024', + }, + labels: {}, + generation: 1, + created_at_ms: 0, + updated_at_ms: 0, + } + + const form = formFromBackend(backend) + + expect(form.model_url).toBe('https://models.example.com/llama3.1.gguf') + expect(form.model_path).toBe('/models/llama3.1.gguf') + expect(form.skip_download).toBe(false) + expect(form.download_timeout_s).toBe('90') + expect(form.threads).toBe('8') + expect(form.ctx_size).toBe('4096') + }) + + it('rehydrates remote openai form fields from typed remote config before legacy spec fallback', () => { + const backend: AiBackendSummary = { + backend_id: 'backend-remote-1', + display_name: 'Remote OpenAI', + provider: 'openai', + model: 'gpt-4o-mini', + hosting: 'remote', + backend_kind: 'openai_chat_completion', + desired_state: 'enabled', + management_mode: 'sms_remote', + credential_ref: null, + spec: { + name: 'backend-remote-1', + kind: 'openai_chat_completion', + operations: ['legacy_operation'], + features: ['legacy_feature'], + transports: ['legacy_transport'], + weight: 120, + priority: 5, + base_url: 'https://legacy.example.com/v1', + provider: 'openai', + model: 'gpt-4o-mini', + credential_ref: 'legacy-credential', + origin: 0, + deployment_id: '', + }, + remote: { + provider_family: 'open_ai_compatible', + config: { + base_url: 'https://api.openai.com/v1', + credential_ref: 'openai-prod', + operations: ['chat_completions', 'embeddings'], + features: ['stream'], + transports: ['http'], + }, + }, + metadata: {}, + labels: {}, + generation: 1, + created_at_ms: 0, + updated_at_ms: 0, + } + + const form = formFromBackend(backend) + + expect(form.credential_ref).toBe('openai-prod') + expect(form.base_url).toBe('https://api.openai.com/v1') + expect(form.operations).toBe('chat_completions, embeddings') + expect(form.features).toBe('stream') + expect(form.transports).toBe('http') + expect(form.weight).toBe('120') + expect(form.priority).toBe('5') + }) +}) diff --git a/web-admin/src/features/ai-backends/AiBackendEditorForm.ts b/web-admin/src/features/ai-backends/AiBackendEditorForm.ts index f5ff0ff9..6ca4f7ec 100644 --- a/web-admin/src/features/ai-backends/AiBackendEditorForm.ts +++ b/web-admin/src/features/ai-backends/AiBackendEditorForm.ts @@ -2,6 +2,7 @@ import type { AiBackendSummary, AiBackendDesiredState, AiBackendHosting, + AiBackendRemoteInput, WriteAiBackendInput, } from '@/api/ai-backends' import type { NodeSummary } from '@/api/types' @@ -172,6 +173,37 @@ function joinCsv(values?: string[] | null): string { return (values || []).join(', ') } +function optionalString(value?: string | null): string { + return value?.trim() || '' +} + +function optionalNumberString(value?: number | null): string { + return value === undefined || value === null ? '' : String(value) +} + +function isOpenAiRemoteInput( + value?: AiBackendRemoteInput, +): value is Extract { + return value?.provider_family === 'open_ai_compatible' +} + +function isOllamaRemoteInput( + value?: AiBackendRemoteInput, +): value is Extract { + return value?.provider_family === 'ollama' +} + +function isKnownRemoteProvider(provider: string): boolean { + return provider === 'openai' || provider === 'ollama' +} + +function isSupportedProviderForHosting(hosting: AiBackendHosting, provider: string): boolean { + const normalizedProvider = provider.trim().toLowerCase() + const supportedProviders = + hosting === 'local' ? LOCAL_PROVIDER_OPTIONS : REMOTE_PROVIDER_OPTIONS + return supportedProviders.includes(normalizedProvider) +} + export function emptyForm(hosting: AiBackendHosting = 'remote'): AiBackendEditorFormState { const provider = defaultProviderForHosting(hosting) const backendKind = defaultBackendKindFor(hosting, provider) @@ -207,86 +239,77 @@ export function emptyForm(hosting: AiBackendHosting = 'remote'): AiBackendEditor } } -export function metadataStringField(value: string, key: string): string { - try { - const parsed = parseMetadata(value) - const raw = parsed[key] - return typeof raw === 'string' ? raw : '' - } catch { - return '' - } -} - -export function metadataBooleanField(value: string, key: string): boolean { - try { - const parsed = parseMetadata(value) - const raw = parsed[key] - if (typeof raw === 'boolean') return raw - if (typeof raw === 'string') return raw.trim() === '1' || raw.trim().toLowerCase() === 'true' - return false - } catch { - return false - } -} - -export function setMetadataStringField(value: string, key: string, nextValue: string): string { - let parsed: Record - try { - parsed = parseMetadata(value) - } catch { - parsed = {} - } - const trimmed = nextValue.trim() - if (trimmed) { - parsed[key] = trimmed - } else { - delete parsed[key] - } - return JSON.stringify(parsed, null, 2) -} - -export function setMetadataBooleanField(value: string, key: string, nextValue: boolean): string { - let parsed: Record - try { - parsed = parseMetadata(value) - } catch { - parsed = {} - } - if (nextValue) { - parsed[key] = '1' - } else { - delete parsed[key] - } - return JSON.stringify(parsed, null, 2) -} - export function formFromBackend(backend?: AiBackendSummary | null): AiBackendEditorFormState { if (!backend) return emptyForm() const metadata = JSON.stringify(backend.metadata || {}, null, 2) + const normalizedProvider = backend.provider.trim().toLowerCase() + const localProvider = + backend.local && backend.local.provider_family === 'llama_cpp' ? backend.local : undefined + const remoteOpenAi = isOpenAiRemoteInput(backend.remote) ? backend.remote : undefined + const remoteOllama = isOllamaRemoteInput(backend.remote) ? backend.remote : undefined + const resolvedCredentialRef = isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.credential_ref || '' + : backend.credential_ref || backend.spec?.credential_ref || '' + const resolvedBaseUrl = isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.base_url || remoteOllama?.config.base_url || '' + : backend.spec?.base_url || '' + const resolvedOperations = isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.operations || remoteOllama?.config.operations + : backend.spec?.operations + const resolvedFeatures = isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.features || remoteOllama?.config.features + : backend.spec?.features + const resolvedTransports = isKnownRemoteProvider(normalizedProvider) + ? remoteOpenAi?.config.transports || remoteOllama?.config.transports + : backend.spec?.transports + const resolvedModelUrl = + normalizedProvider === 'llamacpp' + ? optionalString(localProvider?.config.model_url) + : '' + const resolvedModelPath = + normalizedProvider === 'llamacpp' + ? optionalString(localProvider?.config.model_path) + : '' + const resolvedSkipDownload = + normalizedProvider === 'llamacpp' + ? (localProvider?.config.skip_download ?? false) + : false + const resolvedDownloadTimeout = + normalizedProvider === 'llamacpp' + ? optionalNumberString(localProvider?.config.download_timeout_s) + : '' + const resolvedThreads = + normalizedProvider === 'llamacpp' + ? optionalNumberString(localProvider?.config.threads) + : '' + const resolvedCtxSize = + normalizedProvider === 'llamacpp' + ? optionalNumberString(localProvider?.config.ctx_size) + : '' return { display_name: backend.display_name || '', provider: backend.provider || '', model: backend.model || '', hosting: backend.hosting === 'local' ? 'local' : 'remote', backend_kind: backend.backend_kind || backend.spec?.kind || 'openai-compatible', - credential_ref: backend.credential_ref || backend.spec?.credential_ref || '', + credential_ref: resolvedCredentialRef, desired_state: backend.desired_state === 'disabled' ? 'disabled' : 'enabled', - base_url: backend.spec?.base_url || '', - operations: joinCsv(backend.spec?.operations), - features: joinCsv(backend.spec?.features), - transports: joinCsv(backend.spec?.transports), + base_url: resolvedBaseUrl, + operations: joinCsv(resolvedOperations), + features: joinCsv(resolvedFeatures), + transports: joinCsv(resolvedTransports), weight: String(backend.spec?.weight ?? 100), priority: String(backend.spec?.priority ?? 0), labels: Object.entries(backend.labels || {}) .map(([key, value]) => `${key}=${value}`) .join('\n'), metadata, - model_url: metadataStringField(metadata, 'model_url'), - model_path: metadataStringField(metadata, 'model_path'), - skip_download: metadataBooleanField(metadata, 'skip_download'), - download_timeout_s: metadataStringField(metadata, 'download_timeout_s'), - threads: metadataStringField(metadata, 'threads'), - ctx_size: metadataStringField(metadata, 'ctx_size'), + model_url: resolvedModelUrl, + model_path: resolvedModelPath, + skip_download: resolvedSkipDownload, + download_timeout_s: resolvedDownloadTimeout, + threads: resolvedThreads, + ctx_size: resolvedCtxSize, placement_scope: backend.hosting === 'local' ? 'single_node' : 'all_nodes', placement_state: 'enabled', placement_node_uuid: '', @@ -341,43 +364,16 @@ export function parseMetadata(value: string): Record { } export function buildPayload(form: AiBackendEditorFormState): WriteAiBackendInput { - const metadata = parseMetadata(form.metadata) - const modelUrl = form.model_url.trim() - if (modelUrl) { - metadata.model_url = modelUrl - } else { - delete metadata.model_url - } - const modelPath = form.model_path.trim() - if (modelPath) { - metadata.model_path = modelPath - } else { - delete metadata.model_path + if (!isSupportedProviderForHosting(form.hosting, form.provider)) { + throw new Error(`Unsupported provider for ${form.hosting} backends: ${form.provider.trim()}`) } - if (form.skip_download) { - metadata.skip_download = '1' - } else { - delete metadata.skip_download - } - const downloadTimeout = form.download_timeout_s.trim() - if (downloadTimeout) { - metadata.download_timeout_s = downloadTimeout - } else { - delete metadata.download_timeout_s - } - const threads = form.threads.trim() - if (threads) { - metadata.threads = threads - } else { - delete metadata.threads - } - const ctxSize = form.ctx_size.trim() - if (ctxSize) { - metadata.ctx_size = ctxSize - } else { - delete metadata.ctx_size + const metadata = parseMetadata(form.metadata) + const deleteMetadataKeys = (keys: string[]) => { + for (const key of keys) { + delete metadata[key] + } } - return { + const basePayload: WriteAiBackendInput = { display_name: form.display_name.trim(), provider: form.provider.trim(), model: form.model.trim(), @@ -396,6 +392,98 @@ export function buildPayload(form: AiBackendEditorFormState): WriteAiBackendInpu labels: parseLabels(form.labels), metadata, } + + if (form.hosting === 'local' && form.provider === 'llamacpp') { + deleteMetadataKeys([ + 'model_url', + 'model_path', + 'skip_download', + 'download_timeout_s', + 'server_mode', + 'server_cmd', + 'server_cmd_args', + 'threads', + 'ctx_size', + 'ready_probe', + 'start_timeout_s', + ]) + return { + ...basePayload, + local: { + provider_family: 'llama_cpp', + config: { + model_url: form.model_url.trim() || undefined, + model_path: form.model_path.trim() || undefined, + skip_download: form.skip_download || undefined, + download_timeout_s: form.download_timeout_s.trim() + ? Number(form.download_timeout_s) + : undefined, + threads: form.threads.trim() ? Number(form.threads) : undefined, + ctx_size: form.ctx_size.trim() ? Number(form.ctx_size) : undefined, + }, + }, + } + } + + if (form.hosting === 'local' && form.provider === 'vllm') { + deleteMetadataKeys(['mode', 'managed_externally']) + return { + ...basePayload, + local: { + provider_family: 'vllm', + config: {}, + }, + } + } + + if (form.hosting === 'remote' && form.provider === 'openai') { + return { + ...basePayload, + credential_ref: undefined, + spec: { + ...basePayload.spec, + base_url: undefined, + operations: [], + features: [], + transports: [], + }, + remote: { + provider_family: 'open_ai_compatible', + config: { + base_url: form.base_url.trim() || undefined, + credential_ref: form.credential_ref.trim() || undefined, + operations: splitCsv(form.operations), + features: splitCsv(form.features), + transports: splitCsv(form.transports), + }, + }, + } + } + + if (form.hosting === 'remote' && form.provider === 'ollama') { + return { + ...basePayload, + credential_ref: undefined, + spec: { + ...basePayload.spec, + base_url: undefined, + operations: [], + features: [], + transports: [], + }, + remote: { + provider_family: 'ollama', + config: { + base_url: form.base_url.trim() || undefined, + operations: splitCsv(form.operations), + features: splitCsv(form.features), + transports: splitCsv(form.transports), + }, + }, + } + } + + return basePayload } function isValidHttpUrl(value: string): boolean { @@ -416,6 +504,9 @@ export function validateForm( const nodes = options?.nodes || [] if (!form.display_name.trim()) errors.push('Display name is required') if (!form.provider.trim()) errors.push('Provider is required') + if (form.provider.trim() && !isSupportedProviderForHosting(form.hosting, form.provider)) { + errors.push(`Provider ${form.provider.trim()} is not supported for ${form.hosting} backends`) + } if (!form.model.trim()) errors.push('Model is required') if (!form.backend_kind.trim()) errors.push('Backend kind is required') if ( diff --git a/web-admin/src/features/ai-backends/AiBackendsPage.test.tsx b/web-admin/src/features/ai-backends/AiBackendsPage.test.tsx index f15a1a4f..af907ab9 100644 --- a/web-admin/src/features/ai-backends/AiBackendsPage.test.tsx +++ b/web-admin/src/features/ai-backends/AiBackendsPage.test.tsx @@ -6,19 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AiBackendsPage from '@/features/ai-backends/AiBackendsPage' const mockListAiBackends = vi.fn() +const mockListAiBackendNodeStatuses = vi.fn() const mockDeleteAiBackend = vi.fn() const mockUpdateAiBackend = vi.fn() const mockCreateAiBackend = vi.fn() +const mockPreflightAiBackend = vi.fn() +const mockUpsertAiBackendPlacement = vi.fn() const mockListCredentials = vi.fn() +const mockListNodes = vi.fn() vi.mock('@/api/ai-backends', async () => { const actual = await vi.importActual('@/api/ai-backends') return { ...actual, listAiBackends: (input?: unknown) => mockListAiBackends(input), + listAiBackendNodeStatuses: (backendId: string) => mockListAiBackendNodeStatuses(backendId), deleteAiBackend: (backendId: string) => mockDeleteAiBackend(backendId), updateAiBackend: (backendId: string, input: unknown) => mockUpdateAiBackend(backendId, input), createAiBackend: (input: unknown) => mockCreateAiBackend(input), + preflightAiBackend: (input: unknown) => mockPreflightAiBackend(input), + upsertAiBackendPlacement: (input: unknown) => mockUpsertAiBackendPlacement(input), } }) @@ -26,6 +33,10 @@ vi.mock('@/api/credentials', () => ({ listCredentials: () => mockListCredentials(), })) +vi.mock('@/api/nodes', () => ({ + listNodes: () => mockListNodes(), +})) + function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } }, @@ -48,10 +59,48 @@ describe('AiBackendsPage', () => { }) mockDeleteAiBackend.mockReset() mockDeleteAiBackend.mockResolvedValue({ success: true }) + mockListAiBackendNodeStatuses.mockReset() + mockListAiBackendNodeStatuses.mockResolvedValue({ + success: true, + statuses: [ + { + backend_id: 'b-1', + node_uuid: 'node-1', + observed_generation: 1, + status: 'reconciling', + status_reason: 'pulling', + available: false, + operations: [], + features: [], + transports: [], + last_heartbeat_at_ms: 0, + }, + ], + }) mockUpdateAiBackend.mockReset() mockUpdateAiBackend.mockResolvedValue({ success: true }) mockCreateAiBackend.mockReset() - mockCreateAiBackend.mockResolvedValue({ success: true }) + mockCreateAiBackend.mockResolvedValue({ success: true, backend: { backend_id: 'created-backend' } }) + mockPreflightAiBackend.mockReset() + mockPreflightAiBackend.mockResolvedValue({ success: true, results: [] }) + mockUpsertAiBackendPlacement.mockReset() + mockUpsertAiBackendPlacement.mockResolvedValue({ success: true }) + mockListNodes.mockReset() + mockListNodes.mockResolvedValue({ + nodes: [ + { + uuid: 'node-1', + name: 'spearlet-local', + ip_address: '127.0.0.1', + port: 50052, + status: 'ready', + last_heartbeat: 0, + registered_at: 0, + metadata: {}, + }, + ], + total_count: 1, + }) mockListAiBackends.mockReset() mockListAiBackends.mockResolvedValue({ success: true, @@ -133,4 +182,100 @@ describe('AiBackendsPage', () => { }), ) }) + + it('shows aggregated status reason summary for each backend row', async () => { + renderPage() + + await waitFor(() => expect(mockListAiBackendNodeStatuses).toHaveBeenCalledWith('b-1')) + expect(await screen.findByText('pulling')).toBeTruthy() + }) + + it('preflights local llamacpp sources before creating the backend', async () => { + renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Create Backend' })) + fireEvent.click(screen.getByText('Create Local Backend')) + + await waitFor(() => + expect(screen.getByPlaceholderText('e.g. https://host/path/model.gguf')).toBeTruthy(), + ) + fireEvent.change(screen.getByPlaceholderText('e.g. OpenAI Production'), { + target: { value: 'Local llama.cpp' }, + }) + fireEvent.change(screen.getByPlaceholderText('e.g. gpt-4.1'), { + target: { value: 'llama3.1' }, + }) + fireEvent.change(screen.getByPlaceholderText('e.g. https://host/path/model.gguf'), { + target: { value: 'https://models.example.com/llama3.1.gguf' }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Create Local Backend' })) + + await waitFor(() => + expect(mockPreflightAiBackend).toHaveBeenCalledWith( + expect.objectContaining({ + backend: expect.objectContaining({ + hosting: 'local', + provider: 'llamacpp', + local: { + provider_family: 'llama_cpp', + config: expect.objectContaining({ + model_url: 'https://models.example.com/llama3.1.gguf', + }), + }, + }), + node_uuids: ['node-1'], + }), + ), + ) + expect(mockCreateAiBackend).toHaveBeenCalledTimes(1) + expect(mockUpsertAiBackendPlacement).toHaveBeenCalledWith( + expect.objectContaining({ + backend_id: 'created-backend', + node_uuid: 'node-1', + }), + ) + }) + + it('preflights remote openai backends before creating the backend', async () => { + renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Create Backend' })) + fireEvent.click(screen.getByText('Create Remote Backend')) + + await waitFor(() => + expect(screen.getByPlaceholderText('e.g. OpenAI Production')).toBeTruthy(), + ) + fireEvent.change(screen.getByPlaceholderText('e.g. OpenAI Production'), { + target: { value: 'OpenAI Remote' }, + }) + fireEvent.change(screen.getByPlaceholderText('e.g. gpt-4.1'), { + target: { value: 'gpt-4o-mini' }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Create Remote Backend' })) + + await waitFor(() => + expect(mockPreflightAiBackend).toHaveBeenCalledWith( + expect.objectContaining({ + backend: expect.objectContaining({ + hosting: 'remote', + provider: 'openai', + model: 'gpt-4o-mini', + remote: { + provider_family: 'open_ai_compatible', + config: expect.objectContaining({ + base_url: 'https://api.openai.com/v1', + operations: ['chat_completions'], + }), + }, + }), + node_uuids: ['node-1'], + verification_policy: 'single_node_strict', + requested_checks: ['connectivity', 'auth', 'model_access'], + }), + ), + ) + expect(mockCreateAiBackend).toHaveBeenCalledTimes(1) + }) }) diff --git a/web-admin/src/features/ai-backends/AiBackendsPage.tsx b/web-admin/src/features/ai-backends/AiBackendsPage.tsx index 1195482d..fb6ed6ff 100644 --- a/web-admin/src/features/ai-backends/AiBackendsPage.tsx +++ b/web-admin/src/features/ai-backends/AiBackendsPage.tsx @@ -12,10 +12,13 @@ import { createAiBackend, deleteAiBackend, listAiBackends, + listAiBackendNodeStatuses, listAiModelViews, + preflightAiBackend, setAiBackendDesiredState, upsertAiBackendPlacement, updateAiBackend, + type AiBackendNodeStatusSnapshot, type AiBackendSummary, type WriteAiBackendInput, } from '@/api/ai-backends' @@ -41,6 +44,43 @@ function AvailabilityBadge(props: { readyNodes: number }) { return unavailable } +function summarizeBackendStatusReason(statuses: AiBackendNodeStatusSnapshot[] | undefined): string { + if (!statuses || statuses.length === 0) { + return 'no node status' + } + + const priorityOrder: Array = [ + 'error', + 'degraded', + 'reconciling', + 'pending', + 'disabled', + 'ready', + 'unspecified', + ] + + const sorted = [...statuses].sort( + (left, right) => priorityOrder.indexOf(left.status) - priorityOrder.indexOf(right.status), + ) + const primary = sorted[0] + const normalizedReason = primary.status_reason?.trim() + const sameReasonCount = statuses.filter((status) => { + return ( + status.status === primary.status && + (status.status_reason?.trim() || '') === (normalizedReason || '') + ) + }).length + + const label = normalizedReason || primary.status + if (statuses.length === 1) { + return label + } + if (sameReasonCount > 1) { + return `${label} (${sameReasonCount} nodes)` + } + return `${label} (${primary.node_uuid})` +} + export default function AiBackendsPage() { const queryClient = useQueryClient() const [editDialogOpen, setEditDialogOpen] = useState(false) @@ -85,23 +125,44 @@ export default function AiBackendsPage() { const createMutation = useMutation({ mutationFn: async (input: { backend: WriteAiBackendInput; placementPolicy?: PlacementPolicyInput }) => { + const targetNodeUuids = + input.placementPolicy?.scope === 'all_nodes' + ? (await listNodes({ sort_by: 'last_heartbeat', order: 'desc', limit: 200 })).nodes.map( + (node) => node.uuid, + ) + : input.placementPolicy?.node_uuids || [] + + if (input.placementPolicy && targetNodeUuids.length === 0) { + throw new Error('No nodes available for placement') + } + + const supportsRemotePreflight = + input.backend.hosting === 'remote' && + ['openai', 'openai_compatible', 'ollama'].includes(input.backend.provider) + const supportsLocalPreflight = + input.backend.hosting === 'local' && input.backend.provider === 'llamacpp' + if ((supportsRemotePreflight || supportsLocalPreflight) && targetNodeUuids.length > 0) { + const preflight = await preflightAiBackend({ + backend: input.backend, + node_uuids: targetNodeUuids, + verification_policy: + targetNodeUuids.length <= 1 ? 'single_node_strict' : 'sampled_strict', + requested_checks: + input.backend.hosting === 'remote' + ? ['connectivity', 'auth', 'model_access'] + : undefined, + }) + if (!preflight.success) { + throw new Error(preflight.message || 'AI backend preflight failed') + } + } + const response = await createAiBackend(input.backend) if (!response.success) throw new Error(response.message || 'Failed to create AI backend') const backendId = response.backend?.backend_id if (!backendId) throw new Error('Backend created without backend_id') if (input.placementPolicy) { - const targetNodeUuids = - input.placementPolicy.scope === 'all_nodes' - ? (await listNodes({ sort_by: 'last_heartbeat', order: 'desc', limit: 200 })).nodes.map( - (node) => node.uuid, - ) - : input.placementPolicy.node_uuids - - if (targetNodeUuids.length === 0) { - throw new Error('No nodes available for placement') - } - await Promise.all( targetNodeUuids.map(async (nodeUuid) => { const placementResponse = await upsertAiBackendPlacement({ @@ -169,6 +230,24 @@ export default function AiBackendsPage() { const backends = query.data?.backends || [] const modelViews = modelQuery.data?.views || [] + const statusReasonQuery = useQuery({ + queryKey: ['ai-backend-status-reasons', backends.map((backend) => backend.backend_id)], + enabled: viewMode === 'backends' && backends.length > 0, + refetchInterval: 15_000, + queryFn: async () => { + const entries = await Promise.all( + backends.map(async (backend) => { + const response = await listAiBackendNodeStatuses(backend.backend_id) + return [ + backend.backend_id, + summarizeBackendStatusReason(response.success ? response.statuses : undefined), + ] as const + }), + ) + return Object.fromEntries(entries) + }, + }) + const statusReasonByBackendId = statusReasonQuery.data || {} const totalCount = viewMode === 'backends' ? query.data?.total_count || 0 : modelQuery.data?.total_count || 0 const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)) @@ -367,6 +446,7 @@ export default function AiBackendsPage() { Hosting Kind Desired State + Status reason Generation Actions @@ -398,6 +478,13 @@ export default function AiBackendsPage() { + +
+ {statusReasonQuery.isLoading + ? 'Loading…' + : statusReasonByBackendId[backend.backend_id] || 'no node status'} +
+ {backend.generation}
diff --git a/web-admin/src/features/ai-backends/LocalLlamaCppFields.tsx b/web-admin/src/features/ai-backends/LocalLlamaCppFields.tsx index 0b7a063f..e41a7a43 100644 --- a/web-admin/src/features/ai-backends/LocalLlamaCppFields.tsx +++ b/web-admin/src/features/ai-backends/LocalLlamaCppFields.tsx @@ -3,10 +3,6 @@ import type { Dispatch, SetStateAction } from 'react' import { Input } from '@/components/ui/input' import type { AiBackendEditorFormState } from './AiBackendEditorForm' -import { - setMetadataBooleanField, - setMetadataStringField, -} from './AiBackendEditorForm' export default function LocalLlamaCppFields(props: { form: AiBackendEditorFormState @@ -18,7 +14,7 @@ export default function LocalLlamaCppFields(props: {
Local llama.cpp Runtime
- These fields are stored in metadata and used by the node-local llama.cpp supervisor. + These fields are stored as typed local config and normalized into runtime metadata on save.
@@ -31,7 +27,6 @@ export default function LocalLlamaCppFields(props: { setForm((current) => ({ ...current, model_url: event.target.value, - metadata: setMetadataStringField(current.metadata, 'model_url', event.target.value), })) } placeholder="e.g. https://host/path/model.gguf" @@ -45,7 +40,6 @@ export default function LocalLlamaCppFields(props: { setForm((current) => ({ ...current, model_path: event.target.value, - metadata: setMetadataStringField(current.metadata, 'model_path', event.target.value), })) } placeholder="e.g. /models/llama/model.gguf" @@ -61,11 +55,6 @@ export default function LocalLlamaCppFields(props: { setForm((current) => ({ ...current, skip_download: event.target.checked, - metadata: setMetadataBooleanField( - current.metadata, - 'skip_download', - event.target.checked, - ), })) } /> @@ -81,11 +70,6 @@ export default function LocalLlamaCppFields(props: { setForm((current) => ({ ...current, download_timeout_s: event.target.value, - metadata: setMetadataStringField( - current.metadata, - 'download_timeout_s', - event.target.value, - ), })) } placeholder="3600" @@ -99,7 +83,6 @@ export default function LocalLlamaCppFields(props: { setForm((current) => ({ ...current, threads: event.target.value, - metadata: setMetadataStringField(current.metadata, 'threads', event.target.value), })) } placeholder="8" @@ -113,7 +96,6 @@ export default function LocalLlamaCppFields(props: { setForm((current) => ({ ...current, ctx_size: event.target.value, - metadata: setMetadataStringField(current.metadata, 'ctx_size', event.target.value), })) } placeholder="4096"